@velajs/cloudflare 1.29.0 → 1.30.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +2 -1
  3. package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/cloudflare-application.js +309 -0
  7. package/dist/cloudflare-application.js.map +1 -0
  8. package/dist/cloudflare-factory.js +109 -0
  9. package/dist/cloudflare-factory.js.map +1 -0
  10. package/dist/decorators/queue-consumer.js +39 -0
  11. package/dist/decorators/queue-consumer.js.map +1 -0
  12. package/dist/durable-objects.d.ts +3 -4
  13. package/dist/durable-objects.js +2 -524
  14. package/dist/environment.js +19 -0
  15. package/dist/environment.js.map +1 -0
  16. package/dist/index.d.ts +11 -9
  17. package/dist/index.js +21 -1276
  18. package/dist/nonce/durable-object-nonce.store.js +33 -0
  19. package/dist/nonce/durable-object-nonce.store.js.map +1 -0
  20. package/dist/nonce/nonce-validation.js +18 -0
  21. package/dist/nonce/nonce-validation.js.map +1 -0
  22. package/dist/nonce/nonce.durable-object.js +54 -0
  23. package/dist/nonce/nonce.durable-object.js.map +1 -0
  24. package/dist/{nonce.durable-object-Df4CZi-0.d.ts → nonce.durable-object-Ci9AGssY.d.ts} +2 -3
  25. package/dist/queue/cloudflare-queues.js +215 -0
  26. package/dist/queue/cloudflare-queues.js.map +1 -0
  27. package/dist/queues.d.ts +1 -2
  28. package/dist/queues.js +1 -214
  29. package/dist/rate-limit/cloudflare-rate-limit.store.js +40 -0
  30. package/dist/rate-limit/cloudflare-rate-limit.store.js.map +1 -0
  31. package/dist/schedule-diagnostics.js +51 -0
  32. package/dist/schedule-diagnostics.js.map +1 -0
  33. package/dist/scheduled-event.js +68 -0
  34. package/dist/scheduled-event.js.map +1 -0
  35. package/dist/services/flagship-flag.driver.js +52 -0
  36. package/dist/services/flagship-flag.driver.js.map +1 -0
  37. package/dist/services/kv-cache.store.js +82 -0
  38. package/dist/services/kv-cache.store.js.map +1 -0
  39. package/dist/services/kv-flag.driver.js +62 -0
  40. package/dist/services/kv-flag.driver.js.map +1 -0
  41. package/dist/storage/r2-storage.driver.js +67 -0
  42. package/dist/storage/r2-storage.driver.js.map +1 -0
  43. package/dist/storage/storage-key-claim.js +74 -0
  44. package/dist/storage/storage-key-claim.js.map +1 -0
  45. package/dist/storage/storage-manager.service.js +38 -0
  46. package/dist/storage/storage-manager.service.js.map +1 -0
  47. package/dist/storage/storage.controller.js +64 -0
  48. package/dist/storage/storage.controller.js.map +1 -0
  49. package/dist/storage/storage.module.js +25 -0
  50. package/dist/storage/storage.module.js.map +1 -0
  51. package/dist/storage/storage.service.js +64 -0
  52. package/dist/storage/storage.service.js.map +1 -0
  53. package/dist/storage/storage.tokens.js +7 -0
  54. package/dist/storage/storage.tokens.js.map +1 -0
  55. package/dist/vela-env-DFvyoNT3.d.ts +1 -2
  56. package/dist/websocket/broadcast.js +32 -0
  57. package/dist/websocket/broadcast.js.map +1 -0
  58. package/dist/websocket/cf-room-registry.js +137 -0
  59. package/dist/websocket/cf-room-registry.js.map +1 -0
  60. package/dist/websocket/cf-ws-client.js +94 -0
  61. package/dist/websocket/cf-ws-client.js.map +1 -0
  62. package/dist/websocket/cloudflare-websocket.module.js +25 -0
  63. package/dist/websocket/cloudflare-websocket.module.js.map +1 -0
  64. package/dist/websocket/do-bootstrap.js +56 -0
  65. package/dist/websocket/do-bootstrap.js.map +1 -0
  66. package/dist/websocket/do-live.js +184 -0
  67. package/dist/websocket/do-live.js.map +1 -0
  68. package/dist/websocket/do-pitr.js +81 -0
  69. package/dist/websocket/do-pitr.js.map +1 -0
  70. package/dist/websocket/do-state.js +7 -0
  71. package/dist/websocket/do-state.js.map +1 -0
  72. package/dist/websocket/do-websocket-host.js +169 -0
  73. package/dist/websocket/do-websocket-host.js.map +1 -0
  74. package/dist/websocket/room-id.js +27 -0
  75. package/dist/websocket/room-id.js.map +1 -0
  76. package/dist/websocket/websocket-routing.js +133 -0
  77. package/dist/websocket/websocket-routing.js.map +1 -0
  78. package/dist/websocket/websocket.durable-object.js +138 -0
  79. package/dist/websocket/websocket.durable-object.js.map +1 -0
  80. package/dist/websocket/ws-attachment.js +79 -0
  81. package/dist/websocket/ws-attachment.js.map +1 -0
  82. package/dist/websocket/ws-server-holder.js +38 -0
  83. package/dist/websocket/ws-server-holder.js.map +1 -0
  84. package/package.json +9 -7
  85. package/dist/durable-objects.js.map +0 -1
  86. package/dist/index.js.map +0 -1
  87. package/dist/nonce-validation-Dy8z05A9.js +0 -522
  88. package/dist/nonce-validation-Dy8z05A9.js.map +0 -1
  89. package/dist/queues.js.map +0 -1
@@ -0,0 +1,56 @@
1
+ import { registerCloudflareEnvironment } from "../environment.js";
2
+ import { CfWsClient } from "./cf-ws-client.js";
3
+ import { initDoLive, initializeDoLiveResources } from "./do-live.js";
4
+ import { WsServerHolder } from "./ws-server-holder.js";
5
+ import { CfRoomRegistry } from "./cf-room-registry.js";
6
+ import { VelaApplication } from "@velajs/vela";
7
+ import { WS_SERVER, WsDispatcher, WsServerImpl, local, readWsEntrypointMeta } from "@velajs/vela/websocket";
8
+ import { bootstrap } from "@velajs/vela/internal";
9
+ //#region src/websocket/do-bootstrap.ts
10
+ /**
11
+ * Slim DI bootstrap for the Durable Object isolate: wires the container and runs
12
+ * `OnModuleInit`/`OnApplicationBootstrap` (so `WsDispatcher` discovers gateways)
13
+ * WITHOUT building the Hono app/routes the DO never serves. The DO's `env` is
14
+ * seeded as the global ENV before providers construct, and the ctx-backed server
15
+ * is bound before bootstrap lifecycle so gateway `afterInit`/handlers see it.
16
+ */
17
+ async function buildDoRuntime(rootModule, ctx, options) {
18
+ const { container, routeManager, loader } = await bootstrap(rootModule, { configureContainer: (container) => {
19
+ registerCloudflareEnvironment(container, options.env);
20
+ } });
21
+ const registry = new CfRoomRegistry(ctx);
22
+ const driver = local();
23
+ driver.bind(registry);
24
+ const server = new WsServerImpl(driver);
25
+ if (container.has(WS_SERVER)) {
26
+ const holder = await container.resolveAsync(WS_SERVER);
27
+ if (!(holder instanceof WsServerHolder)) throw new Error("[vela] The WebSocket Durable Object found a WS_SERVER from the core WebSocketModule, which cannot reach Durable Object sockets. On Cloudflare, import CloudflareWebSocketModule.forRoot() instead of WebSocketModule.forRoot().");
28
+ holder.setTarget(server);
29
+ }
30
+ const app = new VelaApplication(container, routeManager);
31
+ app.setInstances(await loader.resolveAllInstances());
32
+ initializeDoLiveResources(container, ctx);
33
+ await app.callOnModuleInit();
34
+ await app.callOnApplicationBootstrap();
35
+ const wsEntrypoints = app.entrypoints.ofKind("websocket", readWsEntrypointMeta);
36
+ const dispatcher = wsEntrypoints[0]?.meta.dispatcher ?? app.get(WsDispatcher);
37
+ registry.setSendPolicyResolver((path) => wsEntrypoints.find((entry) => entry.meta.path === path)?.meta.options.sendPolicy);
38
+ registry.setFrameLimitResolver((path) => dispatcher.getGatewayMaxFrameBytes(path));
39
+ registry.setDeliveryAuthorizer((client) => {
40
+ const path = client instanceof CfWsClient ? client.path : "";
41
+ return dispatcher.authorizeDelivery(path, client);
42
+ });
43
+ const live = initDoLive(app, ctx, registry);
44
+ return {
45
+ dispatcher,
46
+ registry,
47
+ server,
48
+ gatewayPaths: wsEntrypoints.map((ep) => ep.meta.path),
49
+ live,
50
+ close: (signal) => app.close(signal)
51
+ };
52
+ }
53
+ //#endregion
54
+ export { buildDoRuntime };
55
+
56
+ //# sourceMappingURL=do-bootstrap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"do-bootstrap.js","names":[],"sources":["../../src/websocket/do-bootstrap.ts"],"sourcesContent":["import { VelaApplication } from '@velajs/vela';\nimport { bootstrap } from '@velajs/vela/internal';\nimport type { DynamicModule, Type, VelaEnv } from '@velajs/vela';\nimport {\n local,\n readWsEntrypointMeta,\n WsDispatcher,\n WsServerImpl,\n WS_SERVER,\n} from '@velajs/vela/websocket';\nimport type { WsServer } from '@velajs/vela/websocket';\nimport type { LiveEngine } from '@velajs/vela/live';\nimport { registerCloudflareEnvironment } from '../environment';\nimport { CfWsClient } from './cf-ws-client';\nimport { CfRoomRegistry } from './cf-room-registry';\nimport { initDoLive, initializeDoLiveResources } from './do-live';\nimport type { DoStateLike } from './do-state';\nimport { WsServerHolder } from './ws-server-holder';\n\nexport interface DoRuntime {\n dispatcher: WsDispatcher;\n registry: CfRoomRegistry;\n server: WsServer;\n /** Gateway paths from `app.entrypoints.ofKind('websocket')` (discovery order). */\n gatewayPaths: string[];\n /** The live-query engine (undefined when the app doesn't import LiveModule). */\n live?: LiveEngine;\n close(signal?: string): Promise<void>;\n}\n\n/**\n * Slim DI bootstrap for the Durable Object isolate: wires the container and runs\n * `OnModuleInit`/`OnApplicationBootstrap` (so `WsDispatcher` discovers gateways)\n * WITHOUT building the Hono app/routes the DO never serves. The DO's `env` is\n * seeded as the global ENV before providers construct, and the ctx-backed server\n * is bound before bootstrap lifecycle so gateway `afterInit`/handlers see it.\n */\nexport async function buildDoRuntime(\n rootModule: Type | DynamicModule,\n ctx: DoStateLike,\n options: { env: VelaEnv },\n): Promise<DoRuntime> {\n const { container, routeManager, loader } = await bootstrap(rootModule, {\n configureContainer: (container) => {\n registerCloudflareEnvironment(container, options.env);\n },\n });\n\n const registry = new CfRoomRegistry(ctx);\n const driver = local();\n driver.bind(registry);\n const server = new WsServerImpl(driver);\n\n if (container.has(WS_SERVER)) {\n const holder = await container.resolveAsync(WS_SERVER);\n // The core WebSocketModule's server broadcasts through its own sync driver,\n // which never reaches this Durable Object's hibernatable sockets.\n if (!(holder instanceof WsServerHolder)) {\n throw new Error(\n '[vela] The WebSocket Durable Object found a WS_SERVER from the core WebSocketModule, ' +\n 'which cannot reach Durable Object sockets. On Cloudflare, import ' +\n 'CloudflareWebSocketModule.forRoot() instead of WebSocketModule.forRoot().',\n );\n }\n holder.setTarget(server);\n }\n\n const app = new VelaApplication(container, routeManager);\n app.setInstances(await loader.resolveAllInstances());\n initializeDoLiveResources(container, ctx);\n await app.callOnModuleInit();\n await app.callOnApplicationBootstrap();\n\n // The entrypoint registry is the transport contract: one 'websocket' entry\n // per discovered gateway ({ meta: { path, dispatcher } }). Built by\n // callOnApplicationBootstrap(), so this slim no-routes path has it too.\n const wsEntrypoints = app.entrypoints.ofKind('websocket', readWsEntrypointMeta);\n const dispatcher = wsEntrypoints[0]?.meta.dispatcher ?? app.get(WsDispatcher);\n registry.setSendPolicyResolver(\n (path) => wsEntrypoints.find((entry) => entry.meta.path === path)?.meta.options.sendPolicy,\n );\n registry.setFrameLimitResolver((path) => dispatcher.getGatewayMaxFrameBytes(path));\n registry.setDeliveryAuthorizer((client) => {\n const path = client instanceof CfWsClient ? client.path : '';\n return dispatcher.authorizeDelivery(path, client);\n });\n\n // Live queries: wire the SQLite cursor log + local driver mode and replay\n // hibernation-persisted subscriptions into the fresh engine.\n const live = initDoLive(app, ctx, registry);\n\n return {\n // Zero gateways still yields a live dispatcher (module imported, nothing\n // decorated) — fall back to resolving it directly.\n dispatcher,\n registry,\n server,\n gatewayPaths: wsEntrypoints.map((ep) => ep.meta.path),\n live,\n close: (signal?: string) => app.close(signal),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAqCA,eAAsB,eACpB,YACA,KACA,SACoB;CACpB,MAAM,EAAE,WAAW,cAAc,WAAW,MAAM,UAAU,YAAY,EACtE,qBAAqB,cAAc;EACjC,8BAA8B,WAAW,QAAQ,GAAG;CACtD,EACF,CAAC;CAED,MAAM,WAAW,IAAI,eAAe,GAAG;CACvC,MAAM,SAAS,MAAM;CACrB,OAAO,KAAK,QAAQ;CACpB,MAAM,SAAS,IAAI,aAAa,MAAM;CAEtC,IAAI,UAAU,IAAI,SAAS,GAAG;EAC5B,MAAM,SAAS,MAAM,UAAU,aAAa,SAAS;EAGrD,IAAI,EAAE,kBAAkB,iBACtB,MAAM,IAAI,MACR,iOAGF;EAEF,OAAO,UAAU,MAAM;CACzB;CAEA,MAAM,MAAM,IAAI,gBAAgB,WAAW,YAAY;CACvD,IAAI,aAAa,MAAM,OAAO,oBAAoB,CAAC;CACnD,0BAA0B,WAAW,GAAG;CACxC,MAAM,IAAI,iBAAiB;CAC3B,MAAM,IAAI,2BAA2B;CAKrC,MAAM,gBAAgB,IAAI,YAAY,OAAO,aAAa,oBAAoB;CAC9E,MAAM,aAAa,cAAc,EAAE,EAAE,KAAK,cAAc,IAAI,IAAI,YAAY;CAC5E,SAAS,uBACN,SAAS,cAAc,MAAM,UAAU,MAAM,KAAK,SAAS,IAAI,CAAC,EAAE,KAAK,QAAQ,UAClF;CACA,SAAS,uBAAuB,SAAS,WAAW,wBAAwB,IAAI,CAAC;CACjF,SAAS,uBAAuB,WAAW;EACzC,MAAM,OAAO,kBAAkB,aAAa,OAAO,OAAO;EAC1D,OAAO,WAAW,kBAAkB,MAAM,MAAM;CAClD,CAAC;CAID,MAAM,OAAO,WAAW,KAAK,KAAK,QAAQ;CAE1C,OAAO;EAGL;EACA;EACA;EACA,cAAc,cAAc,KAAK,OAAO,GAAG,KAAK,IAAI;EACpD;EACA,QAAQ,WAAoB,IAAI,MAAM,MAAM;CAC9C;AACF"}
@@ -0,0 +1,184 @@
1
+ import { roomToDurableId } from "./room-id.js";
2
+ import { CfWsClient } from "./cf-ws-client.js";
3
+ import { LIVE_CURSOR_LOG, LIVE_DRIVER, LiveEngine, readPersistedLiveSubscriptions } from "@velajs/vela/live";
4
+ //#region src/websocket/do-live.ts
5
+ const DEFAULT_ROOM = "default";
6
+ const DEFAULT_MAX_LOG_ROWS = 4096;
7
+ /**
8
+ * The durable `CursorLog`: an append-only tag-invalidation log in the DO's
9
+ * SQLite (`__vela_live_log`, AUTOINCREMENT seq = cursor) plus an epoch UUID in
10
+ * `__vela_live_meta`. Because the cursor survives hibernation AND trims (it is
11
+ * read from `sqlite_sequence`, lunora's `ctx-db-cdc.ts` trick), a reconnecting
12
+ * client whose gap the log still covers gets a tiny `resume` instead of a
13
+ * re-run — the real-resume half of the live protocol.
14
+ *
15
+ * Constructed un-initialized at module-composition time (the same app module
16
+ * bootstraps in the Worker AND in each DO); `initDoLive` wires the SQLite
17
+ * handle inside the DO. In the Worker isolate it stays un-initialized — and is
18
+ * never consulted there, because `durableObjectLive()` routes every
19
+ * invalidation to the room DO's log (one log scope per room, exactly the
20
+ * protocol's model).
21
+ */
22
+ var DoCursorLog = class {
23
+ maxRows;
24
+ sql;
25
+ epoch;
26
+ constructor(maxRows = DEFAULT_MAX_LOG_ROWS) {
27
+ this.maxRows = maxRows;
28
+ }
29
+ /** @internal — called by `initDoLive` with the DO's `ctx.storage.sql`. */
30
+ _initialize(sql) {
31
+ this.sql = sql;
32
+ sql.exec("CREATE TABLE IF NOT EXISTS __vela_live_log (seq INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL NOT NULL, tags TEXT NOT NULL)");
33
+ sql.exec("CREATE TABLE IF NOT EXISTS __vela_live_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)");
34
+ const row = sql.exec("SELECT v FROM __vela_live_meta WHERE k = 'epoch'").toArray()[0];
35
+ if (row && typeof row.v === "string") this.epoch = row.v;
36
+ else {
37
+ this.epoch = crypto.randomUUID();
38
+ sql.exec("INSERT INTO __vela_live_meta (k, v) VALUES ('epoch', ?)", this.epoch);
39
+ }
40
+ }
41
+ append(tags) {
42
+ const sql = this.assertReady();
43
+ sql.exec("INSERT INTO __vela_live_log (ts, tags) VALUES (?, ?)", Date.now(), JSON.stringify(tags));
44
+ const stamp = this.current();
45
+ if (stamp.cursor > this.maxRows) sql.exec("DELETE FROM __vela_live_log WHERE seq <= ?", stamp.cursor - this.maxRows);
46
+ return stamp;
47
+ }
48
+ current() {
49
+ const sql = this.assertReady();
50
+ let cursor = 0;
51
+ try {
52
+ const row = sql.exec("SELECT seq FROM sqlite_sequence WHERE name = '__vela_live_log'").toArray()[0];
53
+ cursor = typeof row?.seq === "number" ? row.seq : Number(row?.seq ?? 0);
54
+ } catch {
55
+ cursor = 0;
56
+ }
57
+ if (!this.epoch) throw new Error("DoCursorLog epoch is not initialized.");
58
+ return {
59
+ cursor,
60
+ epoch: this.epoch
61
+ };
62
+ }
63
+ evaluateResume(sinceCursor, sinceEpoch, subscriptionTags) {
64
+ const sql = this.assertReady();
65
+ const { cursor, epoch } = this.current();
66
+ if (sinceEpoch !== epoch) return "snapshot";
67
+ if (sinceCursor > cursor) return "snapshot";
68
+ if (sinceCursor === cursor) return "resume";
69
+ const minRow = sql.exec("SELECT MIN(seq) AS m FROM __vela_live_log").toArray()[0];
70
+ const min = minRow?.m == null ? void 0 : Number(minRow.m);
71
+ if (min === void 0 || min > sinceCursor + 1) return "snapshot";
72
+ const subTags = new Set(subscriptionTags);
73
+ for (const row of sql.exec("SELECT tags FROM __vela_live_log WHERE seq > ?", sinceCursor).toArray()) {
74
+ let tags;
75
+ try {
76
+ tags = JSON.parse(String(row.tags));
77
+ } catch {
78
+ return "snapshot";
79
+ }
80
+ if (Array.isArray(tags) && tags.some((tag) => typeof tag === "string" && subTags.has(tag))) return "rerun";
81
+ }
82
+ return "resume";
83
+ }
84
+ assertReady() {
85
+ if (!this.sql) throw new Error("DoCursorLog is not initialized. It only runs inside a SQLite-backed Durable Object (wrangler: new_sqlite_classes) — Worker-side invalidations must go through durableObjectLive(), which routes them to the room DO's log.");
86
+ return this.sql;
87
+ }
88
+ };
89
+ /** One driver per application; construct from a LiveModule driver factory. */
90
+ var CfLiveDriver = class {
91
+ options;
92
+ kind = "durable-object";
93
+ sink;
94
+ localMode = false;
95
+ constructor(options) {
96
+ this.options = options;
97
+ }
98
+ bind(sink) {
99
+ this.sink = sink;
100
+ }
101
+ /** @internal — a DO dispatches to its own engine and SQLite log. */
102
+ _setLocalMode() {
103
+ this.localMode = true;
104
+ }
105
+ dispatch(cmd) {
106
+ if (this.localMode) return this.sink?.applyInvalidation(cmd);
107
+ const { namespace, gatewayPath, defaultRoom } = this.options;
108
+ const room = cmd.room ?? defaultRoom ?? DEFAULT_ROOM;
109
+ return namespace.get(roomToDurableId(namespace, gatewayPath, room)).invalidate({
110
+ ...cmd,
111
+ room
112
+ });
113
+ }
114
+ };
115
+ /** Use in LiveModule.forRootAsync: driver: () => durableObjectLive({ namespace: env.ROOMS, ... }). */
116
+ function durableObjectLive(options) {
117
+ return new CfLiveDriver(options);
118
+ }
119
+ /** @internal — prepare per-DO resources before user lifecycle hooks can invalidate. */
120
+ function initializeDoLiveResources(container, ctx) {
121
+ if (container.has(LIVE_CURSOR_LOG)) {
122
+ const log = container.resolve(LIVE_CURSOR_LOG);
123
+ if (log instanceof DoCursorLog) {
124
+ const sql = ctx.storage?.sql;
125
+ if (!sql) throw new Error("DoCursorLog requires a SQLite-backed Durable Object: add this class to wrangler's `migrations[].new_sqlite_classes`. Falling back is not possible — either enable SQLite or drop the `log: () => durableObjectCursorLog()` option (snapshot-on-reconnect semantics).");
126
+ log._initialize(sql);
127
+ }
128
+ }
129
+ if (container.has(LIVE_DRIVER)) {
130
+ const driver = container.resolve(LIVE_DRIVER);
131
+ if (driver instanceof CfLiveDriver) driver._setLocalMode();
132
+ }
133
+ }
134
+ /**
135
+ * DO-side wiring after lifecycle, called from `buildDoRuntime`: replay every hibernation-persisted
136
+ * subscription into the (fresh) engine so an eviction is invisible to
137
+ * subscribers. Returns the engine for the `invalidate` RPC, or undefined when
138
+ * the app doesn't use LiveModule.
139
+ */
140
+ function initDoLive(app, ctx, registry) {
141
+ const entry = app.entrypoints.ofKind("live")[0];
142
+ if (!entry) return void 0;
143
+ if (typeof entry.meta !== "object" || entry.meta === null || !("engine" in entry.meta)) throw new Error("Invalid live entrypoint metadata.");
144
+ const engine = entry.meta.engine;
145
+ if (!(engine instanceof LiveEngine)) throw new Error("Invalid live entrypoint engine.");
146
+ for (const ws of ctx.getWebSockets()) {
147
+ const client = registry?.clientFor(ws) ?? new CfWsClient(ctx, ws);
148
+ if (!client.id) continue;
149
+ for (const record of readPersistedLiveSubscriptions(client)) engine.restoreSubscription(client.path, client, record);
150
+ }
151
+ return engine;
152
+ }
153
+ let workerLocalLiveWarned = false;
154
+ /**
155
+ * @internal Worker-isolate check, warned once per isolate. `localLive()` hands
156
+ * invalidations to this isolate's own engine, but subscriptions are held by
157
+ * the WebSocket Durable Object, so Worker-side writes would never reach them.
158
+ */
159
+ async function warnWorkerLocalLive(container) {
160
+ if (workerLocalLiveWarned || container.getDiagnostics() === "silent") return;
161
+ if (!container.has(LIVE_DRIVER)) return;
162
+ if ((await container.resolveAsync(LIVE_DRIVER)).kind !== "local") return;
163
+ workerLocalLiveWarned = true;
164
+ console.warn("[vela] LiveModule is running localLive() in the Worker isolate: its subscriptions live in the WebSocket Durable Object, so invalidations sent from the Worker never reach them. Pass driver: () => durableObjectLive({ namespace, gatewayPath }) to LiveModule.");
165
+ }
166
+ /** Ergonomic alias: the log option for `LiveModule.forRoot` on Cloudflare. */
167
+ function durableObjectCursorLog(maxRows) {
168
+ return new DoCursorLog(maxRows);
169
+ }
170
+ /**
171
+ * Invalidate live tags in a room from a Worker (controller / cron / queue
172
+ * consumer) — the live sibling of `broadcastToRoom`. Returns the room log
173
+ * scope's commit stamp for `Vela-Commit-Cursor` stamping.
174
+ */
175
+ async function liveInvalidateToRoom(ns, gatewayPath, room, tags) {
176
+ return ns.get(roomToDurableId(ns, gatewayPath, room)).invalidate({
177
+ room,
178
+ tags
179
+ });
180
+ }
181
+ //#endregion
182
+ export { CfLiveDriver, DoCursorLog, durableObjectCursorLog, durableObjectLive, initDoLive, initializeDoLiveResources, liveInvalidateToRoom, warnWorkerLocalLive };
183
+
184
+ //# sourceMappingURL=do-live.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"do-live.js","names":[],"sources":["../../src/websocket/do-live.ts"],"sourcesContent":["import type { CfRoomRegistry } from './cf-room-registry';\nimport type { Container } from '@velajs/vela/module-kit';\nimport {\n LIVE_CURSOR_LOG,\n LIVE_DRIVER,\n LiveEngine,\n readPersistedLiveSubscriptions,\n} from '@velajs/vela/live';\nimport type {\n CommitStamp,\n CursorLog,\n InvalidationCommand,\n LiveDriver,\n LiveInvalidationSink,\n ResumeVerdict,\n} from '@velajs/vela/live';\nimport { CfWsClient } from './cf-ws-client';\nimport type { DoStateLike, SqlStorageLike } from './do-state';\nimport { roomToDurableId } from './room-id';\n\nconst DEFAULT_ROOM = 'default';\nconst DEFAULT_MAX_LOG_ROWS = 4096;\n\n/**\n * The durable `CursorLog`: an append-only tag-invalidation log in the DO's\n * SQLite (`__vela_live_log`, AUTOINCREMENT seq = cursor) plus an epoch UUID in\n * `__vela_live_meta`. Because the cursor survives hibernation AND trims (it is\n * read from `sqlite_sequence`, lunora's `ctx-db-cdc.ts` trick), a reconnecting\n * client whose gap the log still covers gets a tiny `resume` instead of a\n * re-run — the real-resume half of the live protocol.\n *\n * Constructed un-initialized at module-composition time (the same app module\n * bootstraps in the Worker AND in each DO); `initDoLive` wires the SQLite\n * handle inside the DO. In the Worker isolate it stays un-initialized — and is\n * never consulted there, because `durableObjectLive()` routes every\n * invalidation to the room DO's log (one log scope per room, exactly the\n * protocol's model).\n */\nexport class DoCursorLog implements CursorLog {\n private sql?: SqlStorageLike;\n private epoch?: string;\n\n constructor(private readonly maxRows = DEFAULT_MAX_LOG_ROWS) {}\n\n /** @internal — called by `initDoLive` with the DO's `ctx.storage.sql`. */\n _initialize(sql: SqlStorageLike): void {\n this.sql = sql;\n sql.exec(\n 'CREATE TABLE IF NOT EXISTS __vela_live_log (seq INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL NOT NULL, tags TEXT NOT NULL)',\n );\n sql.exec('CREATE TABLE IF NOT EXISTS __vela_live_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)');\n const row = sql.exec(\"SELECT v FROM __vela_live_meta WHERE k = 'epoch'\").toArray()[0];\n if (row && typeof row.v === 'string') {\n this.epoch = row.v;\n } else {\n this.epoch = crypto.randomUUID();\n sql.exec(\"INSERT INTO __vela_live_meta (k, v) VALUES ('epoch', ?)\", this.epoch);\n }\n }\n\n append(tags: string[]): CommitStamp {\n const sql = this.assertReady();\n sql.exec(\n 'INSERT INTO __vela_live_log (ts, tags) VALUES (?, ?)',\n Date.now(),\n JSON.stringify(tags),\n );\n const stamp = this.current();\n // Bounded retention: trimmed gaps degrade to snapshot-on-reconnect.\n if (stamp.cursor > this.maxRows) {\n sql.exec('DELETE FROM __vela_live_log WHERE seq <= ?', stamp.cursor - this.maxRows);\n }\n return stamp;\n }\n\n current(): CommitStamp {\n const sql = this.assertReady();\n // sqlite_sequence survives DELETE-based trims, so the cursor never\n // rewinds. The table itself only materializes on the first AUTOINCREMENT\n // insert — before that the log is empty and the cursor is 0.\n let cursor = 0;\n try {\n const row = sql\n .exec(\"SELECT seq FROM sqlite_sequence WHERE name = '__vela_live_log'\")\n .toArray()[0];\n cursor = typeof row?.seq === 'number' ? row.seq : Number(row?.seq ?? 0);\n } catch {\n cursor = 0;\n }\n if (!this.epoch) throw new Error('DoCursorLog epoch is not initialized.');\n return { cursor, epoch: this.epoch };\n }\n\n evaluateResume(\n sinceCursor: number,\n sinceEpoch: string,\n subscriptionTags: string[],\n ): ResumeVerdict {\n const sql = this.assertReady();\n const { cursor, epoch } = this.current();\n if (sinceEpoch !== epoch) return 'snapshot'; // forked timeline (reset/recreated DO)\n if (sinceCursor > cursor) return 'snapshot'; // rollback guard\n if (sinceCursor === cursor) return 'resume';\n\n const minRow = sql.exec('SELECT MIN(seq) AS m FROM __vela_live_log').toArray()[0];\n const min = minRow?.m == null ? undefined : Number(minRow.m);\n // The log must still cover (sinceCursor, cursor] — a trimmed gap cannot be reasoned about.\n if (min === undefined || min > sinceCursor + 1) return 'snapshot';\n\n const subTags = new Set(subscriptionTags);\n for (const row of sql\n .exec('SELECT tags FROM __vela_live_log WHERE seq > ?', sinceCursor)\n .toArray()) {\n let tags: unknown;\n try {\n tags = JSON.parse(String(row.tags));\n } catch {\n return 'snapshot';\n }\n if (\n Array.isArray(tags) &&\n tags.some((tag: unknown) => typeof tag === 'string' && subTags.has(tag))\n )\n return 'rerun';\n }\n return 'resume';\n }\n\n private assertReady(): SqlStorageLike {\n if (!this.sql) {\n throw new Error(\n 'DoCursorLog is not initialized. It only runs inside a SQLite-backed Durable Object ' +\n '(wrangler: new_sqlite_classes) — Worker-side invalidations must go through durableObjectLive(), ' +\n \"which routes them to the room DO's log.\",\n );\n }\n return this.sql;\n }\n}\n\nexport interface DurableObjectLiveOptions {\n /** Native, RPC-typed namespace supplied by the application's environment. */\n namespace: LiveNamespace;\n /** Exact `@WebSocketGateway()` path sharing this room/log namespace. */\n gatewayPath: string;\n /** Room used when an invalidation names none. Matches the client default. */\n defaultRoom?: string;\n}\n\nexport interface LiveInvalidateStub {\n invalidate(cmd: InvalidationCommand): Promise<CommitStamp | undefined>;\n}\n\n/** Only the native namespace operations required for live invalidation. */\nexport interface LiveNamespace {\n idFromName(name: string): DurableObjectId;\n get(id: DurableObjectId): LiveInvalidateStub;\n}\n\n/** One driver per application; construct from a LiveModule driver factory. */\nexport class CfLiveDriver implements LiveDriver {\n readonly kind = 'durable-object';\n private sink: LiveInvalidationSink | undefined;\n private localMode = false;\n\n constructor(private readonly options: DurableObjectLiveOptions) {}\n\n bind(sink: LiveInvalidationSink): void {\n this.sink = sink;\n }\n\n /** @internal — a DO dispatches to its own engine and SQLite log. */\n _setLocalMode(): void {\n this.localMode = true;\n }\n\n dispatch(cmd: InvalidationCommand): Promise<CommitStamp | undefined> | CommitStamp | undefined {\n if (this.localMode) return this.sink?.applyInvalidation(cmd);\n const { namespace, gatewayPath, defaultRoom } = this.options;\n const room = cmd.room ?? defaultRoom ?? DEFAULT_ROOM;\n return namespace\n .get(roomToDurableId(namespace, gatewayPath, room))\n .invalidate({ ...cmd, room });\n }\n}\n\n/** Use in LiveModule.forRootAsync: driver: () => durableObjectLive({ namespace: env.ROOMS, ... }). */\nexport function durableObjectLive(options: DurableObjectLiveOptions): CfLiveDriver {\n return new CfLiveDriver(options);\n}\n\n/** The app-facing surface of the engine reached through `app.entrypoints.ofKind('live')`. */\ninterface EntrypointsApp {\n entrypoints: { ofKind(kind: string): Array<{ meta: unknown }> };\n}\n\n/** @internal — prepare per-DO resources before user lifecycle hooks can invalidate. */\nexport function initializeDoLiveResources(container: Container, ctx: DoStateLike): void {\n if (container.has(LIVE_CURSOR_LOG)) {\n const log = container.resolve(LIVE_CURSOR_LOG);\n if (log instanceof DoCursorLog) {\n const sql = ctx.storage?.sql;\n if (!sql) {\n throw new Error(\n 'DoCursorLog requires a SQLite-backed Durable Object: add this class to ' +\n \"wrangler's `migrations[].new_sqlite_classes`. Falling back is not possible — \" +\n 'either enable SQLite or drop the `log: () => durableObjectCursorLog()` option ' +\n '(snapshot-on-reconnect semantics).',\n );\n }\n log._initialize(sql);\n }\n }\n\n if (container.has(LIVE_DRIVER)) {\n const driver = container.resolve(LIVE_DRIVER);\n if (driver instanceof CfLiveDriver) driver._setLocalMode();\n }\n}\n\n/**\n * DO-side wiring after lifecycle, called from `buildDoRuntime`: replay every hibernation-persisted\n * subscription into the (fresh) engine so an eviction is invisible to\n * subscribers. Returns the engine for the `invalidate` RPC, or undefined when\n * the app doesn't use LiveModule.\n */\nexport function initDoLive(\n app: EntrypointsApp,\n ctx: DoStateLike,\n registry?: CfRoomRegistry,\n): LiveEngine | undefined {\n const entry = app.entrypoints.ofKind('live')[0];\n if (!entry) return undefined;\n if (typeof entry.meta !== 'object' || entry.meta === null || !('engine' in entry.meta)) {\n throw new Error('Invalid live entrypoint metadata.');\n }\n const engine = entry.meta.engine;\n if (!(engine instanceof LiveEngine)) throw new Error('Invalid live entrypoint engine.');\n\n // Wake-time replay: subscriptions ride the hibernation attachments.\n for (const ws of ctx.getWebSockets()) {\n const client = registry?.clientFor(ws) ?? new CfWsClient(ctx, ws);\n if (!client.id) continue;\n for (const record of readPersistedLiveSubscriptions(client)) {\n engine.restoreSubscription(client.path, client, record);\n }\n }\n\n return engine;\n}\n\nlet workerLocalLiveWarned = false;\n\n/**\n * @internal Worker-isolate check, warned once per isolate. `localLive()` hands\n * invalidations to this isolate's own engine, but subscriptions are held by\n * the WebSocket Durable Object, so Worker-side writes would never reach them.\n */\nexport async function warnWorkerLocalLive(container: Container): Promise<void> {\n if (workerLocalLiveWarned || container.getDiagnostics() === 'silent') return;\n if (!container.has(LIVE_DRIVER)) return;\n const driver = await container.resolveAsync(LIVE_DRIVER);\n if (driver.kind !== 'local') return;\n workerLocalLiveWarned = true;\n console.warn(\n '[vela] LiveModule is running localLive() in the Worker isolate: its subscriptions live in ' +\n 'the WebSocket Durable Object, so invalidations sent from the Worker never reach them. ' +\n 'Pass driver: () => durableObjectLive({ namespace, gatewayPath }) to LiveModule.',\n );\n}\n\n/** Ergonomic alias: the log option for `LiveModule.forRoot` on Cloudflare. */\nexport function durableObjectCursorLog(maxRows?: number): DoCursorLog {\n return new DoCursorLog(maxRows);\n}\n\n/**\n * Invalidate live tags in a room from a Worker (controller / cron / queue\n * consumer) — the live sibling of `broadcastToRoom`. Returns the room log\n * scope's commit stamp for `Vela-Commit-Cursor` stamping.\n */\nexport async function liveInvalidateToRoom(\n ns: LiveNamespace,\n gatewayPath: string,\n room: string,\n tags: string[],\n): Promise<CommitStamp | undefined> {\n const stub = ns.get(roomToDurableId(ns, gatewayPath, room));\n return stub.invalidate({ room, tags });\n}\n"],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ //#region src/websocket/do-pitr.ts
2
+ const PITR_UNAVAILABLE_CODE = "PITR_UNAVAILABLE";
3
+ /**
4
+ * Thrown when a DO's storage lacks the SQLite bookmark API (non-SQLite DO, or a
5
+ * local runtime without PITR). Carries a stable `code` + HTTP 409 `status`, and
6
+ * a recognizable `name`/message so the studio port can map it to
7
+ * `TIMETRAVEL_UNAVAILABLE` even after the error crosses the Worker→DO RPC hop
8
+ * (which preserves `name` + `message`, not arbitrary own-properties).
9
+ */
10
+ var DoPitrUnavailableError = class extends Error {
11
+ code = PITR_UNAVAILABLE_CODE;
12
+ status = 409;
13
+ constructor(message = "Durable Object point-in-time recovery is unavailable on this storage") {
14
+ super(`${PITR_UNAVAILABLE_CODE}: ${message}`);
15
+ this.name = "DoPitrUnavailableError";
16
+ }
17
+ };
18
+ /**
19
+ * True when `error` signals DO PITR unavailability. Robust across the Worker→DO
20
+ * RPC hop: checks the `code` own-property (same process) AND the `name` / message
21
+ * sentinel (survive RPC serialization) so a downstream can classify it either way.
22
+ */
23
+ function isDoPitrUnavailable(error) {
24
+ if (typeof error !== "object" || error === null) return false;
25
+ const record = error;
26
+ if (record.code === PITR_UNAVAILABLE_CODE) return true;
27
+ if (record.name === "DoPitrUnavailableError") return true;
28
+ return typeof record.message === "string" && record.message.startsWith(`${PITR_UNAVAILABLE_CODE}:`);
29
+ }
30
+ /** Normalize an epoch-ms number / ISO string / Date to the `number | Date` the DO API accepts. */
31
+ function toStorageTime(time) {
32
+ if (typeof time === "number") return time;
33
+ if (time instanceof Date) return time;
34
+ const asNumber = Number(time);
35
+ return time.trim() !== "" && Number.isFinite(asNumber) ? asNumber : new Date(time);
36
+ }
37
+ /**
38
+ * Read a DO's current bookmark, and — when `time` is given — the bookmark closest
39
+ * to that instant. Throws {@link DoPitrUnavailableError} when a needed method is
40
+ * absent, never `undefined is not a function`.
41
+ */
42
+ async function readDoPitrBookmark(storage, time) {
43
+ const getCurrent = storage.getCurrentBookmark;
44
+ if (typeof getCurrent !== "function") throw new DoPitrUnavailableError();
45
+ const current = await getCurrent.call(storage);
46
+ if (time === void 0) return { current };
47
+ const getForTime = storage.getBookmarkForTime;
48
+ if (typeof getForTime !== "function") throw new DoPitrUnavailableError();
49
+ return {
50
+ current,
51
+ forTime: await getForTime.call(storage, toStorageTime(time))
52
+ };
53
+ }
54
+ /**
55
+ * Arm a PITR restore. Resolves the target (an explicit `bookmark` WINS over
56
+ * `time`), arms it via `onNextSessionRestoreBookmark`, and returns the undo
57
+ * bookmark the DO reports for the pre-restore state. Does NOT abort — the caller
58
+ * decides whether to restart now. Throws {@link DoPitrUnavailableError} when the
59
+ * arming API (or the by-time resolver a `time` target needs) is absent.
60
+ */
61
+ async function armDoPitr(storage, opts) {
62
+ const armRestore = storage.onNextSessionRestoreBookmark;
63
+ if (typeof armRestore !== "function") throw new DoPitrUnavailableError();
64
+ let target;
65
+ if (opts.bookmark !== void 0) target = opts.bookmark;
66
+ else if (opts.time !== void 0) {
67
+ const getForTime = storage.getBookmarkForTime;
68
+ if (typeof getForTime !== "function") throw new DoPitrUnavailableError();
69
+ target = await getForTime.call(storage, toStorageTime(opts.time));
70
+ } else throw new DoPitrUnavailableError("a target bookmark or time is required to arm a restore");
71
+ const undoBookmark = await armRestore.call(storage, target);
72
+ return {
73
+ restoredTo: target,
74
+ undoBookmark,
75
+ restarted: opts.restart === true
76
+ };
77
+ }
78
+ //#endregion
79
+ export { DoPitrUnavailableError, armDoPitr, isDoPitrUnavailable, readDoPitrBookmark };
80
+
81
+ //# sourceMappingURL=do-pitr.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"do-pitr.js","names":[],"sources":["../../src/websocket/do-pitr.ts"],"sourcesContent":["/**\n * Durable Object point-in-time recovery (PITR) — thin, testable wrappers over a\n * SQLite-backed DO's native bookmark API. A SQLite Durable Object exposes three\n * storage methods (last-30-days PITR):\n *\n * - `getCurrentBookmark()` — an opaque bookmark for the storage's current state.\n * - `getBookmarkForTime(t)` — the bookmark closest to a wall-clock instant.\n * - `onNextSessionRestoreBookmark(b)` — arm a restore to bookmark `b`; the DO\n * restores to it the next time it starts a session, and the call RETURNS a\n * bookmark for the state JUST BEFORE the restore (the undo handle).\n *\n * These methods are ABSENT on a non-SQLite DO (key-value storage) and in some\n * local-dev runtimes, so this module models storage structurally with all three\n * methods OPTIONAL and degrades to a typed {@link DoPitrUnavailableError} (a\n * `code: 'PITR_UNAVAILABLE'`, HTTP 409 error) rather than an\n * `undefined is not a function` TypeError when a needed method is missing.\n *\n * Neither wrapper aborts the DO — `armDoPitr` only ARMS the restore and returns\n * the undo bookmark; the caller (the WS-DO RPC method) decides whether to\n * `ctx.abort()` to apply it immediately vs. on the next natural restart.\n *\n * This file is `cloudflare:workers`-free and pulls in NOTHING from `@velajs/vela`\n * or `@velajs/studio` — it is the raw capability the studio `TimeTravelPort`\n * wraps. Dependency direction is one-way: studio → cloudflare, never the reverse.\n */\n\n/**\n * The subset of `DurableObjectStorage` this module touches, with every method\n * OPTIONAL so it structurally models a non-SQLite DO whose storage has none of\n * them. A real `DurableObjectStorage` (whose methods are required) is assignable\n * to this shape.\n */\nexport interface DoPitrStorage {\n getCurrentBookmark?(): Promise<string>;\n getBookmarkForTime?(timestamp: number | Date): Promise<string>;\n onNextSessionRestoreBookmark?(bookmark: string): Promise<string>;\n}\n\n/** A read of a DO's current bookmark (+ the by-time bookmark when a time is given). */\nexport interface DoPitrBookmarkRead {\n /** The bookmark for the DO storage's current state. */\n current: string;\n /** The bookmark closest to the requested time (only when `time` was passed). */\n forTime?: string;\n}\n\n/** Arming input for {@link armDoPitr}: a target (bookmark WINS over time) + restart intent. */\nexport interface DoPitrArmOptions {\n /** An explicit target bookmark. Takes precedence over `time`. */\n bookmark?: string;\n /** A wall-clock target (epoch ms, ISO string, or Date), resolved to a bookmark. */\n time?: number | string | Date;\n /** Caller intent to restart-now; recorded on the result. `armDoPitr` never aborts. */\n restart?: boolean;\n}\n\n/** The result of arming a PITR restore (before any restart is applied). */\nexport interface DoPitrArmResult {\n /** The bookmark the restore is armed to. */\n restoredTo: string;\n /** The bookmark for the pre-restore state — restore to this to undo. */\n undoBookmark: string;\n /** Whether a restart-now was requested (the RPC layer performs the actual abort). */\n restarted: boolean;\n}\n\n/** The RPC surface a PITR-capable Vela WebSocket DO stub exposes to a Worker. */\nexport interface VelaDoPitrRpc {\n pitrCurrentBookmark(): Promise<DoPitrBookmarkRead>;\n pitrBookmarkForTime(time: number | string): Promise<DoPitrBookmarkRead>;\n pitrArmRestore(opts: DoPitrArmOptions): Promise<DoPitrArmResult>;\n}\n\n/** Structural view of a DO id (avoids depending on `@cloudflare/workers-types` downstream). */\nexport interface DoPitrId {\n toString(): string;\n readonly name?: string | null;\n}\n\n/**\n * Structural view of a DO namespace binding whose stubs speak the PITR RPC. A\n * downstream (the studio `@velajs/studio/cloudflare` port) types the app's\n * namespace binding as this shape to reach the PITR methods without importing\n * `@cloudflare/workers-types`.\n */\nexport interface DoPitrNamespace {\n idFromName(name: string): DoPitrId;\n get(id: DoPitrId): VelaDoPitrRpc;\n}\n\nconst PITR_UNAVAILABLE_CODE = 'PITR_UNAVAILABLE';\n\n/**\n * Thrown when a DO's storage lacks the SQLite bookmark API (non-SQLite DO, or a\n * local runtime without PITR). Carries a stable `code` + HTTP 409 `status`, and\n * a recognizable `name`/message so the studio port can map it to\n * `TIMETRAVEL_UNAVAILABLE` even after the error crosses the Worker→DO RPC hop\n * (which preserves `name` + `message`, not arbitrary own-properties).\n */\nexport class DoPitrUnavailableError extends Error {\n readonly code = PITR_UNAVAILABLE_CODE;\n readonly status = 409;\n\n constructor(message = 'Durable Object point-in-time recovery is unavailable on this storage') {\n super(`${PITR_UNAVAILABLE_CODE}: ${message}`);\n this.name = 'DoPitrUnavailableError';\n }\n}\n\n/**\n * True when `error` signals DO PITR unavailability. Robust across the Worker→DO\n * RPC hop: checks the `code` own-property (same process) AND the `name` / message\n * sentinel (survive RPC serialization) so a downstream can classify it either way.\n */\nexport function isDoPitrUnavailable(error: unknown): boolean {\n if (typeof error !== 'object' || error === null) return false;\n const record = error as { code?: unknown; name?: unknown; message?: unknown };\n if (record.code === PITR_UNAVAILABLE_CODE) return true;\n if (record.name === 'DoPitrUnavailableError') return true;\n return (\n typeof record.message === 'string' && record.message.startsWith(`${PITR_UNAVAILABLE_CODE}:`)\n );\n}\n\n/** Normalize an epoch-ms number / ISO string / Date to the `number | Date` the DO API accepts. */\nfunction toStorageTime(time: number | string | Date): number | Date {\n if (typeof time === 'number') return time;\n if (time instanceof Date) return time;\n const asNumber = Number(time);\n return time.trim() !== '' && Number.isFinite(asNumber) ? asNumber : new Date(time);\n}\n\n/**\n * Read a DO's current bookmark, and — when `time` is given — the bookmark closest\n * to that instant. Throws {@link DoPitrUnavailableError} when a needed method is\n * absent, never `undefined is not a function`.\n */\nexport async function readDoPitrBookmark(\n storage: DoPitrStorage,\n time?: number | string | Date,\n): Promise<DoPitrBookmarkRead> {\n const getCurrent = storage.getCurrentBookmark;\n if (typeof getCurrent !== 'function') throw new DoPitrUnavailableError();\n const current = await getCurrent.call(storage);\n if (time === undefined) return { current };\n const getForTime = storage.getBookmarkForTime;\n if (typeof getForTime !== 'function') throw new DoPitrUnavailableError();\n const forTime = await getForTime.call(storage, toStorageTime(time));\n return { current, forTime };\n}\n\n/**\n * Arm a PITR restore. Resolves the target (an explicit `bookmark` WINS over\n * `time`), arms it via `onNextSessionRestoreBookmark`, and returns the undo\n * bookmark the DO reports for the pre-restore state. Does NOT abort — the caller\n * decides whether to restart now. Throws {@link DoPitrUnavailableError} when the\n * arming API (or the by-time resolver a `time` target needs) is absent.\n */\nexport async function armDoPitr(\n storage: DoPitrStorage,\n opts: DoPitrArmOptions,\n): Promise<DoPitrArmResult> {\n const armRestore = storage.onNextSessionRestoreBookmark;\n if (typeof armRestore !== 'function') throw new DoPitrUnavailableError();\n\n let target: string;\n if (opts.bookmark !== undefined) {\n target = opts.bookmark;\n } else if (opts.time !== undefined) {\n const getForTime = storage.getBookmarkForTime;\n if (typeof getForTime !== 'function') throw new DoPitrUnavailableError();\n target = await getForTime.call(storage, toStorageTime(opts.time));\n } else {\n throw new DoPitrUnavailableError('a target bookmark or time is required to arm a restore');\n }\n\n const undoBookmark = await armRestore.call(storage, target);\n return { restoredTo: target, undoBookmark, restarted: opts.restart === true };\n}\n"],"mappings":";AA0FA,MAAM,wBAAwB;;;;;;;;AAS9B,IAAa,yBAAb,cAA4C,MAAM;CAChD,OAAgB;CAChB,SAAkB;CAElB,YAAY,UAAU,wEAAwE;EAC5F,MAAM,GAAG,sBAAsB,IAAI,SAAS;EAC5C,KAAK,OAAO;CACd;AACF;;;;;;AAOA,SAAgB,oBAAoB,OAAyB;CAC3D,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CACxD,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,uBAAuB,OAAO;CAClD,IAAI,OAAO,SAAS,0BAA0B,OAAO;CACrD,OACE,OAAO,OAAO,YAAY,YAAY,OAAO,QAAQ,WAAW,GAAG,sBAAsB,EAAE;AAE/F;;AAGA,SAAS,cAAc,MAA6C;CAClE,IAAI,OAAO,SAAS,UAAU,OAAO;CACrC,IAAI,gBAAgB,MAAM,OAAO;CACjC,MAAM,WAAW,OAAO,IAAI;CAC5B,OAAO,KAAK,KAAK,MAAM,MAAM,OAAO,SAAS,QAAQ,IAAI,WAAW,IAAI,KAAK,IAAI;AACnF;;;;;;AAOA,eAAsB,mBACpB,SACA,MAC6B;CAC7B,MAAM,aAAa,QAAQ;CAC3B,IAAI,OAAO,eAAe,YAAY,MAAM,IAAI,uBAAuB;CACvE,MAAM,UAAU,MAAM,WAAW,KAAK,OAAO;CAC7C,IAAI,SAAS,KAAA,GAAW,OAAO,EAAE,QAAQ;CACzC,MAAM,aAAa,QAAQ;CAC3B,IAAI,OAAO,eAAe,YAAY,MAAM,IAAI,uBAAuB;CAEvE,OAAO;EAAE;EAAS,SAAA,MADI,WAAW,KAAK,SAAS,cAAc,IAAI,CAAC;CACxC;AAC5B;;;;;;;;AASA,eAAsB,UACpB,SACA,MAC0B;CAC1B,MAAM,aAAa,QAAQ;CAC3B,IAAI,OAAO,eAAe,YAAY,MAAM,IAAI,uBAAuB;CAEvE,IAAI;CACJ,IAAI,KAAK,aAAa,KAAA,GACpB,SAAS,KAAK;MACT,IAAI,KAAK,SAAS,KAAA,GAAW;EAClC,MAAM,aAAa,QAAQ;EAC3B,IAAI,OAAO,eAAe,YAAY,MAAM,IAAI,uBAAuB;EACvE,SAAS,MAAM,WAAW,KAAK,SAAS,cAAc,KAAK,IAAI,CAAC;CAClE,OACE,MAAM,IAAI,uBAAuB,wDAAwD;CAG3F,MAAM,eAAe,MAAM,WAAW,KAAK,SAAS,MAAM;CAC1D,OAAO;EAAE,YAAY;EAAQ;EAAc,WAAW,KAAK,YAAY;CAAK;AAC9E"}
@@ -0,0 +1,7 @@
1
+ //#region src/websocket/do-state.ts
2
+ /** Cloudflare's serialized WebSocket hibernation attachment ceiling. */
3
+ const MAX_WS_ATTACHMENT_BYTES = 16384;
4
+ //#endregion
5
+ export { MAX_WS_ATTACHMENT_BYTES };
6
+
7
+ //# sourceMappingURL=do-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"do-state.js","names":[],"sources":["../../src/websocket/do-state.ts"],"sourcesContent":["// Minimal structural views of the Durable Object runtime, so the transport\n// logic is unit-testable in Node with fakes. Real `DurableObjectState` and\n// `WebSocket` (from @cloudflare/workers-types) satisfy these structurally.\n\n/** Cloudflare's serialized WebSocket hibernation attachment ceiling. */\nexport const MAX_WS_ATTACHMENT_BYTES = 16_384;\n\nexport interface WsLike {\n readonly readyState?: number;\n readonly bufferedAmount?: number;\n send(message: string | ArrayBuffer): void;\n close(code?: number, reason?: string): void;\n serializeAttachment(value: unknown): void;\n deserializeAttachment(): unknown;\n}\n\n/** Structural view of the DO's SQLite handle (`ctx.storage.sql`, requires `new_sqlite_classes`). */\nexport interface SqlStorageLike {\n exec(query: string, ...bindings: unknown[]): { toArray(): Record<string, unknown>[] };\n}\n\nexport interface DoStateLike {\n readonly id: { toString(): string; readonly name?: string | null };\n acceptWebSocket(ws: WsLike, tags?: string[]): void;\n getWebSockets(tag?: string): WsLike[];\n setWebSocketAutoResponse?(pair: unknown): void;\n /** Present on SQLite-backed DOs — the live cursor log lives here. */\n readonly storage?: { sql?: SqlStorageLike };\n}\n\n/** Per-connection metadata persisted in the hibernation attachment (≤ 16 KiB). */\nexport interface WsAttachment {\n /** Absent on 1.x attachments created before versioned validation. */\n version?: 1;\n connId: string;\n /** Only active sockets may dispatch frames or receive fan-out. */\n state: 'pending' | 'active' | 'rejected';\n userId?: string;\n /** Verified, issuer-qualified connection principal. */\n principal?: {\n issuer: string;\n subject: string;\n principalType: 'user' | 'service';\n };\n /** Trusted server-derived tenant boundary for this connection. */\n tenantId?: string;\n /** Verified auth credential expiry in epoch milliseconds. */\n expiresAtMs?: number;\n /** The gateway route path this socket belongs to — used to route messages. */\n path: string;\n /** Validated inbound/outbound gateway frame ceiling, persisted across hibernation. */\n maxFrameBytes?: number;\n /** Dynamically-joined room names (the hub room is also a hibernation tag). */\n rooms: string[];\n data: Record<string, unknown>;\n}\n"],"mappings":";;AAKA,MAAa,0BAA0B"}
@@ -0,0 +1,169 @@
1
+ import { connTag, roomTag } from "./room-id.js";
2
+ import { socketAttachment } from "./ws-attachment.js";
3
+ import { MAX_WS_ATTACHMENT_BYTES } from "./do-state.js";
4
+ import { WsMessageQueue, assertBroadcastCommandFits } from "@velajs/vela/websocket";
5
+ //#region src/websocket/do-websocket-host.ts
6
+ /**
7
+ * The socket lifecycle inside a WebSocket Durable Object, decoupled from the
8
+ * `cloudflare:workers` base class so it is unit-testable with fakes. The thin
9
+ * `VelaWebSocketDurableObject` shell forwards its hibernation callbacks here.
10
+ */
11
+ var DoWebSocketHost = class {
12
+ ctx;
13
+ dispatcher;
14
+ registry;
15
+ gatewayPaths;
16
+ #messages = /* @__PURE__ */ new WeakMap();
17
+ constructor(ctx, dispatcher, registry, gatewayPaths = []) {
18
+ this.ctx = ctx;
19
+ this.dispatcher = dispatcher;
20
+ this.registry = registry;
21
+ this.gatewayPaths = gatewayPaths;
22
+ }
23
+ /** The single registered gateway's path — a fallback when the Worker didn't forward `x-vela-path`. */
24
+ defaultPath() {
25
+ return this.gatewayPaths[0];
26
+ }
27
+ /**
28
+ * Accept a hibernatable socket: tag it with its hub room + connection id,
29
+ * persist the attachment, then await `OnGatewayConnection` before the caller
30
+ * returns 101. A rejected lifecycle hook closes and fails the upgrade.
31
+ */
32
+ async accept(ws, path, roomId, userId, expiresAtMs, principal) {
33
+ const maxFrameBytes = this.dispatcher.getGatewayMaxFrameBytes(path);
34
+ if (maxFrameBytes === void 0) return false;
35
+ if (expiresAtMs !== void 0 && (!Number.isSafeInteger(expiresAtMs) || expiresAtMs <= Date.now())) return false;
36
+ if (principal !== void 0 && (expiresAtMs === void 0 || userId !== principal.subject || !principal.issuer || !principal.tenantId) || expiresAtMs !== void 0 && principal === void 0) return false;
37
+ const connId = crypto.randomUUID();
38
+ const attachment = {
39
+ version: 1,
40
+ connId,
41
+ state: "pending",
42
+ userId,
43
+ principal: principal === void 0 ? void 0 : {
44
+ issuer: principal.issuer,
45
+ subject: principal.subject,
46
+ principalType: principal.principalType
47
+ },
48
+ tenantId: principal?.tenantId,
49
+ expiresAtMs,
50
+ path,
51
+ maxFrameBytes,
52
+ rooms: [roomId],
53
+ data: {
54
+ ...userId ? { userId } : {},
55
+ ...principal ? {
56
+ principal: {
57
+ issuer: principal.issuer,
58
+ subject: principal.subject,
59
+ principalType: principal.principalType
60
+ },
61
+ tenantId: principal.tenantId
62
+ } : {},
63
+ ...expiresAtMs !== void 0 ? { expiresAtMs } : {}
64
+ }
65
+ };
66
+ if (new TextEncoder().encode(JSON.stringify(attachment)).byteLength > MAX_WS_ATTACHMENT_BYTES) return false;
67
+ try {
68
+ this.ctx.acceptWebSocket(ws, [roomTag(roomId), connTag(connId)]);
69
+ ws.serializeAttachment(attachment);
70
+ } catch {
71
+ try {
72
+ ws.close(1008, "Connection rejected");
73
+ } catch {}
74
+ return false;
75
+ }
76
+ const client = this.registry.clientFor(ws);
77
+ try {
78
+ await this.registry.admitting(ws, () => this.dispatcher.handleOpen(path, client));
79
+ if (!this.transition(ws, "active", "pending")) throw new Error(socketAttachment(ws)?.state === "rejected" ? "WebSocket was rejected while OnGatewayConnection was running: a frame arrived before the connection hook completed, or the connection closed or failed" : "Unable to persist authorized WebSocket state");
80
+ return true;
81
+ } catch (err) {
82
+ await this.dispatcher.handleError(path, client, err).catch(() => {});
83
+ this.transition(ws, "rejected");
84
+ try {
85
+ ws.close(1008, "Connection rejected");
86
+ } catch {}
87
+ return false;
88
+ }
89
+ }
90
+ async onMessage(ws, message) {
91
+ if (!this.isActive(ws)) {
92
+ this.reject(ws, "Connection is not authorized");
93
+ return;
94
+ }
95
+ let queue = this.#messages.get(ws);
96
+ if (!queue) {
97
+ const path = socketAttachment(ws)?.path;
98
+ const options = this.dispatcher.collectEntrypoints().find((entry) => entry.meta.path === path)?.meta.options;
99
+ queue = new WsMessageQueue(() => this.reject(ws, "WebSocket message budget exceeded", 1013), options?.maxPendingMessages, options?.maxPendingBytes);
100
+ this.#messages.set(ws, queue);
101
+ }
102
+ await queue.run(message, async () => {
103
+ if (!this.isActive(ws)) return;
104
+ const client = this.registry.clientFor(ws);
105
+ await this.dispatcher.dispatchMessage(client.path, client, message);
106
+ });
107
+ }
108
+ async onClose(ws, code, reason) {
109
+ this.#messages.get(ws)?.stop();
110
+ if (!this.isActive(ws, false)) {
111
+ this.transition(ws, "rejected");
112
+ try {
113
+ ws.close();
114
+ } catch {}
115
+ return;
116
+ }
117
+ const client = this.registry.clientFor(ws);
118
+ await this.dispatcher.handleClose(client.path, client, code, reason);
119
+ try {
120
+ if (code === 1e3 || code >= 3e3 && code <= 4999) ws.close(code, reason);
121
+ else ws.close();
122
+ } catch {}
123
+ }
124
+ async onError(ws, err) {
125
+ if (!this.isActive(ws, false)) {
126
+ this.reject(ws, "Connection failed before authorization");
127
+ return;
128
+ }
129
+ const client = this.registry.clientFor(ws);
130
+ await this.dispatcher.handleError(client.path, client, err);
131
+ }
132
+ /** Deliver a broadcast command to this DO's local sockets (RPC entry point). */
133
+ broadcast(cmd) {
134
+ assertBroadcastCommandFits(cmd, this.dispatcher.getMaximumGatewayFrameBytes());
135
+ return this.registry.deliverLocal(cmd);
136
+ }
137
+ transition(ws, state, expectedState) {
138
+ try {
139
+ const attachment = socketAttachment(ws);
140
+ if (!attachment) return false;
141
+ if (expectedState !== void 0 && attachment.state !== expectedState) return false;
142
+ attachment.state = state;
143
+ ws.serializeAttachment(attachment);
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+ isActive(ws, checkExpiry = true) {
150
+ try {
151
+ const attachment = socketAttachment(ws);
152
+ if (!attachment || attachment.state !== "active") return false;
153
+ return !checkExpiry || attachment.expiresAtMs === void 0 || Number.isSafeInteger(attachment.expiresAtMs) && attachment.expiresAtMs > Date.now();
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+ reject(ws, reason, code = 1008) {
159
+ this.#messages.get(ws)?.stop();
160
+ this.transition(ws, "rejected");
161
+ try {
162
+ ws.close(code, reason);
163
+ } catch {}
164
+ }
165
+ };
166
+ //#endregion
167
+ export { DoWebSocketHost };
168
+
169
+ //# sourceMappingURL=do-websocket-host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"do-websocket-host.js","names":[],"sources":["../../src/websocket/do-websocket-host.ts"],"sourcesContent":["import { socketAttachment } from './ws-attachment';\nimport { WsMessageQueue, assertBroadcastCommandFits } from '@velajs/vela/websocket';\nimport type { BroadcastCommand, WsDispatcher } from '@velajs/vela/websocket';\nimport type { CfRoomRegistry } from './cf-room-registry';\nimport {\n MAX_WS_ATTACHMENT_BYTES,\n type DoStateLike,\n type WsAttachment,\n type WsLike,\n} from './do-state';\nimport { connTag, roomTag } from './room-id';\n\nexport interface WsConnectionPrincipal {\n issuer: string;\n subject: string;\n principalType: 'user' | 'service';\n tenantId: string;\n}\n\n/**\n * The socket lifecycle inside a WebSocket Durable Object, decoupled from the\n * `cloudflare:workers` base class so it is unit-testable with fakes. The thin\n * `VelaWebSocketDurableObject` shell forwards its hibernation callbacks here.\n */\nexport class DoWebSocketHost {\n readonly #messages = new WeakMap<WsLike, WsMessageQueue>();\n constructor(\n private readonly ctx: DoStateLike,\n private readonly dispatcher: WsDispatcher,\n private readonly registry: CfRoomRegistry,\n private readonly gatewayPaths: readonly string[] = [],\n ) {}\n\n /** The single registered gateway's path — a fallback when the Worker didn't forward `x-vela-path`. */\n defaultPath(): string | undefined {\n return this.gatewayPaths[0];\n }\n\n /**\n * Accept a hibernatable socket: tag it with its hub room + connection id,\n * persist the attachment, then await `OnGatewayConnection` before the caller\n * returns 101. A rejected lifecycle hook closes and fails the upgrade.\n */\n async accept(\n ws: WsLike,\n path: string,\n roomId: string,\n userId?: string,\n expiresAtMs?: number,\n principal?: WsConnectionPrincipal,\n ): Promise<boolean> {\n const maxFrameBytes = this.dispatcher.getGatewayMaxFrameBytes(path);\n if (maxFrameBytes === undefined) return false;\n if (\n expiresAtMs !== undefined &&\n (!Number.isSafeInteger(expiresAtMs) || expiresAtMs <= Date.now())\n ) {\n return false;\n }\n if (\n (principal !== undefined &&\n (expiresAtMs === undefined ||\n userId !== principal.subject ||\n !principal.issuer ||\n !principal.tenantId)) ||\n (expiresAtMs !== undefined && principal === undefined)\n ) {\n return false;\n }\n\n const connId = crypto.randomUUID();\n const attachment: WsAttachment = {\n version: 1,\n connId,\n state: 'pending',\n userId,\n principal:\n principal === undefined\n ? undefined\n : {\n issuer: principal.issuer,\n subject: principal.subject,\n principalType: principal.principalType,\n },\n tenantId: principal?.tenantId,\n expiresAtMs,\n path,\n maxFrameBytes,\n rooms: [roomId],\n data: {\n ...(userId ? { userId } : {}),\n ...(principal\n ? {\n principal: {\n issuer: principal.issuer,\n subject: principal.subject,\n principalType: principal.principalType,\n },\n tenantId: principal.tenantId,\n }\n : {}),\n ...(expiresAtMs !== undefined ? { expiresAtMs } : {}),\n },\n };\n if (new TextEncoder().encode(JSON.stringify(attachment)).byteLength > MAX_WS_ATTACHMENT_BYTES) {\n return false;\n }\n try {\n this.ctx.acceptWebSocket(ws, [roomTag(roomId), connTag(connId)]);\n ws.serializeAttachment(attachment);\n } catch {\n try {\n ws.close(1008, 'Connection rejected');\n } catch {\n // already closed\n }\n return false;\n }\n\n const client = this.registry.clientFor(ws);\n try {\n // Broadcasts the hook issues skip this still-pending socket.\n await this.registry.admitting(ws, () => this.dispatcher.handleOpen(path, client));\n // Another callback may have rejected the socket while the asynchronous\n // connection hook was still pending. Never resurrect that terminal state\n // after the hook resolves.\n if (!this.transition(ws, 'active', 'pending')) {\n throw new Error(\n socketAttachment(ws)?.state === 'rejected'\n ? 'WebSocket was rejected while OnGatewayConnection was running: a frame ' +\n 'arrived before the connection hook completed, or the connection closed or failed'\n : 'Unable to persist authorized WebSocket state',\n );\n }\n return true;\n } catch (err) {\n await this.dispatcher.handleError(path, client, err).catch(() => {});\n this.transition(ws, 'rejected');\n try {\n ws.close(1008, 'Connection rejected');\n } catch {\n // already closed\n }\n return false;\n }\n }\n\n async onMessage(ws: WsLike, message: string | ArrayBuffer): Promise<void> {\n if (!this.isActive(ws)) {\n this.reject(ws, 'Connection is not authorized');\n return;\n }\n let queue = this.#messages.get(ws);\n if (!queue) {\n const path = socketAttachment(ws)?.path;\n const options = this.dispatcher.collectEntrypoints().find((entry) => entry.meta.path === path)\n ?.meta.options;\n queue = new WsMessageQueue(\n () => this.reject(ws, 'WebSocket message budget exceeded', 1013),\n options?.maxPendingMessages,\n options?.maxPendingBytes,\n );\n this.#messages.set(ws, queue);\n }\n await queue.run(message, async () => {\n if (!this.isActive(ws)) return;\n const client = this.registry.clientFor(ws);\n await this.dispatcher.dispatchMessage(client.path, client, message);\n });\n }\n\n async onClose(ws: WsLike, code: number, reason: string): Promise<void> {\n this.#messages.get(ws)?.stop();\n if (!this.isActive(ws, false)) {\n this.transition(ws, 'rejected');\n try {\n ws.close();\n } catch {\n // already closed\n }\n return;\n }\n const client = this.registry.clientFor(ws);\n await this.dispatcher.handleClose(client.path, client, code, reason);\n // Complete the closing handshake. Only 1000 and 3000-4999 are valid for\n // close(); reserved/abnormal codes (1005/1006/1015, etc.) throw a RangeError,\n // so fall back to a codeless close on those.\n try {\n if (code === 1000 || (code >= 3000 && code <= 4999)) ws.close(code, reason);\n else ws.close();\n } catch {\n // socket already closed\n }\n }\n\n async onError(ws: WsLike, err: unknown): Promise<void> {\n if (!this.isActive(ws, false)) {\n this.reject(ws, 'Connection failed before authorization');\n return;\n }\n const client = this.registry.clientFor(ws);\n await this.dispatcher.handleError(client.path, client, err);\n }\n\n /** Deliver a broadcast command to this DO's local sockets (RPC entry point). */\n broadcast(cmd: BroadcastCommand): void | Promise<void> {\n assertBroadcastCommandFits(cmd, this.dispatcher.getMaximumGatewayFrameBytes());\n return this.registry.deliverLocal(cmd);\n }\n\n private transition(\n ws: WsLike,\n state: WsAttachment['state'],\n expectedState?: WsAttachment['state'],\n ): boolean {\n try {\n const attachment = socketAttachment(ws);\n if (!attachment) return false;\n if (expectedState !== undefined && attachment.state !== expectedState) return false;\n attachment.state = state;\n ws.serializeAttachment(attachment);\n return true;\n } catch {\n return false;\n }\n }\n\n private isActive(ws: WsLike, checkExpiry = true): boolean {\n try {\n const attachment = socketAttachment(ws);\n if (!attachment || attachment.state !== 'active') return false;\n return (\n !checkExpiry ||\n attachment.expiresAtMs === undefined ||\n (Number.isSafeInteger(attachment.expiresAtMs) && attachment.expiresAtMs > Date.now())\n );\n } catch {\n return false;\n }\n }\n\n private reject(ws: WsLike, reason: string, code = 1008): void {\n this.#messages.get(ws)?.stop();\n this.transition(ws, 'rejected');\n try {\n ws.close(code, reason);\n } catch {\n // already closed\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAwBA,IAAa,kBAAb,MAA6B;CAGR;CACA;CACA;CACA;CALnB,4BAAqB,IAAI,QAAgC;CACzD,YACE,KACA,YACA,UACA,eAAmD,CAAC,GACpD;EAJiB,KAAA,MAAA;EACA,KAAA,aAAA;EACA,KAAA,WAAA;EACA,KAAA,eAAA;CAChB;;CAGH,cAAkC;EAChC,OAAO,KAAK,aAAa;CAC3B;;;;;;CAOA,MAAM,OACJ,IACA,MACA,QACA,QACA,aACA,WACkB;EAClB,MAAM,gBAAgB,KAAK,WAAW,wBAAwB,IAAI;EAClE,IAAI,kBAAkB,KAAA,GAAW,OAAO;EACxC,IACE,gBAAgB,KAAA,MACf,CAAC,OAAO,cAAc,WAAW,KAAK,eAAe,KAAK,IAAI,IAE/D,OAAO;EAET,IACG,cAAc,KAAA,MACZ,gBAAgB,KAAA,KACf,WAAW,UAAU,WACrB,CAAC,UAAU,UACX,CAAC,UAAU,aACd,gBAAgB,KAAA,KAAa,cAAc,KAAA,GAE5C,OAAO;EAGT,MAAM,SAAS,OAAO,WAAW;EACjC,MAAM,aAA2B;GAC/B,SAAS;GACT;GACA,OAAO;GACP;GACA,WACE,cAAc,KAAA,IACV,KAAA,IACA;IACE,QAAQ,UAAU;IAClB,SAAS,UAAU;IACnB,eAAe,UAAU;GAC3B;GACN,UAAU,WAAW;GACrB;GACA;GACA;GACA,OAAO,CAAC,MAAM;GACd,MAAM;IACJ,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;IAC3B,GAAI,YACA;KACE,WAAW;MACT,QAAQ,UAAU;MAClB,SAAS,UAAU;MACnB,eAAe,UAAU;KAC3B;KACA,UAAU,UAAU;IACtB,IACA,CAAC;IACL,GAAI,gBAAgB,KAAA,IAAY,EAAE,YAAY,IAAI,CAAC;GACrD;EACF;EACA,IAAI,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,UAAU,UAAU,CAAC,CAAC,CAAC,aAAa,yBACpE,OAAO;EAET,IAAI;GACF,KAAK,IAAI,gBAAgB,IAAI,CAAC,QAAQ,MAAM,GAAG,QAAQ,MAAM,CAAC,CAAC;GAC/D,GAAG,oBAAoB,UAAU;EACnC,QAAQ;GACN,IAAI;IACF,GAAG,MAAM,MAAM,qBAAqB;GACtC,QAAQ,CAER;GACA,OAAO;EACT;EAEA,MAAM,SAAS,KAAK,SAAS,UAAU,EAAE;EACzC,IAAI;GAEF,MAAM,KAAK,SAAS,UAAU,UAAU,KAAK,WAAW,WAAW,MAAM,MAAM,CAAC;GAIhF,IAAI,CAAC,KAAK,WAAW,IAAI,UAAU,SAAS,GAC1C,MAAM,IAAI,MACR,iBAAiB,EAAE,CAAC,EAAE,UAAU,aAC5B,2JAEA,8CACN;GAEF,OAAO;EACT,SAAS,KAAK;GACZ,MAAM,KAAK,WAAW,YAAY,MAAM,QAAQ,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC;GACnE,KAAK,WAAW,IAAI,UAAU;GAC9B,IAAI;IACF,GAAG,MAAM,MAAM,qBAAqB;GACtC,QAAQ,CAER;GACA,OAAO;EACT;CACF;CAEA,MAAM,UAAU,IAAY,SAA8C;EACxE,IAAI,CAAC,KAAK,SAAS,EAAE,GAAG;GACtB,KAAK,OAAO,IAAI,8BAA8B;GAC9C;EACF;EACA,IAAI,QAAQ,KAAK,UAAU,IAAI,EAAE;EACjC,IAAI,CAAC,OAAO;GACV,MAAM,OAAO,iBAAiB,EAAE,CAAC,EAAE;GACnC,MAAM,UAAU,KAAK,WAAW,mBAAmB,CAAC,CAAC,MAAM,UAAU,MAAM,KAAK,SAAS,IAAI,CAAC,EAC1F,KAAK;GACT,QAAQ,IAAI,qBACJ,KAAK,OAAO,IAAI,qCAAqC,IAAI,GAC/D,SAAS,oBACT,SAAS,eACX;GACA,KAAK,UAAU,IAAI,IAAI,KAAK;EAC9B;EACA,MAAM,MAAM,IAAI,SAAS,YAAY;GACnC,IAAI,CAAC,KAAK,SAAS,EAAE,GAAG;GACxB,MAAM,SAAS,KAAK,SAAS,UAAU,EAAE;GACzC,MAAM,KAAK,WAAW,gBAAgB,OAAO,MAAM,QAAQ,OAAO;EACpE,CAAC;CACH;CAEA,MAAM,QAAQ,IAAY,MAAc,QAA+B;EACrE,KAAK,UAAU,IAAI,EAAE,CAAC,EAAE,KAAK;EAC7B,IAAI,CAAC,KAAK,SAAS,IAAI,KAAK,GAAG;GAC7B,KAAK,WAAW,IAAI,UAAU;GAC9B,IAAI;IACF,GAAG,MAAM;GACX,QAAQ,CAER;GACA;EACF;EACA,MAAM,SAAS,KAAK,SAAS,UAAU,EAAE;EACzC,MAAM,KAAK,WAAW,YAAY,OAAO,MAAM,QAAQ,MAAM,MAAM;EAInE,IAAI;GACF,IAAI,SAAS,OAAS,QAAQ,OAAQ,QAAQ,MAAO,GAAG,MAAM,MAAM,MAAM;QACrE,GAAG,MAAM;EAChB,QAAQ,CAER;CACF;CAEA,MAAM,QAAQ,IAAY,KAA6B;EACrD,IAAI,CAAC,KAAK,SAAS,IAAI,KAAK,GAAG;GAC7B,KAAK,OAAO,IAAI,wCAAwC;GACxD;EACF;EACA,MAAM,SAAS,KAAK,SAAS,UAAU,EAAE;EACzC,MAAM,KAAK,WAAW,YAAY,OAAO,MAAM,QAAQ,GAAG;CAC5D;;CAGA,UAAU,KAA6C;EACrD,2BAA2B,KAAK,KAAK,WAAW,4BAA4B,CAAC;EAC7E,OAAO,KAAK,SAAS,aAAa,GAAG;CACvC;CAEA,WACE,IACA,OACA,eACS;EACT,IAAI;GACF,MAAM,aAAa,iBAAiB,EAAE;GACtC,IAAI,CAAC,YAAY,OAAO;GACxB,IAAI,kBAAkB,KAAA,KAAa,WAAW,UAAU,eAAe,OAAO;GAC9E,WAAW,QAAQ;GACnB,GAAG,oBAAoB,UAAU;GACjC,OAAO;EACT,QAAQ;GACN,OAAO;EACT;CACF;CAEA,SAAiB,IAAY,cAAc,MAAe;EACxD,IAAI;GACF,MAAM,aAAa,iBAAiB,EAAE;GACtC,IAAI,CAAC,cAAc,WAAW,UAAU,UAAU,OAAO;GACzD,OACE,CAAC,eACD,WAAW,gBAAgB,KAAA,KAC1B,OAAO,cAAc,WAAW,WAAW,KAAK,WAAW,cAAc,KAAK,IAAI;EAEvF,QAAQ;GACN,OAAO;EACT;CACF;CAEA,OAAe,IAAY,QAAgB,OAAO,MAAY;EAC5D,KAAK,UAAU,IAAI,EAAE,CAAC,EAAE,KAAK;EAC7B,KAAK,WAAW,IAAI,UAAU;EAC9B,IAAI;GACF,GAAG,MAAM,MAAM,MAAM;EACvB,QAAQ,CAER;CACF;AACF"}
@@ -0,0 +1,27 @@
1
+ //#region src/websocket/room-id.ts
2
+ /** Hibernation tag marking a socket's hub room (set at accept; immutable after). */
3
+ function roomTag(roomId) {
4
+ return `room:${roomId}`;
5
+ }
6
+ /** Hibernation tag addressing one connection directly. */
7
+ function connTag(connId) {
8
+ return `conn:${connId}`;
9
+ }
10
+ const DO_ROOM_NAME_PREFIX = "vela:ws:v2:";
11
+ const MAX_DO_ROOM_NAME_BYTES = 1024;
12
+ const encoder = new TextEncoder();
13
+ /** Stable, collision-free Durable Object name for one gateway's room. */
14
+ function durableObjectRoomName(gatewayPath, roomId) {
15
+ if (typeof gatewayPath !== "string" || gatewayPath.length === 0 || typeof roomId !== "string" || roomId.length === 0 || /[\u0000-\u001f\u007f]/.test(gatewayPath) || /[\u0000-\u001f\u007f]/.test(roomId)) throw new Error("A non-empty, control-free gateway path and room id are required");
16
+ const name = `${DO_ROOM_NAME_PREFIX}${encodeURIComponent(gatewayPath)}:${encodeURIComponent(roomId)}`;
17
+ if (encoder.encode(name).byteLength > MAX_DO_ROOM_NAME_BYTES) throw new Error("The gateway/room Durable Object name exceeds 1024 bytes");
18
+ return name;
19
+ }
20
+ /** One Durable Object instance per gateway + room, addressed by name. */
21
+ function roomToDurableId(ns, gatewayPath, roomId) {
22
+ return ns.idFromName(durableObjectRoomName(gatewayPath, roomId));
23
+ }
24
+ //#endregion
25
+ export { connTag, durableObjectRoomName, roomTag, roomToDurableId };
26
+
27
+ //# sourceMappingURL=room-id.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"room-id.js","names":[],"sources":["../../src/websocket/room-id.ts"],"sourcesContent":["// Canonical room ↔ Durable Object mappings. The SAME functions are used by the\n// Worker upgrade route (to pick the DO) and by every server-initiated emit — so\n// a connection and a later broadcast always resolve to the same DO instance.\n\n/** Hibernation tag marking a socket's hub room (set at accept; immutable after). */\nexport function roomTag(roomId: string): string {\n return `room:${roomId}`;\n}\n\n/** Hibernation tag addressing one connection directly. */\nexport function connTag(connId: string): string {\n return `conn:${connId}`;\n}\n\nconst DO_ROOM_NAME_PREFIX = 'vela:ws:v2:';\nconst MAX_DO_ROOM_NAME_BYTES = 1_024;\nconst encoder = new TextEncoder();\n\n/** Stable, collision-free Durable Object name for one gateway's room. */\nexport function durableObjectRoomName(gatewayPath: string, roomId: string): string {\n if (\n typeof gatewayPath !== 'string' ||\n gatewayPath.length === 0 ||\n typeof roomId !== 'string' ||\n roomId.length === 0 ||\n /[\\u0000-\\u001f\\u007f]/.test(gatewayPath) ||\n /[\\u0000-\\u001f\\u007f]/.test(roomId)\n ) {\n throw new Error('A non-empty, control-free gateway path and room id are required');\n }\n const name = `${DO_ROOM_NAME_PREFIX}${encodeURIComponent(gatewayPath)}:${encodeURIComponent(roomId)}`;\n if (encoder.encode(name).byteLength > MAX_DO_ROOM_NAME_BYTES) {\n throw new Error('The gateway/room Durable Object name exceeds 1024 bytes');\n }\n return name;\n}\n\n/** One Durable Object instance per gateway + room, addressed by name. */\nexport function roomToDurableId(\n ns: Pick<DurableObjectNamespace, 'idFromName'>,\n gatewayPath: string,\n roomId: string,\n): DurableObjectId {\n return ns.idFromName(durableObjectRoomName(gatewayPath, roomId));\n}\n"],"mappings":";;AAKA,SAAgB,QAAQ,QAAwB;CAC9C,OAAO,QAAQ;AACjB;;AAGA,SAAgB,QAAQ,QAAwB;CAC9C,OAAO,QAAQ;AACjB;AAEA,MAAM,sBAAsB;AAC5B,MAAM,yBAAyB;AAC/B,MAAM,UAAU,IAAI,YAAY;;AAGhC,SAAgB,sBAAsB,aAAqB,QAAwB;CACjF,IACE,OAAO,gBAAgB,YACvB,YAAY,WAAW,KACvB,OAAO,WAAW,YAClB,OAAO,WAAW,KAClB,wBAAwB,KAAK,WAAW,KACxC,wBAAwB,KAAK,MAAM,GAEnC,MAAM,IAAI,MAAM,iEAAiE;CAEnF,MAAM,OAAO,GAAG,sBAAsB,mBAAmB,WAAW,EAAE,GAAG,mBAAmB,MAAM;CAClG,IAAI,QAAQ,OAAO,IAAI,CAAC,CAAC,aAAa,wBACpC,MAAM,IAAI,MAAM,yDAAyD;CAE3E,OAAO;AACT;;AAGA,SAAgB,gBACd,IACA,aACA,QACiB;CACjB,OAAO,GAAG,WAAW,sBAAsB,aAAa,MAAM,CAAC;AACjE"}