@velajs/cloudflare 1.24.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/CHANGELOG.md +128 -0
- package/README.md +192 -62
- package/dist/durable-objects.d.ts +9 -8
- package/dist/durable-objects.js +30 -18
- package/dist/durable-objects.js.map +1 -1
- package/dist/index.d.ts +141 -108
- package/dist/index.js +334 -172
- package/dist/index.js.map +1 -1
- package/dist/{nonce-validation-Bcqf3FvY.js → nonce-validation-Dy8z05A9.js} +140 -131
- package/dist/nonce-validation-Dy8z05A9.js.map +1 -0
- package/dist/{nonce.durable-object-Df3_42Sy.d.ts → nonce.durable-object-Df4CZi-0.d.ts} +9 -6
- package/dist/queues.d.ts +49 -0
- package/dist/queues.js +215 -0
- package/dist/queues.js.map +1 -0
- package/dist/vela-env-DFvyoNT3.d.ts +10 -0
- package/package.json +11 -10
- package/dist/nonce-validation-Bcqf3FvY.js.map +0 -1
- package/dist/queue.d.ts +0 -26
- package/dist/queue.js +0 -41
- package/dist/queue.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { C as durableObjectRoomName, T as roomToDurableId,
|
|
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 {
|
|
4
|
-
import { ConnectedSocket, DEFAULT_WS_MAX_FRAME_BYTES, MessageBody, SubscribeMessage, WS_GATEWAY_METADATA, WS_SERVER, WebSocketGateway, WebSocketServer, WsDispatcher, WsException, assertBroadcastCommandFits,
|
|
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
|
-
/**
|
|
37
|
-
function
|
|
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,
|
|
51
|
-
*
|
|
52
|
-
* the
|
|
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)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
roomId
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
forwardHeaders.set("x-vela-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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/
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 (
|
|
144
|
-
*
|
|
145
|
-
* - `queue` — Queue consumer handler (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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,
|
|
354
|
+
async scheduled(event, env, _ctx) {
|
|
258
355
|
assertCloudflareEnvironment(this.env, env);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
269
|
-
*
|
|
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
|
|
273
|
-
*
|
|
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:
|
|
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
|
-
|
|
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,58 +445,173 @@ var CloudflareApplication = class {
|
|
|
329
445
|
}
|
|
330
446
|
}
|
|
331
447
|
/**
|
|
332
|
-
* Handle Cloudflare Queue consumer events.
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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
|
-
|
|
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.`);
|
|
463
|
+
await settleEntrypoints(handlers.map((ep) => this.dispatchEntrypoint(ep, batch, env, ctx)));
|
|
340
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
|
+
*/
|
|
341
490
|
async close(signal) {
|
|
491
|
+
for (const controller of this.#scheduled.values()) controller.abort();
|
|
492
|
+
await Promise.allSettled(this.#scheduled.keys());
|
|
342
493
|
return this.#app.close(signal);
|
|
343
494
|
}
|
|
344
495
|
};
|
|
345
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
|
|
346
544
|
//#region src/cloudflare-factory.ts
|
|
347
|
-
|
|
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
|
+
*/
|
|
348
570
|
function cloudflareAdapter(options) {
|
|
571
|
+
const { env } = options;
|
|
349
572
|
return {
|
|
350
573
|
name: "cloudflare",
|
|
351
574
|
requestMiddleware: [async (context, next) => {
|
|
352
|
-
assertCloudflareEnvironment(
|
|
575
|
+
assertCloudflareEnvironment(env, context.env);
|
|
353
576
|
await next();
|
|
354
577
|
}],
|
|
355
|
-
invocationTransport: ({ app }) => (request) => Promise.resolve(app.fetch(request,
|
|
578
|
+
invocationTransport: ({ app }) => (request) => Promise.resolve(app.fetch(request, env)),
|
|
356
579
|
getClientIp: (c) => getConnInfo(c).remote.address ?? null,
|
|
357
580
|
configureContainer: (container) => {
|
|
358
|
-
registerCloudflareEnvironment(container,
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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);
|
|
362
588
|
}
|
|
363
589
|
};
|
|
364
590
|
}
|
|
365
|
-
/**
|
|
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
|
+
*/
|
|
366
597
|
async function createCloudflareApp(rootModule, options) {
|
|
367
|
-
const velaApp = await VelaFactory.create(
|
|
598
|
+
const velaApp = await VelaFactory.create(rootModule, {
|
|
368
599
|
globalPrefix: options.globalPrefix,
|
|
369
600
|
security: options.security,
|
|
370
601
|
middleware: options.middleware?.(options.env),
|
|
371
|
-
adapters: [cloudflareAdapter(options)]
|
|
602
|
+
adapters: [cloudflareAdapter(options), ...options.adapters ?? []]
|
|
372
603
|
});
|
|
373
604
|
const app = new CloudflareApplication(velaApp, options.env);
|
|
374
605
|
app.scanInstances(velaApp.getInstances());
|
|
375
|
-
registerWebSocketRoutes(app.getHonoApp(), app.getWsGatewayRoutes());
|
|
606
|
+
registerWebSocketRoutes(app.getHonoApp(), app.getWsGatewayRoutes(), velaApp.getContainer());
|
|
376
607
|
return app;
|
|
377
608
|
}
|
|
378
609
|
/**
|
|
379
|
-
* Worker entrypoint with one bootstrap per environment identity.
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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.
|
|
382
613
|
*/
|
|
383
|
-
function createCloudflareWorker(rootModule, options) {
|
|
614
|
+
function createCloudflareWorker(rootModule, options = {}) {
|
|
384
615
|
const applications = /* @__PURE__ */ new WeakMap();
|
|
385
616
|
const application = (env) => {
|
|
386
617
|
const existing = applications.get(env);
|
|
@@ -893,75 +1124,6 @@ function kvFlagDriver(kv, options) {
|
|
|
893
1124
|
return new KvFlagDriver(kv, options);
|
|
894
1125
|
}
|
|
895
1126
|
//#endregion
|
|
896
|
-
//#region src/decorators/env.ts
|
|
897
|
-
/**
|
|
898
|
-
* Parameter decorator to inject Cloudflare environment bindings.
|
|
899
|
-
*
|
|
900
|
-
* Without arguments, returns the entire `env` object.
|
|
901
|
-
* With a binding name, returns that specific binding.
|
|
902
|
-
*
|
|
903
|
-
* @example
|
|
904
|
-
* ```ts
|
|
905
|
-
* @Get()
|
|
906
|
-
* handle(@Env() env: WorkerEnv) { ... }
|
|
907
|
-
*
|
|
908
|
-
* @Get()
|
|
909
|
-
* handle(@Env('MY_KV') kv: KVNamespace) { ... }
|
|
910
|
-
* ```
|
|
911
|
-
*/
|
|
912
|
-
const Env = createParamDecorator((bindingName, ctx) => {
|
|
913
|
-
const env = ctx.getContext().env;
|
|
914
|
-
if (typeof env !== "object" || env === null) return void 0;
|
|
915
|
-
return bindingName ? Reflect.get(env, bindingName) : env;
|
|
916
|
-
});
|
|
917
|
-
//#endregion
|
|
918
|
-
//#region src/decorators/scheduled.ts
|
|
919
|
-
const SCHEDULED_METADATA_KEY = "cloudflare:scheduled";
|
|
920
|
-
registerEntrypointKind({
|
|
921
|
-
kind: "cf:scheduled",
|
|
922
|
-
metaKey: SCHEDULED_METADATA_KEY,
|
|
923
|
-
level: "method"
|
|
924
|
-
});
|
|
925
|
-
function parseScheduledMetadata(value) {
|
|
926
|
-
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");
|
|
927
|
-
return {
|
|
928
|
-
cron: value.cron,
|
|
929
|
-
methodName: value.methodName
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
/**
|
|
933
|
-
* Marks a method as a scheduled (cron) handler.
|
|
934
|
-
*
|
|
935
|
-
* @example
|
|
936
|
-
* ```ts
|
|
937
|
-
* @Injectable()
|
|
938
|
-
* class WorkerService {
|
|
939
|
-
* @Scheduled('0 * * * *')
|
|
940
|
-
* async hourlyCron() {
|
|
941
|
-
* console.log('Running hourly');
|
|
942
|
-
* }
|
|
943
|
-
* }
|
|
944
|
-
* ```
|
|
945
|
-
*/
|
|
946
|
-
function Scheduled(cron) {
|
|
947
|
-
if (!parseCron(cron, { dialect: "cloudflare" })) throw new TypeError(`Invalid Cloudflare cron expression: ${cron}`);
|
|
948
|
-
return (target, propertyKey, _descriptor) => {
|
|
949
|
-
const existing = getScheduledMetadata(target);
|
|
950
|
-
existing.push({
|
|
951
|
-
cron,
|
|
952
|
-
methodName: String(propertyKey)
|
|
953
|
-
});
|
|
954
|
-
defineMetadata(SCHEDULED_METADATA_KEY, existing, target.constructor);
|
|
955
|
-
};
|
|
956
|
-
}
|
|
957
|
-
function getScheduledMetadata(target) {
|
|
958
|
-
const ctor = typeof target === "function" ? target : target.constructor;
|
|
959
|
-
const value = getMetadata(SCHEDULED_METADATA_KEY, ctor);
|
|
960
|
-
if (value === void 0) return [];
|
|
961
|
-
if (!Array.isArray(value)) throw new TypeError("Invalid Cloudflare scheduled metadata list");
|
|
962
|
-
return value.map(parseScheduledMetadata);
|
|
963
|
-
}
|
|
964
|
-
//#endregion
|
|
965
1127
|
//#region src/decorators/queue-consumer.ts
|
|
966
1128
|
const QUEUE_CONSUMER_METADATA_KEY = "cloudflare:queue-consumer";
|
|
967
1129
|
registerEntrypointKind({
|
|
@@ -1109,6 +1271,6 @@ function durableObjectNonceStore(options) {
|
|
|
1109
1271
|
} };
|
|
1110
1272
|
}
|
|
1111
1273
|
//#endregion
|
|
1112
|
-
export { CloudflareApplication, CloudflareWebSocketModule, ConnectedSocket, DoCursorLog, DoPitrUnavailableError,
|
|
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 };
|
|
1113
1275
|
|
|
1114
1276
|
//# sourceMappingURL=index.js.map
|