# Liminal > Effect x Actors ## Quickstart Liminal is a typed actor framework built on Effect. A minimal app has five pieces: 1. a `Client` protocol shared by browser and server 2. an `Actor` that implements that protocol 3. hydration logic for newly connected sockets 4. method handlers that run inside actor context 5. a runtime host, such as Cloudflare Workers and Durable Objects This quickstart uses tic-tac-toe as the running example. Each step links to the focused concept page with the full code. ### Define the client protocol Create a `Client` with state, external method schemas, and event schemas. This is the shared wire contract for browser and server. Read: [Clients](/core/clients), [Methods](/core/methods), and [Events](/core/events). ### Define the actor Create an `Actor` that points at the client protocol and defines the durable actor name plus per-client attachments. Read: [Actors](/core/actors) and [Actor Context](/core/actor-context). ### Add lifecycle and handlers Use `hydrate` for newly upgraded sockets, especially initial state. Implement external methods with `Actor.handler(...)`. Read: [Lifecycle](/core/lifecycle), [Actor Handlers](/core/actor-handlers), and [Client Handles](/core/client-handles). ### Host on Cloudflare Cloudflare hosting defines a Durable Object namespace and a separate actor runtime, then upgrades an HTTP route into a bound actor instance. Read: [Actor Namespace](/core/actor-namespace), [HTTP Upgrades](/actor-routing/upgrades), and [Worker Entrypoint](/actor-routing/worker-entrypoint). ### Connect from the browser Provide the protocol with `Client.layerSocket(...)`, reducers, and `BrowserSocket.layerWebSocketConstructor`. Once the layer is in scope, use `Client.fn(...)` for calls, `Client.events` for events, and `Client.state` for hydrated state. Read: [Client Layer](/core/client-layer), [Client Calls](/core/client-calls), [Events](/core/events), and [Client State](/state/client-state). ## Audition `Audition` combines multiple clients into one state stream, call surface, and event stream. For larger apps, this avoids forcing the UI to swap service references manually when flows shift between contexts, such as moving from a lobby into a chat room. ### Merge clients ```ts import { Audition } from "liminal" import { ChatClient } from "./chat/ChatClient.ts" import { LobbyClient } from "./lobby/LobbyClient.ts" export const audition = Audition.empty.pipe(Audition.add(LobbyClient), Audition.add(ChatClient)) ``` After that, callers can use one method surface: ```ts Effect.gen(function* () { yield* audition.fn("JoinRoom")({ roomId }) yield* audition.fn("SendMessage")({ content: "Hello!" }) }) ``` The `audition.fn(method)` signature mirrors `Client.fn(method)`. The effect's required services are the union of services for every underlying client in the audition. ### Merge events ```ts const source = audition.events ``` An app-wide view can listen to merged events, or subscribe to `audition.state` for the currently active hydrated state. ```ts const source = audition.events.pipe(Stream.forever) const state = audition.state.pipe(Stream.forever) ``` ### Method resolution order `Audition.add(...)` is ordered. With: ```ts Audition.empty.pipe(Audition.add(LobbyClient), Audition.add(ChatClient)) ``` runtime lookup tries `LobbyClient` first, then falls back to `ChatClient`. Fallback only triggers when the prior client produces an `AuditionError`. Other errors such as `ConnectionError` or `UnresolvedError` propagate directly without trying the next client. If multiple clients expose the same method name, keep the method definitions identical. The type-level merge assumes matching shapes for overlapping method keys. ## Client State Client state is part of the `Client.Service(...)` definition. When a client connects, the actor runtime sends an initial state value during audition. After that, client-side reducers fold incoming events into the current state. There is no separate state service. The client itself exposes the state stream, event stream, call surface, and typed reducer helper. ### Define state on the client ```ts import { Schema as S } from "effect" import { Client } from "liminal" export const Player = S.Literals(["X", "O"]) export const Coordinate = S.Literals([0, 1, 2]) export const Coordinates = S.Tuple([Coordinate, Coordinate]) export class TicTacToeClient extends Client.Service()("examples/TicTacToeClient", { state: { awaitingPartner: S.Boolean, name: Player, }, events: { GameStarted: {}, MoveMade: { player: Player, position: Coordinates, }, GameEnded: { winner: S.optional(Player), }, }, external: { Move: { payload: S.Struct({ position: Coordinates }), success: S.Void, failure: S.Never, }, }, }) {} ``` The `state` record is Effect Schema struct fields. The actor runtime must hydrate a value matching that shape for every new connection. ### Hydrate on connect In a Workerd actor runtime, `hydrate` returns the initial state for the connecting client. ```ts import { Effect } from "effect" import { TicTacToeActor } from "./TicTacToeActor.ts" export default Effect.gen(function* () { const { clients } = yield* TicTacToeActor if (clients.size === 1) { return { awaitingPartner: true, name: "X" as const, } } yield* TicTacToeActor.others.send("GameStarted", {}) return { awaitingPartner: false, name: "O" as const, } }).pipe(Effect.orDie) ``` Hydration is the snapshot. Events sent after hydration are deltas that reducers apply locally. ### Reduce events Reducers live next to the client runtime. `Client.reducer(...)` narrows the event tag and returns the reducer unchanged at runtime. ```ts import { Effect } from "effect" import { TicTacToeClient } from "./TicTacToeClient.ts" export const GameStarted = TicTacToeClient.reducer( "GameStarted", () => ({ name }) => Effect.succeed({ name, awaitingPartner: false }), ) export const MoveMade = TicTacToeClient.reducer("MoveMade", () => (state) => Effect.succeed(state)) export const GameEnded = TicTacToeClient.reducer("GameEnded", () => (state) => Effect.succeed(state)) ``` Each reducer receives the event first and the current state second. Return the next state to publish an update, or return `void` to leave the current state unchanged. ### Provide reducers Pass the reducer table to `Client.layerSocket(...)`. ```ts import { BrowserSocket } from "@effect/platform-browser" import { Layer } from "effect" import { Atom } from "effect/unstable/reactivity" import { Client } from "liminal" import { TicTacToeClient } from "./TicTacToeClient.ts" import * as reducers from "./reducers.ts" export const runtime = Atom.runtime( Client.layerSocket({ client: TicTacToeClient, url: "/play", replay: { mode: "startup" }, reducers, }).pipe(Layer.provide(BrowserSocket.layerWebSocketConstructor)), ) ``` ### Consume state `TicTacToeClient.state` is a stream of hydrated and reduced state values. ```ts import { runtime } from "./runtime" import { TicTacToeClient } from "./TicTacToeClient.ts" export const stateAtom = runtime.atom(TicTacToeClient.state) ``` Read [Snapshot and Delta Events](/state/snapshot-delta-events) for event design guidance. ## Snapshot and Delta Events Client state depends on a deliberate snapshot-and-delta pattern: 1. `hydrate` returns the initial state during audition. 2. Later handlers emit delta events such as `MoveMade` or `GameEnded`. 3. Client reducers fold deltas into the hydrated state. Without hydration, a reconnecting UI usually needs a separate imperative fetch path. ### Hydrate on connect ```ts export const hydrate = Effect.gen(function* () { const room = yield* loadRoom return { roomId: room.id, members: room.members, messages: room.messages, } }) ``` ### Delta from handlers ```ts export const sendMessage = RoomActor.handler( "SendMessage", Effect.fn(function* ({ content }) { const message = yield* saveMessage(content) yield* RoomActor.all.send("MessageAdded", { message, }) }), ) ``` ### Reduce locally ```ts export const MessageAdded = RoomClient.reducer( "MessageAdded", ({ message }) => (state) => Effect.succeed({ ...state, messages: [...state.messages, message], }), ) ``` This keeps the client state model hydrated before the UI sees it and event-driven afterward. Read [Lifecycle](/core/lifecycle) for `hydrate` and [Client State](/state/client-state) for the reduction loop. ## Browser Setup For Cloudflare and browsers, the simplest setup is Effect's lightweight OTLP exporter from `effect/unstable/observability`. It works with `FetchHttpClient` and does not require the Node OpenTelemetry SDK. ```bash pnpm add effect ``` If you want to use the official OpenTelemetry JavaScript SDK in a browser or Node process, add `@effect/opentelemetry` and the relevant OpenTelemetry packages instead. The rest of this guide uses Effect's built-in OTLP layer because it fits Workers and browser runtimes directly. ### Configure the client runtime Add an OTLP layer to the same runtime that provides your Liminal client. Browsers usually send OTLP through a same-origin proxy. See [Collectors](/instrumentation/collectors) for CORS and credential guidance. ```ts // app/runtime.ts import { BrowserSocket } from "@effect/platform-browser" import { Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { Otlp } from "effect/unstable/observability" import { Atom } from "effect/unstable/reactivity" import { Client } from "liminal" import { TicTacToeClient } from "./TicTacToeClient.ts" import * as State from "./State.ts" const TelemetryLive = Otlp.layerJson({ baseUrl: "/otel", resource: { serviceName: "tic-tac-toe-web", attributes: { "deployment.environment": import.meta.env.MODE, }, }, tracerExportInterval: "1 second", loggerExportInterval: "1 second", }).pipe(Layer.provide(FetchHttpClient.layer)) export const runtime = Atom.runtime( State.layer.pipe( Layer.provideMerge( Client.layerSocket({ client: TicTacToeClient, url: "/play", replay: { mode: "startup" }, }).pipe(Layer.provide(BrowserSocket.layerWebSocketConstructor)), ), Layer.provideMerge(TelemetryLive), ), ) ``` That is enough for client acquire/listen/send/call spans, client logs, outbound method-call trace envelopes, and inbound event enqueue spans. Those enqueue spans cover Liminal receiving an event and publishing it into `Client.events`; downstream stream consumers should add their own application spans when needed. ## Cloudflare Setup The Worker runtime and Durable Object actor runtime both need telemetry in their Effect layers. Both runtimes flush the OTLP exporter after Worker fetches and Durable Object fetch/message/close/error callbacks, so short-lived Worker executions can export the spans they just produced. The actor runtime has its own runtime, including after hibernation wakeups, so the telemetry layer should be fully provided with `FetchHttpClient.layer`. ### Define telemetry ```ts // api/TelemetryLive.ts import { Config, Effect, Layer, Redacted } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { Otlp } from "effect/unstable/observability" export const telemetryLive = (serviceName: string) => Otlp.layerJson( Effect.gen(function* () { const token = yield* Config.redacted("OTEL_TOKEN") return { baseUrl: yield* Config.string("OTEL_EXPORTER_OTLP_ENDPOINT"), resource: { serviceName, attributes: { "deployment.environment": "cloudflare", }, }, headers: { authorization: `Bearer ${Redacted.value(token)}`, }, tracerExportInterval: "1 second", loggerExportInterval: "1 second", } }), ).pipe(Layer.provide(FetchHttpClient.layer)) ``` If your collector does not require authentication, omit `OTEL_TOKEN` and `headers`. ### Add telemetry to the Worker prelude ```ts import { telemetryLive } from "./TelemetryLive.ts" export default Worker.make({ handler: ApiLive.pipe(HttpRouter.toHttpEffect, Effect.flatten), prelude: Layer.mergeAll( KvLive, telemetryLive("tic-tac-toe-worker"), TicTacToeNamespace.layer, Assets.layer("ASSETS"), ), }) ``` Read [Worker Entrypoint](/actor-routing/worker-entrypoint) for the full Worker file. Set `OTEL_EXPORTER_OTLP_ENDPOINT` as a Wrangler var and `OTEL_TOKEN` as a secret if your collector requires authentication. See [Collectors](/instrumentation/collectors) for endpoint, proxy, and credential guidance. ### Add telemetry to the actor runtime The actor runtime does not need a separate tracing wrapper. It runs inside the `WorkerdActorRuntime` Effect runtime, and its `prelude` should include telemetry when actor spans and logs should be exported. ```ts prelude: Layer.mergeAll(KvLive, telemetryLive("tic-tac-toe-actor")), ``` Read [Actor Namespace](/core/actor-namespace) for the namespace/runtime split and [Prelude vs Layer](/core/prelude-vs-layer) for the dependency lifetime split. ## Collectors For local development, run any OTLP HTTP collector and point both browser and Worker telemetry at it. The endpoint should accept: * `POST /v1/traces` * `POST /v1/logs` * `POST /v1/metrics` For browser telemetry, configure CORS on the collector or proxy through your app. For Worker telemetry, use Wrangler secrets for authorization headers and avoid putting tokens in client-side code. ### Span names Useful span names: * `effect-workerd.Entry.fetch` * `liminal.Client.acquire` * `liminal.Client.listen` * `liminal.Client.send` * `liminal.Client.fn` * `liminal.Client.enqueue-event` * `liminal.ClientDirectory.register` * `liminal.ClientDirectory.unregister` * `liminal.Actor.all.send` * `liminal.Actor.all.disconnect` * `liminal.Actor.others.send` * `liminal.Actor.others.disconnect` * `liminal.workerd.WorkerdActorNamespace.upgrade` * `liminal.workerd.WorkerdActorRuntime.hydrate` * `liminal.workerd.WorkerdActorRuntime.fetch` * `liminal.workerd.WorkerdActorRuntime.socket-message` * `liminal.workerd.WorkerdActorRuntime.socket-close` * `liminal.workerd.WorkerdActorRuntime.socket-error` * `liminal.workerd.WorkerdActorRuntime.handler` * `liminal.workerd.WorkerdActorRuntime.send` * `liminal.workerd.WorkerdActorRuntime.disconnect` * `liminal.workerd.WorkerdActorRuntime.fn-internal` ### Attributes Useful attributes: * `_tag`: method or event tag * `client`: Liminal client service id * `liminal.session.id`: durable socket-session id, persisted through hibernation * `liminal.link`: `session` or `transport` * `liminal.transport`: `websocket`, `worker`, or `durable-object-fetch` * `package`: `liminal` or `effect-workerd` * `module`: instrumentation source module ## Instrumentation Liminal is instrumented with Effect spans and logs at the runtime boundaries where a client, transport, Worker, Durable Object, or actor handler hands work to the next layer. If you provide an Effect OpenTelemetry layer, those built-in spans and logs are exported without changing your actor code. ### Built-in coverage Liminal uses Effect's tracing model. Every span is a normal Effect span, every diagnostic log is an Effect log, and propagation works through Effect's `Tracer` service. The built-in instrumentation covers Worker requests, Durable Object upgrades, Durable Object fetches, socket messages, actor lifecycle hooks, method handlers, event sends, client calls, client send/listen loops, and client-side event enqueueing. Method responses complete the original client call under the stored client span; they do not create a separate response span. Read [Collectors](/instrumentation/collectors) for useful span names and attributes. ### Logs Liminal adds structured log annotations through its diagnostic helpers, including package/module fields and operation-specific fields such as method or event tags. When an OTLP logger is installed, Effect log records include the active trace and span identifiers, so logs line up with the surrounding traces in your backend. ### Setup * [Propagation](/instrumentation/propagation) covers trace context across HTTP, WebSockets, and worker messages. * [Session Continuity](/instrumentation/session-continuity) covers hibernatable Cloudflare sockets. * [Trace Story](/instrumentation/trace-story) shows a browser-to-actor call from end to end. * [Browser Setup](/instrumentation/browser-setup) configures the client runtime. * [Cloudflare Setup](/instrumentation/cloudflare-setup) configures Worker and actor runtimes. * [Collectors](/instrumentation/collectors) lists useful span names, attributes, and collector requirements. ## Propagation There are two propagation paths. HTTP boundaries use Effect's `HttpTraceContext` module, which reads and writes standard tracing headers: * W3C `traceparent` * B3 single-header `b3` * B3 multi-header `x-b3-*` WebSocket messages and browser worker-runner messages use a small Liminal trace envelope: ```ts { traceId: string spanId: string sampled: boolean } ``` That envelope appears on protocol messages that need per-message causality: * `F.Payload.trace` for client-to-actor method calls * `Event.trace` for actor-to-client events This mirrors Effect RPC's socket propagation strategy: the sender includes the current span identity in the message envelope, and the receiver turns it back into an external parent span. ## Session Continuity Cloudflare Durable Objects can hibernate. Because an object can sleep between messages, Liminal does not model a WebSocket connection as one long live span. Long-running spans are difficult to end reliably and can produce oversized traces. ### Persisted session metadata Instead, Liminal persists session metadata in the WebSocket attachment: ```ts { attachments: unknown sessionId: string trace: { traceId: string spanId: string sampled: boolean } } ``` After hibernation, the Durable Object hydrates that metadata and links the registration work back to the stored session trace. Short spans still represent the actual work: message receive, handler execution, event send, close, error, and hydration. The session id is attached to actor-side spans when a socket is known. Handler, event-send, on-connect, and hydration paths also link back to the stored session trace. ### Useful attributes and links Look for these attributes and links in your telemetry backend: * `liminal.session.id`: stable per connected socket session * `liminal.link = session`: link from handler, event-send, on-connect, and hydration spans back to the session trace * `liminal.link = transport`: link from handler spans back to the local message-receive span * `liminal.transport = websocket` or `worker` ## Trace Story A typical browser-to-actor method call produces a connected trace across client, transport, actor, and event handling. ### Browser to actor 1. The browser calls `client.fn("Move")(...)`. 2. `Client.fn` creates a client span. 3. The client encodes that span into `F.Payload.trace`. 4. The Durable Object receives the WebSocket message and creates a `socket-message` transport span. 5. The actor handler span is parented to `F.Payload.trace` and linked to the local transport span. 6. If the handler emits an event, `event.send` creates a producer span and attaches `Event.trace`. 7. The browser receives the event and creates an `event.enqueue` consumer span parented to `Event.trace`. 8. The response listener resolves the original call under the stored client span. There is no separate response span. `event.enqueue` covers Liminal publishing the event into the client's internal PubSub. It does not implicitly parent arbitrary `Client.events` stream consumers; app-level event processing should create its own spans if that distinction matters. ### HTTP upgrade The HTTP upgrade path also participates: 1. `Worker.fetch` extracts incoming `traceparent` or B3 headers and creates a server span. 2. `WorkerdActorNamespace.upgrade` runs under that span. 3. The namespace forwards trace headers to `stub.fetch`. 4. The actor runtime `fetch` callback extracts those headers inside the Durable Object and creates the Durable Object-side server span. It also creates the socket session id and persists the session trace in the WebSocket attachment. Read [Propagation](/instrumentation/propagation) for the trace envelope details. ## Actor Context Handlers and lifecycle hooks read actor runtime context by yielding the actor tag. ```ts Effect.gen(function* () { const { name, clients, currentClient } = yield* TicTacToeActor const { player } = yield* currentClient.attachments }) ``` The context includes: * `name`: the durable actor name for the current actor instance * `clients`: all connected client handles * `currentClient`: the client handle for the current invocation ### Request-local services Many handlers want convenient access to values derived from actor context. On Cloudflare, use the runtime `layer` to derive request-local services like `CurrentUserId` or `Authorization` from the actor name and current client attachments. Read [Prelude vs Layer](/core/prelude-vs-layer) for the full Cloudflare pattern. ## Actor Handlers Handlers implement client methods inside actor context. The simplest handler pattern is actor-bound. ```ts import { Effect } from "effect" import { TicTacToeActor } from "./TicTacToeActor.ts" export const handleMove = TicTacToeActor.handler( "Move", Effect.fn(function* ({ position }) { const { currentClient, name: gameId } = yield* TicTacToeActor const { player } = yield* currentClient.attachments yield* TicTacToeActor.all.send("MoveMade", { player, position }) yield* saveMove(gameId, player, position) }), ) ``` `Actor.handler("Method", ...)` is a typed pass-through. It returns the handler unchanged at runtime but narrows the handler's payload, success, and failure types to the named method. ### Related concepts * [Actor Context](/core/actor-context) covers `yield* ActorTag`. * [Client Handles](/core/client-handles) covers sends, broadcasts, saves, attachments, and disconnects. * [Methods](/core/methods) covers shared `handler(...)` implementations. ## Actor Namespace `WorkerdActorNamespace` is the Worker-side handle for a Cloudflare Durable Object actor. The namespace is separate from the actor runtime. It binds a Cloudflare Durable Object namespace, validates upgrade requests, and exposes handles for upgrading clients or calling internal methods between Durable Object instances. ```ts import { WorkerdActorNamespace } from "liminal/workerd" import { TicTacToeActor } from "./TicTacToeActor.ts" export class TicTacToeNamespace extends WorkerdActorNamespace.Service()("TicTacToeNamespace", { binding: "TICTACTOE", actor: TicTacToeActor, internal: {}, }) {} ``` ### Fields * `binding`: the Cloudflare Durable Object binding name * `actor`: the Liminal actor definition * `internal`: methods callable through namespace handles from other Durable Object instances or Worker code ### Bind an actor instance Use `Namespace.bind(name)` to get a handle for one Durable Object instance. ```ts const game = TicTacToeNamespace.bind(gameId) yield * game.upgrade({ player: "X" }) yield * game.call("SomeInternalMethod", { value: 1 }) ``` `upgrade(...)` forwards the current request into the Durable Object as a WebSocket upgrade. `call(...)` invokes one of the namespace's internal methods through Durable Object RPC. ### Provide the namespace Use `TicTacToeNamespace.layer` in the Worker runtime. The binding name comes from the namespace definition. ```ts const WorkerLive = TicTacToeNamespace.layer ``` ### Define the runtime The Durable Object class itself comes from `WorkerdActorRuntime.make(...)`. ```ts import { Effect, Layer } from "effect" import { WorkerdActorRuntime } from "liminal/workerd" import Move from "./handleMove.ts" import hydrate from "./hydrate.ts" import { KvLive } from "./KvLive.ts" import { TicTacToeNamespace } from "./TicTacToeNamespace.ts" export class TicTacToeRuntime extends WorkerdActorRuntime.make({ namespace: TicTacToeNamespace, prelude: KvLive, hydrate, onDisconnect: Effect.void, external: { Move }, internal: {}, layer: Layer.empty, hibernation: "5 seconds", }) {} ``` Runtime fields: * `namespace`: the namespace definition this Durable Object implements * `prelude`: long-lived runtime dependencies for the Durable Object instance * `hydrate`: returns initial client state during audition * `external`: handlers for client-callable methods from `Client.external` * `internal`: handlers for namespace-callable methods from `Namespace.internal` * `layer`: short-lived, invocation-scoped dependencies derived from actor context * `onDisconnect`: cleanup or notifications for closed sockets * `hibernation`: optional hibernatable WebSocket timeout (`Duration.Input`) Read [Prelude vs Layer](/core/prelude-vs-layer) for the dependency model. ## Actors Actors are the server-side runtime for a client definition. An actor ties together three things: * a durable name * a client definition * attachment state stored per connected client ```ts import { Schema as S } from "effect" import { Actor } from "liminal" import { Player, TicTacToeClient } from "./TicTacToeClient.ts" export class TicTacToeActor extends Actor.Service()("examples/TicTacToeActor", { client: TicTacToeClient, name: S.String, attachments: { player: Player }, }) {} ``` ### Fields * `client` points at the `Client` definition the actor implements. * `name` is a schema for the actor instance's unique identifier. * `attachments` is a struct-fields record for per-connected-client state. Use `S.String` for an opaque actor id, or a branded schema such as `S.String.pipe(S.brand("GameId"))` if you want the id type to be distinct. ### Name versus attachments Keep the split clear: * `name` identifies the actor instance. * `attachments` identify or describe one connected client. For tic-tac-toe, the name is the game id and attachments record which player (`X` or `O`) the connected socket is playing as. This lets multiple clients connect to the same actor while keeping client-specific context isolated from one another. ## Client Calls `Client.fn(...)` invokes a named external method from the client protocol. ```ts Effect.gen(function* () { yield* TicTacToeClient.fn("Move")({ position: [1, 1], }) }) ``` The method name selects the payload, success, and failure types from the client's `external` table. ### Failure types The effect error type is wider than just the method's domain failure. A call can fail with: * the method's typed `failure` schema * `ConnectionError` if the transport fails * `AuditionError` if the client connects to an actor that does not match its client type * `UnresolvedError` if the underlying transport dies before the call resolves ### Invalidate the session `Client.invalidate` tears down the underlying transport session. The next use re-acquires it. ```ts Effect.gen(function* () { yield* TicTacToeClient.invalidate }) ``` This is useful when application state changes in a way that makes the current transport session stale, such as logout or tenant switching. ## Client Handles Attachments are per-client state. Client handles are the per-socket API actors use to communicate with connected clients. ### Client handle operations Client handles support the core per-socket operations: * `yield* client.attachments`: read per-socket state * `yield* client.send(...)`: emit an event to one client * `yield* client.save(...)`: persist updated attachment state for that client * `yield* client.disconnect`: close that client's socket ### Actor-wide senders Actors add whole-actor senders via two groups: * `TicTacToeActor.all`: every connected client * `TicTacToeActor.others`: every client other than `currentClient` Each exposes the same `send` and `disconnect` shape: * `yield* TicTacToeActor.all.send(...)`: broadcast to every connected client * `yield* TicTacToeActor.all.disconnect`: disconnect every connected client * `yield* TicTacToeActor.others.send(...)`: broadcast to everyone except the calling client * `yield* TicTacToeActor.others.disconnect`: disconnect everyone except the calling client ### Broadcast ```ts Effect.gen(function* () { yield* TicTacToeActor.all.send("GameEnded", { winner: player }) }) ``` ### Selective disconnect ```ts Effect.gen(function* () { const { clients } = yield* TicTacToeActor for (const client of clients) { const { player } = yield* client.attachments if (bannedPlayers.has(player)) { yield* client.disconnect } } }) ``` ### Whole-actor disconnect ```ts Effect.gen(function* () { yield* TicTacToeActor.all.disconnect }) ``` ### Attachments are not actor storage `client.save(...)` updates serialized attachment state for one connected client. That is useful for values like: * the caller's user id * the caller's role in a game * per-socket cursors or last-seen sequence numbers Do not use attachments as whole-actor storage. Persist shared state elsewhere, for example through Effect's `KeyValueStore` interface backed by an R2 bucket. ## Client Layer `Client.layerSocket(...)` turns a `Client` protocol into a live Effect layer backed by a WebSocket. Use it in browser runtimes that connect to a Liminal actor through an HTTP upgrade route. ### Define the layer Provide the client definition, reducers, the socket endpoint, and the platform WebSocket constructor. ```ts import { BrowserSocket } from "@effect/platform-browser" import { Layer } from "effect" import { Client } from "liminal" import { TicTacToeClient } from "./TicTacToeClient.ts" import * as reducers from "./reducers.ts" const TicTacToeClientLive = Client.layerSocket({ client: TicTacToeClient, url: "/play", replay: { mode: "startup" }, reducers, }).pipe(Layer.provide(BrowserSocket.layerWebSocketConstructor)) ``` `layerSocket` accepts: * `client`: the `Client` definition * `reducers`: a reducer table keyed by event tag * `url`: the WebSocket endpoint path, defaulting to `"/"` * `protocols`: one or more extra WebSocket sub-protocols * `replay`: optional event replay configuration * `onConnect`: optional effect that runs with the hydrated state once audition succeeds Replay semantics are covered in [Events](/core/events). `layerSocket` requires a `Socket.WebSocketConstructor` in the environment. In browsers, that usually comes from `BrowserSocket.layerWebSocketConstructor` in `@effect/platform-browser`. ### Use the live client After providing the layer, calls use `Client.fn(...)`, event streams use `Client.events`, and hydrated state uses `Client.state`. Read [Client Calls](/core/client-calls) and [Events](/core/events) for those APIs. ## Clients A `Client` defines the wire contract between browser clients and actors. Everything else in Liminal hangs off that contract: * actors point at a client definition * external handlers implement the methods callable by connected clients * `Audition`s can merge multiple clients into one event stream and call surface * reducers derive local state from events after the initial hydration ### Define a client A client is a named state schema, external method table, and event table. ```ts import { Schema as S } from "effect" import { Client } from "liminal" export const Player = S.Literals(["X", "O"]) export const Coordinate = S.Literals([0, 1, 2]) export const Coordinates = S.Tuple([Coordinate, Coordinate]) export class TicTacToeClient extends Client.Service()("examples/TicTacToeClient", { events: { GameStarted: {}, MoveMade: { player: Player, position: Coordinates, }, GameEnded: { winner: S.optional(Player), }, }, external: { Move: { payload: S.Struct({ position: Coordinates, }), success: S.Void, failure: S.Never, }, }, state: { awaitingPartner: S.Boolean, name: Player, }, }) {} ``` ### Shape * `state` is the struct-fields record hydrated when the client connects. * `external` is a record of `{ payload, success, failure }` methods callable through `Client.fn(...)`. * `events` is a record of plain struct fields. * Liminal tags events automatically with `_tag`. ### Next concepts * [Methods](/core/methods) covers reusable method definitions. * [Client Calls](/core/client-calls) covers `Client.fn(...)`. * [Events](/core/events) covers `Client.events` and replay. * [Client State](/state/client-state) covers hydration and reducers. * [Actors](/core/actors) covers the server-side runtime for this protocol. ## Events `Client.events` is the event stream for a client protocol. Events arrive as a tagged union keyed by `_tag`. ```ts Effect.gen(function* () { yield* TicTacToeClient.events.pipe( Stream.runForEach( Effect.fn(function* (event) { switch (event._tag) { case "AwaitingPartner": { break } case "GameStarted": { const { player } = event break } case "MoveMade": { const { player, position } = event break } case "GameEnded": { const { winner } = event break } } }), ), ) }) ``` For larger apps, many screens should not consume `Client.events` directly. Feed the event stream into an [Client State](/state/client-state) instead. ### Replay Replay only affects `client.events`. ```ts Client.layerSocket({ client: TicTacToeClient, url: "/play", replay: { mode: "startup", limit: 16, }, }) ``` Important details: * No `replay`: subscribers only see events once they are connected. * `mode: "startup"`: the first subscriber gets the buffered events once. * `mode: "all-subscribers"`: every subscriber gets the buffer. * `limit` caps buffer size. When omitted, the buffer is unbounded. * Method successes and failures are never replayed. ## Lifecycle `hydrate` runs when a client socket is newly upgraded into an actor. Use it to return the connecting client's initial state and notify already-connected clients. ### Hydrate initial state ```ts import { Effect } from "effect" import { TicTacToeActor } from "./TicTacToeActor.ts" export const hydrate = Effect.gen(function* () { const { clients } = yield* TicTacToeActor if (clients.size === 1) { return { awaitingPartner: true, name: "X" as const, } } yield* TicTacToeActor.others.send("GameStarted", {}) return { awaitingPartner: false, name: "O" as const, } }).pipe(Effect.orDie) ``` `hydrate` is the snapshot for a reconnectable client. Tic-tac-toe returns `awaitingPartner: true` to the first socket, then sends `GameStarted` to the existing player and returns ready state to the second player. `onDisconnect` runs when a socket closes or disconnects and is supplied to `WorkerdActorRuntime.make(...)`. Read [Snapshot and Delta Events](/state/snapshot-delta-events) for the state pattern and [Client State](/state/client-state) for the hydration and reduction loop. ## Methods Methods describe request-response calls in a protocol. External methods are callable by connected clients. Internal methods are callable between Durable Object instances through a namespace handle. ### Define a shared method ```ts import { Schema as S } from "effect" export const SendMessage = { payload: S.Struct({ content: S.String, }), success: S.Struct({ messageId: S.String, timestamp: S.Date, }), failure: S.Never, } ``` Shared methods such as `UpdateProfile` or `ReportUser` can live under a common `shared/methods` directory and be inserted into a client's `external` table or a namespace's `internal` table. ### Implement a shared handler If a method is shared, its handler can be shared too. ```ts import { Effect } from "effect" import { handler } from "liminal" import { UpdateProfile } from "../shared/methods/UpdateProfile.ts" export default handler( UpdateProfile, Effect.fn(function* ({ displayName, avatarUrl }) { // shared logic here return { updated: true } }), ) ``` If the handler only makes sense for one actor, `Actor.handler("Method", ...)` is usually simpler for external methods. Internal handlers are supplied to `WorkerdActorRuntime.make({ internal: ... })`. ## Prelude vs Layer The `prelude` and `layer` fields in `WorkerdActorRuntime.make(...)` serve different lifetimes. Use `prelude` for long-lived infrastructure: * database access * config * logging * asset bindings * Cloudflare service bindings Use `layer` for short-lived context derived from the current client-specific actor invocation: * current user id * current authorization ### Derive request context The next example builds a `layer` that provides an actor-specific `CurrentUserId` derived from two different actor definitions. ```ts import { Context, Effect, Layer, Schema as S } from "effect" import { ChatActor } from "../chat/ChatActor.ts" import { LobbyActor } from "../lobby/LobbyActor.ts" const UserId = S.String.pipe(S.brand("UserId")) export class CurrentUserId extends Context.Service()("CurrentUserId") {} // Use the actor name as the id. export const layerLobby = Effect.gen(function* () { const { name } = yield* LobbyActor return name }).pipe(Layer.effect(CurrentUserId)) // Use the current client attachment as the id. export const layerChat = Effect.gen(function* () { const { currentClient } = yield* ChatActor const { userId } = yield* currentClient.attachments return userId }).pipe(Layer.effect(CurrentUserId)) ``` In Effect v4, context is defined with `Context.Service()(id)`. This replaces `Context.Tag` from earlier versions. Read [Actor Context](/core/actor-context) for the values available through `yield* ActorTag`. ## HTTP Upgrades `Namespace.bind(name).upgrade(attachments)` is the handoff from HTTP to the Durable Object. An HTTP route decides: * which actor name to connect to * which actor type to use * what the initial attachments should be `upgrade(...)` handles the rest. ### Upgrade from a route ```ts import { Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { ChatNamespace } from "./chat/ChatNamespace.ts" import { LobbyNamespace } from "./lobby/LobbyNamespace.ts" export const ApiLive = Layer.mergeAll( HttpRouter.add( "GET", "/connect", Effect.gen(function* () { const sessionToken = yield* readSessionToken const user = yield* lookupUser(sessionToken) if (user) { const roomId = yield* getActiveRoom(user.id) return yield* ChatNamespace.bind(roomId).upgrade({ userId: user.id }) } return yield* LobbyNamespace.bind(sessionToken).upgrade({}) }), ), ) ``` Every request that upgrades with the same actor name lands in the same Durable Object instance. ### What upgrade does On the Cloudflare side, `upgrade(...)`: * resolves the Durable Object by actor name * validates that the connecting WebSocket is using the expected Liminal client id * serializes attachments onto the WebSocket * returns the `101 Switching Protocols` response The validation step is why client and actor definitions must line up. If the wrong client tries to connect, the session fails with an audition error rather than silently misbehaving. ## Worker Entrypoint The Worker entrypoint usually exports: * a default `fetch` handler * each Durable Object class as a named export `Worker.make(...)` supplies the Worker-side runtime for ordinary HTTP handling. `WorkerdActorNamespace` provides the Worker-side Durable Object binding, while `WorkerdActorRuntime.make(...)` defines the Durable Object class. ### Build the entrypoint ```ts // main.ts import { Effect, Layer } from "effect" import { Assets, Worker } from "effect-workerd" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import * as GameState from "./Games.ts" import { KvLive } from "./KvLive.ts" import { TicTacToeNamespace } from "./TicTacToeNamespace.ts" import { TicTacToeRuntime } from "./TicTacToeRuntime.ts" export { TicTacToeRuntime as TicTacToe } const ApiLive = Layer.mergeAll( HttpRouter.add("GET", "/", Effect.succeed(HttpServerResponse.text("ok"))), HttpRouter.add( "GET", "/play", Effect.gen(function* () { const { gameId, player } = yield* GameState.init return yield* TicTacToeNamespace.bind(gameId).upgrade({ player }) }), ), HttpRouter.cors({ allowedHeaders: ["*"], allowedMethods: ["*"], allowedOrigins: ["*"], }), HttpRouter.add("*", "/*", Assets.forward), ) export default Worker.make({ handler: ApiLive.pipe(HttpRouter.toHttpEffect, Effect.flatten), prelude: Layer.mergeAll(KvLive, TicTacToeNamespace.layer, Assets.layer("ASSETS")), }) ``` ### Things to notice * `HttpRouter.add(method, path, effect)` mounts a single route. * `HttpRouter.cors(...)` wires up CORS for every route in the router. * `HttpRouter.add("*", "/*", Assets.forward)` falls through to the Cloudflare Assets binding for unmatched requests. * `Worker.make({ handler, prelude })` produces the Worker `fetch` export. * The Durable Object runtime class must be re-exported from the Worker entrypoint under the binding's class name.