Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/five-horses-pick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"effect-workerd": patch
"liminal": patch
---

Move accumulator api directly into client and implement internal actor method bindings.
2 changes: 1 addition & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ jobs:
- uses: actions/checkout@v6
- run: "git -c url.https://github.com/.insteadOf=git@github.com: submodule update --init konfik"
- uses: ./konfik/.github/actions/configure_environment
- run: pnpm fmt:check
- run: pnpm oxfmt --check
- run: pnpm build
- run: pnpm test
6 changes: 3 additions & 3 deletions docs/pages/actor-routing/upgrades.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# HTTP Upgrades

`Namespace.upgrade(name, attachments)` is the handoff from HTTP to the Durable Object.
`Namespace.bind(name).upgrade(attachments)` is the handoff from HTTP to the Durable Object.

An HTTP route decides:

Expand Down Expand Up @@ -29,10 +29,10 @@ export const ApiLive = Layer.mergeAll(

if (user) {
const roomId = yield* getActiveRoom(user.id)
return yield* ChatNamespace.upgrade(roomId, { userId: user.id })
return yield* ChatNamespace.bind(roomId).upgrade({ userId: user.id })
}

return yield* LobbyNamespace.upgrade(sessionToken, {})
return yield* LobbyNamespace.bind(sessionToken).upgrade({})
}),
),
)
Expand Down
13 changes: 7 additions & 6 deletions docs/pages/actor-routing/worker-entrypoint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ 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` separately
manages the Durable Object runtime for actor messages.
`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

Expand All @@ -19,8 +19,9 @@ 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 { TicTacToeNamespace }
export { TicTacToeRuntime as TicTacToe }

const ApiLive = Layer.mergeAll(
HttpRouter.add("GET", "/", Effect.succeed(HttpServerResponse.text("ok"))),
Expand All @@ -29,7 +30,7 @@ const ApiLive = Layer.mergeAll(
"/play",
Effect.gen(function* () {
const { gameId, player } = yield* GameState.init
return yield* TicTacToeNamespace.upgrade(gameId, { player })
return yield* TicTacToeNamespace.bind(gameId).upgrade({ player })
}),
),
HttpRouter.cors({
Expand All @@ -42,7 +43,7 @@ const ApiLive = Layer.mergeAll(

export default Worker.make({
handler: ApiLive.pipe(HttpRouter.toHttpEffect, Effect.flatten),
prelude: Layer.mergeAll(KvLive, TicTacToeNamespace.layer("TICTACTOE"), Assets.layer("ASSETS")),
prelude: Layer.mergeAll(KvLive, TicTacToeNamespace.layer, Assets.layer("ASSETS")),
})
```

Expand All @@ -52,4 +53,4 @@ export default Worker.make({
- `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 class must be re-exported from the Worker entrypoint.
- The Durable Object runtime class must be re-exported from the Worker entrypoint under the binding's class name.
4 changes: 2 additions & 2 deletions docs/pages/core/actor-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The context includes:

Many handlers want convenient access to values derived from actor context.

On Cloudflare, use the namespace `layer` to derive request-local services like `CurrentUserId` or `Authorization` from
the actor name and current client attachments.
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.
2 changes: 1 addition & 1 deletion docs/pages/core/actor-handlers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ handler's payload, success, and failure types to the named method.

- [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 `Method.handler(...)` implementations.
- [Methods](/core/methods) covers shared `handler(...)` implementations.
89 changes: 63 additions & 26 deletions docs/pages/core/actor-namespace.mdx
Original file line number Diff line number Diff line change
@@ -1,47 +1,84 @@
# Actor Namespace

`WorkerdActorNamespace` is the Cloudflare Durable Object wrapper around a Liminal actor.
`WorkerdActorNamespace` is the Worker-side handle for a Cloudflare Durable Object actor.

A namespace wraps one actor type as one Durable Object class.
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
// TicTacToeNamespace.ts
import { Effect, Layer } from "effect"
import { WorkerdActorNamespace } from "liminal/workerd"

import { handleMove } from "./handleMove.ts"
import { KvLive } from "./KvLive.ts"
import { TicTacToeActor } from "./TicTacToeActor.ts"

const onConnect = Effect.gen(function* () {
const { clients, currentClient } = yield* TicTacToeActor
if (clients.size === 1) {
yield* currentClient.send("AwaitingPartner", {})
} else {
yield* TicTacToeActor.others.send("GameStarted", { player: "X" })
yield* currentClient.send("GameStarted", { player: "O" })
}
}).pipe(Effect.orDie)

export class TicTacToeNamespace extends WorkerdActorNamespace.Service<TicTacToeNamespace>()("TicTacToeNamespace", {
binding: "TICTACTOE",
actor: TicTacToeActor,
prelude: KvLive,
onConnect,
handlers: { Move: handleMove },
layer: Layer.empty,
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
- `layer`: short-lived, request-local dependencies derived from actor context
- `onConnect`: logic that runs for newly upgraded sockets
- `handlers`: the method implementation table
- `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`)

The Durable Object binding name is not part of the definition. It is supplied later when you call
`TicTacToeNamespace.layer("TICTACTOE")` to provide the namespace to your Worker runtime.

Read [Prelude vs Layer](/core/prelude-vs-layer) for the dependency model.
6 changes: 3 additions & 3 deletions docs/pages/core/client-calls.mdx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# Client Calls

`Client.f(...)` invokes a named method from the client protocol.
`Client.fn(...)` invokes a named external method from the client protocol.

```ts
Effect.gen(function* () {
yield* TicTacToeClient.f("Move")({
yield* TicTacToeClient.fn("Move")({
position: [1, 1],
})
})
```

The method name selects the payload, success, and failure types from the `Client` definition.
The method name selects the payload, success, and failure types from the client's `external` table.

## Failure types

Expand Down
9 changes: 7 additions & 2 deletions docs/pages/core/client-layer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,32 @@ Use it in browser runtimes that connect to a Liminal actor through an HTTP upgra

## Define the layer

Provide the client definition, the socket endpoint, and the platform WebSocket constructor.
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).

Expand All @@ -36,6 +40,7 @@ Replay semantics are covered in [Events](/core/events).

## Use the live client

After providing the layer, calls use `Client.f(...)` and event streams use `Client.events`.
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.
24 changes: 14 additions & 10 deletions docs/pages/core/clients.mdx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# Clients

A `Client` defines the wire contract between clients and actors.
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
- handlers implement the client's methods
- 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 set of methods and events.
A client is a named state schema, external method table, and event table.

```ts
import { Schema as S } from "effect"
Expand All @@ -22,10 +23,7 @@ export const Coordinates = S.Tuple([Coordinate, Coordinate])

export class TicTacToeClient extends Client.Service<TicTacToeClient>()("examples/TicTacToeClient", {
events: {
AwaitingPartner: {},
GameStarted: {
player: Player,
},
GameStarted: {},
MoveMade: {
player: Player,
position: Coordinates,
Expand All @@ -34,7 +32,7 @@ export class TicTacToeClient extends Client.Service<TicTacToeClient>()("examples
winner: S.optional(Player),
},
},
methods: {
external: {
Move: {
payload: S.Struct({
position: Coordinates,
Expand All @@ -43,18 +41,24 @@ export class TicTacToeClient extends Client.Service<TicTacToeClient>()("examples
failure: S.Never,
},
},
state: {
awaitingPartner: S.Boolean,
name: Player,
},
}) {}
```

## Shape

- `methods` is a record of `{ payload, success, failure }`, where each value is a schema.
- `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.f(...)`.
- [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.
2 changes: 1 addition & 1 deletion docs/pages/core/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Effect.gen(function* () {
```

For larger apps, many screens should not consume `Client.events` directly. Feed the event stream into an
[Accumulator](/state/accumulator) instead.
[Client State](/state/client-state) instead.

## Replay

Expand Down
33 changes: 21 additions & 12 deletions docs/pages/core/lifecycle.mdx
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
# Lifecycle

`onConnect` runs when a client socket is newly upgraded into an actor.
`hydrate` runs when a client socket is newly upgraded into an actor.

Use it to send initial state, reject a client, update attachments, or notify other clients.
Use it to return the connecting client's initial state and notify already-connected clients.

## Send initial events
## Hydrate initial state

```ts
import { Effect } from "effect"

import { TicTacToeActor } from "./TicTacToeActor.ts"

export const onConnect = Effect.gen(function* () {
const { clients, currentClient } = yield* TicTacToeActor
export const hydrate = Effect.gen(function* () {
const { clients } = yield* TicTacToeActor
if (clients.size === 1) {
yield* currentClient.send("AwaitingPartner", {})
} else {
yield* TicTacToeActor.others.send("GameStarted", { player: "X" })
yield* currentClient.send("GameStarted", { player: "O" })
return {
awaitingPartner: true,
name: "X" as const,
}
}

yield* TicTacToeActor.others.send("GameStarted", {})
return {
awaitingPartner: false,
name: "O" as const,
}
}).pipe(Effect.orDie)
```

`onConnect` is the usual place to emit the first snapshot event for a reconnectable client. Tic-tac-toe sends an
`AwaitingPartner` event to the first socket, then sends `GameStarted` to both players once the second socket connects.
`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
[Accumulator](/state/accumulator) for the reduction loop.
[Client State](/state/client-state) for the hydration and reduction loop.
Loading