#HTTP Links
Alepha's link system provides type-safe cross-service communication through $client and $remote. The same API works for local calls (in-process), remote calls (HTTP), and browser-to-server calls.
#$client: Type-Safe Action Proxy
$client<T>() creates a proxy object that mirrors the actions of a controller class. Property access on the proxy returns virtual actions that can be called like functions.
1import { $client } from "alepha/server/links"; 2import { $action } from "alepha/server"; 3import { z } from "alepha"; 4 5class ProductController { 6 getProduct = $action({ 7 path: "/products/:id", 8 schema: { 9 params: z.object({ id: z.uuid() }),10 response: z.object({ id: z.uuid(), name: z.text(), price: z.number() }),11 },12 handler: async ({ params }) => {13 return await this.repo.findById(params.id);14 },15 });16}17 18class OrderService {19 products = $client<ProductController>();20 21 createOrder = $action({22 method: "POST",23 path: "/orders",24 schema: {25 body: z.object({ productId: z.uuid(), quantity: z.integer() }),26 },27 handler: async ({ body }) => {28 // Type-safe call: params type is inferred from ProductController.getProduct29 const product = await this.products.getProduct({30 params: { id: body.productId },31 });32 return { product: product.name, total: product.price * body.quantity };33 },34 });35}
#Local vs Remote Resolution
When the target action exists in the same process, $client calls the handler directly with no HTTP overhead. When the action is on a remote service, it makes an HTTP request. This is transparent to the caller.
#Calling a Remote Alepha App
Give the scope a hostname and $client resolves against that app's action registry instead of anything local. Nothing else is needed: the calling process registers no actions, declares no routes and binds no port.
1import { $env, z } from "alepha"; 2import { $action } from "alepha/server"; 3import { $client } from "alepha/server/links"; 4 5// The remote app's controller. In a real consumer this is an `import type` 6// away - the type is erased at build time, so it costs nothing at runtime, 7// but the source does have to be reachable from the caller. 8class QualityController { 9 pushQualityRun = $action({10 method: "POST",11 path: "/projects/:projectId/quality",12 schema: {13 params: z.object({ projectId: z.text() }),14 body: z.object({ coverage: z.number() }),15 response: z.object({ id: z.text() }),16 },17 handler: async ({ params, body }) => {18 return { id: `${params.projectId}:${body.coverage}` };19 },20 });21}22 23class CoverageReporter {24 env = $env(25 z.object({26 LORE_URL: z.text({ default: "https://lore.alepha.dev" }),27 LORE_API_KEY: z.text({ default: "" }),28 }),29 );30 31 lore = $client<QualityController>({32 hostname: String(this.env.LORE_URL),33 authorization: () => `Bearer ${this.env.LORE_API_KEY}`,34 });35 36 push = async (projectId: string, coverage: number) => {37 await this.lore.pushQualityRun({38 params: { projectId },39 body: { coverage },40 });41 };42}
Register AlephaServerLinksClient rather than AlephaServerLinks in such a process. It carries $client, LinkProvider and the HTTP client, and nothing that serves.
1import { Alepha } from "alepha";2import { AlephaServerLinksClient } from "alepha/server/links";3 4const alepha = Alepha.create()5 .with(AlephaServerLinksClient)6 .with(CoverageReporter);
#The credential
authorization takes a string or a thunk, and the thunk is awaited per request - a refreshing token is the reason it is accepted at all, and a client that resolved it once would work until the token expired and then fail for good.
It is sent with the registry fetch as well as with the calls, and that is not an optimisation. /api/_links prunes every action the caller may not invoke, so an anonymous fetch omits each $secure one and the call fails with Action <name> not found for a route that plainly exists and that you are plainly allowed to call.
Header precedence, weakest first:
| Source | Beats |
|---|---|
| the ambient incoming request (ALS) | nothing - it only fills a gap |
scope headers |
ALS |
scope authorization |
scope headers |
per-call options.request.headers |
everything |
authorization is accepted without a hostname, and is applied wherever a request is actually made - which means it is inert when the link resolves to a local handler, because there is no request to carry it.
#What a remote client does not reach
$sse. A local SSE action works through the handler branch and returns a real stream; a remote one would leave as a plain fetch, which answers with a response. It is dropped from the type of a client whose scope names a host, and refused by name at call time for one the type cannot narrow.- Anything the source is not reachable for. The type comes from
import type, so the controller's source has to be on disk. Generating types from/api/_linksfor a consumer with no source access is deliberately out of scope - see No generated clients below.
#The registry is cached per host, per caller
One fetch per host, held for five minutes (remoteRegistryTtl on the links options atom) and then revalidated - the endpoint emits an ETag, so an expired entry costs a 304 rather than a payload. Two hosts are held independently; neither evicts the other. The cache key is the host plus a hash of the headers that identify you to it, never the credential itself, so one caller's registry is never served to another.
If the registry cannot be fetched, the failure says so and names the host. It does not fall back to an empty registry, which would report Action not found for a server that is merely down.
#/api/_links is public
The registry endpoint answers any caller, authenticated or not, with the anonymous action surface. That is how the browser bootstraps before login, and it is not new - but a remote client makes it a surface you rely on deliberately, so it is worth stating rather than discovering: the names, paths and methods of your unsecured actions are public.
Actions behind $secure are pruned from an anonymous response, and are listed under restricted only for callers who have authenticated.
#No generated clients
Alepha does not and will not generate API clients. The type-safe path is import type, and for consumers outside TypeScript the answer is alepha/server/swagger, which serves an OpenAPI document that a dedicated codegen tool can read. That is a standing principle, not a gap waiting to be filled.
#Virtual Actions
Each property on a $client proxy returns a VirtualAction with these methods:
| Method | Description |
|---|---|
action(config) |
Default call. Local-first - calls the handler directly if available, otherwise HTTP. |
action.run(config) |
Same as calling the action directly. Local-first. |
action.fetch(config) |
Always makes an HTTP request, even if the action is local. |
action.can() |
Returns true if the current user has permission to call this action. |
1// Direct call (local-first) 2const product = await this.products.getProduct({ params: { id } }); 3 4// Force HTTP 5const response = await this.products.getProduct.fetch({ params: { id } }); 6 7// Permission check 8if (this.products.getProduct.can()) { 9 // user has access10}
#What can() actually asks
can("getProduct") is "is this action in the registry the server sent me". The
server prunes an action whose $secure permissions the caller's roles do not
carry, so the answer is application scope: the same for every project, team
or workspace the viewer can open.
For an application where a member's powers vary per resource, that is only half
the question, and the missing half is the one that decides whether a button
renders. ScopeGrantsProvider is where the other half goes:
1import { ScopeGrantsProvider } from "alepha/server/links"; 2 3class ProjectScopeGrants extends ScopeGrantsProvider { 4 protected readonly alepha = $inject(Alepha); 5 6 public override current(): readonly string[] | undefined { 7 return this.alepha.store.get(currentProjectAtom)?.permissions; 8 } 9}10 11alepha.with({ provide: ScopeGrantsProvider, use: ProjectScopeGrants });
can(action) is then: in the registry (unchanged) and, when a scope
answers, every permission that action requires is in the scope's set. Each
action's permissions travel in the registry itself (permissions on the action
entry, filled from its $secure options, $owns({ requires }) included), so no
screen ever repeats a permission string. They are static per action rather than
per user, so they cost nothing per request and leak nothing: the caller can
already see the action.
The call is synchronous because it runs during render on both sides of hydration. The scope's permission set is therefore something the page's loader has already put on an atom - this reads it, it does not fetch.
⚠️ undefined and [] are different answers. undefined means "not inside a
scope", so nothing is narrowed; [] means "inside one, holding nothing", which
hides every action that names a permission. Returning [] for a page outside
any scope blanks the whole UI.
⚠️ can("group:name") - a string with a colon - is a different question
entirely: it asks about the caller's application permissions, not about an
action, and a scope never touches it.
#$remote: Remote Service Access
$remote defines a connection to an external service. Use it when services run as separate deployments.
$remoteor$client({ hostname })? They overlap, and picking the wrong one is the usual mistake.$remoteis service-to-service: it is declared as a primitive on a class, it belongs to an app you also run, and it can carry a service account and proxy the remote's endpoints through your own server.$client({ hostname })is a consumer calling an app it does not host - a CLI, a worker, a script - and it declares nothing, registers nothing and serves nothing.
1import { $remote } from "alepha/server/links"; 2import { $env, z } from "alepha"; 3 4class Gateway { 5 env = $env( 6 z.object({ 7 PAYMENTS_URL: z.text({ default: "http://localhost:4000" }), 8 }), 9 );10 11 payments = $remote({12 url: this.env.PAYMENTS_URL,13 });14}
Auto-discovery of remote services is not currently supported. The
$remoteURL must be configured manually. Future versions may support service discovery via Redis.
#Service Account Authentication
For authenticated service-to-service communication, attach a service account:
1import { $remote } from "alepha/server/links"; 2import { $serviceAccount } from "alepha/security"; 3 4class Gateway { 5 sa = $serviceAccount({ 6 oauth2: { 7 url: "https://auth.internal/oauth2/token", 8 clientId: "gateway", 9 clientSecret: "your-client-secret",10 },11 });12 13 payments = $remote({14 url: "https://payments.internal",15 serviceAccount: this.sa,16 });17}
#Proxying
Set proxy: true to expose the remote service's endpoints through the current server:
1payments = $remote({2 url: "https://payments.internal",3 proxy: true,4});5// Remote endpoints are now accessible via this server
This is useful when you have a backend-for-frontend (BFF) pattern and want to aggregate multiple services under a single API.
proxy also accepts an object form - proxy: { noInternal: true } makes the remote reachable only through the proxy, not via internal $client calls - and $remote takes a name to label the link (defaults to the class member's name).
#Browser Usage
In React, use useClient<T>() to call server actions from the browser:
1import { useClient } from "alepha/react"; 2 3function ProductPage() { 4 const api = useClient<ProductController>(); 5 6 const loadProduct = async (id: string) => { 7 const product = await api.getProduct({ params: { id } }); 8 // product is fully typed 9 };10}
In the browser, all calls go through HTTP. During SSR, local actions are called directly.
#How Links Work
The LinkProvider maintains a registry of all available actions (local and remote). When a $client proxy is accessed:
- It looks up the action by name in the link registry.
- If the action has a local handler (same process), it calls the handler directly.
- If the action is remote (has a
host), it makes an HTTP request viaHttpClient. - Authorization headers from the current request context are forwarded automatically.
The proxy is built using JavaScript Proxy, so property access is intercepted at runtime and mapped to link lookups.