Hyperlinkv0.8.0-beta.28

Hyperlink for Effect

Build cross-runtime Services on Effect.

An Effect Service lives inside one runtime. A Hyperlink Service doesnt: define it once, run it on one runtime, and call it from another over RPC, with the same typed Handle.

A real app runs as more than one runtime — a worker draining a queue here, a scheduler filling it there. Wiring those together normally means one side owns a Hyperlink and the others reach it through a hand-rolled HTTP client. Cross-runtime Services drop that: every Hyperlink is reached with the same typed Handle, wherever it runs.

Here are two Hyperlinks — a queue and a scheduled process — on two runtimes, working together.

// two resources, defined once
class Emails extends QueueHyperlink.Tag<Emails>()("app/Emails", EmailJob) {} // a queue of EmailJob
class Digest extends Process.Tag<Digest>()("app/Digest") {}                 // a scheduled process

Node.httpServer(serve) is platform-agnostic — it just needs an HTTP server provided, and that provide is where you pick your runtime. Define it once as a small helper; swapping NodeHttpServer for Bun, Deno, or an edge runtime is the only line that changes:

// your app, once — the single place that names a platform (data-last, so it pipes)
const nodeServer = (port: number) => <A, E, R>(resource: Layer.Layer<A, E, R>) =>
  Node.httpServer(resource).pipe(
    Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),
  )

Now the worker runtime is one pipe — QueueHyperlink.serve gives Emails its worker (the effect that drains each job), piped onto port 3001:

const worker = QueueHyperlink
  .serve(Emails, { effect: sendEmail })
  .pipe(nodeServer(3001))
// worker: Layer — provide it to a runtime to run the queue on :3001

The scheduler runtime runs Digest every hour, and each run enqueues into Emails — a queue that lives on the other runtime, reached by port:

const scheduler = Process.layer(Digest, {
  effect: Effect.gen(function* () {
    const emails = yield* Emails            // emails: the Emails handle (here, an RPC client)
    const email = yield* nextEmail          // email: EmailJob
    yield* emails.add(email)                // add(email: EmailJob): Effect<void>
  }),
  polling: Polling.spaced(Duration.hours(1)),
}).pipe(Layer.provide(Hyperlink.connect(Emails, Hyperlink.protocolHttp(3001))))
// scheduler: Layer — the scheduler runtime

Digest runs on the scheduler, Emails on the worker — yet inside the process, yield* Emails and emails.add(…) read exactly as if the two shared one process. Two Hyperlinks, two runtimes, one program. Move a runtime to another machine and only its port becomes a url — nothing else changes.

Operate them live

A cross-runtime Service isnt just callable across runtimes — its operable across them. The same Handle that enqueues also controls and observes, so you steer and inspect the workers queue from anywhere its reached:

const emails = yield* Emails            // emails: the Emails handle — local OR an RPC client, same type

yield* emails.pause                     // pause: Effect<void> — stop draining, at runtime
const depth = yield* emails.size.get    // depth: number — how many are waiting, right now
yield* emails.events.pipe(Stream.runForEach(onChange)) // events: Stream<QueueEvent> — every change, live

And it comes with dashboards over the same Tag — a pm CLI, a TUI, and a web dashboard — each reading the Hyperlink without ever touching its Implementation.

Working with peers

The same Tag also lets a Hyperlink reach its peers — its own other instances — and coordinate with them. Take sessions sharded across droplets: each Node holds the entries it owns, and a lookup for someone elses session is forwarded to the Node that owns it. ShardMap is that pattern as a Hyperlink factory — schemas on the Tag, routed ops, leaf shards, fleet sizes.

class Sessions extends ShardMap.Tag<Sessions>()("app/Sessions", {
  key: SessionId,
  value: Session,
  keyOf: (s) => s.id,
}).pipe(
  Hyperlink.distributed([DropletEast, DropletWest, DropletCentral]),
) {}

Serve a droplet with the mesh discharge — local shard + peer clients from one materialization:

const east = ShardMap.serve(Sessions).pipe(
  Layer.provide(Hyperlink.peersLayer(Sessions, DropletEast)),
  nodeServer(3001),
)

From any Node, a caller just asks — ownership and the cross-Node hop stay inside the Hyperlink:

const program = Effect.gen(function* () {
  const sessions = yield* Sessions
  const session = yield* sessions.get(id) // Option<Session> — from whoever owns it
})

An unreachable owner degrades to a miss instead of blocking. Every instance an equal — reached, and reaching others, through the same Tag.

Build your own

Everything so far — Emails, Digest, Sessions — is built on one primitive you use directly. A Hyperlink is a Contract plus an Implementation, and its first-class, not an escape hatch.

Describe the Contract — methods and their schemas:

class Counter extends Hyperlink.Tag<Counter>()("app/Counter", {
  value: Hyperlink.ref(Schema.Number),          // an observable value — get + live changes
  increment: Hyperlink.effectFn({ by: Schema.Number }),
  reset: Hyperlink.effect(Schema.Void),
}) {}

Give it an Implementation:

const counterImpl = Effect.gen(function* () {
  const ref = yield* SubscriptionRef.make(0)
  return {
    value: Hyperlink.subscribable(ref),                    // surface the ref as the observable field
    increment: ({ by }: { by: number }) => SubscriptionRef.update(ref, (n) => n + by),
    reset: SubscriptionRef.set(ref, 0),
  }
})

Thats it — its now a cross-runtime Service like any built-in. The same Tag, provided the same three ways:

Hyperlink.layer(Counter, counterImpl)                        // in-process
Hyperlink.serve(Counter, counterImpl).pipe(nodeServer(4000)) // served over RPC
Hyperlink.connect(Counter, Hyperlink.protocolHttp(4000))                          // reached from another runtime

And it gets the rest for free — the live value, runtime control, and a slot in the pm CLI, TUI, and web dashboards — because its the same kind of thing Emails is.

The included types

You dont start from scratch, either — the types you reach for most ship ready-made, each a cross-runtime Service you use like an Effect primitive:

  • Long-running processes (Process) — continuous or recurring work: a polling cadence, arm/disarm schedule windows, execution history, and more.

  • Queue (QueueHyperlink) — a priority work queue: enqueue items, workers drain them with dedup, retry, and concurrency control; durable when you provide a store.

  • Shard map (ShardMap) — partitioned key/value across a fleet: routed get / put / delete, leaf shards, and fleet size folds via peers.

Edit this page on GitHub