Managing Layers
A Hyperlink Service is defined once: a Tag with a Contract, and an Implementation behind it. Where it runs (and how you reach it) is decided entirely by the Layer you provide. The code that uses it never changes. yield* Tag reads the same whether the HyperService runs in this process, is served over RPC, or is a client to one running elsewhere.
Core Concepts covered that idea. This page is the Layer vocabulary: in-process, served, remote, or across a fleet. Creating a Hyperlink Service builds one Tag end to end when youre ready.
The Tag is fixed. The Layer varies. Swap in-process for remote at the composition root; leave the consuming code alone.
Running in-process
Run the implementation in the current runtime:
const const inProcess: Layer.Layer<
Jobs | Hyperlink.Local<Jobs>,
never,
never
>
const inProcess: {
build: (memoMap: MemoMap, scope: Scope.Scope) => Effect<Context.Context<Jobs | Local<Jobs>>, never, never>;
pipe: { <A>(this: A): A; <A, B = never>(this: A, ab: (_: A) => B): B; <A, B = never, C = never>(this: A, ab: (_: A) => B, bc: (_: B) => C): C; <A, B = never, C = never, D = never>(this: A, ab: (_: A) => B, bc: (_: B) => C, cd: (_: C) => D): D; <…;
}
inProcess = import HyperlinkHyperlink.layer<Jobs, {
readonly run: Hyperlink.Method<undefined, Schema.Void, Schema.Never, false, Hyperlink.MethodAnnotations, Hyperlink.Derive>;
}, never>(tag: Hyperlink.HyperlinkTag<Jobs, {
readonly run: Hyperlink.Method<undefined, Schema.Void, Schema.Never, false, Hyperlink.MethodAnnotations, Hyperlink.Derive>;
}, {
readonly run: Effect.Effect<void, never, never>;
}>, impl: Effect.Effect<Hyperlink.ImplWithDefaultOverrides<{
readonly run: Hyperlink.Method<undefined, Schema.Void, Schema.Never, false, Hyperlink.MethodAnnotations, Hyperlink.Derive>;
}>, never, never>): Layer.Layer<...> (+3 overloads)
export layer
layer(class Jobsclass Jobs {
key: Identifier;
Service: {
run: Effect.Effect<void, never, never>;
};
description: string | undefined;
of: (this: void, self: { readonly run: Effect.Effect<void, never, never> }) => { readonly run: Effect.Effect<void, never, never> };
context: (self: { readonly run: Effect.Effect<void, never, never> }) => Context<Jobs>;
use: (f: (service: { readonly run: Effect.Effect<void, never, never> }) => Effect.Effect<A, E, R>) => Effect.Effect<A, E, Jobs | R>;
useSync: (f: (service: { readonly run: Effect.Effect<void, never, never> }) => A) => Effect.Effect<A, never, Jobs>;
Identifier: Identifier;
stack: string | undefined;
pipe: { <A>(this: A): A; <A, B = never>(this: A, ab: (_: A) => B): B; <A, B = never, C = never>(this: A, ab: (_: A) => B, bc: (_: B) => C): C; <A, B = never, C = never, D = never>(this: A, ab: (_: A) => B, bc: (_: B) => C, cd: (_: C) => D): D; <…;
toString: () => string;
toJSON: () => unknown;
}
Jobs, const jobsImpl: Effect.Effect<{
readonly run: Effect.Effect<void>
}>
const jobsImpl: {
pipe: { <A>(this: A): A; <A, B = never>(this: A, ab: (_: A) => B): B; <A, B = never, C = never>(this: A, ab: (_: A) => B, bc: (_: B) => C): C; <A, B = never, C = never, D = never>(this: A, ab: (_: A) => B, bc: (_: B) => C, cd: (_: C) => D): D; <…;
toString: () => string;
toJSON: () => unknown;
}
jobsImpl)
const program: Effect.Effect<
void,
never,
Jobs
>
const program: {
pipe: { <A>(this: A): A; <A, B = never>(this: A, ab: (_: A) => B): B; <A, B = never, C = never>(this: A, ab: (_: A) => B, bc: (_: B) => C): C; <A, B = never, C = never, D = never>(this: A, ab: (_: A) => B, bc: (_: B) => C, cd: (_: C) => D): D; <…;
toString: () => string;
toJSON: () => unknown;
}
program.Pipeable.pipe<Effect.Effect<void, never, Jobs>, Effect.Effect<void, never, never>>(this: Effect.Effect<void, never, Jobs>, ab: (_: Effect.Effect<void, never, Jobs>) => Effect.Effect<void, never, never>): Effect.Effect<void, never, never> (+21 overloads)pipe(import EffectEffect.const provide: {
<
Layers extends [
Layer.Any,
...Array<Layer.Any>
]
>(
layers: Layers,
options?:
| { readonly local?: boolean | undefined }
| undefined
): <A, E, R>(
self: Effect<A, E, R>
) => Effect<
A,
E | Layer.Error<Layers[number]>,
| Layer.Services<Layers[number]>
| Exclude<R, Layer.Success<Layers[number]>>
>
<ROut, E2, RIn>(
layer: Layer.Layer<ROut, E2, RIn>,
options?:
| { readonly local?: boolean | undefined }
| undefined
): <A, E, R>(
self: Effect<A, E, R>
) => Effect<A, E | E2, RIn | Exclude<R, ROut>>
<R2>(context: Context.Context<R2>): <A, E, R>(
self: Effect<A, E, R>
) => Effect<A, E, Exclude<R, R2>>
<
A,
E,
R,
Layers extends [
Layer.Any,
...Array<Layer.Any>
]
>(
self: Effect<A, E, R>,
layers: Layers,
options?:
| { readonly local?: boolean | undefined }
| undefined
): Effect<
A,
E | Layer.Error<Layers[number]>,
| Layer.Services<Layers[number]>
| Exclude<R, Layer.Success<Layers[number]>>
>
<A, E, R, ROut, E2, RIn>(
self: Effect<A, E, R>,
layer: Layer.Layer<ROut, E2, RIn>,
options?:
| { readonly local?: boolean | undefined }
| undefined
): Effect<A, E | E2, RIn | Exclude<R, ROut>>
<A, E, R, R2>(
self: Effect<A, E, R>,
context: Context.Context<R2>
): Effect<A, E, Exclude<R, R2>>
}
Provides dependencies to an effect using layers or a context. Use options.local
to build the layer every time; by default, layers are shared between provide
calls.
Example (Providing dependencies with a layer)
import { Context, Effect, Layer } from "effect"
interface Database {
readonly query: (sql: string) => Effect.Effect<string>
}
const Database = Context.Service<Database>("Database")
const DatabaseLive = Layer.succeed(Database)({
query: Effect.fn("Database.query")((sql: string) => Effect.succeed(`Result for: ${sql}`))
})
const program = Effect.gen(function*() {
const db = yield* Database
return yield* db.query("SELECT * FROM users")
})
const provided = Effect.provide(program, DatabaseLive)
Effect.runPromise(provided).then(console.log)
// Output: "Result for: SELECT * FROM users"
provide(const inProcess: Layer.Layer<
Jobs | Hyperlink.Local<Jobs>,
never,
never
>
const inProcess: {
build: (memoMap: MemoMap, scope: Scope.Scope) => Effect<Context.Context<Jobs | Local<Jobs>>, never, never>;
pipe: { <A>(this: A): A; <A, B = never>(this: A, ab: (_: A) => B): B; <A, B = never, C = never>(this: A, ab: (_: A) => B, bc: (_: B) => C): C; <A, B = never, C = never, D = never>(this: A, ab: (_: A) => B, bc: (_: B) => C, cd: (_: C) => D): D; <…;
}
inProcess)) // `yield* Jobs` runs jobsImpl locallyServing over the network
To expose a HyperService over RPC, pick a protocol listen. Node.listen is the neutral spine (no transport bind). Day to day you call one of four siblings that share its overload family: Node.http, Node.ws, Node.unix, Node.nPipe. Toggle the wire; every form stays the same:
// Tag + Implementation
Node.http(Jobs, jobsImpl) // nameless, ephemeral port, Lookup Soft-baked
Node.http(Jobs, jobsImpl, 3000) // or ":3000" or "http://127.0.0.1:3000/rpc"
Node.http(Jobs, jobsImpl, Worker) // named Node Tag
// Serve layers on one /rpc
Node.http(Hyperlink.serve(Jobs, jobsImpl), 3000) // one serve, no array
Node.http( // several HyperServices
[
Hyperlink.serve(Jobs, jobsImpl),
Hyperlink.serve(Emails, emailsImpl),
],
3000,
)
Node.http(Worker, Hyperlink.serve(Jobs, jobsImpl), 3000) // named Node + serve(s)Pick the sibling that matches the deployment:
Node.http: RPC over HTTP POST. Default for servers, CLIs, and a handful of streams.Node.ws: one multiplexed WebSocket per client. Prefer for browsers: many live streams starve under HTTP/1.1s ~6 connections per origin.Node.unix/Node.nPipe: same-machine IPC (Unix socket / Windows named pipe).
Omit the address for an ephemeral bind. Pass a port, ":port", or url for HTTP/WebSocket; pass a path for IPC. Object form ({ port, url, unlink, … }) remains when you need more than the address.
Nameless listens Soft-bake Lookup.layer when identity is not already in the environment. Override with Layer.provide(Lookup.layerOptions({ path })) (or Lookup.client / Lookup.layerNode) when it is.
Every listen auto-mounts Node.status and /health.
Node.httpServer / Node.wsServer are escape hatches for a custom platform bind (non-loopback host, your own HttpServer layer, dual-protocol on one process). Prefer Node.http / Node.ws for the common case.
Connecting a client
Servings mirror: a remote HyperService needs the client Handle for the Tag and a transport to the Node that runs it. A Node is a named endpoint that carries the address. Nameless listens stamp that address for you; a Node.Tag makes it self-describing in source.
// Same bare port as Node.http(Jobs, jobsImpl, 3000)
program.pipe(Effect.provide(Hyperlink.connect(Jobs, Hyperlink.protocolHttp(3000))))
// Or declare a node with that port and share the transport across clients
class JobsNode extends Node.Tag<JobsNode>()("jobs", 3000) {}
const transport = Hyperlink.http(JobsNode)
const appLayer = Layer.mergeAll(
transport,
Hyperlink.client(Jobs).pipe(Layer.provide(transport)),
)Two client families:
Hyperlink.connect(tag, protocol): you pass the wire (protocolHttp/protocolWebsocket/protocolIpc). No node required. Browser-safe: only the protocol you pass is bundled.Hyperlink.http/ws/unix/nPipe(node): batteries included. The wire is in the name; the node supplies the address. Share that layer with everyHyperlink.client(tag)on the same connection.
Bare ports resolve through HYPERLINK_CLIENT_HOST (default localhost), so protocolHttp(3000) / protocolWebsocket(3000) match a listen on 3000. Prefer WebSocket in the browser (Hyperlink.ws(node) or connect(tag, protocolWebsocket(port))); HTTP starves at the ~6-connection cap.
Client and server must speak the same wire. A ws client cannot talk to an http server.
Those shortcuts sit on one seam: a transport is an RpcClient.Protocol layer, and Hyperlink.layerProtocol(protocol) makes it the ambient client wire that nodeless Hyperlink.client(tag) calls (and peer folds) read. You rarely reach for layerProtocol directly; it is there for custom serialization or a hand-rolled transport.
Effect.provide(app, Hyperlink.layerProtocol(Hyperlink.protocolWebsocket(3000))) // one wire, whole appDependencies on the server
A HyperService may depend on other Effect services, including other HyperServices. Hyperlink.serve / serveRemote, the included HyperService serves (WorkPool.serve, Daemon.serve, Gate.serve), and the protocol listens preserve that requirement R. They do not close dependencies at the server boundary. Composition matches Effects Layer.mergeAll: list the serve layers, then Layer.provide what they need outside.
Provide a shared dependency once onto the whole server:
Node.http(
[
WorkPool.serve(Emails, { effect: sendEmail }),
Daemon.serve(Digest, { effect: fillQueue }),
],
3000,
).pipe(Layer.provide(Db.layer))When two HyperServices on one /rpc need mutually exclusive implementations of the same dependency, provide onto each serve layer:
Node.http(
[
Hyperlink.serveRemote(Matches, impl).pipe(Layer.provide(plainHandlers)),
Hyperlink.serveRemote(Import, impl).pipe(Layer.provide(hookedHandlers)),
],
3000,
)Hyperlink.provide(dep, [serveA, serveB]) is sugar for these HyperServices, on this dependency. Engine tags use WorkPool.serve / Daemon.serve / Gate.serve (they also run the worker or tick); Hyperlink.serve / serveRemote only mount handlers. See examples/serve-per-hyperlink-deps.ts.
Fleets and peers
When a HyperService runs across many Nodes and its instances coordinate (see Fleets & Peers), server-to-server peer calls have their own transport. Hyperlink.peersLayer(tag, ThisNode) discharges the mesh. Peer dials default to HTTP, so a fleet whose Nodes serve WebSocket must move the peer mesh onto WebSocket too: one knob per Node.
Node.ws([Hyperlink.serve(WorkerPool, poolImpl)], 3000).pipe(
Layer.provide(Hyperlink.peersLayer(WorkerPool, ThisNode)),
Layer.provide(Hyperlink.layerPeerProtocol(Hyperlink.protocolWebsocket)), // peers speak ws too
)Without it, a websocket-served fleets fold (fleetActive, activeByNode, …) reaches a ws-only /rpc over HTTP and 404s, silently collapsing to own-node values. Peer urls stay on the nodes; layerPeerProtocol only chooses how to dial them.
Picking the wire
| Server | Client | Peers | |
|---|---|---|---|
| HTTP (default) | Node.http(tag, impl, 3000) | connect(tag, protocolHttp(port)) / http(node) | default |
| WebSocket (browser, many streams) | Node.ws(tag, impl, 3000) | ws(node) / protocolWebsocket(port) | layerPeerProtocol(protocolWebsocket) |
| IPC (same machine) | Node.unix(tag, impl) / nPipe | unix(node) / nPipe(node) / protocolIpc |
Pick per deployment, not per call. Every side of one wire must agree. In-process HyperServices (Hyperlink.layer) have no transport at all.
Next
Build one Tag end to end in Creating a Hyperlink Service, or go deeper on multi-node coordination in Fleets & Peers.