@weft-js/realtime-durable-object 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,250 @@
1
- # @weft-js/realtime-durable-object
1
+ <p align="center">
2
+ <a href="https://github.com/Ravonus/weft">
3
+ <img src="https://raw.githubusercontent.com/Ravonus/weft/main/site/public/logo.svg" width="88" height="88" alt="Weft">
4
+ </a>
5
+ </p>
2
6
 
3
- > **Early release (0.1.0).** APIs will change between minor versions.
7
+ <h1 align="center">@weft-js/realtime-durable-object</h1>
4
8
 
5
- A Cloudflare Durable Object as the realtime bus, on cloudflare_durable.
9
+ <p align="center">
10
+ <strong>A Cloudflare Durable Object as Weft's realtime bus.</strong><br>
11
+ Live data, channels and shared state across every Durable Object hub on Cloudflare, with no Redis or NATS to run.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/@weft-js/realtime-durable-object"><img src="https://img.shields.io/npm/v/@weft-js/realtime-durable-object?color=145c3c&label=npm" alt="npm version"></a>
16
+ <a href="https://github.com/Ravonus/weft/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-145c3c" alt="MIT license"></a>
17
+ <a href="https://github.com/Ravonus/weft/actions"><img src="https://img.shields.io/github/actions/workflow/status/Ravonus/weft/quality.yml?branch=main&label=checks" alt="checks"></a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#quick-start">Quick start</a> ·
22
+ <a href="https://github.com/Ravonus/weft/tree/main/docs">Docs</a> ·
23
+ <a href="https://github.com/Ravonus/weft/tree/main/examples">Examples</a> ·
24
+ <a href="https://github.com/Ravonus/weft/blob/main/CHANGELOG.md">Changelog</a> ·
25
+ <a href="https://github.com/Ravonus/weft">GitHub</a>
26
+ </p>
27
+
28
+ > [!IMPORTANT]
29
+ > **Early release (0.1.x).** APIs can change between minor versions. Pin the version and read the
30
+ > [changelog](https://github.com/Ravonus/weft/blob/main/CHANGELOG.md) before you upgrade.
31
+
32
+ On Cloudflare, Weft's realtime hub runs in many places at once, and a Worker can't hold a socket open past the request
33
+ that started it. This package gives the hubs a meeting point that can: one Durable Object per channel, `WeftBusObject`,
34
+ which holds a WebSocket to every hub listening on that channel and writes each publish to all of them. It's the bus for
35
+ Weft apps deployed with Nitro's `cloudflare_durable` preset.
36
+
37
+ ## Highlights
38
+
39
+ - **One object per channel.** Each channel name maps to its own `WeftBusObject` through `idFromName`, so busy channels
40
+ don't share an object.
41
+ - **Hibernation API.** The object accepts sockets with `state.acceptWebSocket`, so Cloudflare can evict it from memory
42
+ between messages without dropping the sockets.
43
+ - **Holds nothing but sockets.** No storage, no history: a publish is a POST that the object writes to every socket it
44
+ holds.
45
+ - **Redials with backoff.** A lost socket is reported to the hub as a gap, then redialled after 500 ms, doubling up to
46
+ 30 s, until it's back or the subscription stops.
47
+ - **No runtime imports.** The source imports only types, so workerd runs the file as it is. An app doesn't need
48
+ `@cloudflare/workers-types` for it.
49
+ - **Checked by the build.** `weft build` refuses this bus on any preset other than `cloudflare_durable` (R002), where it
50
+ would silently lose messages.
51
+ - **Tested on workerd.** The tests run the object and two hubs in Miniflare, with no Cloudflare account.
52
+
53
+ ## Install
6
54
 
7
55
  ```bash
56
+ # npm
8
57
  npm install @weft-js/realtime-durable-object
58
+
59
+ # pnpm
60
+ pnpm add @weft-js/realtime-durable-object
61
+
62
+ # bun
63
+ bun add @weft-js/realtime-durable-object
64
+ ```
65
+
66
+ Or let the `weft` CLI install it and add it to `weft.config.ts` in one step:
67
+
68
+ ```bash
69
+ npx weft add realtime-durable-object
70
+ ```
71
+
72
+
73
+ You also need a Cloudflare account and `wrangler` to deploy. `weft deploy` writes a `wrangler.toml` for the
74
+ `cloudflare` target and builds with `weft build --preset cloudflare_durable`.
75
+
76
+ ## Quick start
77
+
78
+ Unlike `@weft-js/realtime-redis` and `@weft-js/realtime-nats`, this bus can't be built from plugin options: it needs a
79
+ Durable Object namespace binding, and a binding only exists at run time. So the plugin in `weft.config.ts` only
80
+ declares the binding, and the bus itself is set in `src/realtime.server.ts`. There are four steps.
81
+
82
+ **1. The plugin.** `weft add` writes this:
83
+
84
+ ```ts
85
+ // weft.config.ts
86
+ import { defineConfig } from '@weft-js/core/config'
87
+ import realtimeDurableObject from
88
+ '@weft-js/realtime-durable-object/plugin'
89
+
90
+ export default defineConfig({
91
+ plugins: [realtimeDurableObject()],
92
+ })
93
+ ```
94
+
95
+ **2. The bus.** Pass the `WEFT_BUS` binding to `durableObjectBus`:
96
+
97
+ <!-- example: skip reaching `env` depends on how your worker exposes bindings -->
98
+ ```ts
99
+ // src/realtime.server.ts
100
+ import { realtime } from '@weft-js/start'
101
+ import { durableObjectBus } from '@weft-js/realtime-durable-object'
102
+ import { env } from 'cloudflare:workers'
103
+
104
+ export default realtime({
105
+ bus: durableObjectBus({ namespace: env.WEFT_BUS }),
106
+ })
107
+ ```
108
+
109
+ > [!TIP]
110
+ > `cloudflare:workers` is Cloudflare's module for reading bindings outside a request handler. Any way of reaching the
111
+ > binding works: `durableObjectBus` only needs an object with `idFromName(name)` and `get(id)`.
112
+
113
+ **3. The class.** Export `WeftBusObject` from the worker entry, so Cloudflare can create it:
114
+
115
+ ```ts
116
+ export { WeftBusObject } from '@weft-js/realtime-durable-object'
117
+ ```
118
+
119
+ **4. The binding and migration.** Add them to `wrangler.toml`:
120
+
121
+ ```toml
122
+ [[durable_objects.bindings]]
123
+ name = "WEFT_BUS"
124
+ class_name = "WeftBusObject"
125
+
126
+ [[migrations]]
127
+ tag = "weft-bus-v1"
128
+ new_sqlite_classes = ["WeftBusObject"]
129
+ ```
130
+
131
+ Then build and deploy:
132
+
133
+ ```bash
134
+ npx weft build --preset cloudflare_durable
135
+ cd .output && npx wrangler deploy
136
+ ```
137
+
138
+ ## How it plugs into Weft
139
+
140
+ Weft's realtime hub keeps everything that has state (tags, topics, replay rings, cursors, presence) and hands the bus
141
+ three things to do: publish a string to a channel, subscribe to a channel, and close. That is the `Bus` interface in
142
+ [`@weft-js/start/bus`](https://github.com/Ravonus/weft/blob/main/packages/start/src/bus.ts). The bus is at-most-once
143
+ and ordered per publisher.
144
+
145
+ ```text
146
+ publishing hub ──POST message──▶ WeftBusObject for channel "c"
147
+ │
148
+ ws.send
149
+ ▼
150
+ every hub subscribed to "c"
151
+ (each holds a WebSocket to the object)
152
+ ```
153
+
154
+ - **`publish(channel, message)`** gets the channel's object with `namespace.get(namespace.idFromName(channel))` and
155
+ POSTs the message to it. The object answers `204` after writing it to every socket. Any other status rejects the
156
+ publish with `durable object bus: publish answered <status>`.
157
+ - **`subscribe(channel, on, gap)`** asks the same object for a WebSocket (an `upgrade: websocket` request), accepts it,
158
+ and calls `on` for each message. It resolves once the socket is open.
159
+ - **A lost socket** calls `gap`, then redials after `min(30 s, 500 ms × 2^attempt)`. A failed redial is another gap and
160
+ a longer wait. A successful one resets the backoff.
161
+ - **`close()`** stops every subscription this bus opened, closing their sockets with code 1000.
162
+
163
+ ### Why it needs `cloudflare_durable`
164
+
165
+ A hub's subscription is a WebSocket that must stay open for as long as the hub listens. A stateless Worker's I/O
166
+ belongs to the request that opened it, so the socket would close with that request. Nitro's `cloudflare_durable`
167
+ preset runs the hub inside a Durable Object, which lives long enough to hold it. On any other preset `weft build`
168
+ fails with R002.
169
+
170
+ `weft deploy` recognises this bus from the source: a `durableObjectBus(` call, an import of
171
+ `@weft-js/realtime-durable-object` or the plugin in `weft.config.ts` counts as a shared bus, so E009 (the memory bus on
172
+ a many-instance host) doesn't fire.
173
+
174
+ ### What the plugin declares
175
+
176
+ The plugin provides no slot. It declares the binding the bus reaches and gives agents the setup steps.
177
+
178
+ | Permission | Value |
179
+ | --- | --- |
180
+ | `net` | `binding:WEFT_BUS` |
181
+
182
+ ## API reference
183
+
184
+ | Export | From | What it is |
185
+ | --- | --- | --- |
186
+ | `durableObjectBus({ namespace })` | `@weft-js/realtime-durable-object` | The `Bus` for `src/realtime.server.ts`: one `WeftBusObject` per channel, reached through `namespace`. Must be used from a Durable Object hub. |
187
+ | `WeftBusObject` | `@weft-js/realtime-durable-object` | The Durable Object class. Export it from the worker and bind it as `WEFT_BUS` with a `new_sqlite_classes` migration. |
188
+ | `DurableObjectNamespaceLike` | `@weft-js/realtime-durable-object` | Type: the part of a namespace binding the bus uses, `idFromName(name)` and `get(id)`. `env.WEFT_BUS` fits it. |
189
+ | default export, `durableObject` | `@weft-js/realtime-durable-object/plugin` | The plugin for `weft.config.ts`. Takes no options; declares `binding:WEFT_BUS`. |
190
+
191
+ ### `durableObjectBus` options
192
+
193
+ | Option | Type | Default | What it does |
194
+ | --- | --- | --- | --- |
195
+ | `namespace` | `DurableObjectNamespaceLike` | required | The Durable Object namespace bound to `WeftBusObject`, usually `env.WEFT_BUS`. |
196
+
197
+ ### `WeftBusObject`
198
+
199
+ | Method | What it does |
200
+ | --- | --- |
201
+ | `fetch(request)` | With `upgrade: websocket`: accepts a socket through the hibernation API and answers `101`. Otherwise: reads the body as the message, sends it to every socket, answers `204`. |
202
+ | `webSocketMessage()` | Ignores messages from hubs. Traffic only flows out of the object. |
203
+ | `webSocketClose(ws)` | Closes its end with code 1000. |
204
+
205
+ ## Limits
206
+
207
+ - **At-most-once.** The object stores nothing. A hub that was disconnected misses what was published meanwhile, and
208
+ gets a gap instead.
209
+ - **`cloudflare_durable` only.** Not for Node, Bun, Deno or other serverless hosts; use Redis, NATS or `postgresBus`
210
+ there.
211
+ - **One object per channel.** Each object's sockets and request rate are subject to Cloudflare's Durable Object limits.
212
+ - **Messages are strings.** The hub serializes; the bus never parses. A message over 1 MiB is refused by the hub (R008).
213
+
214
+ ## Troubleshooting
215
+
216
+ | Symptom | Cause | Fix |
217
+ | --- | --- | --- |
218
+ | `R002` "`durableObjectBus` holds a socket open, and on the `<preset>` preset that socket dies…" | Built for a preset other than `cloudflare_durable` | `weft build --preset cloudflare_durable`. |
219
+ | `durable object bus: publish answered 500` | The object threw, or the binding points at another class | Check the binding's `class_name` is `WeftBusObject` and the class is exported from the worker. |
220
+ | `durable object bus: subscribe answered <status>` | The object didn't upgrade the request | Same as above; a route or middleware in front of the object can also strip the upgrade. |
221
+ | `env.WEFT_BUS` is undefined | The binding is missing from `wrangler.toml`, or has another name | Add the `[[durable_objects.bindings]]` entry named `WEFT_BUS`. |
222
+ | Cloudflare refuses the deploy over the class | No migration creates it | Add a `[[migrations]]` entry with `new_sqlite_classes = ["WeftBusObject"]`. |
223
+ | Pages on one hub miss messages after an object restart | The socket dropped: a gap, then a redial | Expected. What must survive goes through a mutation; clients refetch after a gap. |
224
+
225
+ ## Related packages
226
+
227
+ | Package | What it is |
228
+ | --- | --- |
229
+ | [`@weft-js/start`](https://www.npmjs.com/package/@weft-js/start) | The full-stack runtime: the realtime hub, `realtime()`, `memoryBus` and `postgresBus`. |
230
+ | [`@weft-js/realtime-redis`](https://www.npmjs.com/package/@weft-js/realtime-redis) | The `realtime.bus` slot on Redis pub/sub, for server hosts. |
231
+ | [`@weft-js/realtime-nats`](https://www.npmjs.com/package/@weft-js/realtime-nats) | The `realtime.bus` slot on core NATS, for server hosts. |
232
+ | [`@weft-js/multiplayer`](https://www.npmjs.com/package/@weft-js/multiplayer) | Presence, cursors and a relay on `channel`. Its traffic crosses hubs through this bus. |
233
+ | [`@weft-js/cli`](https://www.npmjs.com/package/@weft-js/cli) | `weft build`, `weft deploy` and the R002 and E009 checks. |
234
+ | [`@weft-js/core`](https://www.npmjs.com/package/@weft-js/core) | The compiler and reactive runtime, the Vite plugin, the plugin system and test utilities. |
235
+
236
+ ## Contributing
237
+
238
+ See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). To test this package:
239
+
240
+ ```bash
241
+ pnpm --filter @weft-js/realtime-durable-object test
9
242
  ```
243
+
244
+ The tests run `WeftBusObject` and two Durable Object hubs on workerd through Miniflare: a message crossing from one hub
245
+ to another, a refused publish, and a lost socket becoming a gap, a redial and a clean `close`. No account or deploy is
246
+ needed.
247
+
248
+ ## License
249
+
250
+ [MIT](https://github.com/Ravonus/weft/blob/main/LICENSE)
package/package.json CHANGED
@@ -1,16 +1,25 @@
1
1
  {
2
2
  "name": "@weft-js/realtime-durable-object",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "@weft-js/realtime-durable-object: a Cloudflare Durable Object as the realtime bus, on cloudflare_durable",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravonus/weft.git",
9
+ "directory": "packages/realtime-durable-object"
10
+ },
11
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/realtime-durable-object#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Ravonus/weft/issues"
14
+ },
6
15
  "type": "module",
7
16
  "exports": {
8
17
  ".": "./src/index.ts",
9
18
  "./plugin": "./plugin.mjs"
10
19
  },
11
20
  "peerDependencies": {
12
- "@weft-js/start": "^0.1.0",
13
- "@weft-js/core": "^0.1.0"
21
+ "@weft-js/start": "^0.1.3",
22
+ "@weft-js/core": "^0.1.3"
14
23
  },
15
24
  "engines": {
16
25
  "node": ">=26"
@@ -32,4 +41,4 @@
32
41
  "publishConfig": {
33
42
  "access": "public"
34
43
  }
35
- }
44
+ }
package/plugin.mjs CHANGED
@@ -8,7 +8,10 @@ export const durableObject = definePlugin({
8
8
  weft: '*',
9
9
  permissions: { net: ['binding:WEFT_BUS'] },
10
10
  contribute: () => ({
11
- agents: { title: 'Realtime bus (Durable Object)', text: 'Set `bus: durableObjectBus({ namespace: env.WEFT_BUS })` in `src/realtime.server.ts`, and export `WeftBusObject` from the worker with a `new_sqlite_classes` migration. It needs the `cloudflare_durable` preset (R002): a stateless Worker can\'t hold the socket.' },
11
+ agents: {
12
+ title: 'Realtime bus (Durable Object)',
13
+ text: "Set `bus: durableObjectBus({ namespace: env.WEFT_BUS })` in `src/realtime.server.ts`, and export `WeftBusObject` from the worker with a `new_sqlite_classes` migration. It needs the `cloudflare_durable` preset (R002): a stateless Worker can't hold the socket.",
14
+ },
12
15
  }),
13
16
  })
14
17
 
package/src/index.ts CHANGED
@@ -8,18 +8,34 @@
8
8
  import type { Bus } from '@weft-js/start/bus'
9
9
 
10
10
  /** The parts of Workers' API used here, so an app doesn't need `@cloudflare/workers-types` for this file. */
11
- interface Stub { fetch(url: string, init?: RequestInit): Promise<Response & { webSocket?: CfSocket | null }> }
11
+ interface Stub {
12
+ fetch(url: string, init?: RequestInit): Promise<Response & { webSocket?: CfSocket | null }>
13
+ }
12
14
  /** A Durable Object namespace binding, as far as the bus uses one: `env.WEFT_BUS` fits it. */
13
- export interface DurableObjectNamespaceLike { idFromName(name: string): unknown; get(id: unknown): Stub }
14
- interface CfSocket { accept(): void; send(m: string): void; close(code?: number): void; addEventListener(t: string, f: (e: { data?: unknown }) => void): void }
15
- interface CfState { acceptWebSocket(ws: CfSocket): void; getWebSockets(): CfSocket[] }
15
+ export interface DurableObjectNamespaceLike {
16
+ idFromName(name: string): unknown
17
+ get(id: unknown): Stub
18
+ }
19
+ interface CfSocket {
20
+ accept(): void
21
+ send(m: string): void
22
+ close(code?: number): void
23
+ addEventListener(t: string, f: (e: { data?: unknown }) => void): void
24
+ }
25
+ interface CfState {
26
+ acceptWebSocket(ws: CfSocket): void
27
+ getWebSockets(): CfSocket[]
28
+ }
16
29
  declare const WebSocketPair: new () => [CfSocket, CfSocket]
17
30
 
18
31
  const BASE = 'https://weft-bus/'
19
32
 
20
33
  /** Export it from the worker entry and bind it: `new_sqlite_classes: ['WeftBusObject']`, binding `WEFT_BUS`. */
21
34
  export class WeftBusObject {
22
- constructor(private state: CfState, _env?: unknown) {}
35
+ constructor(
36
+ private state: CfState,
37
+ _env?: unknown,
38
+ ) {}
23
39
 
24
40
  async fetch(request: Request): Promise<Response> {
25
41
  if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
@@ -29,14 +45,18 @@ export class WeftBusObject {
29
45
  }
30
46
  const message = await request.text()
31
47
  for (const ws of this.state.getWebSockets()) {
32
- try { ws.send(message) } catch {} // a socket mid-close: its own `gap` covers it
48
+ try {
49
+ ws.send(message)
50
+ } catch {} // a socket mid-close: its own `gap` covers it
33
51
  }
34
52
  return new Response(null, { status: 204 })
35
53
  }
36
54
 
37
55
  webSocketMessage() {}
38
56
  webSocketClose(ws: CfSocket) {
39
- try { ws.close(1000) } catch {}
57
+ try {
58
+ ws.close(1000)
59
+ } catch {}
40
60
  }
41
61
  }
42
62
 
@@ -51,19 +71,27 @@ export function durableObjectBus({ namespace }: { namespace: DurableObjectNamesp
51
71
  if (!res.ok) throw new Error(`durable object bus: publish answered ${res.status}`)
52
72
  },
53
73
  async subscribe(channel, on, gap) {
54
- let stopped = false, attempt = 0, ws: CfSocket | undefined
74
+ let stopped = false,
75
+ attempt = 0,
76
+ ws: CfSocket | undefined
55
77
  const dial = async (): Promise<void> => {
56
78
  const res = await stub(channel).fetch(BASE, { headers: { upgrade: 'websocket' } })
57
79
  ws = res.webSocket ?? undefined
58
80
  if (!ws) throw new Error(`durable object bus: subscribe answered ${res.status}`)
59
81
  ws.accept()
60
82
  if (stopped) return ws.close(1000) // stopped while this redial was out
61
- ws.addEventListener('message', e => on(String(e.data)))
83
+ ws.addEventListener('message', (e) => on(String(e.data)))
62
84
  const lost = () => {
63
85
  ws = undefined
64
86
  if (stopped) return
65
87
  gap?.()
66
- setTimeout(() => void dial().then(() => { attempt = 0 }, lost), Math.min(30_000, 500 * 2 ** attempt++))
88
+ setTimeout(
89
+ () =>
90
+ void dial().then(() => {
91
+ attempt = 0
92
+ }, lost),
93
+ Math.min(30_000, 500 * 2 ** attempt++),
94
+ )
67
95
  }
68
96
  ws.addEventListener('close', lost)
69
97
  ws.addEventListener('error', () => {})
@@ -72,7 +100,9 @@ export function durableObjectBus({ namespace }: { namespace: DurableObjectNamesp
72
100
  const stop = () => {
73
101
  stopped = true
74
102
  open.delete(stop)
75
- try { ws?.close(1000) } catch {}
103
+ try {
104
+ ws?.close(1000)
105
+ } catch {}
76
106
  }
77
107
  open.add(stop)
78
108
  return stop