Hyperlinkv0.9.0-beta.0

Hyperlink for Effect

Define once. Run anywhere. yield* everywhere.

An Effect Service lives in one runtime. A Hyperlink Service is still a Service, same Tag, same yield*, but its Contract is schema-typed, so the seam can sit between processes, not just modules. You define it once; you decide later whether it runs in-process, on another core, or across the network. The call site does not change.

What you yield* is a typed Handle: call methods, observe live state, steer the service at runtime. Local and remote are the same type. Change the Contract and TypeScript flags every caller in every process that imports the Tag. One surface.

The rest of this page is that idea under load: two runtimes sharing a queue, the same Handle operating it live, building your own HyperService, then peers across a fleet.

Two runtimes, one program

A worker drains a queue; a scheduler fills it. Two runtimes, one Tag, no hand-rolled client on the scheduler side.

Define two HyperServices once, a priority queue and a scheduled daemon (included tools, used here as the demo):

class Emails extends WorkPool.Tag<Emails>()("app/Emails", { payload: EmailJob }) {}
class Digest extends Daemon.Tag<Digest>()("app/Digest") {}

Same-machine, nameless: Node.unix mints a Node when you dont pass one (no Node.Tag, no path, no port). Discovery is built in:

const worker = Node.unix([
  WorkPool.serve(Emails, { effect: sendEmail }),
])

The scheduler dials the same Tag, still yield* Emails:

const scheduler = Daemon.layer(Digest, {
  effect: Effect.gen(function* () {
    const emails = yield* Emails   // same Handle type as local
    const email = yield* nextEmail
    yield* emails.add(email)
  }),
  polling: Polling.spaced(Duration.hours(1)),
}).pipe(Layer.provide(Hyperlink.unix(Emails)))

Digest runs on the scheduler, Emails on the worker, yet emails.add(…) looks like one process. Two HyperServices, two runtimes, one program. (Named Node: Node.unix(Worker, …) pairs with Hyperlink.unix(Worker); nameless: Node.unix([serve…]) pairs with Hyperlink.unix(Tag).)

When you need another machine (or a browser), step up to HTTP. Same worker, same Tag, only the listen changes:

const worker = Node.http(
  WorkPool.serve(Emails, { effect: sendEmail }),
  3001,
)

The scheduler dials with Hyperlink.connect(Emails, Hyperlink.protocolHttp(3001)). Move a runtime to another machine and only the address changes. Day to day, prefer Node.http / Node.ws; the full Layer vocabulary (including escape hatches) is in Managing Layers.

The same Handle steers it

Callable across runtimes is half the product. The Handle is also operable across them (pause, depth, live events) from anywhere the Tag is reached:

const emails = yield* Emails            // local OR remote: same type

yield* emails.pause                     // stop draining, at runtime
const depth = yield* emails.size.get    // how many waiting, right now
yield* emails.events.pipe(Stream.runForEach(onChange))

Dashboards ride the same Tag, a CLI, a TUI, and a web dashboard, without touching the Implementation.

Build your own

Emails and Digest are not special cases. Every Hyperlink Service is a Contract plus an Implementation. You use that primitive directly:

class Counter extends Hyperlink.Tag<Counter>()("app/Counter", {
  value: Hyperlink.ref(Schema.Number),
  increment: Hyperlink.effectFn({ by: Schema.Number }),
  reset: Hyperlink.effect(Schema.Void),
}) {}
const counterImpl = Effect.gen(function* () {
  const ref = yield* SubscriptionRef.make(0)
  return {
    value: Hyperlink.subscribable(ref),
    increment: ({ by }: { by: number }) => SubscriptionRef.update(ref, (n) => n + by),
    reset: SubscriptionRef.set(ref, 0),
  }
})

Same Tag, three placements, in-process, served, or connected:

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

It gets operability and dashboard slots for free, because it is the same kind of thing Emails is. Walk through this end to end in Creating a Hyperlink Service.

Working with peers

The same Tag can reach its peers, other instances of itself, and coordinate. Sessions sharded across droplets: each Node holds what it owns; a lookup for someone elses session is forwarded to the owner. ShardMap is that pattern as an included HyperService factory:

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

Serve a droplet, local shard + peer clients from one materialization:

const east = Node.http([ShardMap.serve(Sessions)], 3001).pipe(
  Layer.provide(Hyperlink.peersLayer(Sessions, DropletEast)),
)

From any Node, a caller reads through the Tag; ownership and the hop stay inside the HyperService:

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 is an equal: reached, and reaching others, through the same Tag.

Building your own is the focus. The package also ships a few included Hyperlink Services you can drop in when you need them:

  • WorkPool: priority work queue (enqueue, drain, dedup, retry, concurrency)

  • Daemon: continuous or recurring work (polling, schedules, run history)

  • ShardMap: partitioned key/value across a fleet, with peer routing

  • Gate · Telemetry · FleetHealth: concurrency gates and glass over the mesh

Edit this page on GitHub