Hyperlinkv0.9.0-beta.0
DraftMigration checklist — 3/6
  • Current API — no legacy surface
  • LSP code previews
  • Clean example types
  • Verified examples
  • Follows the docs standards
  • Owner-reviewed

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 inProcess = Hyperlink.layer(Jobs, jobsImpl)

program.pipe(Effect.provide(inProcess)) // `yield* Jobs` runs jobsImpl locally

Serving 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 every Hyperlink.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 app

Dependencies on the server

A HyperService may depend on other Effect services, including other HyperServices. Hyperlink.serve / serveRemote, the engine forms (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

ServerClientPeers
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) / nPipeunix(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.

Edit this page on GitHub