@weft-js/realtime-durable-object 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ravonus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @weft-js/realtime-durable-object
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Early release (0.1.0).** APIs will change between minor versions.
4
+
5
+ A Cloudflare Durable Object as the realtime bus, on cloudflare_durable.
6
+
7
+ ```bash
8
+ npm install @weft-js/realtime-durable-object
9
+ ```
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@weft-js/realtime-durable-object",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "@weft-js/realtime-durable-object: a Cloudflare Durable Object as the realtime bus, on cloudflare_durable",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./plugin": "./plugin.mjs"
10
+ },
11
+ "peerDependencies": {
12
+ "@weft-js/start": "^0.1.0",
13
+ "@weft-js/core": "^0.1.0"
14
+ },
15
+ "engines": {
16
+ "node": ">=26"
17
+ },
18
+ "keywords": [
19
+ "weft"
20
+ ],
21
+ "files": [
22
+ "src",
23
+ "plugin.mjs",
24
+ "plugin.d.mts",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "overrides": {
29
+ "undici": "7.30.1",
30
+ "sharp": "0.35.5"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }
package/plugin.d.mts ADDED
@@ -0,0 +1,5 @@
1
+ import type { PluginInstance } from '@weft-js/core/plugin'
2
+
3
+ declare const plugin: (options?: Record<string, unknown>) => PluginInstance<unknown>
4
+ /** `durableObject()` for `weft.config.ts`: declares the `WEFT_BUS` binding. The bus itself is set in `src/realtime.server.ts`. */
5
+ export default plugin
package/plugin.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // durableObject() for weft.config.ts: declares what the bus reaches. A binding only exists at run time, so unlike the
2
+ // other buses it can't be built from options: `src/realtime.server.ts` sets `durableObjectBus({ namespace })`.
3
+ import { definePlugin } from '@weft-js/core/plugin'
4
+
5
+ export const durableObject = definePlugin({
6
+ name: '@weft-js/realtime-durable-object',
7
+ version: '0.1.0',
8
+ weft: '*',
9
+ permissions: { net: ['binding:WEFT_BUS'] },
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.' },
12
+ }),
13
+ })
14
+
15
+ export default durableObject
package/src/index.ts ADDED
@@ -0,0 +1,85 @@
1
+ // A Durable Object per channel (research 32 §6). `WeftBusObject` holds the sockets, through the hibernation API, and
2
+ // nothing else: a publish is a POST it writes to all of them. A hub's subscribe is a WebSocket to the object, and a
3
+ // lost one is a `gap`. No runtime imports, so workerd can run this file as it is.
4
+ //
5
+ // A Worker's I/O belongs to the request that opened it, so a stateless Worker can't hold that socket: this needs a hub
6
+ // that lives in a Durable Object, Nitro's `cloudflare_durable` preset (`weft build` refuses the others, R002).
7
+
8
+ import type { Bus } from '@weft-js/start/bus'
9
+
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 }> }
12
+ /** 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[] }
16
+ declare const WebSocketPair: new () => [CfSocket, CfSocket]
17
+
18
+ const BASE = 'https://weft-bus/'
19
+
20
+ /** Export it from the worker entry and bind it: `new_sqlite_classes: ['WeftBusObject']`, binding `WEFT_BUS`. */
21
+ export class WeftBusObject {
22
+ constructor(private state: CfState, _env?: unknown) {}
23
+
24
+ async fetch(request: Request): Promise<Response> {
25
+ if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
26
+ const pair = new WebSocketPair()
27
+ this.state.acceptWebSocket(pair[1])
28
+ return new Response(null, { status: 101, webSocket: pair[0] } as ResponseInit)
29
+ }
30
+ const message = await request.text()
31
+ for (const ws of this.state.getWebSockets()) {
32
+ try { ws.send(message) } catch {} // a socket mid-close: its own `gap` covers it
33
+ }
34
+ return new Response(null, { status: 204 })
35
+ }
36
+
37
+ webSocketMessage() {}
38
+ webSocketClose(ws: CfSocket) {
39
+ try { ws.close(1000) } catch {}
40
+ }
41
+ }
42
+
43
+ /** The `realtime.bus` for `src/realtime.server.ts`: one `WeftBusObject` per channel, which must be reached from a Durable Object hub. */
44
+ export function durableObjectBus({ namespace }: { namespace: DurableObjectNamespaceLike }): Bus {
45
+ const stub = (channel: string) => namespace.get(namespace.idFromName(channel))
46
+ const open = new Set<() => void>()
47
+ return {
48
+ name: 'durable-object',
49
+ async publish(channel, message) {
50
+ const res = await stub(channel).fetch(BASE, { method: 'POST', body: message })
51
+ if (!res.ok) throw new Error(`durable object bus: publish answered ${res.status}`)
52
+ },
53
+ async subscribe(channel, on, gap) {
54
+ let stopped = false, attempt = 0, ws: CfSocket | undefined
55
+ const dial = async (): Promise<void> => {
56
+ const res = await stub(channel).fetch(BASE, { headers: { upgrade: 'websocket' } })
57
+ ws = res.webSocket ?? undefined
58
+ if (!ws) throw new Error(`durable object bus: subscribe answered ${res.status}`)
59
+ ws.accept()
60
+ if (stopped) return ws.close(1000) // stopped while this redial was out
61
+ ws.addEventListener('message', e => on(String(e.data)))
62
+ const lost = () => {
63
+ ws = undefined
64
+ if (stopped) return
65
+ gap?.()
66
+ setTimeout(() => void dial().then(() => { attempt = 0 }, lost), Math.min(30_000, 500 * 2 ** attempt++))
67
+ }
68
+ ws.addEventListener('close', lost)
69
+ ws.addEventListener('error', () => {})
70
+ }
71
+ await dial()
72
+ const stop = () => {
73
+ stopped = true
74
+ open.delete(stop)
75
+ try { ws?.close(1000) } catch {}
76
+ }
77
+ open.add(stop)
78
+ return stop
79
+ },
80
+ close() {
81
+ for (const stop of open) stop()
82
+ return Promise.resolve()
83
+ },
84
+ }
85
+ }