@velajs/cloudflare 1.28.0 → 1.29.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,7 +1,7 @@
1
- import { C as durableObjectRoomName, T as roomToDurableId, _ as readDoPitrBookmark, a as durableObjectLive, b as assertCloudflareEnvironment, c as liveInvalidateToRoom, g as isDoPitrUnavailable, h as armDoPitr, i as durableObjectCursorLog, m as DoPitrUnavailableError, n as isValidExpiry, p as WsServerHolder, r as DoCursorLog, t as isCanonicalBoundedText, v as __decorate, x as registerCloudflareEnvironment, y as resolveCloudflareRoot } from "./nonce-validation-CeVihShV.js";
1
+ import { C as durableObjectRoomName, T as roomToDurableId, a as armDoPitr, b as assertCloudflareEnvironment, c as __decorate, d as durableObjectLive, h as warnWorkerLocalLive, i as DoPitrUnavailableError, l as DoCursorLog, m as liveInvalidateToRoom, n as isValidExpiry, o as isDoPitrUnavailable, r as WsServerHolder, s as readDoPitrBookmark, t as isCanonicalBoundedText, u as durableObjectCursorLog, x as registerCloudflareEnvironment } from "./nonce-validation-Dy8z05A9.js";
2
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, getEntrypointModuleId, getMetadata, getTrustedRequestIdentity, parseCron, registerEntrypointKind, resolveEntrypoint, resolveErrorReporter, resolveScopedComponentsAsync, runInEntrypointScope, shouldFilterCatch } from "@velajs/vela";
4
- import { ConnectedSocket, DEFAULT_WS_MAX_FRAME_BYTES, MessageBody, SubscribeMessage, WS_GATEWAY_METADATA, WS_SERVER, WebSocketGateway, WebSocketServer, WsDispatcher, WsException, assertBroadcastCommandFits, authenticateWebSocketUpgrade, readWsEntrypointMeta, resolveGatewayRoomId, resolveGatewayRoomParam, resolveMaxFrameBytes } from "@velajs/vela/websocket";
3
+ import { ConfigurableModuleBuilder, Controller, Get, Inject, Injectable, InjectionToken, Module, PipelineRunner, Req, SCHEDULE_INVOCATION_SEED, Scope, VelaFactory, buildEntrypointExecutionContext, cronDialectAmbiguity, defineMetadata, defineProvider, getEntrypointModuleId, getMetadata, getTrustedRequestIdentity, invokeScheduledJob, parseCronMetadata, parseIntervalMetadata, registerEntrypointKind, resolveEntrypoint, resolveErrorReporter, resolveScopedComponentsAsync, runInEntrypointScope, scheduledJobComponents, shouldFilterCatch } from "@velajs/vela";
4
+ import { ConnectedSocket, DEFAULT_WS_MAX_FRAME_BYTES, MessageBody, SubscribeMessage, WS_GATEWAY_METADATA, WS_SERVER, WebSocketGateway, WebSocketServer, WsDispatcher, WsException, assertBroadcastCommandFits, createWebSocketUpgradeGate, readWsEntrypointMeta, resolveGatewayRoomId, resolveGatewayRoomParam, resolveMaxFrameBytes } from "@velajs/vela/websocket";
5
5
  import { STORAGE_SIGNED_URL_PURPOSE, joinStoragePath, signUrl, verifySignedUrl } from "@velajs/vela/storage";
6
6
  //#region src/websocket/websocket-routing.ts
7
7
  const MAX_IDENTITY_FIELD_BYTES = 2048;
@@ -33,64 +33,80 @@ function combineIdentities(requestIdentity, upgradeIdentity) {
33
33
  expiresAtMs: Math.min(requestIdentity.expiresAtMs, upgradeIdentity.expiresAtMs)
34
34
  };
35
35
  }
36
- /** Read `@WebSocketGateway({ path, binding })` off a resolved instance (CF-hosted gateways only). */
37
- function collectWsGatewayRoutes(instance) {
36
+ /** An instance's constructor is the class token its module registered. */
37
+ function isClass(value) {
38
+ return typeof value === "function";
39
+ }
40
+ /**
41
+ * Read `@WebSocketGateway({ path, binding })` off a resolved instance (CF-hosted
42
+ * gateways only). The application container names the module that declares the
43
+ * gateway when exactly one module registers it.
44
+ */
45
+ function collectWsGatewayRoutes(instance, container) {
38
46
  const options = getMetadata(WS_GATEWAY_METADATA, instance.constructor);
39
47
  if (!options?.path || !options?.binding) return [];
40
48
  resolveGatewayRoomParam(options);
41
49
  resolveMaxFrameBytes(options);
50
+ const gateway = instance.constructor;
51
+ const owners = isClass(gateway) ? container.getOwnerModuleIds(gateway) : [];
42
52
  return [{
43
53
  path: options.path,
44
54
  binding: options.binding,
45
- options: { ...options }
55
+ options: { ...options },
56
+ ...owners.length === 1 ? { moduleId: owners[0] } : {}
46
57
  }];
47
58
  }
48
59
  /**
49
60
  * Registers the upgrade routes on the Worker's Hono app. Each route validates
50
- * the `Upgrade` header, resolves the room's Durable Object, and forwards the raw
51
- * request injecting spoof-safe `x-vela-*` headers the DO reads. The DO returns
52
- * the `101` with the client socket.
61
+ * the `Upgrade` header, authenticates through the gateway's authenticator
62
+ * (resolved once from `container`, the application's DI container), resolves
63
+ * the room's Durable Object, and forwards the raw request — injecting
64
+ * spoof-safe `x-vela-*` headers the DO reads. The DO returns the `101` with the
65
+ * client socket.
53
66
  */
54
- function registerWebSocketRoutes(hono, routes) {
55
- for (const route of routes) hono.get(route.path, async (c) => {
56
- if (c.req.header("upgrade")?.toLowerCase() !== "websocket") return c.text("Expected WebSocket upgrade", 426);
57
- const headers = new Headers(c.req.raw.headers);
58
- headers.delete("x-vela-room");
59
- headers.delete("x-vela-path");
60
- headers.delete("x-vela-user");
61
- headers.delete("x-vela-expires-at");
62
- headers.delete("x-vela-expires-at-ms");
63
- headers.delete("x-vela-issuer");
64
- headers.delete("x-vela-subject");
65
- headers.delete("x-vela-principal-type");
66
- headers.delete("x-vela-tenant");
67
- const sanitizedRequest = new Request(c.req.raw, { headers });
68
- let roomId;
69
- try {
70
- roomId = resolveGatewayRoomId(route.options, (name) => c.req.param(name));
71
- } catch {
72
- return c.text("Invalid WebSocket room", 400);
73
- }
74
- const upgrade = await authenticateWebSocketUpgrade(route.options, sanitizedRequest, roomId);
75
- if (upgrade === false) return c.text("WebSocket upgrade forbidden", 403);
76
- const requestIdentity = accessIdentity(c);
77
- if (requestIdentity === null) return c.text("Invalid WebSocket identity", 403);
78
- const identity = combineIdentities(requestIdentity, upgrade.identity);
79
- if (identity === null) return c.text("Conflicting WebSocket identities", 403);
80
- if (identity && identity.expiresAtMs <= Date.now()) return c.text("WebSocket identity expired", 403);
81
- const forwardHeaders = new Headers(upgrade.request.headers);
82
- forwardHeaders.set("x-vela-room", roomId);
83
- forwardHeaders.set("x-vela-path", route.path);
84
- if (identity) {
85
- forwardHeaders.set("x-vela-user", identity.principal.subject);
86
- forwardHeaders.set("x-vela-issuer", identity.principal.issuer);
87
- forwardHeaders.set("x-vela-subject", identity.principal.subject);
88
- forwardHeaders.set("x-vela-principal-type", identity.principal.principalType);
89
- forwardHeaders.set("x-vela-tenant", identity.tenantId);
90
- forwardHeaders.set("x-vela-expires-at-ms", String(identity.expiresAtMs));
91
- }
92
- return forwardToRoom(c.env, route.binding, route.path, roomId, new Request(upgrade.request, { headers: forwardHeaders }));
93
- });
67
+ function registerWebSocketRoutes(hono, routes, container) {
68
+ for (const route of routes) {
69
+ const authenticate = createWebSocketUpgradeGate(container, route);
70
+ hono.get(route.path, async (c) => {
71
+ if (c.req.header("upgrade")?.toLowerCase() !== "websocket") return c.text("Expected WebSocket upgrade", 426);
72
+ const headers = new Headers(c.req.raw.headers);
73
+ headers.delete("x-vela-room");
74
+ headers.delete("x-vela-path");
75
+ headers.delete("x-vela-user");
76
+ headers.delete("x-vela-expires-at");
77
+ headers.delete("x-vela-expires-at-ms");
78
+ headers.delete("x-vela-issuer");
79
+ headers.delete("x-vela-subject");
80
+ headers.delete("x-vela-principal-type");
81
+ headers.delete("x-vela-tenant");
82
+ const sanitizedRequest = new Request(c.req.raw, { headers });
83
+ let roomId;
84
+ try {
85
+ roomId = resolveGatewayRoomId(route.options, (name) => c.req.param(name));
86
+ } catch {
87
+ return c.text("Invalid WebSocket room", 400);
88
+ }
89
+ const upgrade = await authenticate(sanitizedRequest, roomId);
90
+ if (upgrade === false) return c.text("WebSocket upgrade forbidden", 403);
91
+ const requestIdentity = accessIdentity(c);
92
+ if (requestIdentity === null) return c.text("Invalid WebSocket identity", 403);
93
+ const identity = combineIdentities(requestIdentity, upgrade.identity);
94
+ if (identity === null) return c.text("Conflicting WebSocket identities", 403);
95
+ if (identity && identity.expiresAtMs <= Date.now()) return c.text("WebSocket identity expired", 403);
96
+ const forwardHeaders = new Headers(upgrade.request.headers);
97
+ forwardHeaders.set("x-vela-room", roomId);
98
+ forwardHeaders.set("x-vela-path", route.path);
99
+ if (identity) {
100
+ forwardHeaders.set("x-vela-user", identity.principal.subject);
101
+ forwardHeaders.set("x-vela-issuer", identity.principal.issuer);
102
+ forwardHeaders.set("x-vela-subject", identity.principal.subject);
103
+ forwardHeaders.set("x-vela-principal-type", identity.principal.principalType);
104
+ forwardHeaders.set("x-vela-tenant", identity.tenantId);
105
+ forwardHeaders.set("x-vela-expires-at-ms", String(identity.expiresAtMs));
106
+ }
107
+ return forwardToRoom(c.env, route.binding, route.path, roomId, new Request(upgrade.request, { headers: forwardHeaders }));
108
+ });
109
+ }
94
110
  }
95
111
  /**
96
112
  * Gateway metadata contains a runtime binding name, so the native type is
@@ -114,12 +130,70 @@ async function forwardToRoom(env, binding, path, room, request) {
114
130
  return response;
115
131
  }
116
132
  //#endregion
117
- //#region src/cloudflare-application.ts
118
- registerEntrypointKind({
119
- kind: "cf:vela-cron",
120
- metaKey: CRON_METADATA,
121
- level: "method"
133
+ //#region src/scheduled-event.ts
134
+ /**
135
+ * Request-scoped Cloudflare view of the scheduled trigger, seeded into each
136
+ * `@Cron` job's invocation scope by the Cloudflare adapter. Inject it where a
137
+ * job needs platform controls such as `noRetry()`; the job's argument stays
138
+ * the portable `ScheduleInvocation`. It resolves only inside a scheduled
139
+ * invocation; resolving it anywhere else throws. A cron job fired on demand
140
+ * (Studio's run-now) receives a synthetic event: `cron` is the job's
141
+ * expression and `noRetry()` does nothing.
142
+ *
143
+ * The token provides itself as request-scoped in every container, so a class
144
+ * that injects it is request-scoped wherever the graph boots (a Worker, a
145
+ * Durable Object, the CLI or a testing module) and is built only for an
146
+ * invocation, never at bootstrap.
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * @Injectable({ scope: Scope.REQUEST })
151
+ * class Reports {
152
+ * constructor(@Inject(CLOUDFLARE_SCHEDULED_EVENT) private readonly trigger: CloudflareScheduledEvent) {}
153
+ *
154
+ * @Cron('0 3 * * *', { dialect: 'cloudflare' })
155
+ * async nightly(tick: CronInvocation) {
156
+ * if (!(await this.upstreamAvailable(tick.signal))) this.trigger.noRetry();
157
+ * }
158
+ * }
159
+ * ```
160
+ */
161
+ const CLOUDFLARE_SCHEDULED_EVENT = new InjectionToken("@velajs/cloudflare:scheduled-event", {
162
+ scope: Scope.REQUEST,
163
+ factory: () => {
164
+ throw new Error("CLOUDFLARE_SCHEDULED_EVENT can only be resolved inside a scheduled invocation: the Cloudflare adapter seeds it into each @Cron job scope for a cron trigger.");
165
+ }
122
166
  });
167
+ /** @internal Freeze the injected view; `noRetry` keeps the native receiver. */
168
+ function cloudflareScheduledEvent(event, scheduledTime) {
169
+ return Object.freeze({
170
+ cron: event.cron,
171
+ scheduledTime,
172
+ noRetry: () => {
173
+ event.noRetry?.();
174
+ }
175
+ });
176
+ }
177
+ /**
178
+ * Seeds a cron job fired outside a trigger (Studio's run-now) the way a
179
+ * trigger would: a synthetic event whose `cron` is the invocation's
180
+ * expression, with its `scheduledTime` and a `noRetry()` that does nothing.
181
+ * Interval jobs never run from Workers triggers, so they get no event.
182
+ */
183
+ const seedScheduledEvent = (scope, invocation) => {
184
+ if (invocation.kind !== "cron") return;
185
+ scope.setRequestInstance(CLOUDFLARE_SCHEDULED_EVENT, cloudflareScheduledEvent({ cron: invocation.expression }, invocation.scheduledTime));
186
+ };
187
+ /**
188
+ * @internal Provide `SCHEDULE_INVOCATION_SEED`, so jobs fired on demand get a
189
+ * synthetic event. Each trigger seeds the real event into its jobs' scopes.
190
+ */
191
+ function registerScheduledEventSeed(container) {
192
+ container.register(defineProvider(SCHEDULE_INVOCATION_SEED, { useValue: seedScheduledEvent }));
193
+ container.markGlobalToken(SCHEDULE_INVOCATION_SEED);
194
+ }
195
+ //#endregion
196
+ //#region src/cloudflare-application.ts
123
197
  function invoke(instance, methodName, args) {
124
198
  const method = Reflect.get(instance, methodName);
125
199
  if (typeof method !== "function") throw new Error(`Method '${String(methodName)}' is not a function on ${instance.constructor.name}`);
@@ -131,6 +205,20 @@ function entrypointString(meta, property) {
131
205
  if (typeof value !== "string") throw new Error(`Invalid entrypoint metadata: ${property} must be a string.`);
132
206
  return value;
133
207
  }
208
+ /**
209
+ * `QueueModule`'s native consumer reports each failure of a batch once itself
210
+ * (processor failures where they ran, transport failures on settlement), so
211
+ * its rejection is not reported again here.
212
+ */
213
+ const SELF_REPORTING_KINDS = /* @__PURE__ */ new Set(["cf:queue:module"]);
214
+ const warnedRawJobs = /* @__PURE__ */ new Set();
215
+ /** The logical queue of a Vela job envelope, without importing the queue subsystem. */
216
+ function envelopeQueue(body) {
217
+ if (typeof body !== "object" || body === null) return void 0;
218
+ const queue = Reflect.get(body, "queue");
219
+ if (typeof queue !== "string" || typeof Reflect.get(body, "id") !== "string" || typeof Reflect.get(body, "name") !== "string" || !("data" in body)) return;
220
+ return queue;
221
+ }
134
222
  /** Wait for every matching handler, even when one fails before its siblings. */
135
223
  async function settleEntrypoints(work) {
136
224
  const errors = (await Promise.allSettled(work)).flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
@@ -140,15 +228,16 @@ async function settleEntrypoints(work) {
140
228
  /**
141
229
  * Wraps VelaApplication with Cloudflare-specific handlers:
142
230
  * - `fetch` — HTTP request handler (from Hono)
143
- * - `scheduled` — Cron trigger handler (matches `@Scheduled()` decorators
144
- * AND vela's own `@Cron()` jobs)
145
- * - `queue` — Queue consumer handler (matches `@QueueConsumer()` decorators)
231
+ * - `scheduled` — Cron trigger handler (runs the `@Cron()` jobs whose
232
+ * expression is the trigger's exact string)
233
+ * - `queue` — Queue consumer handler (`@QueueConsumer()` by physical queue,
234
+ * then `QueueModule`'s native consumer)
146
235
  * - `mountOpenApi` — Serve an OpenAPI document (and optional Scalar UI) on
147
236
  * the underlying Hono app
148
237
  *
149
238
  * @example
150
239
  * ```ts
151
- * const app = await createCloudflareApp(AppModule, { env, envToken: ENV });
240
+ * const app = await createCloudflareApp(AppModule, { env });
152
241
  * export default {
153
242
  * fetch: app.fetch,
154
243
  * scheduled: app.scheduled.bind(app),
@@ -159,7 +248,7 @@ async function settleEntrypoints(work) {
159
248
  * @example
160
249
  * ```ts
161
250
  * // Serve OpenAPI docs alongside your routes
162
- * const app = await createCloudflareApp(AppModule, { env, envToken: ENV });
251
+ * const app = await createCloudflareApp(AppModule, { env });
163
252
  * const document = createOpenApiDocument(AppModule);
164
253
  * app.mountOpenApi({ document, ui: 'scalar' });
165
254
  * // GET /openapi.json -> JSON document
@@ -170,6 +259,8 @@ var CloudflareApplication = class {
170
259
  env;
171
260
  #wsGatewayRoutes = [];
172
261
  #app;
262
+ /** Scheduled triggers in flight; close() aborts their signals and awaits them. */
263
+ #scheduled = /* @__PURE__ */ new Map();
173
264
  constructor(app, env) {
174
265
  this.env = env;
175
266
  this.#app = app;
@@ -190,7 +281,7 @@ var CloudflareApplication = class {
190
281
  *
191
282
  * @example
192
283
  * ```ts
193
- * const app = await createCloudflareApp(AppModule, { env, envToken: ENV });
284
+ * const app = await createCloudflareApp(AppModule, { env });
194
285
  * const auth = app.get(BetterAuthService);
195
286
  * ```
196
287
  */
@@ -209,7 +300,7 @@ var CloudflareApplication = class {
209
300
  * ```ts
210
301
  * import { createOpenApiDocument } from '@velajs/vela';
211
302
  *
212
- * const app = await createCloudflareApp(AppModule, { env, envToken: ENV });
303
+ * const app = await createCloudflareApp(AppModule, { env });
213
304
  * const document = createOpenApiDocument(AppModule, {
214
305
  * info: { title: 'My API', version: '1.0.0' },
215
306
  * });
@@ -235,12 +326,14 @@ var CloudflareApplication = class {
235
326
  if (meta.options.binding) routes.set(meta.path, {
236
327
  path: meta.path,
237
328
  binding: meta.options.binding,
238
- options: { ...meta.options }
329
+ options: { ...meta.options },
330
+ moduleId: meta.moduleId
239
331
  });
240
332
  }
333
+ const container = this.#app.getContainer();
241
334
  for (const instance of instances) {
242
335
  if (!instance || typeof instance !== "object") continue;
243
- for (const route of collectWsGatewayRoutes(instance)) if (!routes.has(route.path)) routes.set(route.path, route);
336
+ for (const route of collectWsGatewayRoutes(instance, container)) if (!routes.has(route.path)) routes.set(route.path, route);
244
337
  }
245
338
  this.#wsGatewayRoutes.splice(0, this.#wsGatewayRoutes.length, ...routes.values());
246
339
  }
@@ -249,28 +342,45 @@ var CloudflareApplication = class {
249
342
  return [...this.#wsGatewayRoutes];
250
343
  }
251
344
  /**
252
- * Handle Cloudflare scheduled (cron) events.
253
- * Matches the event's cron expression to `@Scheduled()` and vela `@Cron()`
254
- * handlers read from `app.entrypoints`; each handler runs inside a fresh
255
- * request-scoped child (request-scoped providers rebuild per tick).
345
+ * Handle a Cloudflare cron trigger. Runs every core `@Cron()` job whose
346
+ * expression is exactly `event.cron` (the trigger string is compared as
347
+ * delivered, never re-evaluated) through `invokeScheduledJob`, the dispatch
348
+ * primitive every runtime shares. Each job receives only its
349
+ * `ScheduleInvocation`, runs in a fresh invocation scope seeded with
350
+ * {@link CLOUDFLARE_SCHEDULED_EVENT}, and honors signed `ScheduleModule`
351
+ * dispatch. The trigger settles after every matching job and its managed
352
+ * work (`EXECUTION_LIFETIME.waitUntil`/`defer`) settle; failures reject it.
256
353
  */
257
- async scheduled(event, env, ctx) {
354
+ async scheduled(event, env, _ctx) {
258
355
  assertCloudflareEnvironment(this.env, env);
259
- await settleEntrypoints([...this.#app.entrypoints.ofKind("cf:scheduled").map((ep) => ({
260
- ep,
261
- cron: entrypointString(ep.meta, "cron")
262
- })), ...this.#app.entrypoints.ofKind("cf:vela-cron").map((ep) => ({
263
- ep,
264
- cron: entrypointString(ep.meta, "expression")
265
- }))].filter((h) => h.cron === event.cron).map(({ ep }) => this.dispatchEntrypoint(ep, event, env, ctx)));
356
+ const jobs = this.#app.entrypoints.ofKind("schedule:cron", parseCronMetadata).filter((entry) => entry.meta.expression === event.cron);
357
+ if (jobs.length === 0) return;
358
+ const controller = new AbortController();
359
+ const scheduledTime = event.scheduledTime ?? Date.now();
360
+ const invocation = Object.freeze({
361
+ kind: "cron",
362
+ expression: event.cron,
363
+ scheduledTime,
364
+ signal: controller.signal
365
+ });
366
+ const trigger = cloudflareScheduledEvent(event, scheduledTime);
367
+ const container = this.#app.getContainer();
368
+ const running = settleEntrypoints(jobs.map((entry) => invokeScheduledJob(container, entry, invocation, { seed: (scope) => scope.setRequestInstance(CLOUDFLARE_SCHEDULED_EVENT, trigger) })));
369
+ this.#scheduled.set(running, controller);
370
+ try {
371
+ await running;
372
+ } finally {
373
+ this.#scheduled.delete(running);
374
+ }
266
375
  }
267
376
  /**
268
- * Run one entrypoint handler inside a fresh request scope, through the
269
- * shared guard → interceptor pipeline (components declared with
377
+ * Run one queue consumer inside a fresh request scope, through the shared
378
+ * guard → interceptor pipeline (components declared with
270
379
  * `@UseGuards`/`@UseInterceptors`/`@UseFilters` on the consumer class or
271
380
  * method). HTTP-global components deliberately do NOT apply — an HTTP auth
272
- * guard has no business rejecting a queue batch. Unclaimed errors rethrow
273
- * so the platform's retry semantics stay intact.
381
+ * guard has no business rejecting a queue batch. Unclaimed errors are
382
+ * reported once (`QueueModule`'s consumer reports its own) and rethrow so
383
+ * the platform's retry semantics stay intact.
274
384
  */
275
385
  async dispatchEntrypoint(ep, payload, env, platformContext) {
276
386
  const targetClass = ep.token;
@@ -278,10 +388,11 @@ var CloudflareApplication = class {
278
388
  if (ep.methodName === void 0) throw new Error("Entrypoint must declare a handler method.");
279
389
  const methodName = ep.methodName;
280
390
  const reportContext = {
281
- edge: ep.kind.startsWith("cf:queue") ? "queue" : "schedule",
391
+ edge: "queue",
282
392
  source: `${targetClass.name}.${String(methodName)}`
283
393
  };
284
394
  let reported;
395
+ let delegated;
285
396
  try {
286
397
  await runInEntrypointScope(this.#app.getContainer(), async (scope, lifetime) => {
287
398
  const moduleId = getEntrypointModuleId(scope, ep);
@@ -307,11 +418,16 @@ var CloudflareApplication = class {
307
418
  invoke: async (args) => {
308
419
  const instance = await resolveEntrypoint(scope, ep);
309
420
  if (typeof instance !== "object" || instance === null) throw new Error("Entrypoint must resolve to an object.");
310
- return invoke(instance, methodName, args);
421
+ try {
422
+ return await invoke(instance, methodName, args);
423
+ } catch (error) {
424
+ if (SELF_REPORTING_KINDS.has(ep.kind)) delegated = { error };
425
+ throw error;
426
+ }
311
427
  }
312
428
  });
313
429
  } catch (error) {
314
- resolveErrorReporter(scope).report(error, reportContext);
430
+ if (delegated?.error !== error) resolveErrorReporter(scope).report(error, reportContext);
315
431
  for (const filter of filters) if (shouldFilterCatch(filter, error)) {
316
432
  await filter.catch(error, context);
317
433
  return;
@@ -329,74 +445,173 @@ var CloudflareApplication = class {
329
445
  }
330
446
  }
331
447
  /**
332
- * Handle Cloudflare Queue consumer events.
333
- * Matches the batch queue name to `@QueueConsumer()` handlers read from
334
- * `app.entrypoints`; each batch is processed inside a fresh request-scoped
335
- * child (request-scoped providers rebuild per batch — no boot-time captives).
448
+ * Handle Cloudflare Queue consumer events. `@QueueConsumer()` handlers read
449
+ * from `app.entrypoints` claim a batch by its physical queue name and
450
+ * receive it whole. A batch no handler claims goes to `QueueModule`'s native
451
+ * consumer (`cloudflareQueues()` from `@velajs/cloudflare/queues`), which
452
+ * routes each job by its logical queue. Each dispatch runs inside a fresh
453
+ * request-scoped child (request-scoped providers rebuild per batch — no
454
+ * boot-time captives). A batch nothing claims rejects: resolving would let
455
+ * Cloudflare acknowledge every message implicitly.
336
456
  */
337
457
  async queue(batch, env, ctx) {
338
458
  assertCloudflareEnvironment(this.env, env);
339
- const handlers = [...this.#app.entrypoints.ofKind("cf:queue"), ...this.#app.entrypoints.ofKind("cf:queue:module")].filter((ep) => entrypointString(ep.meta, "queueName") === batch.queue);
340
- if (handlers.length === 0) throw new Error(`No consumer for queue '${batch.queue}'.`);
459
+ const raw = this.#app.entrypoints.ofKind("cf:queue").filter((ep) => entrypointString(ep.meta, "queueName") === batch.queue);
460
+ if (raw.length > 0) this.warnRawJobs(batch);
461
+ const handlers = raw.length > 0 ? raw : this.#app.entrypoints.ofKind("cf:queue:module");
462
+ if (handlers.length === 0) throw new Error(`No consumer claims queue '${batch.queue}'. Add @QueueConsumer('${batch.queue}') to a provider, or deliver it through QueueModule.forRoot({ driver: cloudflareQueues() }) with a QueueModule.registerQueue() for each queue it carries. The batch is rejected unacknowledged, so Cloudflare retries it and then routes it to the configured dead-letter queue.`);
341
463
  await settleEntrypoints(handlers.map((ep) => this.dispatchEntrypoint(ep, batch, env, ctx)));
342
464
  }
465
+ /**
466
+ * A raw `@QueueConsumer` owns its physical queue's batches and must not carry
467
+ * jobs of a queue registered with `QueueModule`: those reach their
468
+ * `@Processor` only if the raw handler dispatches them itself, while
469
+ * `cloudflareQueues()` delivers registered queues. Warn once per physical and
470
+ * logical queue (unless diagnostics are silent); the raw consumer still
471
+ * receives and settles the batch.
472
+ */
473
+ warnRawJobs(batch) {
474
+ if (this.#app.getContainer().getDiagnostics() === "silent") return;
475
+ const registered = new Set(this.#app.entrypoints.ofKind("queue:registration").map((entry) => typeof entry.meta === "object" && entry.meta !== null ? Reflect.get(entry.meta, "name") : void 0));
476
+ if (registered.size === 0) return;
477
+ for (const message of batch.messages) {
478
+ const queue = envelopeQueue(typeof message === "object" && message !== null ? Reflect.get(message, "body") : void 0);
479
+ if (queue === void 0 || !registered.has(queue)) continue;
480
+ const warning = `[vela] @QueueConsumer('${batch.queue}') received jobs of registered queue '${queue}'. The raw consumer owns '${batch.queue}' and must not carry jobs of registered queues: they reach @Processor('${queue}') only if the raw handler dispatches them itself. Deliver '${queue}' through cloudflareQueues() instead: send it to a physical queue that no @QueueConsumer claims.`;
481
+ if (warnedRawJobs.has(warning)) continue;
482
+ warnedRawJobs.add(warning);
483
+ console.warn(warning);
484
+ }
485
+ }
486
+ /**
487
+ * Abort the signals of scheduled jobs still running, wait for them and their
488
+ * managed work to settle, then close the application.
489
+ */
343
490
  async close(signal) {
491
+ for (const controller of this.#scheduled.values()) controller.abort();
492
+ await Promise.allSettled(this.#scheduled.keys());
344
493
  return this.#app.close(signal);
345
494
  }
346
495
  };
347
496
  //#endregion
497
+ //#region src/schedule-diagnostics.ts
498
+ const reported = /* @__PURE__ */ new Set();
499
+ /** `'throw'` fails the caller, `'log'` warns once per message in this isolate. */
500
+ function reportScheduleDiagnostic(container, message) {
501
+ const mode = container.getDiagnostics();
502
+ if (mode === "silent") return;
503
+ if (mode === "throw") throw new Error(message);
504
+ if (reported.has(message)) return;
505
+ reported.add(message);
506
+ console.warn(message);
507
+ }
508
+ function jobName(entry) {
509
+ return `${typeof entry.token === "function" ? entry.token.name : String(entry.token)}.${entry.meta.methodName}`;
510
+ }
511
+ /**
512
+ * Guards, interceptors and filters declared for a job never run on its
513
+ * trigger, and a direct job that declares guards is refused on every trigger.
514
+ */
515
+ function reportComponents(container, label, entry) {
516
+ const decorators = scheduledJobComponents(container, entry);
517
+ if (decorators.length === 0) return;
518
+ reportScheduleDiagnostic(container, `[vela] ${label} declares ${decorators.length > 1 ? `${decorators.slice(0, -1).join(", ")} and ${decorators.at(-1)}` : decorators.join("")}, which do not run for scheduled jobs: a direct job runs no guards, interceptors or filters${decorators.includes("@UseGuards") ? ", and one that declares guards refuses to run" : ""}. Use signed ScheduleModule dispatch and declare them on the signed route to run the job through the request pipeline.`);
519
+ }
520
+ /**
521
+ * Report schedule declarations a Workers cron trigger cannot honor as written:
522
+ * a dialect-ambiguous `@Cron`, `dialect: 'unix'`, `timeZone: 'local'`,
523
+ * `@Interval` jobs, and guards, interceptors or filters declared for a job
524
+ * (a direct job that declares guards is refused when it fires).
525
+ * The container's diagnostics policy applies: `'throw'` fails bootstrap and the
526
+ * default `'log'` warns once per declaration, so the first event of a Worker
527
+ * (which bootstraps the application) never fails because of these checks.
528
+ * `vela deploy check` rejects the cron declarations, `@Interval` jobs and
529
+ * guarded direct jobs before deployment (`ambiguous-cron-dialect`,
530
+ * `incompatible-cron-options`, `unsupported-interval`, `scheduled-job-guards`).
531
+ */
532
+ function reportCloudflareScheduleDiagnostics(container, entrypoints) {
533
+ for (const entry of entrypoints.ofKind("schedule:cron", parseCronMetadata)) {
534
+ const cron = `@Cron('${entry.meta.expression}') on ${jobName(entry)}`;
535
+ const ambiguity = cronDialectAmbiguity(entry.meta);
536
+ if (ambiguity) reportScheduleDiagnostic(container, `[vela] ${cron} declares no dialect, and ${ambiguity}. Workers deliver the trigger with Cloudflare semantics while Node reads it with Vela's unix dialect; declare { dialect: 'cloudflare' } so it fires on the same days on every runtime.`);
537
+ if (entry.meta.dialect === "unix") reportScheduleDiagnostic(container, `[vela] ${cron} declares dialect 'unix', but Cloudflare delivers its trigger with Cloudflare cron semantics (weekdays 1 = Sunday through 7 = Saturday). Declare { dialect: 'cloudflare' } and write the expression for Cloudflare.`);
538
+ if (entry.meta.timeZone === "local") reportScheduleDiagnostic(container, `[vela] ${cron} declares timeZone 'local', but Cloudflare cron triggers run in UTC. Remove timeZone or set it to 'UTC'.`);
539
+ reportComponents(container, cron, entry);
540
+ }
541
+ for (const entry of entrypoints.ofKind("schedule:interval", parseIntervalMetadata)) reportScheduleDiagnostic(container, `[vela] @Interval(${entry.meta.ms}) on ${jobName(entry)} never runs on Workers: cron triggers drive only @Cron jobs. Replace it with a @Cron job and a Wrangler trigger, or run it under ScheduleNodeModule on Node.`);
542
+ }
543
+ //#endregion
348
544
  //#region src/cloudflare-factory.ts
349
- /** Bind an application to one environment before provider factories and lifecycle hooks. */
545
+ function readStrings(meta, property) {
546
+ const value = typeof meta === "object" && meta !== null ? Reflect.get(meta, property) : void 0;
547
+ if (typeof value === "string") return [value];
548
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
549
+ throw new TypeError(`Invalid queue consumer metadata: ${property}.`);
550
+ }
551
+ /**
552
+ * `@QueueConsumer` handlers own their physical queue. A `QueueModule`
553
+ * registration that pins the same physical queue with `consumer` would never
554
+ * see its batches, so bootstrap rejects the overlap.
555
+ */
556
+ function assertQueueConsumerOwnership(entrypoints) {
557
+ const pinned = new Set(entrypoints.ofKind("cf:queue:module").flatMap((entry) => readStrings(entry.meta, "consumers")));
558
+ for (const entry of entrypoints.ofKind("cf:queue")) {
559
+ const [queue] = readStrings(entry.meta, "queueName");
560
+ if (queue !== void 0 && pinned.has(queue)) throw new Error(`Ambiguous consumer ownership for queue '${queue}': @QueueConsumer('${queue}') and a QueueModule.registerQueue({ consumer: '${queue}' }) both claim it. Keep one owner.`);
561
+ }
562
+ }
563
+ /**
564
+ * Bind an application to one environment: seeded as the global ENV before
565
+ * provider factories and lifecycle hooks, and asserted on every request. The
566
+ * adapter also supplies the `InternalDispatcher` transport, so signed queue and
567
+ * schedule dispatch re-enter this application's routes, and reports schedule
568
+ * declarations a cron trigger cannot honor through the diagnostics policy.
569
+ */
350
570
  function cloudflareAdapter(options) {
571
+ const { env } = options;
351
572
  return {
352
573
  name: "cloudflare",
353
574
  requestMiddleware: [async (context, next) => {
354
- assertCloudflareEnvironment(options.env, context.env);
575
+ assertCloudflareEnvironment(env, context.env);
355
576
  await next();
356
577
  }],
357
- invocationTransport: ({ app }) => (request) => Promise.resolve(app.fetch(request, options.env)),
578
+ invocationTransport: ({ app }) => (request) => Promise.resolve(app.fetch(request, env)),
358
579
  getClientIp: (c) => getConnInfo(c).remote.address ?? null,
359
580
  configureContainer: (container) => {
360
- registerCloudflareEnvironment(container, {
361
- token: options.envToken,
362
- env: options.env
363
- });
581
+ registerCloudflareEnvironment(container, env);
582
+ registerScheduledEventSeed(container);
583
+ },
584
+ onBootstrap: async ({ app, container }) => {
585
+ assertQueueConsumerOwnership(app.entrypoints);
586
+ reportCloudflareScheduleDiagnostics(container, app.entrypoints);
587
+ await warnWorkerLocalLive(container);
364
588
  }
365
589
  };
366
590
  }
367
- /** Build an application for one native Workers environment. Call inside a platform event. */
591
+ /**
592
+ * Build an application for one native Workers environment. Call inside a platform event.
593
+ * The root is static: a module class or a `DynamicModule` declared at module scope.
594
+ * Read bindings in providers (`@InjectEnv()`) and module factories
595
+ * (`forRootAsync({ inject: [ENV], useFactory })`), which run for each application.
596
+ */
368
597
  async function createCloudflareApp(rootModule, options) {
369
- const velaApp = await VelaFactory.create(await resolveCloudflareRoot(rootModule, options.env), {
598
+ const velaApp = await VelaFactory.create(rootModule, {
370
599
  globalPrefix: options.globalPrefix,
371
600
  security: options.security,
372
601
  middleware: options.middleware?.(options.env),
373
- adapters: [cloudflareAdapter(options)]
602
+ adapters: [cloudflareAdapter(options), ...options.adapters ?? []]
374
603
  });
375
604
  const app = new CloudflareApplication(velaApp, options.env);
376
- const consumers = /* @__PURE__ */ new Map();
377
- for (const entry of [...app.entrypoints.ofKind("cf:queue"), ...app.entrypoints.ofKind("cf:queue:module")]) {
378
- const meta = entry.meta;
379
- if (typeof meta !== "object" || meta === null || !("queueName" in meta) || typeof meta.queueName !== "string") {
380
- await app.close();
381
- throw new TypeError("Invalid queue consumer metadata.");
382
- }
383
- const previous = consumers.get(meta.queueName);
384
- if (previous && (previous === "cf:queue:module" || entry.kind === "cf:queue:module")) {
385
- await app.close();
386
- throw new Error(`Ambiguous consumer ownership for queue '${meta.queueName}'.`);
387
- }
388
- consumers.set(meta.queueName, entry.kind);
389
- }
390
605
  app.scanInstances(velaApp.getInstances());
391
- registerWebSocketRoutes(app.getHonoApp(), app.getWsGatewayRoutes());
606
+ registerWebSocketRoutes(app.getHonoApp(), app.getWsGatewayRoutes(), velaApp.getContainer());
392
607
  return app;
393
608
  }
394
609
  /**
395
- * Worker entrypoint with one bootstrap per environment identity. Weak keys let
396
- * obsolete environments and secrets be collected. Concurrent cold events share
397
- * construction; failed construction is evicted so the next event can retry.
610
+ * Worker entrypoint with one bootstrap per environment identity. Concurrent cold
611
+ * events share construction; failed construction is evicted so the next event
612
+ * can retry. Weak keys stop this cache from retaining a replaced environment.
398
613
  */
399
- function createCloudflareWorker(rootModule, options) {
614
+ function createCloudflareWorker(rootModule, options = {}) {
400
615
  const applications = /* @__PURE__ */ new WeakMap();
401
616
  const application = (env) => {
402
617
  const existing = applications.get(env);
@@ -909,75 +1124,6 @@ function kvFlagDriver(kv, options) {
909
1124
  return new KvFlagDriver(kv, options);
910
1125
  }
911
1126
  //#endregion
912
- //#region src/decorators/env.ts
913
- /**
914
- * Parameter decorator to inject Cloudflare environment bindings.
915
- *
916
- * Without arguments, returns the entire `env` object.
917
- * With a binding name, returns that specific binding.
918
- *
919
- * @example
920
- * ```ts
921
- * @Get()
922
- * handle(@Env() env: WorkerEnv) { ... }
923
- *
924
- * @Get()
925
- * handle(@Env('MY_KV') kv: KVNamespace) { ... }
926
- * ```
927
- */
928
- const Env = createParamDecorator((bindingName, ctx) => {
929
- const env = ctx.getContext().env;
930
- if (typeof env !== "object" || env === null) return void 0;
931
- return bindingName ? Reflect.get(env, bindingName) : env;
932
- });
933
- //#endregion
934
- //#region src/decorators/scheduled.ts
935
- const SCHEDULED_METADATA_KEY = "cloudflare:scheduled";
936
- registerEntrypointKind({
937
- kind: "cf:scheduled",
938
- metaKey: SCHEDULED_METADATA_KEY,
939
- level: "method"
940
- });
941
- function parseScheduledMetadata(value) {
942
- if (typeof value !== "object" || value === null || !("cron" in value) || typeof value.cron !== "string" || !("methodName" in value) || typeof value.methodName !== "string" || !parseCron(value.cron, { dialect: "cloudflare" })) throw new TypeError("Invalid Cloudflare scheduled metadata");
943
- return {
944
- cron: value.cron,
945
- methodName: value.methodName
946
- };
947
- }
948
- /**
949
- * Marks a method as a scheduled (cron) handler.
950
- *
951
- * @example
952
- * ```ts
953
- * @Injectable()
954
- * class WorkerService {
955
- * @Scheduled('0 * * * *')
956
- * async hourlyCron() {
957
- * console.log('Running hourly');
958
- * }
959
- * }
960
- * ```
961
- */
962
- function Scheduled(cron) {
963
- if (!parseCron(cron, { dialect: "cloudflare" })) throw new TypeError(`Invalid Cloudflare cron expression: ${cron}`);
964
- return (target, propertyKey, _descriptor) => {
965
- const existing = getScheduledMetadata(target);
966
- existing.push({
967
- cron,
968
- methodName: String(propertyKey)
969
- });
970
- defineMetadata(SCHEDULED_METADATA_KEY, existing, target.constructor);
971
- };
972
- }
973
- function getScheduledMetadata(target) {
974
- const ctor = typeof target === "function" ? target : target.constructor;
975
- const value = getMetadata(SCHEDULED_METADATA_KEY, ctor);
976
- if (value === void 0) return [];
977
- if (!Array.isArray(value)) throw new TypeError("Invalid Cloudflare scheduled metadata list");
978
- return value.map(parseScheduledMetadata);
979
- }
980
- //#endregion
981
1127
  //#region src/decorators/queue-consumer.ts
982
1128
  const QUEUE_CONSUMER_METADATA_KEY = "cloudflare:queue-consumer";
983
1129
  registerEntrypointKind({
@@ -1125,6 +1271,6 @@ function durableObjectNonceStore(options) {
1125
1271
  } };
1126
1272
  }
1127
1273
  //#endregion
1128
- export { CloudflareApplication, CloudflareWebSocketModule, ConnectedSocket, DoCursorLog, DoPitrUnavailableError, Env, FlagshipFlagDriver, KVCacheInvalidationStore, 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, parseScheduledMetadata, readDoPitrBookmark };
1274
+ export { CLOUDFLARE_SCHEDULED_EVENT, CloudflareApplication, CloudflareWebSocketModule, ConnectedSocket, DoCursorLog, DoPitrUnavailableError, FlagshipFlagDriver, KVCacheInvalidationStore, KVCacheStore, KvFlagDriver, MessageBody, QueueConsumer, R2StorageDriver, STORAGE_OPTIONS, StorageController, StorageManagerService, StorageModule, StorageService, SubscribeMessage, WebSocketGateway, WebSocketServer, WsException, armDoPitr, broadcastToRoom, cloudflareAdapter, cloudflareRateLimitStore, createCloudflareApp, createCloudflareWorker, durableObjectCursorLog, durableObjectLive, durableObjectNonceStore, durableObjectRoomName, flagshipFlagDriver, isDoPitrUnavailable, kvFlagDriver, liveInvalidateToRoom, readDoPitrBookmark };
1129
1275
 
1130
1276
  //# sourceMappingURL=index.js.map