@velajs/cloudflare 1.10.1 → 2.0.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/dist/index.js CHANGED
@@ -1,48 +1,49 @@
1
- import { CRON_METADATA, ConfigurableModuleBuilder, Controller, Get, Inject, Injectable, InjectionToken, Module, PipelineRunner, Req, VelaApplication, VelaFactory, bootstrap, buildEntrypointExecutionContext, createParamDecorator, defineConfigurableModule, defineMetadata, getMetadata, registerEntrypointKind, runInEntrypointScope, shouldFilterCatch } from "@velajs/vela";
1
+ import { C as roomToDurableId, _ as resolveCloudflareRoot, a as durableObjectLive, c as liveInvalidateToRoom, d as WsServerHolder, f as DoPitrUnavailableError, g as __decorate, h as readDoPitrBookmark, i as durableObjectCursorLog, m as isDoPitrUnavailable, n as isValidExpiry, p as armDoPitr, r as DoCursorLog, t as isCanonicalBoundedText, v as assertCloudflareEnvironment, x as durableObjectRoomName, y as registerCloudflareEnvironment } from "./nonce-validation-LE0vFk-7.js";
2
+ import { getConnInfo } from "hono/cloudflare-workers";
3
+ import { CRON_METADATA, ConfigurableModuleBuilder, Controller, Get, Inject, Injectable, InjectionToken, Module, PipelineRunner, Req, VelaFactory, buildEntrypointExecutionContext, createParamDecorator, defineMetadata, defineProvider, getMetadata, getTrustedRequestIdentity, registerEntrypointKind, runInEntrypointScope, shouldFilterCatch } from "@velajs/vela";
2
4
  import { ComponentManager } from "@velajs/vela/internal";
3
- import { ConnectedSocket, MessageBody, SubscribeMessage, WS_GATEWAY_METADATA, WS_SERVER, WebSocketGateway, WebSocketServer, WsDispatcher, WsException, WsServerImpl, local } from "@velajs/vela/websocket";
4
- import { LIVE_CURSOR_LOG, LIVE_DRIVER, readPersistedLiveSubscriptions } from "@velajs/vela/live";
5
- import { joinStoragePath, signUrl, verifySignedUrl } from "@velajs/vela/storage";
6
- import { DurableObject } from "cloudflare:workers";
7
- //#region src/binding-ref.ts
8
- var BindingRef = class {
9
- bindingName;
10
- _value;
11
- constructor(bindingName) {
12
- this.bindingName = bindingName;
13
- }
14
- get value() {
15
- if (this._value === void 0) throw new Error(`Cloudflare binding '${this.bindingName}' not initialized. Ensure createCloudflareApp() is used and a request has been made.`);
16
- return this._value;
17
- }
18
- /** @internal — called by createCloudflareApp middleware */
19
- _initialize(value) {
20
- this._value = value;
21
- }
22
- };
23
- //#endregion
24
- //#region src/websocket/room-id.ts
25
- /** Hibernation tag marking a socket's hub room (set at accept; immutable after). */
26
- function roomTag(roomId) {
27
- return `room:${roomId}`;
5
+ import { ConnectedSocket, DEFAULT_WS_MAX_FRAME_BYTES, MessageBody, SubscribeMessage, WS_GATEWAY_METADATA, WS_SERVER, WebSocketGateway, WebSocketServer, WsDispatcher, WsException, assertBroadcastCommandFits, authenticateWebSocketUpgrade, resolveGatewayRoomId, resolveGatewayRoomParam, resolveMaxFrameBytes } from "@velajs/vela/websocket";
6
+ import { STORAGE_SIGNED_URL_PURPOSE, joinStoragePath, signUrl, verifySignedUrl } from "@velajs/vela/storage";
7
+ //#region src/websocket/websocket-routing.ts
8
+ const MAX_IDENTITY_FIELD_BYTES = 2048;
9
+ const encoder = new TextEncoder();
10
+ function isIdentityField(value) {
11
+ return typeof value === "string" && value.length > 0 && !value.includes("\r") && !value.includes("\n") && encoder.encode(value).byteLength <= MAX_IDENTITY_FIELD_BYTES;
28
12
  }
29
- /** Hibernation tag addressing one connection directly. */
30
- function connTag(connId) {
31
- return `conn:${connId}`;
13
+ /** `null` means an identity was present but violated the transport contract. */
14
+ function accessIdentity(c) {
15
+ const value = getTrustedRequestIdentity(c.req.raw);
16
+ if (!value) return void 0;
17
+ const { principal, tenantId, expiresAtMs } = value;
18
+ if (!isIdentityField(principal.issuer) || !isIdentityField(principal.subject) || !isIdentityField(tenantId) || typeof expiresAtMs !== "number" || !Number.isSafeInteger(expiresAtMs) || expiresAtMs <= 0) return null;
19
+ return {
20
+ principal,
21
+ tenantId,
22
+ expiresAtMs
23
+ };
32
24
  }
33
- /** One Durable Object instance per room, addressed by name. */
34
- function roomToDurableId(ns, roomId) {
35
- return ns.idFromName(roomId);
25
+ /** `null` means two independently verified identities disagree. */
26
+ function combineIdentities(requestIdentity, upgradeIdentity) {
27
+ if (!requestIdentity && !upgradeIdentity) return void 0;
28
+ if (!requestIdentity) return upgradeIdentity;
29
+ if (!upgradeIdentity) return requestIdentity;
30
+ if (requestIdentity.principal.issuer !== upgradeIdentity.principal.issuer || requestIdentity.principal.subject !== upgradeIdentity.principal.subject || requestIdentity.principal.principalType !== upgradeIdentity.principal.principalType || requestIdentity.tenantId !== upgradeIdentity.tenantId) return null;
31
+ return {
32
+ principal: { ...upgradeIdentity.principal },
33
+ tenantId: upgradeIdentity.tenantId,
34
+ expiresAtMs: Math.min(requestIdentity.expiresAtMs, upgradeIdentity.expiresAtMs)
35
+ };
36
36
  }
37
- //#endregion
38
- //#region src/websocket/websocket-routing.ts
39
37
  /** Read `@WebSocketGateway({ path, binding })` off a resolved instance (CF-hosted gateways only). */
40
38
  function collectWsGatewayRoutes(instance) {
41
39
  const options = getMetadata(WS_GATEWAY_METADATA, instance.constructor);
42
40
  if (!options?.path || !options?.binding) return [];
41
+ resolveGatewayRoomParam(options);
42
+ resolveMaxFrameBytes(options);
43
43
  return [{
44
44
  path: options.path,
45
- binding: options.binding
45
+ binding: options.binding,
46
+ options: { ...options }
46
47
  }];
47
48
  }
48
49
  /**
@@ -54,21 +55,65 @@ function collectWsGatewayRoutes(instance) {
54
55
  function registerWebSocketRoutes(hono, routes) {
55
56
  for (const route of routes) hono.get(route.path, async (c) => {
56
57
  if (c.req.header("upgrade")?.toLowerCase() !== "websocket") return c.text("Expected WebSocket upgrade", 426);
57
- const ns = c.env[route.binding];
58
- if (!ns) return c.text(`Durable Object binding '${route.binding}' is not configured`, 500);
59
- const roomId = c.req.param("id") ?? route.path;
60
- const stub = ns.get(roomToDurableId(ns, roomId));
61
58
  const headers = new Headers(c.req.raw.headers);
62
59
  headers.delete("x-vela-room");
63
60
  headers.delete("x-vela-path");
64
61
  headers.delete("x-vela-user");
65
- headers.set("x-vela-room", roomId);
66
- headers.set("x-vela-path", route.path);
67
- const userId = c.get("userId");
68
- if (userId) headers.set("x-vela-user", String(userId));
69
- return stub.fetch(new Request(c.req.raw, { headers }));
62
+ headers.delete("x-vela-expires-at");
63
+ headers.delete("x-vela-expires-at-ms");
64
+ headers.delete("x-vela-issuer");
65
+ headers.delete("x-vela-subject");
66
+ headers.delete("x-vela-principal-type");
67
+ headers.delete("x-vela-tenant");
68
+ const sanitizedRequest = new Request(c.req.raw, { headers });
69
+ let roomId;
70
+ try {
71
+ roomId = resolveGatewayRoomId(route.options, (name) => c.req.param(name));
72
+ } catch {
73
+ return c.text("Invalid WebSocket room", 400);
74
+ }
75
+ const upgrade = await authenticateWebSocketUpgrade(route.options, sanitizedRequest, roomId);
76
+ if (upgrade === false) return c.text("WebSocket upgrade forbidden", 403);
77
+ const requestIdentity = accessIdentity(c);
78
+ if (requestIdentity === null) return c.text("Invalid WebSocket identity", 403);
79
+ const identity = combineIdentities(requestIdentity, upgrade.identity);
80
+ if (identity === null) return c.text("Conflicting WebSocket identities", 403);
81
+ if (identity && identity.expiresAtMs <= Date.now()) return c.text("WebSocket identity expired", 403);
82
+ const forwardHeaders = new Headers(upgrade.request.headers);
83
+ forwardHeaders.set("x-vela-room", roomId);
84
+ forwardHeaders.set("x-vela-path", route.path);
85
+ if (identity) {
86
+ forwardHeaders.set("x-vela-user", identity.principal.subject);
87
+ forwardHeaders.set("x-vela-issuer", identity.principal.issuer);
88
+ forwardHeaders.set("x-vela-subject", identity.principal.subject);
89
+ forwardHeaders.set("x-vela-principal-type", identity.principal.principalType);
90
+ forwardHeaders.set("x-vela-tenant", identity.tenantId);
91
+ forwardHeaders.set("x-vela-expires-at-ms", String(identity.expiresAtMs));
92
+ }
93
+ return forwardToRoom(c.env, route.binding, route.path, roomId, new Request(upgrade.request, { headers: forwardHeaders }));
70
94
  });
71
95
  }
96
+ /**
97
+ * Gateway metadata contains a runtime binding name, so the native type is
98
+ * erased. Validate only the operations consumed here and their observable
99
+ * results; never assert that an arbitrary value implements a native namespace.
100
+ */
101
+ async function forwardToRoom(env, binding, path, room, request) {
102
+ if (typeof env !== "object" || env === null) throw new Error("Worker environment is missing");
103
+ const namespace = Reflect.get(env, binding);
104
+ if (typeof namespace !== "object" || namespace === null) return new Response(`Durable Object binding '${binding}' is not configured`, { status: 500 });
105
+ const idFromName = Reflect.get(namespace, "idFromName");
106
+ const get = Reflect.get(namespace, "get");
107
+ if (typeof idFromName !== "function" || typeof get !== "function") throw new Error("Invalid Durable Object namespace");
108
+ const id = Reflect.apply(idFromName, namespace, [durableObjectRoomName(path, room)]);
109
+ const stub = Reflect.apply(get, namespace, [id]);
110
+ if (typeof stub !== "object" || stub === null) throw new Error("Invalid Durable Object stub");
111
+ const fetch = Reflect.get(stub, "fetch");
112
+ if (typeof fetch !== "function") throw new Error("Durable Object stub has no fetch operation");
113
+ const response = await Reflect.apply(fetch, stub, [request]);
114
+ if (!(response instanceof Response)) throw new Error("Durable Object returned an invalid response");
115
+ return response;
116
+ }
72
117
  //#endregion
73
118
  //#region src/cloudflare-application.ts
74
119
  registerEntrypointKind({
@@ -77,9 +122,15 @@ registerEntrypointKind({
77
122
  level: "method"
78
123
  });
79
124
  function invoke(instance, methodName, args) {
80
- const method = instance[methodName];
125
+ const method = Reflect.get(instance, methodName);
81
126
  if (typeof method !== "function") throw new Error(`Method '${methodName}' is not a function on ${instance.constructor.name}`);
82
- return method.apply(instance, args);
127
+ return Reflect.apply(method, instance, args);
128
+ }
129
+ function entrypointString(meta, property) {
130
+ if (typeof meta !== "object" || meta === null) throw new Error("Invalid entrypoint metadata.");
131
+ const value = Reflect.get(meta, property);
132
+ if (typeof value !== "string") throw new Error(`Invalid entrypoint metadata: ${property} must be a string.`);
133
+ return value;
83
134
  }
84
135
  /**
85
136
  * Wraps VelaApplication with Cloudflare-specific handlers:
@@ -92,7 +143,7 @@ function invoke(instance, methodName, args) {
92
143
  *
93
144
  * @example
94
145
  * ```ts
95
- * const app = await createCloudflareApp(AppModule);
146
+ * const app = await createCloudflareApp(AppModule, { env, envToken: ENV });
96
147
  * export default {
97
148
  * fetch: app.fetch,
98
149
  * scheduled: app.scheduled.bind(app),
@@ -103,7 +154,7 @@ function invoke(instance, methodName, args) {
103
154
  * @example
104
155
  * ```ts
105
156
  * // Serve OpenAPI docs alongside your routes
106
- * const app = await createCloudflareApp(AppModule);
157
+ * const app = await createCloudflareApp(AppModule, { env, envToken: ENV });
107
158
  * const document = createOpenApiDocument(AppModule);
108
159
  * app.mountOpenApi({ document, ui: 'scalar' });
109
160
  * // GET /openapi.json -> JSON document
@@ -112,30 +163,35 @@ function invoke(instance, methodName, args) {
112
163
  */
113
164
  var CloudflareApplication = class {
114
165
  app;
166
+ env;
115
167
  wsGatewayRoutes = [];
116
- constructor(app) {
168
+ constructor(app, env) {
117
169
  this.app = app;
170
+ this.env = env;
171
+ this.get = app.get.bind(app);
118
172
  }
119
- get fetch() {
120
- return this.app.fetch;
121
- }
173
+ fetch = async (request, env, ctx) => {
174
+ assertCloudflareEnvironment(this.env, env);
175
+ return this.app.fetch(request, env, ctx);
176
+ };
122
177
  getHonoApp() {
123
178
  return this.app.getHonoApp();
124
179
  }
125
180
  /**
126
181
  * Resolve a provider from the application's DI container (delegates to
127
182
  * `VelaApplication.get`). Handy for grabbing a service — e.g. an auth service —
128
- * to use inside `createCloudflareApp({ middleware: [...] })` request middleware,
183
+ * to use inside `createCloudflareApp({ middleware: env => [...] })` request middleware,
129
184
  * which runs outside the DI request pipeline.
130
185
  *
131
186
  * @example
132
187
  * ```ts
133
- * const app = await createCloudflareApp(AppModule);
134
- * const auth = app.get<BetterAuthService>(BetterAuthService);
188
+ * const app = await createCloudflareApp(AppModule, { env, envToken: ENV });
189
+ * const auth = app.get(BetterAuthService);
135
190
  * ```
136
191
  */
137
- get(token) {
138
- return this.app.get(token);
192
+ get;
193
+ get entrypoints() {
194
+ return this.app.entrypoints;
139
195
  }
140
196
  /**
141
197
  * Serve a pre-built OpenAPI document (and optionally a Scalar UI) on the
@@ -148,7 +204,7 @@ var CloudflareApplication = class {
148
204
  * ```ts
149
205
  * import { createOpenApiDocument } from '@velajs/vela';
150
206
  *
151
- * const app = await createCloudflareApp(AppModule);
207
+ * const app = await createCloudflareApp(AppModule, { env, envToken: ENV });
152
208
  * const document = createOpenApiDocument(AppModule, {
153
209
  * info: { title: 'My API', version: '1.0.0' },
154
210
  * });
@@ -184,12 +240,13 @@ var CloudflareApplication = class {
184
240
  * request-scoped child (request-scoped providers rebuild per tick).
185
241
  */
186
242
  async scheduled(event, env, ctx) {
243
+ assertCloudflareEnvironment(this.env, env);
187
244
  const handlers = [...this.app.entrypoints.ofKind("cf:scheduled").map((ep) => ({
188
245
  ep,
189
- cron: ep.meta.cron
246
+ cron: entrypointString(ep.meta, "cron")
190
247
  })), ...this.app.entrypoints.ofKind("cf:vela-cron").map((ep) => ({
191
248
  ep,
192
- cron: ep.meta.expression
249
+ cron: entrypointString(ep.meta, "expression")
193
250
  }))].filter((h) => h.cron === event.cron);
194
251
  await Promise.all(handlers.map(({ ep }) => this.dispatchEntrypoint(ep, [
195
252
  event,
@@ -207,10 +264,12 @@ var CloudflareApplication = class {
207
264
  */
208
265
  async dispatchEntrypoint(ep, args) {
209
266
  const targetClass = ep.token;
267
+ if (typeof targetClass !== "function") throw new Error("Entrypoint token must be a class.");
210
268
  const methodName = String(ep.methodName);
211
269
  const context = buildEntrypointExecutionContext(ep.kind, targetClass, methodName, args[0]);
212
270
  await runInEntrypointScope(this.app.getContainer(), async (scope) => {
213
271
  const instance = scope.resolve(ep.token);
272
+ if (typeof instance !== "object" || instance === null) throw new Error("Entrypoint must resolve to an object.");
214
273
  const guards = ComponentManager.resolveGuards(ComponentManager.getScopedComponents("guard", targetClass, methodName), scope);
215
274
  const interceptors = ComponentManager.resolveInterceptors(ComponentManager.getScopedComponents("interceptor", targetClass, methodName), scope);
216
275
  const filters = ComponentManager.resolveFilters([...ComponentManager.getScopedComponents("filter", targetClass, methodName)].reverse(), scope);
@@ -238,7 +297,8 @@ var CloudflareApplication = class {
238
297
  * child (request-scoped providers rebuild per batch — no boot-time captives).
239
298
  */
240
299
  async queue(batch, env, ctx) {
241
- const handlers = this.app.entrypoints.ofKind("cf:queue").filter((ep) => ep.meta.queueName === batch.queue);
300
+ assertCloudflareEnvironment(this.env, env);
301
+ const handlers = this.app.entrypoints.ofKind("cf:queue").filter((ep) => entrypointString(ep.meta, "queueName") === batch.queue);
242
302
  await Promise.all(handlers.map((ep) => this.dispatchEntrypoint(ep, [
243
303
  batch,
244
304
  env,
@@ -250,668 +310,144 @@ var CloudflareApplication = class {
250
310
  }
251
311
  };
252
312
  //#endregion
253
- //#region src/env-ref.ts
254
- const ENV_SENTINEL = "__cf_env__";
255
- /**
256
- * Holds the entire Cloudflare Worker `env` record (bindings + vars/secrets),
257
- * not a single binding. Subclasses {@link BindingRef} so createCloudflareApp's
258
- * binding-init middleware collects and initializes it through the same path;
259
- * the factory special-cases it to pass the full `env` instead of one binding.
260
- */
261
- var EnvRef = class extends BindingRef {
262
- constructor() {
263
- super(ENV_SENTINEL);
264
- }
265
- };
266
- //#endregion
267
- //#region src/websocket/cf-ws-client.ts
268
- const MAX_ATTACHMENT_BYTES = 16384;
269
- const encoder = new TextEncoder();
270
- const EMPTY = {
271
- connId: "",
272
- path: "",
273
- rooms: [],
274
- data: {}
275
- };
276
- /**
277
- * Core `WsClient` over a native Cloudflare `WebSocket` inside a Durable Object.
278
- * Per-connection state lives in the hibernation attachment (survives eviction),
279
- * so a fresh `CfWsClient` is reconstructed per message with no in-memory state.
280
- */
281
- var CfWsClient = class {
282
- ctx;
283
- ws;
284
- attachment;
285
- constructor(ctx, ws) {
286
- this.ctx = ctx;
287
- this.ws = ws;
288
- const raw = ws.deserializeAttachment();
289
- this.attachment = raw ?? {
290
- ...EMPTY,
291
- data: {}
292
- };
293
- }
294
- get id() {
295
- return this.attachment.connId;
296
- }
297
- /** The gateway route path this socket connected on (used to route messages). */
298
- get path() {
299
- return this.attachment.path;
300
- }
301
- get rooms() {
302
- return new Set(this.attachment.rooms);
303
- }
304
- get data() {
305
- return this.attachment.data;
306
- }
307
- set data(value) {
308
- this.attachment.data = value;
309
- }
310
- get raw() {
311
- return this.ws;
312
- }
313
- send(event, data, id) {
314
- this.ws.send(JSON.stringify(id !== void 0 ? {
315
- id,
316
- event,
317
- data
318
- } : {
319
- event,
320
- data
321
- }));
322
- }
323
- sendRaw(payload) {
324
- this.ws.send(payload);
325
- }
326
- join(room) {
327
- if (!this.attachment.rooms.includes(room)) {
328
- this.attachment.rooms.push(room);
329
- this.persist();
330
- }
331
- }
332
- leave(room) {
333
- const next = this.attachment.rooms.filter((r) => r !== room);
334
- if (next.length !== this.attachment.rooms.length) {
335
- this.attachment.rooms = next;
336
- this.persist();
337
- }
338
- }
339
- /** Persist `data`/room mutations to the hibernation attachment. */
340
- commit() {
341
- this.persist();
342
- }
343
- close(code, reason) {
344
- this.ws.close(code, reason);
345
- }
346
- persist() {
347
- const serialized = JSON.stringify(this.attachment);
348
- if (encoder.encode(serialized).length > MAX_ATTACHMENT_BYTES) throw new Error("WebSocket attachment exceeds the 16 KiB Cloudflare limit. Store large per-connection state in Durable Object storage keyed by connId instead.");
349
- this.ws.serializeAttachment(this.attachment);
350
- }
351
- };
352
- //#endregion
353
- //#region src/websocket/do-live.ts
354
- const DEFAULT_ROOM = "default";
355
- const DEFAULT_MAX_LOG_ROWS = 4096;
356
- /**
357
- * The durable `CursorLog`: an append-only tag-invalidation log in the DO's
358
- * SQLite (`__vela_live_log`, AUTOINCREMENT seq = cursor) plus an epoch UUID in
359
- * `__vela_live_meta`. Because the cursor survives hibernation AND trims (it is
360
- * read from `sqlite_sequence`, lunora's `ctx-db-cdc.ts` trick), a reconnecting
361
- * client whose gap the log still covers gets a tiny `resume` instead of a
362
- * re-run — the real-resume half of the live protocol.
363
- *
364
- * Constructed un-initialized at module-composition time (the same app module
365
- * bootstraps in the Worker AND in each DO); `initDoLive` wires the SQLite
366
- * handle inside the DO. In the Worker isolate it stays un-initialized — and is
367
- * never consulted there, because `durableObjectLive()` routes every
368
- * invalidation to the room DO's log (one log scope per room, exactly the
369
- * protocol's model).
370
- */
371
- var DoCursorLog = class {
372
- maxRows;
373
- sql;
374
- epoch;
375
- constructor(maxRows = DEFAULT_MAX_LOG_ROWS) {
376
- this.maxRows = maxRows;
377
- }
378
- /** @internal — called by `initDoLive` with the DO's `ctx.storage.sql`. */
379
- _initialize(sql) {
380
- this.sql = sql;
381
- sql.exec("CREATE TABLE IF NOT EXISTS __vela_live_log (seq INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL NOT NULL, tags TEXT NOT NULL)");
382
- sql.exec("CREATE TABLE IF NOT EXISTS __vela_live_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)");
383
- const row = sql.exec("SELECT v FROM __vela_live_meta WHERE k = 'epoch'").toArray()[0];
384
- if (row && typeof row.v === "string") this.epoch = row.v;
385
- else {
386
- this.epoch = crypto.randomUUID();
387
- sql.exec("INSERT INTO __vela_live_meta (k, v) VALUES ('epoch', ?)", this.epoch);
388
- }
389
- }
390
- append(tags) {
391
- const sql = this.assertReady();
392
- sql.exec("INSERT INTO __vela_live_log (ts, tags) VALUES (?, ?)", Date.now(), JSON.stringify(tags));
393
- const stamp = this.current();
394
- if (stamp.cursor > this.maxRows) sql.exec("DELETE FROM __vela_live_log WHERE seq <= ?", stamp.cursor - this.maxRows);
395
- return stamp;
396
- }
397
- current() {
398
- const sql = this.assertReady();
399
- let cursor = 0;
400
- try {
401
- const row = sql.exec("SELECT seq FROM sqlite_sequence WHERE name = '__vela_live_log'").toArray()[0];
402
- cursor = typeof row?.seq === "number" ? row.seq : Number(row?.seq ?? 0);
403
- } catch {
404
- cursor = 0;
405
- }
406
- return {
407
- cursor,
408
- epoch: this.epoch
409
- };
410
- }
411
- evaluateResume(sinceCursor, sinceEpoch, subscriptionTags) {
412
- const sql = this.assertReady();
413
- const { cursor, epoch } = this.current();
414
- if (sinceEpoch !== epoch) return "snapshot";
415
- if (sinceCursor > cursor) return "snapshot";
416
- if (sinceCursor === cursor) return "resume";
417
- const minRow = sql.exec("SELECT MIN(seq) AS m FROM __vela_live_log").toArray()[0];
418
- const min = minRow?.m == null ? void 0 : Number(minRow.m);
419
- if (min === void 0 || min > sinceCursor + 1) return "snapshot";
420
- const subTags = new Set(subscriptionTags);
421
- for (const row of sql.exec("SELECT tags FROM __vela_live_log WHERE seq > ?", sinceCursor).toArray()) {
422
- let tags;
423
- try {
424
- tags = JSON.parse(String(row.tags));
425
- } catch {
426
- return "snapshot";
427
- }
428
- if (Array.isArray(tags) && tags.some((tag) => subTags.has(tag))) return "rerun";
429
- }
430
- return "resume";
431
- }
432
- assertReady() {
433
- 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.");
434
- return this.sql;
435
- }
436
- };
437
- /**
438
- * The Cloudflare `LiveDriver`. Dual-mode, because the SAME app module
439
- * bootstraps in both isolates:
440
- *
441
- * - **Worker** (HTTP mutations, queue consumers, crons): route the command to
442
- * the room's Durable Object over the `invalidate` RPC — the same canonical
443
- * `roomToDurableId` mapping the upgrade route and `broadcastToRoom` use —
444
- * and return THAT log scope's commit stamp (what `Vela-Commit-Cursor`
445
- * must carry).
446
- * - **DO** (writes issued from inside the object): apply to the local engine.
447
- */
448
- function durableObjectLive(options) {
449
- let sink;
450
- let env;
451
- let localMode = false;
452
- return {
453
- kind: "durable-object",
454
- bind(boundSink) {
455
- sink = boundSink;
456
- },
457
- _initializeEnv(capturedEnv) {
458
- env = capturedEnv;
459
- },
460
- _setLocalMode() {
461
- localMode = true;
462
- },
463
- dispatch(cmd) {
464
- if (localMode) return sink?.applyInvalidation(cmd);
465
- const namespace = env?.[options.binding];
466
- if (!namespace) throw new Error(`durableObjectLive: binding '${options.binding}' is not available. In a Worker, createCloudflareApp() captures env on the first request; check the wrangler binding name.`);
467
- const room = cmd.room ?? options.defaultRoom ?? DEFAULT_ROOM;
468
- return namespace.get(roomToDurableId(namespace, room)).invalidate({
469
- ...cmd,
470
- room
471
- });
472
- }
473
- };
474
- }
475
- const isCfLiveDriver = (value) => typeof value === "object" && value !== null && typeof value._setLocalMode === "function" && typeof value._initializeEnv === "function";
476
- /** Worker-side wiring, called from `cloudflareAdapter`'s first-request middleware. */
477
- function initializeWorkerLive(container, env) {
478
- let driver;
479
- try {
480
- driver = container.resolve(LIVE_DRIVER);
481
- } catch {
482
- return;
483
- }
484
- if (isCfLiveDriver(driver)) driver._initializeEnv(env);
485
- }
486
- /**
487
- * DO-side wiring, called from `buildDoRuntime`: initialize the SQLite cursor
488
- * log, flip the driver to local mode, and replay every hibernation-persisted
489
- * subscription into the (fresh) engine so an eviction is invisible to
490
- * subscribers. Returns the engine for the `invalidate` RPC, or undefined when
491
- * the app doesn't use LiveModule.
492
- */
493
- function initDoLive(app, container, ctx) {
494
- const entry = app.entrypoints.ofKind("live")[0];
495
- if (!entry) return void 0;
496
- const engine = entry.meta.engine;
497
- try {
498
- const log = container.resolve(LIVE_CURSOR_LOG);
499
- if (log instanceof DoCursorLog) {
500
- const sql = ctx.storage?.sql;
501
- 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).");
502
- log._initialize(sql);
503
- }
504
- } catch (err) {
505
- if (err instanceof Error && err.message.includes("new_sqlite_classes")) throw err;
506
- }
507
- try {
508
- const driver = container.resolve(LIVE_DRIVER);
509
- if (isCfLiveDriver(driver)) driver._setLocalMode();
510
- } catch {}
511
- for (const ws of ctx.getWebSockets()) {
512
- const client = new CfWsClient(ctx, ws);
513
- for (const record of readPersistedLiveSubscriptions(client)) engine.restoreSubscription(client.path, client, record);
514
- }
515
- return engine;
516
- }
517
- /** Ergonomic alias: the log option for `LiveModule.forRoot` on Cloudflare. */
518
- function durableObjectCursorLog(maxRows) {
519
- return new DoCursorLog(maxRows);
520
- }
521
- /**
522
- * Invalidate live tags in a room from a Worker (controller / cron / queue
523
- * consumer) — the live sibling of `broadcastToRoom`. Returns the room log
524
- * scope's commit stamp for `Vela-Commit-Cursor` stamping.
525
- */
526
- async function liveInvalidateToRoom(ns, room, tags) {
527
- return ns.get(roomToDurableId(ns, room)).invalidate({
528
- room,
529
- tags
530
- });
531
- }
532
- //#endregion
533
313
  //#region src/cloudflare-factory.ts
534
- function collectBindingRefs(container) {
535
- const refs = [];
536
- for (const value of container.getUseValues()) if (value instanceof BindingRef) refs.push(value);
537
- return refs;
538
- }
539
- /**
540
- * Create a Cloudflare Workers application.
541
- *
542
- * Sets up a one-time Hono middleware that captures `c.env` on the first
543
- * request and initializes all configured binding refs. Optional
544
- * {@link CreateCloudflareAppOptions} are forwarded to the underlying
545
- * `VelaFactory.create` so consumers don't have to wrap the resulting
546
- * application in an outer Hono just to set a `globalPrefix` or attach
547
- * extra request middleware.
548
- *
549
- * @example
550
- * ```ts
551
- * // Minimal — backwards compatible
552
- * const app = await createCloudflareApp(AppModule);
553
- * export default app; // has .fetch, .scheduled, .queue
554
- * ```
555
- *
556
- * @example
557
- * ```ts
558
- * // With a global prefix and outer middleware
559
- * const app = await createCloudflareApp(AppModule, {
560
- * globalPrefix: '/v1',
561
- * middleware: [
562
- * async (c, next) => {
563
- * c.set('tenantId', c.req.header('x-tenant-id') ?? 'public');
564
- * await next();
565
- * },
566
- * ],
567
- * });
568
- * ```
569
- */
570
- /**
571
- * The Cloudflare platform binding as a vela {@link RuntimeAdapter}: a one-time
572
- * request middleware captures `c.env` on the first request and initializes
573
- * every `BindingRef`/`EnvRef` (collected at `onBootstrap`, before any request
574
- * can arrive). Adapter `requestMiddleware` is prepended to the global chain by
575
- * `VelaFactory.create`, so user middleware can safely read binding refs.
576
- *
577
- * Exposed so consumers composing `VelaFactory.create` themselves can opt in:
578
- *
579
- * ```ts
580
- * const app = await VelaFactory.create(AppModule, { adapters: [cloudflareAdapter()] });
581
- * ```
582
- */
583
- function cloudflareAdapter() {
584
- let initialized = false;
585
- let refs = [];
586
- let liveContainer;
314
+ /** Bind an application to one environment before provider factories and lifecycle hooks. */
315
+ function cloudflareAdapter(options) {
587
316
  return {
588
317
  name: "cloudflare",
589
- requestMiddleware: [async (c, next) => {
590
- if (!initialized) {
591
- initialized = true;
592
- const env = c.env ?? {};
593
- for (const ref of refs) if (ref instanceof EnvRef) ref._initialize(env);
594
- else ref._initialize(env[ref.bindingName]);
595
- if (liveContainer) initializeWorkerLive(liveContainer, env);
596
- }
318
+ requestMiddleware: [async (context, next) => {
319
+ assertCloudflareEnvironment(options.env, context.env);
597
320
  await next();
598
321
  }],
599
- onBootstrap: ({ container }) => {
600
- liveContainer = container;
601
- refs = collectBindingRefs(container);
322
+ invocationTransport: ({ app }) => (request) => Promise.resolve(app.fetch(request, options.env)),
323
+ getClientIp: (c) => getConnInfo(c).remote.address ?? null,
324
+ configureContainer: (container) => {
325
+ registerCloudflareEnvironment(container, {
326
+ token: options.envToken,
327
+ env: options.env
328
+ });
602
329
  }
603
330
  };
604
331
  }
605
- async function createCloudflareApp(rootModule, options = {}) {
606
- const velaApp = await VelaFactory.create(rootModule, {
332
+ /** Build an application for one native Workers environment. Call inside a platform event. */
333
+ async function createCloudflareApp(rootModule, options) {
334
+ const velaApp = await VelaFactory.create(resolveCloudflareRoot(rootModule, options.env), {
607
335
  globalPrefix: options.globalPrefix,
608
- middleware: options.middleware,
609
- adapters: [cloudflareAdapter()]
336
+ security: options.security,
337
+ middleware: options.middleware?.(options.env),
338
+ adapters: [cloudflareAdapter(options)]
610
339
  });
611
- const cfApp = new CloudflareApplication(velaApp);
612
- cfApp.scanInstances(velaApp.getInstances());
613
- registerWebSocketRoutes(cfApp.getHonoApp(), cfApp.getWsGatewayRoutes());
614
- return cfApp;
615
- }
616
- //#endregion
617
- //#region src/tokens.ts
618
- const KV_BINDING_REF = new InjectionToken("CF_KV_BINDING_REF");
619
- const D1_BINDING_REF = new InjectionToken("CF_D1_BINDING_REF");
620
- const R2_BINDING_REF = new InjectionToken("CF_R2_BINDING_REF");
621
- const QUEUE_BINDING_REF = new InjectionToken("CF_QUEUE_BINDING_REF");
622
- const DO_BINDING_REF = new InjectionToken("CF_DO_BINDING_REF");
623
- const AI_BINDING_REF = new InjectionToken("CF_AI_BINDING_REF");
624
- const VECTORIZE_BINDING_REF = new InjectionToken("CF_VECTORIZE_BINDING_REF");
625
- const HYPERDRIVE_BINDING_REF = new InjectionToken("CF_HYPERDRIVE_BINDING_REF");
626
- const ENV_REF = new InjectionToken("CF_ENV_REF");
627
- //#endregion
628
- //#region \0@oxc-project+runtime@0.139.0/helpers/esm/decorateMetadata.js
629
- function __decorateMetadata(k, v) {
630
- if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
340
+ const app = new CloudflareApplication(velaApp, options.env);
341
+ app.scanInstances(velaApp.getInstances());
342
+ registerWebSocketRoutes(app.getHonoApp(), app.getWsGatewayRoutes());
343
+ return app;
631
344
  }
632
- //#endregion
633
- //#region \0@oxc-project+runtime@0.139.0/helpers/esm/decorateParam.js
634
- function __decorateParam(paramIndex, decorator) {
635
- return function(target, key) {
636
- decorator(target, key, paramIndex);
345
+ /**
346
+ * Worker entrypoint with one bootstrap per environment identity. Weak keys let
347
+ * obsolete environments and secrets be collected. Concurrent cold events share
348
+ * construction; failed construction is evicted so the next event can retry.
349
+ */
350
+ function createCloudflareWorker(rootModule, options) {
351
+ const applications = /* @__PURE__ */ new WeakMap();
352
+ const application = (env) => {
353
+ const existing = applications.get(env);
354
+ if (existing) return existing;
355
+ const pending = createCloudflareApp(rootModule, {
356
+ ...options,
357
+ env
358
+ });
359
+ applications.set(env, pending);
360
+ pending.catch(() => {
361
+ if (applications.get(env) === pending) applications.delete(env);
362
+ });
363
+ return pending;
364
+ };
365
+ return {
366
+ async fetch(request, env, ctx) {
367
+ return (await application(env)).fetch(request, env, ctx);
368
+ },
369
+ async scheduled(event, env, ctx) {
370
+ return (await application(env)).scheduled(event, env, ctx);
371
+ },
372
+ async queue(batch, env, ctx) {
373
+ return (await application(env)).queue(batch, env, ctx);
374
+ }
637
375
  };
638
376
  }
639
377
  //#endregion
640
- //#region \0@oxc-project+runtime@0.139.0/helpers/esm/decorate.js
641
- function __decorate(decorators, target, key, desc) {
642
- var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
643
- if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
644
- else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
645
- return c > 3 && r && Object.defineProperty(target, key, r), r;
646
- }
378
+ //#region src/storage/storage.tokens.ts
379
+ const STORAGE_OPTIONS = new InjectionToken("STORAGE_OPTIONS");
647
380
  //#endregion
648
- //#region src/services/kv.service.ts
649
- let KVService = class KVService {
650
- ref;
651
- constructor(ref) {
652
- this.ref = ref;
653
- }
654
- get namespace() {
655
- return this.ref.value;
656
- }
381
+ //#region src/storage/storage-key-claim.ts
382
+ /** R2-compatible object-key claim bound. Keeps decode work predictably small. */
383
+ const MAX_STORAGE_KEY_BYTES = 1024;
384
+ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
385
+ const ROOT_TOKEN_PATTERNS = {
386
+ date: "\\d{4}-\\d{2}-\\d{2}",
387
+ year: "\\d{4}",
388
+ month: "(?:0[1-9]|1[0-2])",
389
+ day: "(?:0[1-9]|[12]\\d|3[01])",
390
+ uuid: "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}"
657
391
  };
658
- KVService = __decorate([
659
- Injectable(),
660
- __decorateParam(0, Inject(KV_BINDING_REF)),
661
- __decorateMetadata("design:paramtypes", [Object])
662
- ], KVService);
663
- //#endregion
664
- //#region src/modules/create-binding-module.ts
665
- function createBindingModule(opts) {
666
- const className = `${opts.name}Module`;
667
- const moduleClass = { [className]: class {} }[className];
668
- return defineConfigurableModule({
669
- module: moduleClass,
670
- methodName: "forRoot",
671
- keyFrom: ({ binding }) => binding,
672
- providers: ({ binding }) => [{
673
- provide: opts.bindingRefToken,
674
- useValue: new BindingRef(binding)
675
- }, opts.serviceClass],
676
- exports: [opts.serviceClass, opts.bindingRefToken]
677
- });
392
+ /** Encode an object key as an opaque, canonical base64url query claim. */
393
+ function encodeStorageKeyClaim(key) {
394
+ const bytes = new TextEncoder().encode(key);
395
+ if (bytes.byteLength === 0 || bytes.byteLength > 1024) throw new Error(`Storage key must be 1–${MAX_STORAGE_KEY_BYTES} UTF-8 bytes`);
396
+ let binary = "";
397
+ for (const byte of bytes) binary += String.fromCharCode(byte);
398
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
678
399
  }
679
- //#endregion
680
- //#region src/modules/kv.module.ts
681
- const KVModule = createBindingModule({
682
- name: "KV",
683
- serviceClass: KVService,
684
- bindingRefToken: KV_BINDING_REF
685
- });
686
- //#endregion
687
- //#region src/services/d1.service.ts
688
- let D1Service = class D1Service {
689
- ref;
690
- constructor(ref) {
691
- this.ref = ref;
692
- }
693
- get database() {
694
- return this.ref.value;
695
- }
696
- };
697
- D1Service = __decorate([
698
- Injectable(),
699
- __decorateParam(0, Inject(D1_BINDING_REF)),
700
- __decorateMetadata("design:paramtypes", [Object])
701
- ], D1Service);
702
- //#endregion
703
- //#region src/modules/d1.module.ts
704
- const D1Module = createBindingModule({
705
- name: "D1",
706
- serviceClass: D1Service,
707
- bindingRefToken: D1_BINDING_REF
708
- });
709
- //#endregion
710
- //#region src/services/r2.service.ts
711
- let R2Service = class R2Service {
712
- ref;
713
- constructor(ref) {
714
- this.ref = ref;
715
- }
716
- get bucket() {
717
- return this.ref.value;
718
- }
719
- };
720
- R2Service = __decorate([
721
- Injectable(),
722
- __decorateParam(0, Inject(R2_BINDING_REF)),
723
- __decorateMetadata("design:paramtypes", [Object])
724
- ], R2Service);
725
- //#endregion
726
- //#region src/modules/r2.module.ts
727
- const R2Module = createBindingModule({
728
- name: "R2",
729
- serviceClass: R2Service,
730
- bindingRefToken: R2_BINDING_REF
731
- });
732
- //#endregion
733
- //#region src/services/queue.service.ts
734
- let QueueService = class QueueService {
735
- ref;
736
- constructor(ref) {
737
- this.ref = ref;
738
- }
739
- get queue() {
740
- return this.ref.value;
741
- }
742
- };
743
- QueueService = __decorate([
744
- Injectable(),
745
- __decorateParam(0, Inject(QUEUE_BINDING_REF)),
746
- __decorateMetadata("design:paramtypes", [Object])
747
- ], QueueService);
748
- //#endregion
749
- //#region src/modules/queue.module.ts
750
- const QueueModule = createBindingModule({
751
- name: "Queue",
752
- serviceClass: QueueService,
753
- bindingRefToken: QUEUE_BINDING_REF
754
- });
755
- //#endregion
756
- //#region src/services/durable-object.service.ts
757
- let DurableObjectService = class DurableObjectService {
758
- ref;
759
- constructor(ref) {
760
- this.ref = ref;
761
- }
762
- get namespace() {
763
- return this.ref.value;
764
- }
765
- };
766
- DurableObjectService = __decorate([
767
- Injectable(),
768
- __decorateParam(0, Inject(DO_BINDING_REF)),
769
- __decorateMetadata("design:paramtypes", [Object])
770
- ], DurableObjectService);
771
- //#endregion
772
- //#region src/modules/durable-object.module.ts
773
- const DurableObjectModule = createBindingModule({
774
- name: "DurableObject",
775
- serviceClass: DurableObjectService,
776
- bindingRefToken: DO_BINDING_REF
777
- });
778
- //#endregion
779
- //#region src/services/ai.service.ts
780
- let AIService = class AIService {
781
- ref;
782
- constructor(ref) {
783
- this.ref = ref;
784
- }
785
- get binding() {
786
- return this.ref.value;
787
- }
788
- };
789
- AIService = __decorate([
790
- Injectable(),
791
- __decorateParam(0, Inject(AI_BINDING_REF)),
792
- __decorateMetadata("design:paramtypes", [Object])
793
- ], AIService);
794
- //#endregion
795
- //#region src/modules/ai.module.ts
796
- const AIModule = createBindingModule({
797
- name: "AI",
798
- serviceClass: AIService,
799
- bindingRefToken: AI_BINDING_REF
800
- });
801
- //#endregion
802
- //#region src/services/vectorize.service.ts
803
- let VectorizeService = class VectorizeService {
804
- ref;
805
- constructor(ref) {
806
- this.ref = ref;
807
- }
808
- get index() {
809
- return this.ref.value;
810
- }
811
- };
812
- VectorizeService = __decorate([
813
- Injectable(),
814
- __decorateParam(0, Inject(VECTORIZE_BINDING_REF)),
815
- __decorateMetadata("design:paramtypes", [Object])
816
- ], VectorizeService);
817
- //#endregion
818
- //#region src/modules/vectorize.module.ts
819
- const VectorizeModule = createBindingModule({
820
- name: "Vectorize",
821
- serviceClass: VectorizeService,
822
- bindingRefToken: VECTORIZE_BINDING_REF
823
- });
824
- //#endregion
825
- //#region src/services/hyperdrive.service.ts
826
- let HyperdriveService = class HyperdriveService {
827
- ref;
828
- constructor(ref) {
829
- this.ref = ref;
830
- }
831
- get binding() {
832
- return this.ref.value;
833
- }
834
- get connectionString() {
835
- return this.binding.connectionString;
836
- }
837
- get host() {
838
- return this.binding.host;
839
- }
840
- get port() {
841
- return this.binding.port;
842
- }
843
- get user() {
844
- return this.binding.user;
845
- }
846
- get password() {
847
- return this.binding.password;
848
- }
849
- get database() {
850
- return this.binding.database;
851
- }
852
- };
853
- HyperdriveService = __decorate([
854
- Injectable(),
855
- __decorateParam(0, Inject(HYPERDRIVE_BINDING_REF)),
856
- __decorateMetadata("design:paramtypes", [Object])
857
- ], HyperdriveService);
858
- //#endregion
859
- //#region src/modules/hyperdrive.module.ts
860
- const HyperdriveModule = createBindingModule({
861
- name: "Hyperdrive",
862
- serviceClass: HyperdriveService,
863
- bindingRefToken: HYPERDRIVE_BINDING_REF
864
- });
865
- //#endregion
866
- //#region src/services/env.service.ts
867
- let EnvService = class EnvService {
868
- ref;
869
- constructor(ref) {
870
- this.ref = ref;
871
- }
872
- /** The full env record. Throws if read before the first request. */
873
- get env() {
874
- return this.ref.value;
875
- }
876
- /** Read a single env entry (binding, var, or secret) by name. */
877
- get(key) {
878
- return this.ref.value[key];
400
+ /** Decode exactly one canonical base64url layer. Malformed/non-UTF-8 claims return undefined. */
401
+ function decodeStorageKeyClaim(claim) {
402
+ try {
403
+ if (claim.length === 0 || claim.length > Math.ceil(4096 / 3) || !BASE64URL_RE.test(claim) || claim.length % 4 === 1) return;
404
+ const base64 = claim.replace(/-/g, "+").replace(/_/g, "/");
405
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
406
+ const binary = atob(padded);
407
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
408
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
409
+ if (bytes.byteLength === 0 || bytes.byteLength > 1024) return void 0;
410
+ const decoded = new TextDecoder("utf-8", {
411
+ fatal: true,
412
+ ignoreBOM: false
413
+ }).decode(bytes);
414
+ return encodeStorageKeyClaim(decoded) === claim ? decoded : void 0;
415
+ } catch {
416
+ return;
879
417
  }
880
- };
881
- EnvService = __decorate([
882
- Injectable(),
883
- __decorateParam(0, Inject(ENV_REF)),
884
- __decorateMetadata("design:paramtypes", [Object])
885
- ], EnvService);
886
- //#endregion
887
- //#region src/modules/env.module.ts
888
- /**
889
- * Provides {@link EnvService} GLOBALLY so any module's provider factories can
890
- * `inject: [EnvService]`. Register once on the root module:
891
- *
892
- * ```ts
893
- * @Module({ imports: [EnvModule.forRoot(), AuthModule.forRootAsync({ ... })] })
894
- * class AppModule {}
895
- * ```
896
- *
897
- * The {@link EnvRef} it provides is collected and initialized by
898
- * createCloudflareApp's binding-init middleware on the first request (the
899
- * factory passes it the full `env`, not a single binding).
900
- */
901
- var EnvModule = class EnvModule {
902
- static forRoot() {
903
- const ref = new EnvRef();
904
- return {
905
- module: EnvModule,
906
- global: true,
907
- providers: [{
908
- provide: ENV_REF,
909
- useValue: ref
910
- }, EnvService],
911
- exports: [EnvService, ENV_REF]
912
- };
418
+ }
419
+ function isDotSegment(segment) {
420
+ let decoded = segment;
421
+ for (let i = 0; i < 2; i++) {
422
+ if (decoded === "" || decoded === "." || decoded === "..") return true;
423
+ try {
424
+ const next = decodeURIComponent(decoded);
425
+ if (next === decoded) break;
426
+ decoded = next;
427
+ } catch {
428
+ break;
429
+ }
913
430
  }
914
- };
431
+ return decoded === "" || decoded === "." || decoded === "..";
432
+ }
433
+ function rootSegmentPattern(segment) {
434
+ let pattern = "";
435
+ let cursor = 0;
436
+ for (const match of segment.matchAll(/\{(date|year|month|day|uuid)\}/g)) {
437
+ pattern += segment.slice(cursor, match.index).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
438
+ pattern += ROOT_TOKEN_PATTERNS[match[1]];
439
+ cursor = match.index + match[0].length;
440
+ }
441
+ pattern += segment.slice(cursor).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
442
+ return pattern;
443
+ }
444
+ /** Assert a canonical key is beneath a static or templated configured root. */
445
+ function isStorageKeyWithinRoot(key, root) {
446
+ const segments = (root ?? "").split(/[/\\]+/).filter((segment) => !isDotSegment(segment));
447
+ if (segments.length === 0) return key.length > 0;
448
+ const rootPattern = segments.map(rootSegmentPattern).join("/");
449
+ return new RegExp(`^(?:${rootPattern})(?:/|$)`).test(key);
450
+ }
915
451
  //#endregion
916
452
  //#region src/storage/r2-storage.driver.ts
917
453
  /** Base path of the StorageController presign-proxy route. */
@@ -955,9 +491,19 @@ var R2StorageDriver = class {
955
491
  }
956
492
  async getPresignedUrl(path, method, expiresIn) {
957
493
  if (!this.config.secret) throw new Error("A signing secret is required for presigned URLs (set APP_SECRET).");
958
- if (!Number.isFinite(expiresIn) || expiresIn <= 0) throw new Error(`Invalid presigned URL expiry: ${expiresIn}s (must be a positive number).`);
494
+ if (!Number.isSafeInteger(expiresIn) || expiresIn <= 0) throw new Error(`Invalid presigned URL expiry: ${expiresIn}s (must be a positive safe integer).`);
495
+ const claim = encodeStorageKeyClaim(path);
496
+ const routePath = `${STORAGE_ROUTE_BASE}/${encodeURIComponent(this.config.disk)}`;
497
+ const query = new URLSearchParams({
498
+ key: claim,
499
+ method
500
+ });
959
501
  return {
960
- url: await signUrl(`${`${STORAGE_ROUTE_BASE}/${this.config.disk}/${path}`}?method=${method}`, this.config.secret, { expiresIn }),
502
+ url: await signUrl(`${routePath}?${query}`, this.config.secret, {
503
+ expiresIn,
504
+ method,
505
+ purpose: STORAGE_SIGNED_URL_PURPOSE
506
+ }),
961
507
  method,
962
508
  expiresIn,
963
509
  expiresAt: new Date(Date.now() + expiresIn * 1e3)
@@ -965,16 +511,23 @@ var R2StorageDriver = class {
965
511
  }
966
512
  };
967
513
  //#endregion
968
- //#region src/storage/storage.tokens.ts
969
- const STORAGE_OPTIONS = new InjectionToken("STORAGE_OPTIONS");
514
+ //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateMetadata.js
515
+ function __decorateMetadata(k, v) {
516
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
517
+ }
518
+ //#endregion
519
+ //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateParam.js
520
+ function __decorateParam(paramIndex, decorator) {
521
+ return function(target, key) {
522
+ decorator(target, key, paramIndex);
523
+ };
524
+ }
970
525
  //#endregion
971
526
  //#region src/storage/storage-manager.service.ts
972
527
  let StorageManagerService = class StorageManagerService {
973
528
  options;
974
- env;
975
- constructor(options, env) {
529
+ constructor(options) {
976
530
  this.options = options;
977
- this.env = env;
978
531
  }
979
532
  hasDisk(disk) {
980
533
  return this.options.disks.some((d) => d.disk === disk);
@@ -985,53 +538,59 @@ let StorageManagerService = class StorageManagerService {
985
538
  return config;
986
539
  }
987
540
  getDriver(disk) {
988
- const config = this.getDiskConfig(disk);
989
- const bucket = this.env.get(config.binding);
990
- if (!bucket) throw new Error(`R2 binding "${config.binding}" for disk "${disk}" was not found in env.`);
991
541
  return new R2StorageDriver({
992
542
  disk,
993
- bucket,
994
- secret: this.env.get("APP_SECRET")
543
+ bucket: this.getDiskConfig(disk).bucket,
544
+ secret: this.options.secret
995
545
  });
996
546
  }
997
547
  };
998
548
  StorageManagerService = __decorate([
999
549
  Injectable(),
1000
550
  __decorateParam(0, Inject(STORAGE_OPTIONS)),
1001
- __decorateParam(1, Inject(EnvService)),
1002
- __decorateMetadata("design:paramtypes", [Object, typeof EnvService === "undefined" ? Object : EnvService])
551
+ __decorateMetadata("design:paramtypes", [Object])
1003
552
  ], StorageManagerService);
1004
553
  //#endregion
1005
554
  //#region src/storage/storage.controller.ts
1006
555
  let StorageController = class StorageController {
1007
556
  manager;
1008
- env;
1009
- constructor(manager, env) {
557
+ options;
558
+ constructor(manager, options) {
1010
559
  this.manager = manager;
1011
- this.env = env;
560
+ this.options = options;
1012
561
  }
1013
562
  async download(c) {
1014
- const secret = this.env.get("APP_SECRET");
563
+ const secret = this.options.secret;
1015
564
  if (!secret) return new Response("Storage signing is not configured", { status: 500 });
565
+ if (!await verifySignedUrl(c.req.url, secret, {
566
+ method: c.req.method,
567
+ purpose: STORAGE_SIGNED_URL_PURPOSE
568
+ })) return new Response("Invalid or expired URL", { status: 403 });
569
+ const url = new URL(c.req.url);
570
+ if (url.searchParams.get("method") !== "GET") return new Response("URL is not scoped for reads", { status: 403 });
1016
571
  const disk = c.req.param("disk");
1017
572
  if (!disk || !this.manager.hasDisk(disk)) return new Response("Unknown disk", { status: 404 });
1018
- if (!await verifySignedUrl(c.req.url, secret)) return new Response("Invalid or expired URL", { status: 403 });
1019
- const url = new URL(c.req.url);
1020
- if ((url.searchParams.get("method") ?? "GET") !== "GET") return new Response("URL is not scoped for reads", { status: 403 });
1021
- const { pathname } = url;
1022
- const prefix = `/storage/${disk}/`;
1023
- const idx = pathname.indexOf(prefix);
1024
- const fullPath = idx >= 0 ? decodeURIComponent(pathname.slice(idx + prefix.length)) : "";
573
+ const claim = url.searchParams.get("key");
574
+ const decoded = claim ? decodeStorageKeyClaim(claim) : void 0;
575
+ if (!decoded) return new Response("Malformed storage key claim", { status: 400 });
576
+ const fullPath = joinStoragePath(void 0, decoded);
577
+ if (fullPath !== decoded) return new Response("Invalid storage key", { status: 403 });
578
+ if (!isStorageKeyWithinRoot(fullPath, this.manager.getDiskConfig(disk).root)) return new Response("Storage key is outside the configured root", { status: 403 });
1025
579
  try {
1026
580
  const result = await this.manager.getDriver(disk).download(fullPath);
1027
- return new Response(result.toStream(), { headers: { "content-type": result.contentType } });
581
+ const filename = fullPath.split("/").at(-1) || "download";
582
+ return new Response(result.toStream(), { headers: {
583
+ "content-type": result.contentType || "application/octet-stream",
584
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
585
+ "x-content-type-options": "nosniff"
586
+ } });
1028
587
  } catch {
1029
588
  return new Response("Not found", { status: 404 });
1030
589
  }
1031
590
  }
1032
591
  };
1033
592
  __decorate([
1034
- Get("/:disk/*"),
593
+ Get("/:disk"),
1035
594
  __decorateParam(0, Req()),
1036
595
  __decorateMetadata("design:type", Function),
1037
596
  __decorateMetadata("design:paramtypes", [Object]),
@@ -1040,8 +599,8 @@ __decorate([
1040
599
  StorageController = __decorate([
1041
600
  Controller("storage"),
1042
601
  __decorateParam(0, Inject(StorageManagerService)),
1043
- __decorateParam(1, Inject(EnvService)),
1044
- __decorateMetadata("design:paramtypes", [typeof StorageManagerService === "undefined" ? Object : StorageManagerService, typeof EnvService === "undefined" ? Object : EnvService])
602
+ __decorateParam(1, Inject(STORAGE_OPTIONS)),
603
+ __decorateMetadata("design:paramtypes", [typeof StorageManagerService === "undefined" ? Object : StorageManagerService, Object])
1045
604
  ], StorageController);
1046
605
  //#endregion
1047
606
  //#region src/storage/storage.service.ts
@@ -1087,7 +646,7 @@ let StorageService = class StorageService {
1087
646
  validateExpiry(expiresIn) {
1088
647
  const cfg = this.options.presignedUrl ?? DEFAULT_PRESIGN;
1089
648
  const value = expiresIn ?? cfg.defaultExpiry;
1090
- if (!Number.isFinite(value) || value < 1 || value > cfg.maxExpiry) throw new Error(`Presigned URL expiry ${value}s is out of range (1–${cfg.maxExpiry}s).`);
649
+ if (!Number.isSafeInteger(value) || value < 1 || value > cfg.maxExpiry) throw new Error(`Presigned URL expiry ${value}s is out of range (1–${cfg.maxExpiry}s).`);
1091
650
  return value;
1092
651
  }
1093
652
  };
@@ -1105,7 +664,6 @@ const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({
1105
664
  }).build();
1106
665
  let StorageModule = class StorageModule extends ConfigurableModuleClass {};
1107
666
  StorageModule = __decorate([Module({
1108
- imports: [EnvModule.forRoot()],
1109
667
  providers: [StorageManagerService, StorageService],
1110
668
  controllers: [StorageController],
1111
669
  exports: [
@@ -1116,13 +674,19 @@ StorageModule = __decorate([Module({
1116
674
  })], StorageModule);
1117
675
  //#endregion
1118
676
  //#region src/services/kv-cache.store.ts
1119
- let KVCacheStore = class KVCacheStore {
1120
- kv;
1121
- constructor(kv) {
1122
- this.kv = kv;
1123
- }
1124
- get ns() {
1125
- return this.kv.namespace;
677
+ /**
678
+ * Cloudflare KV-backed {@link CacheStore}. Values are JSON-encoded. Intended as
679
+ * the slow tier under a `TieredCacheStore` (memory L1 → KV L2), but usable
680
+ * standalone from a typed environment factory: `new KVCacheStore(env.CACHE)`.
681
+ * Reads return unknown JSON; validate values at the consuming boundary.
682
+ *
683
+ * Note: Cloudflare KV requires `expirationTtl >= 60s`, so sub-minute TTLs are
684
+ * clamped up. Keep short TTLs on the memory tier; use KV for longer-lived entries.
685
+ */
686
+ var KVCacheStore = class {
687
+ ns;
688
+ constructor(ns) {
689
+ this.ns = ns;
1126
690
  }
1127
691
  async get(key) {
1128
692
  const value = await this.ns.get(key, "json");
@@ -1144,11 +708,6 @@ let KVCacheStore = class KVCacheStore {
1144
708
  } while (cursor);
1145
709
  }
1146
710
  };
1147
- KVCacheStore = __decorate([
1148
- Injectable(),
1149
- __decorateParam(0, Inject(KVService)),
1150
- __decorateMetadata("design:paramtypes", [typeof KVService === "undefined" ? Object : KVService])
1151
- ], KVCacheStore);
1152
711
  //#endregion
1153
712
  //#region src/services/flagship-flag.driver.ts
1154
713
  /**
@@ -1161,16 +720,12 @@ KVCacheStore = __decorate([
1161
720
  * dev-proxy tunnel dropping) propagates — `@velajs/feature-flags`'s service owns
1162
721
  * the never-throw guarantee.
1163
722
  *
1164
- * The binding only exists per request in a Worker, so pass a lazy accessor when
1165
- * wiring from a bootstrap factory (mirroring how {@link EnvService} reads are
1166
- * deferred); a resolved binding may be passed directly in tests.
723
+ * Build the driver inside a provider factory with the native environment:
1167
724
  *
1168
725
  * ```ts
1169
726
  * FeatureFlagsModule.forRootAsync({
1170
- * inject: [EnvService],
1171
- * useFactory: (env: EnvService) => ({
1172
- * drivers: [flagshipFlagDriver(() => env.get<FlagshipBinding>('FLAGS')!)],
1173
- * }),
727
+ * inject: [ENV],
728
+ * useFactory: (env: WorkerEnv) => ({ drivers: [flagshipFlagDriver(env.FLAGS)] }),
1174
729
  * });
1175
730
  * ```
1176
731
  *
@@ -1204,18 +759,36 @@ function flagshipFlagDriver(binding, options) {
1204
759
  }
1205
760
  //#endregion
1206
761
  //#region src/services/kv-flag.driver.ts
1207
- let KvFlagDriver = class KvFlagDriver {
1208
- kv;
762
+ /**
763
+ * Cloudflare KV-backed {@link FeatureFlagDriver}. Flags are stored as JSON
764
+ * values under an optional key prefix and read with `get(key, 'json')`. Reads
765
+ * are type-checked against the requested type: a missing key or a value of the
766
+ * wrong JSON type returns the caller's `fallback`. KV has no targeting, so the
767
+ * evaluation context is ignored.
768
+ *
769
+ * The driver stays honest — it does **not** swallow errors. A KV failure (or a
770
+ * `SyntaxError` from a malformed stored value) propagates; the never-throw
771
+ * guarantee lives in `@velajs/feature-flags`'s service layer.
772
+ *
773
+ * Placed like {@link KVCacheStore}: construct it in a wiring factory over a
774
+ * resolved {@link KVNamespace}.
775
+ *
776
+ * ```ts
777
+ * FeatureFlagsModule.forRootAsync({
778
+ * inject: [ENV],
779
+ * useFactory: (env: WorkerEnv) => ({ drivers: [new KvFlagDriver(env.CACHE, { prefix: 'flag:' })] }),
780
+ * });
781
+ * ```
782
+ */
783
+ var KvFlagDriver = class {
784
+ ns;
1209
785
  name;
1210
786
  prefix;
1211
- constructor(kv, options = {}) {
1212
- this.kv = kv;
787
+ constructor(ns, options = {}) {
788
+ this.ns = ns;
1213
789
  this.name = options.name ?? "kv";
1214
790
  this.prefix = options.prefix ?? "";
1215
791
  }
1216
- get ns() {
1217
- return this.kv.namespace;
1218
- }
1219
792
  getBoolean(key, fallback, _ctx) {
1220
793
  return this.read(key, fallback, (v) => typeof v === "boolean");
1221
794
  }
@@ -1239,11 +812,6 @@ let KvFlagDriver = class KvFlagDriver {
1239
812
  return matches(value) ? value : fallback;
1240
813
  }
1241
814
  };
1242
- KvFlagDriver = __decorate([
1243
- Injectable(),
1244
- __decorateParam(0, Inject(KVService)),
1245
- __decorateMetadata("design:paramtypes", [typeof KVService === "undefined" ? Object : KVService, Object])
1246
- ], KvFlagDriver);
1247
815
  /** Convenience factory for {@link KvFlagDriver}. */
1248
816
  function kvFlagDriver(kv, options) {
1249
817
  return new KvFlagDriver(kv, options);
@@ -1259,7 +827,7 @@ function kvFlagDriver(kv, options) {
1259
827
  * @example
1260
828
  * ```ts
1261
829
  * @Get()
1262
- * handle(@Env() env: CloudflareEnv) { ... }
830
+ * handle(@Env() env: WorkerEnv) { ... }
1263
831
  *
1264
832
  * @Get()
1265
833
  * handle(@Env('MY_KV') kv: KVNamespace) { ... }
@@ -1267,8 +835,8 @@ function kvFlagDriver(kv, options) {
1267
835
  */
1268
836
  const Env = createParamDecorator((bindingName, ctx) => {
1269
837
  const env = ctx.getContext().env;
1270
- if (!env) return void 0;
1271
- return bindingName ? env[bindingName] : env;
838
+ if (typeof env !== "object" || env === null) return void 0;
839
+ return bindingName ? Reflect.get(env, bindingName) : env;
1272
840
  });
1273
841
  //#endregion
1274
842
  //#region src/decorators/scheduled.ts
@@ -1338,262 +906,6 @@ function QueueConsumer(queueName) {
1338
906
  };
1339
907
  }
1340
908
  //#endregion
1341
- //#region src/websocket/cf-room-registry.ts
1342
- /**
1343
- * Core `RoomRegistry` backed by a Durable Object's hibernatable sockets. Room
1344
- * membership lives in tags (hub room, set at accept) + the attachment (dynamic
1345
- * `join()`), never in DO instance fields — so it survives hibernation with zero
1346
- * rehydration.
1347
- */
1348
- var CfRoomRegistry = class {
1349
- ctx;
1350
- constructor(ctx) {
1351
- this.ctx = ctx;
1352
- }
1353
- register(_client) {}
1354
- leaveAll(_client) {}
1355
- join(client, room) {
1356
- return client.join(room);
1357
- }
1358
- leave(client, room) {
1359
- return client.leave(room);
1360
- }
1361
- localIdsInRoom(room) {
1362
- return this.socketsForRoom(room).map((ws) => this.attachmentOf(ws).connId);
1363
- }
1364
- deliverLocal(cmd) {
1365
- const excludeIds = new Set(cmd.exceptIds ?? []);
1366
- const excludeRooms = cmd.exceptRooms ?? [];
1367
- const targets = cmd.rooms.length === 0 ? this.ctx.getWebSockets() : this.collectRooms(cmd.rooms);
1368
- const seen = /* @__PURE__ */ new Set();
1369
- for (const ws of targets) {
1370
- const att = this.attachmentOf(ws);
1371
- if (seen.has(att.connId)) continue;
1372
- seen.add(att.connId);
1373
- if (excludeIds.has(att.connId)) continue;
1374
- if (excludeRooms.some((r) => att.rooms.includes(r))) continue;
1375
- ws.send(cmd.frame);
1376
- }
1377
- }
1378
- collectRooms(rooms) {
1379
- const set = /* @__PURE__ */ new Set();
1380
- for (const room of rooms) for (const ws of this.socketsForRoom(room)) set.add(ws);
1381
- return [...set];
1382
- }
1383
- socketsForRoom(room) {
1384
- return this.ctx.getWebSockets().filter((ws) => this.attachmentOf(ws).rooms.includes(room));
1385
- }
1386
- attachmentOf(ws) {
1387
- return ws.deserializeAttachment() ?? {
1388
- connId: "",
1389
- path: "",
1390
- rooms: [],
1391
- data: {}
1392
- };
1393
- }
1394
- /** Reconstruct a `WsClient` for a raw socket (e.g. inside gateway lifecycle scans). */
1395
- clientFor(ws) {
1396
- return new CfWsClient(this.ctx, ws);
1397
- }
1398
- };
1399
- //#endregion
1400
- //#region src/websocket/ws-server-holder.ts
1401
- let WsServerHolder = class WsServerHolder {
1402
- target;
1403
- setTarget(server) {
1404
- this.target = server;
1405
- }
1406
- get resolved() {
1407
- if (!this.target) throw new Error("WebSocket server is only available inside a WebSocket Durable Object. To push from a Worker HTTP handler, use broadcastToRoom(namespace, room, ...).");
1408
- return this.target;
1409
- }
1410
- emit(event, data) {
1411
- return this.resolved.emit(event, data);
1412
- }
1413
- to(room) {
1414
- return this.resolved.to(room);
1415
- }
1416
- in(room) {
1417
- return this.resolved.in(room);
1418
- }
1419
- except(room) {
1420
- return this.resolved.except(room);
1421
- }
1422
- };
1423
- WsServerHolder = __decorate([Injectable()], WsServerHolder);
1424
- //#endregion
1425
- //#region src/websocket/do-bootstrap.ts
1426
- /**
1427
- * Slim DI bootstrap for the Durable Object isolate: wires the container and runs
1428
- * `OnModuleInit`/`OnApplicationBootstrap` (so `WsDispatcher` discovers gateways)
1429
- * WITHOUT building the Hono app/routes the DO never serves. Cloudflare binding
1430
- * refs are initialized straight from the DO's `env`, and the ctx-backed server
1431
- * is bound before bootstrap lifecycle so gateway `afterInit`/handlers see it.
1432
- */
1433
- async function buildDoRuntime(rootModule, ctx, env) {
1434
- const { container, routeManager, loader } = await bootstrap(rootModule);
1435
- for (const value of container.getUseValues()) if (value instanceof EnvRef) value._initialize(env);
1436
- else if (value instanceof BindingRef) value._initialize(env[value.bindingName]);
1437
- const registry = new CfRoomRegistry(ctx);
1438
- const driver = local();
1439
- driver.bind(registry);
1440
- const server = new WsServerImpl(driver);
1441
- try {
1442
- const holder = container.resolve(WS_SERVER);
1443
- if (holder instanceof WsServerHolder) holder.setTarget(server);
1444
- } catch {}
1445
- const app = new VelaApplication(container, routeManager);
1446
- app.setInstances(await loader.resolveAllInstances());
1447
- await app.callOnModuleInit();
1448
- await app.callOnApplicationBootstrap();
1449
- const wsEntrypoints = app.entrypoints.ofKind("websocket");
1450
- const live = initDoLive(app, container, ctx);
1451
- return {
1452
- dispatcher: wsEntrypoints[0]?.meta.dispatcher ?? app.get(WsDispatcher),
1453
- registry,
1454
- server,
1455
- gatewayPaths: wsEntrypoints.map((ep) => ep.meta.path),
1456
- live,
1457
- close: (signal) => app.close(signal)
1458
- };
1459
- }
1460
- //#endregion
1461
- //#region src/websocket/do-websocket-host.ts
1462
- /**
1463
- * The socket lifecycle inside a WebSocket Durable Object, decoupled from the
1464
- * `cloudflare:workers` base class so it is unit-testable with fakes. The thin
1465
- * `VelaWebSocketDurableObject` shell forwards its hibernation callbacks here.
1466
- */
1467
- var DoWebSocketHost = class {
1468
- ctx;
1469
- dispatcher;
1470
- registry;
1471
- gatewayPaths;
1472
- constructor(ctx, dispatcher, registry, gatewayPaths = []) {
1473
- this.ctx = ctx;
1474
- this.dispatcher = dispatcher;
1475
- this.registry = registry;
1476
- this.gatewayPaths = gatewayPaths;
1477
- }
1478
- /** The single registered gateway's path — a fallback when the Worker didn't forward `x-vela-path`. */
1479
- defaultPath() {
1480
- return this.gatewayPaths[0];
1481
- }
1482
- /**
1483
- * Accept a hibernatable socket: tag it with its hub room + connection id,
1484
- * persist the attachment, then fire `OnGatewayConnection` WITHOUT blocking the
1485
- * 101 response (the caller returns it immediately).
1486
- */
1487
- accept(ws, path, roomId, userId) {
1488
- const connId = crypto.randomUUID();
1489
- this.ctx.acceptWebSocket(ws, [roomTag(roomId), connTag(connId)]);
1490
- const attachment = {
1491
- connId,
1492
- userId,
1493
- path,
1494
- rooms: [roomId],
1495
- data: userId ? { userId } : {}
1496
- };
1497
- ws.serializeAttachment(attachment);
1498
- Promise.resolve(this.dispatcher.handleOpen(path, new CfWsClient(this.ctx, ws))).catch((err) => console.warn("[vela] websocket handleConnection failed:", err));
1499
- }
1500
- async onMessage(ws, message) {
1501
- const client = new CfWsClient(this.ctx, ws);
1502
- await this.dispatcher.dispatchMessage(client.path, client, message);
1503
- }
1504
- async onClose(ws, code, reason) {
1505
- const client = new CfWsClient(this.ctx, ws);
1506
- await this.dispatcher.handleClose(client.path, client, code, reason);
1507
- try {
1508
- if (code === 1e3 || code >= 3e3 && code <= 4999) ws.close(code, reason);
1509
- else ws.close();
1510
- } catch {}
1511
- }
1512
- async onError(ws, err) {
1513
- const client = new CfWsClient(this.ctx, ws);
1514
- await this.dispatcher.handleError(client.path, client, err);
1515
- }
1516
- /** Deliver a broadcast command to this DO's local sockets (RPC entry point). */
1517
- broadcast(cmd) {
1518
- this.registry.deliverLocal(cmd);
1519
- }
1520
- };
1521
- //#endregion
1522
- //#region src/websocket/websocket.durable-object.ts
1523
- const PING = "{\"event\":\"ping\"}";
1524
- const PONG = "{\"event\":\"pong\"}";
1525
- /**
1526
- * Base class for the WebSocket Durable Object. The user exports a named subclass
1527
- * (matching their `wrangler.toml` `class_name`) built from their `AppModule`:
1528
- *
1529
- * ```ts
1530
- * export class ChatRoom extends VelaWebSocketDurableObject(AppModule) {}
1531
- * ```
1532
- *
1533
- * It owns the raw hibernation socket lifecycle (Hono's `upgradeWebSocket` cannot
1534
- * bridge DO hibernation) and forwards every event into the runtime-agnostic
1535
- * `WsDispatcher` via {@link DoWebSocketHost}.
1536
- */
1537
- function VelaWebSocketDurableObject(rootModule) {
1538
- return class VelaWsDurableObject extends DurableObject {
1539
- host;
1540
- liveEngine;
1541
- ready;
1542
- constructor(ctx, env) {
1543
- super(ctx, env);
1544
- try {
1545
- ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(PING, PONG));
1546
- } catch {}
1547
- this.ready = ctx.blockConcurrencyWhile(async () => {
1548
- const runtime = await buildDoRuntime(rootModule, ctx, env);
1549
- this.host = new DoWebSocketHost(ctx, runtime.dispatcher, runtime.registry, runtime.gatewayPaths);
1550
- this.liveEngine = runtime.live;
1551
- });
1552
- }
1553
- async fetch(request) {
1554
- await this.ready;
1555
- if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected WebSocket upgrade", { status: 426 });
1556
- const { 0: client, 1: server } = new WebSocketPair();
1557
- const url = new URL(request.url);
1558
- const roomId = request.headers.get("x-vela-room") ?? this.ctx.id.name ?? url.pathname;
1559
- const path = request.headers.get("x-vela-path") ?? this.host.defaultPath() ?? url.pathname;
1560
- const userId = request.headers.get("x-vela-user") || void 0;
1561
- this.host.accept(server, path, roomId, userId);
1562
- return new Response(null, {
1563
- status: 101,
1564
- webSocket: client
1565
- });
1566
- }
1567
- async webSocketMessage(ws, message) {
1568
- await this.ready;
1569
- await this.host.onMessage(ws, message);
1570
- }
1571
- async webSocketClose(ws, code, reason) {
1572
- await this.ready;
1573
- await this.host.onClose(ws, code, reason);
1574
- }
1575
- async webSocketError(ws, error) {
1576
- await this.ready;
1577
- await this.host.onError(ws, error);
1578
- }
1579
- /** DO RPC — server-initiated broadcast forwarded from a Worker (see `broadcastToRoom`). */
1580
- async broadcast(cmd) {
1581
- await this.ready;
1582
- this.host.broadcast(cmd);
1583
- }
1584
- /**
1585
- * DO RPC — live tag invalidation forwarded from a Worker (durableObjectLive /
1586
- * liveInvalidateToRoom). Appends to THIS log scope's cursor log and returns
1587
- * the commit stamp (what `Vela-Commit-Cursor` carries); the subscription
1588
- * refreshes fan out asynchronously.
1589
- */
1590
- async invalidate(cmd) {
1591
- await this.ready;
1592
- return this.liveEngine?.applyInvalidation(cmd);
1593
- }
1594
- };
1595
- }
1596
- //#endregion
1597
909
  //#region src/websocket/cloudflare-websocket.module.ts
1598
910
  /**
1599
911
  * Cloudflare counterpart to the core `WebSocketModule.forRoot()`. Import this in
@@ -1604,12 +916,10 @@ function VelaWebSocketDurableObject(rootModule) {
1604
916
  */
1605
917
  var CloudflareWebSocketModule = class CloudflareWebSocketModule {
1606
918
  static forRoot() {
919
+ const providers = [defineProvider(WS_SERVER, { useClass: WsServerHolder }), WsDispatcher];
1607
920
  return {
1608
921
  module: CloudflareWebSocketModule,
1609
- providers: [{
1610
- provide: WS_SERVER,
1611
- useClass: WsServerHolder
1612
- }, WsDispatcher],
922
+ providers,
1613
923
  exports: [WS_SERVER, WsDispatcher]
1614
924
  };
1615
925
  }
@@ -1624,11 +934,11 @@ var CloudflareWebSocketModule = class CloudflareWebSocketModule {
1624
934
  *
1625
935
  * @example
1626
936
  * ```ts
1627
- * // In a controller — ns from DurableObjectService.namespace
1628
- * await broadcastToRoom(ns, `org:${id}`, 'order.created', order);
937
+ * // In a controller — ns from the typed Worker environment
938
+ * await broadcastToRoom(ns, '/orgs/:orgId/ws', `org:${id}`, 'order.created', order);
1629
939
  * ```
1630
940
  */
1631
- async function broadcastToRoom(ns, room, event, data, options) {
941
+ async function broadcastToRoom(ns, gatewayPath, room, event, data, options) {
1632
942
  const cmd = {
1633
943
  rooms: [room],
1634
944
  exceptIds: options?.exceptIds,
@@ -1637,9 +947,77 @@ async function broadcastToRoom(ns, room, event, data, options) {
1637
947
  data
1638
948
  })
1639
949
  };
1640
- await ns.get(roomToDurableId(ns, room)).broadcast(cmd);
950
+ const maxFrameBytes = resolveMaxFrameBytes({ maxFrameBytes: options?.maxFrameBytes ?? DEFAULT_WS_MAX_FRAME_BYTES });
951
+ assertBroadcastCommandFits(cmd, maxFrameBytes);
952
+ await ns.get(roomToDurableId(ns, gatewayPath, room)).broadcast(cmd);
953
+ }
954
+ //#endregion
955
+ //#region src/rate-limit/cloudflare-rate-limit.store.ts
956
+ /**
957
+ * Adapt a Cloudflare Workers Rate Limiting binding to Vela's throttler store.
958
+ *
959
+ * The platform binding makes the allow/deny decision. It does not expose exact
960
+ * counters or reset timestamps, so this adapter intentionally omits `remaining`.
961
+ */
962
+ function cloudflareRateLimitStore(binding, options) {
963
+ if (!binding || typeof binding !== "function" && typeof binding.limit !== "function") throw new TypeError("A Cloudflare Rate Limiting binding is required");
964
+ if (!Number.isSafeInteger(options.limit) || options.limit <= 0 || options.limit >= Number.MAX_SAFE_INTEGER) throw new RangeError("Rate limit must be a positive safe integer");
965
+ if (options.periodSeconds !== 10 && options.periodSeconds !== 60) throw new RangeError("Cloudflare rate-limit periods must be 10 or 60 seconds");
966
+ const maxKeyBytes = options.maxKeyBytes ?? 1024;
967
+ if (!Number.isSafeInteger(maxKeyBytes) || maxKeyBytes <= 0 || maxKeyBytes > 4096) throw new RangeError("maxKeyBytes must be between 1 and 4096");
968
+ const ttlMs = options.periodSeconds * 1e3;
969
+ const encoder = new TextEncoder();
970
+ const resolveBinding = typeof binding === "function" ? binding : () => binding;
971
+ return {
972
+ async increment(key, requestedTtlMs) {
973
+ if (requestedTtlMs !== ttlMs) throw new Error(`Cloudflare binding period mismatch: expected ${ttlMs}ms, received ${requestedTtlMs}ms`);
974
+ if (typeof key !== "string" || key.length === 0 || /[\u0000-\u001f\u007f]/.test(key) || encoder.encode(key).byteLength > maxKeyBytes) throw new Error("Refusing an invalid or oversized rate-limit key");
975
+ const currentBinding = resolveBinding();
976
+ if (!currentBinding || typeof currentBinding.limit !== "function") throw new Error("Cloudflare Rate Limiting binding is unavailable");
977
+ const decision = await currentBinding.limit({ key });
978
+ if (!decision || typeof decision.success !== "boolean") throw new Error("Cloudflare rate-limit binding returned an invalid decision");
979
+ return {
980
+ count: decision.success ? 0 : options.limit + 1,
981
+ ttlMs,
982
+ allowed: decision.success,
983
+ enforcedLimit: options.limit
984
+ };
985
+ },
986
+ reset() {
987
+ throw new Error("Cloudflare Rate Limiting bindings do not support counter reset");
988
+ }
989
+ };
990
+ }
991
+ //#endregion
992
+ //#region src/nonce/durable-object-nonce.store.ts
993
+ const APP_NAMESPACE_PREFIX = "vela:nonce:v1:";
994
+ const MAX_APP_NAMESPACE_BYTES = 128;
995
+ /**
996
+ * Strict, cross-isolate {@link NonceStore} backed by one SQLite Durable Object
997
+ * per explicit application namespace.
998
+ *
999
+ * Invalid input, an unavailable/malformed binding, RPC failure, or a malformed
1000
+ * RPC result all deny the claim (`false`). Only the literal boolean `true` from
1001
+ * the Durable Object is accepted.
1002
+ */
1003
+ function durableObjectNonceStore(options) {
1004
+ if (!options || typeof options !== "object") throw new TypeError("Durable Object nonce-store options are required");
1005
+ if (!isCanonicalBoundedText(options.appNamespace, MAX_APP_NAMESPACE_BYTES)) throw new TypeError(`appNamespace must be canonical, non-empty, and at most ${MAX_APP_NAMESPACE_BYTES} UTF-8 bytes`);
1006
+ if (typeof options.binding !== "function") throw new TypeError("A lazy Durable Object namespace binding resolver is required");
1007
+ const objectName = `${APP_NAMESPACE_PREFIX}${options.appNamespace}`;
1008
+ return { async claim(nonce, expEpochSeconds) {
1009
+ const now = Math.floor(Date.now() / 1e3);
1010
+ if (!isCanonicalBoundedText(nonce, 512) || !isValidExpiry(expEpochSeconds, now)) return false;
1011
+ try {
1012
+ const namespace = await options.binding();
1013
+ const id = namespace.idFromName(objectName);
1014
+ return await namespace.get(id).claim(nonce, expEpochSeconds) === true;
1015
+ } catch {
1016
+ return false;
1017
+ }
1018
+ } };
1641
1019
  }
1642
1020
  //#endregion
1643
- export { AIModule, AIService, CloudflareApplication, CloudflareWebSocketModule, ConnectedSocket, D1Module, D1Service, DoCursorLog, DurableObjectModule, DurableObjectService, Env, EnvModule, EnvService, FlagshipFlagDriver, HyperdriveModule, HyperdriveService, KVCacheStore, KVModule, KVService, KvFlagDriver, MessageBody, QueueConsumer, QueueModule, QueueService, R2Module, R2Service, R2StorageDriver, STORAGE_OPTIONS, Scheduled, StorageController, StorageManagerService, StorageModule, StorageService, SubscribeMessage, VectorizeModule, VectorizeService, VelaWebSocketDurableObject, WebSocketGateway, WebSocketServer, WsException, broadcastToRoom, cloudflareAdapter, createCloudflareApp, durableObjectCursorLog, durableObjectLive, flagshipFlagDriver, kvFlagDriver, liveInvalidateToRoom };
1021
+ export { CloudflareApplication, CloudflareWebSocketModule, ConnectedSocket, DoCursorLog, DoPitrUnavailableError, Env, FlagshipFlagDriver, KVCacheStore, KvFlagDriver, MessageBody, QueueConsumer, R2StorageDriver, STORAGE_OPTIONS, Scheduled, StorageController, StorageManagerService, StorageModule, StorageService, SubscribeMessage, WebSocketGateway, WebSocketServer, WsException, armDoPitr, broadcastToRoom, cloudflareAdapter, cloudflareRateLimitStore, createCloudflareApp, createCloudflareWorker, durableObjectCursorLog, durableObjectLive, durableObjectNonceStore, durableObjectRoomName, flagshipFlagDriver, isDoPitrUnavailable, kvFlagDriver, liveInvalidateToRoom, readDoPitrBookmark };
1644
1022
 
1645
1023
  //# sourceMappingURL=index.js.map