@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 +16 -0
- package/README.md +8 -2
- package/package.json +33 -4
- package/plugin.d.mts +5 -0
- package/plugin.mjs +15 -0
- package/src/index.ts +85 -0
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
|
-
#
|
|
1
|
+
# @weft-js/realtime-durable-object
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|