@velajs/cloudflare 1.29.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +2 -1
- package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateParam.js +8 -0
- package/dist/cloudflare-application.js +309 -0
- package/dist/cloudflare-application.js.map +1 -0
- package/dist/cloudflare-factory.js +109 -0
- package/dist/cloudflare-factory.js.map +1 -0
- package/dist/decorators/queue-consumer.js +39 -0
- package/dist/decorators/queue-consumer.js.map +1 -0
- package/dist/durable-objects.d.ts +3 -4
- package/dist/durable-objects.js +2 -524
- package/dist/environment.js +19 -0
- package/dist/environment.js.map +1 -0
- package/dist/index.d.ts +11 -9
- package/dist/index.js +21 -1276
- package/dist/nonce/durable-object-nonce.store.js +33 -0
- package/dist/nonce/durable-object-nonce.store.js.map +1 -0
- package/dist/nonce/nonce-validation.js +18 -0
- package/dist/nonce/nonce-validation.js.map +1 -0
- package/dist/nonce/nonce.durable-object.js +54 -0
- package/dist/nonce/nonce.durable-object.js.map +1 -0
- package/dist/{nonce.durable-object-Df4CZi-0.d.ts → nonce.durable-object-Ci9AGssY.d.ts} +2 -3
- package/dist/queue/cloudflare-queues.js +215 -0
- package/dist/queue/cloudflare-queues.js.map +1 -0
- package/dist/queues.d.ts +1 -2
- package/dist/queues.js +1 -214
- package/dist/rate-limit/cloudflare-rate-limit.store.js +40 -0
- package/dist/rate-limit/cloudflare-rate-limit.store.js.map +1 -0
- package/dist/schedule-diagnostics.js +51 -0
- package/dist/schedule-diagnostics.js.map +1 -0
- package/dist/scheduled-event.js +68 -0
- package/dist/scheduled-event.js.map +1 -0
- package/dist/services/flagship-flag.driver.js +52 -0
- package/dist/services/flagship-flag.driver.js.map +1 -0
- package/dist/services/kv-cache.store.js +82 -0
- package/dist/services/kv-cache.store.js.map +1 -0
- package/dist/services/kv-flag.driver.js +62 -0
- package/dist/services/kv-flag.driver.js.map +1 -0
- package/dist/storage/r2-storage.driver.js +67 -0
- package/dist/storage/r2-storage.driver.js.map +1 -0
- package/dist/storage/storage-key-claim.js +74 -0
- package/dist/storage/storage-key-claim.js.map +1 -0
- package/dist/storage/storage-manager.service.js +38 -0
- package/dist/storage/storage-manager.service.js.map +1 -0
- package/dist/storage/storage.controller.js +64 -0
- package/dist/storage/storage.controller.js.map +1 -0
- package/dist/storage/storage.module.js +25 -0
- package/dist/storage/storage.module.js.map +1 -0
- package/dist/storage/storage.service.js +64 -0
- package/dist/storage/storage.service.js.map +1 -0
- package/dist/storage/storage.tokens.js +7 -0
- package/dist/storage/storage.tokens.js.map +1 -0
- package/dist/vela-env-DFvyoNT3.d.ts +1 -2
- package/dist/websocket/broadcast.js +32 -0
- package/dist/websocket/broadcast.js.map +1 -0
- package/dist/websocket/cf-room-registry.js +137 -0
- package/dist/websocket/cf-room-registry.js.map +1 -0
- package/dist/websocket/cf-ws-client.js +94 -0
- package/dist/websocket/cf-ws-client.js.map +1 -0
- package/dist/websocket/cloudflare-websocket.module.js +25 -0
- package/dist/websocket/cloudflare-websocket.module.js.map +1 -0
- package/dist/websocket/do-bootstrap.js +56 -0
- package/dist/websocket/do-bootstrap.js.map +1 -0
- package/dist/websocket/do-live.js +184 -0
- package/dist/websocket/do-live.js.map +1 -0
- package/dist/websocket/do-pitr.js +81 -0
- package/dist/websocket/do-pitr.js.map +1 -0
- package/dist/websocket/do-state.js +7 -0
- package/dist/websocket/do-state.js.map +1 -0
- package/dist/websocket/do-websocket-host.js +169 -0
- package/dist/websocket/do-websocket-host.js.map +1 -0
- package/dist/websocket/room-id.js +27 -0
- package/dist/websocket/room-id.js.map +1 -0
- package/dist/websocket/websocket-routing.js +133 -0
- package/dist/websocket/websocket-routing.js.map +1 -0
- package/dist/websocket/websocket.durable-object.js +138 -0
- package/dist/websocket/websocket.durable-object.js.map +1 -0
- package/dist/websocket/ws-attachment.js +79 -0
- package/dist/websocket/ws-attachment.js.map +1 -0
- package/dist/websocket/ws-server-holder.js +38 -0
- package/dist/websocket/ws-server-holder.js.map +1 -0
- package/package.json +9 -7
- package/dist/durable-objects.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/nonce-validation-Dy8z05A9.js +0 -522
- package/dist/nonce-validation-Dy8z05A9.js.map +0 -1
- package/dist/queues.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,1276 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
/** `null` means two independently verified identities disagree. */
|
|
25
|
-
function combineIdentities(requestIdentity, upgradeIdentity) {
|
|
26
|
-
if (!requestIdentity && !upgradeIdentity) return void 0;
|
|
27
|
-
if (!requestIdentity) return upgradeIdentity;
|
|
28
|
-
if (!upgradeIdentity) return requestIdentity;
|
|
29
|
-
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;
|
|
30
|
-
return {
|
|
31
|
-
principal: { ...upgradeIdentity.principal },
|
|
32
|
-
tenantId: upgradeIdentity.tenantId,
|
|
33
|
-
expiresAtMs: Math.min(requestIdentity.expiresAtMs, upgradeIdentity.expiresAtMs)
|
|
34
|
-
};
|
|
35
|
-
}
|
|
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) {
|
|
46
|
-
const options = getMetadata(WS_GATEWAY_METADATA, instance.constructor);
|
|
47
|
-
if (!options?.path || !options?.binding) return [];
|
|
48
|
-
resolveGatewayRoomParam(options);
|
|
49
|
-
resolveMaxFrameBytes(options);
|
|
50
|
-
const gateway = instance.constructor;
|
|
51
|
-
const owners = isClass(gateway) ? container.getOwnerModuleIds(gateway) : [];
|
|
52
|
-
return [{
|
|
53
|
-
path: options.path,
|
|
54
|
-
binding: options.binding,
|
|
55
|
-
options: { ...options },
|
|
56
|
-
...owners.length === 1 ? { moduleId: owners[0] } : {}
|
|
57
|
-
}];
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Registers the upgrade routes on the Worker's Hono app. Each route validates
|
|
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.
|
|
66
|
-
*/
|
|
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
|
-
}
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Gateway metadata contains a runtime binding name, so the native type is
|
|
113
|
-
* erased. Validate only the operations consumed here and their observable
|
|
114
|
-
* results; never assert that an arbitrary value implements a native namespace.
|
|
115
|
-
*/
|
|
116
|
-
async function forwardToRoom(env, binding, path, room, request) {
|
|
117
|
-
if (typeof env !== "object" || env === null) throw new Error("Worker environment is missing");
|
|
118
|
-
const namespace = Reflect.get(env, binding);
|
|
119
|
-
if (typeof namespace !== "object" || namespace === null) return new Response(`Durable Object binding '${binding}' is not configured`, { status: 500 });
|
|
120
|
-
const idFromName = Reflect.get(namespace, "idFromName");
|
|
121
|
-
const get = Reflect.get(namespace, "get");
|
|
122
|
-
if (typeof idFromName !== "function" || typeof get !== "function") throw new Error("Invalid Durable Object namespace");
|
|
123
|
-
const id = Reflect.apply(idFromName, namespace, [durableObjectRoomName(path, room)]);
|
|
124
|
-
const stub = Reflect.apply(get, namespace, [id]);
|
|
125
|
-
if (typeof stub !== "object" || stub === null) throw new Error("Invalid Durable Object stub");
|
|
126
|
-
const fetch = Reflect.get(stub, "fetch");
|
|
127
|
-
if (typeof fetch !== "function") throw new Error("Durable Object stub has no fetch operation");
|
|
128
|
-
const response = await Reflect.apply(fetch, stub, [request]);
|
|
129
|
-
if (!(response instanceof Response)) throw new Error("Durable Object returned an invalid response");
|
|
130
|
-
return response;
|
|
131
|
-
}
|
|
132
|
-
//#endregion
|
|
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
|
-
}
|
|
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
|
|
197
|
-
function invoke(instance, methodName, args) {
|
|
198
|
-
const method = Reflect.get(instance, methodName);
|
|
199
|
-
if (typeof method !== "function") throw new Error(`Method '${String(methodName)}' is not a function on ${instance.constructor.name}`);
|
|
200
|
-
return Reflect.apply(method, instance, args);
|
|
201
|
-
}
|
|
202
|
-
function entrypointString(meta, property) {
|
|
203
|
-
if (typeof meta !== "object" || meta === null) throw new Error("Invalid entrypoint metadata.");
|
|
204
|
-
const value = Reflect.get(meta, property);
|
|
205
|
-
if (typeof value !== "string") throw new Error(`Invalid entrypoint metadata: ${property} must be a string.`);
|
|
206
|
-
return value;
|
|
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
|
-
}
|
|
222
|
-
/** Wait for every matching handler, even when one fails before its siblings. */
|
|
223
|
-
async function settleEntrypoints(work) {
|
|
224
|
-
const errors = (await Promise.allSettled(work)).flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
|
|
225
|
-
if (errors.length === 1) throw errors[0];
|
|
226
|
-
if (errors.length > 1) throw new AggregateError(errors, "Multiple entrypoint handlers failed.");
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* Wraps VelaApplication with Cloudflare-specific handlers:
|
|
230
|
-
* - `fetch` — HTTP request handler (from Hono)
|
|
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)
|
|
235
|
-
* - `mountOpenApi` — Serve an OpenAPI document (and optional Scalar UI) on
|
|
236
|
-
* the underlying Hono app
|
|
237
|
-
*
|
|
238
|
-
* @example
|
|
239
|
-
* ```ts
|
|
240
|
-
* const app = await createCloudflareApp(AppModule, { env });
|
|
241
|
-
* export default {
|
|
242
|
-
* fetch: app.fetch,
|
|
243
|
-
* scheduled: app.scheduled.bind(app),
|
|
244
|
-
* queue: app.queue.bind(app),
|
|
245
|
-
* };
|
|
246
|
-
* ```
|
|
247
|
-
*
|
|
248
|
-
* @example
|
|
249
|
-
* ```ts
|
|
250
|
-
* // Serve OpenAPI docs alongside your routes
|
|
251
|
-
* const app = await createCloudflareApp(AppModule, { env });
|
|
252
|
-
* const document = createOpenApiDocument(AppModule);
|
|
253
|
-
* app.mountOpenApi({ document, ui: 'scalar' });
|
|
254
|
-
* // GET /openapi.json -> JSON document
|
|
255
|
-
* // GET /scalar -> Scalar UI (loads from CDN)
|
|
256
|
-
* ```
|
|
257
|
-
*/
|
|
258
|
-
var CloudflareApplication = class {
|
|
259
|
-
env;
|
|
260
|
-
#wsGatewayRoutes = [];
|
|
261
|
-
#app;
|
|
262
|
-
/** Scheduled triggers in flight; close() aborts their signals and awaits them. */
|
|
263
|
-
#scheduled = /* @__PURE__ */ new Map();
|
|
264
|
-
constructor(app, env) {
|
|
265
|
-
this.env = env;
|
|
266
|
-
this.#app = app;
|
|
267
|
-
this.get = app.get.bind(app);
|
|
268
|
-
}
|
|
269
|
-
fetch = async (request, env, ctx) => {
|
|
270
|
-
assertCloudflareEnvironment(this.env, env);
|
|
271
|
-
return this.#app.fetch(request, env, ctx);
|
|
272
|
-
};
|
|
273
|
-
getHonoApp() {
|
|
274
|
-
return this.#app.getHonoApp();
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Resolve a provider from the application's DI container (delegates to
|
|
278
|
-
* `VelaApplication.get`). Handy for grabbing a service — e.g. an auth service —
|
|
279
|
-
* to use inside `createCloudflareApp({ middleware: env => [...] })` request middleware,
|
|
280
|
-
* which runs outside the DI request pipeline.
|
|
281
|
-
*
|
|
282
|
-
* @example
|
|
283
|
-
* ```ts
|
|
284
|
-
* const app = await createCloudflareApp(AppModule, { env });
|
|
285
|
-
* const auth = app.get(BetterAuthService);
|
|
286
|
-
* ```
|
|
287
|
-
*/
|
|
288
|
-
get;
|
|
289
|
-
get entrypoints() {
|
|
290
|
-
return this.#app.entrypoints;
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Serve a pre-built OpenAPI document (and optionally a Scalar UI) on the
|
|
294
|
-
* underlying Hono app. Delegates verbatim to `VelaApplication.mountOpenApi`,
|
|
295
|
-
* so the JSON endpoint defaults to `/openapi.json` and the Scalar UI (when
|
|
296
|
-
* opted in) defaults to `/scalar`. Edge-safe — the UI HTML loads Scalar from
|
|
297
|
-
* a CDN at runtime, nothing is bundled server-side.
|
|
298
|
-
*
|
|
299
|
-
* @example
|
|
300
|
-
* ```ts
|
|
301
|
-
* import { createOpenApiDocument } from '@velajs/vela';
|
|
302
|
-
*
|
|
303
|
-
* const app = await createCloudflareApp(AppModule, { env });
|
|
304
|
-
* const document = createOpenApiDocument(AppModule, {
|
|
305
|
-
* info: { title: 'My API', version: '1.0.0' },
|
|
306
|
-
* });
|
|
307
|
-
* app.mountOpenApi({ document, ui: 'scalar' });
|
|
308
|
-
* // GET /openapi.json -> { openapi: '3.1.0', ... }
|
|
309
|
-
* // GET /scalar -> Scalar UI HTML
|
|
310
|
-
* ```
|
|
311
|
-
*/
|
|
312
|
-
mountOpenApi(options) {
|
|
313
|
-
this.#app.mountOpenApi(options);
|
|
314
|
-
return this;
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* @internal Upgrade routes come from validated gateway entrypoints, including
|
|
318
|
-
* request-scoped gateways without a bootstrap instance. Retain the instance
|
|
319
|
-
* scan for legacy applications that only declare forwarding metadata.
|
|
320
|
-
*/
|
|
321
|
-
scanInstances(instances) {
|
|
322
|
-
const routes = /* @__PURE__ */ new Map();
|
|
323
|
-
for (const ep of this.#app.entrypoints.ofKind("websocket")) {
|
|
324
|
-
if (typeof ep.meta !== "object" || ep.meta === null || !("dispatcher" in ep.meta)) continue;
|
|
325
|
-
const meta = readWsEntrypointMeta(ep.meta);
|
|
326
|
-
if (meta.options.binding) routes.set(meta.path, {
|
|
327
|
-
path: meta.path,
|
|
328
|
-
binding: meta.options.binding,
|
|
329
|
-
options: { ...meta.options },
|
|
330
|
-
moduleId: meta.moduleId
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
const container = this.#app.getContainer();
|
|
334
|
-
for (const instance of instances) {
|
|
335
|
-
if (!instance || typeof instance !== "object") continue;
|
|
336
|
-
for (const route of collectWsGatewayRoutes(instance, container)) if (!routes.has(route.path)) routes.set(route.path, route);
|
|
337
|
-
}
|
|
338
|
-
this.#wsGatewayRoutes.splice(0, this.#wsGatewayRoutes.length, ...routes.values());
|
|
339
|
-
}
|
|
340
|
-
/** @internal — upgrade routes discovered from the application's gateways. */
|
|
341
|
-
getWsGatewayRoutes() {
|
|
342
|
-
return [...this.#wsGatewayRoutes];
|
|
343
|
-
}
|
|
344
|
-
/**
|
|
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.
|
|
353
|
-
*/
|
|
354
|
-
async scheduled(event, env, _ctx) {
|
|
355
|
-
assertCloudflareEnvironment(this.env, env);
|
|
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
|
-
}
|
|
375
|
-
}
|
|
376
|
-
/**
|
|
377
|
-
* Run one queue consumer inside a fresh request scope, through the shared
|
|
378
|
-
* guard → interceptor pipeline (components declared with
|
|
379
|
-
* `@UseGuards`/`@UseInterceptors`/`@UseFilters` on the consumer class or
|
|
380
|
-
* method). HTTP-global components deliberately do NOT apply — an HTTP auth
|
|
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.
|
|
384
|
-
*/
|
|
385
|
-
async dispatchEntrypoint(ep, payload, env, platformContext) {
|
|
386
|
-
const targetClass = ep.token;
|
|
387
|
-
if (typeof targetClass !== "function") throw new Error("Entrypoint token must be a class.");
|
|
388
|
-
if (ep.methodName === void 0) throw new Error("Entrypoint must declare a handler method.");
|
|
389
|
-
const methodName = ep.methodName;
|
|
390
|
-
const reportContext = {
|
|
391
|
-
edge: "queue",
|
|
392
|
-
source: `${targetClass.name}.${String(methodName)}`
|
|
393
|
-
};
|
|
394
|
-
let reported;
|
|
395
|
-
let delegated;
|
|
396
|
-
try {
|
|
397
|
-
await runInEntrypointScope(this.#app.getContainer(), async (scope, lifetime) => {
|
|
398
|
-
const moduleId = getEntrypointModuleId(scope, ep);
|
|
399
|
-
const context = buildEntrypointExecutionContext(ep.kind, targetClass, methodName, payload, moduleId, scope);
|
|
400
|
-
const invocationContext = { waitUntil(promise) {
|
|
401
|
-
lifetime.waitUntil(promise);
|
|
402
|
-
platformContext.waitUntil(promise);
|
|
403
|
-
} };
|
|
404
|
-
let filters = [];
|
|
405
|
-
try {
|
|
406
|
-
filters = (await resolveScopedComponentsAsync("filter", targetClass, methodName, scope, moduleId)).toReversed();
|
|
407
|
-
const guards = await resolveScopedComponentsAsync("guard", targetClass, methodName, scope, moduleId);
|
|
408
|
-
const interceptors = await resolveScopedComponentsAsync("interceptor", targetClass, methodName, scope, moduleId);
|
|
409
|
-
await PipelineRunner.run({
|
|
410
|
-
context,
|
|
411
|
-
guards,
|
|
412
|
-
interceptors,
|
|
413
|
-
resolveArgs: async () => [
|
|
414
|
-
payload,
|
|
415
|
-
env,
|
|
416
|
-
invocationContext
|
|
417
|
-
],
|
|
418
|
-
invoke: async (args) => {
|
|
419
|
-
const instance = await resolveEntrypoint(scope, ep);
|
|
420
|
-
if (typeof instance !== "object" || instance === null) throw new Error("Entrypoint must resolve to an object.");
|
|
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
|
-
}
|
|
427
|
-
}
|
|
428
|
-
});
|
|
429
|
-
} catch (error) {
|
|
430
|
-
if (delegated?.error !== error) resolveErrorReporter(scope).report(error, reportContext);
|
|
431
|
-
for (const filter of filters) if (shouldFilterCatch(filter, error)) {
|
|
432
|
-
await filter.catch(error, context);
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
reported = { error };
|
|
436
|
-
throw error;
|
|
437
|
-
}
|
|
438
|
-
});
|
|
439
|
-
} catch (error) {
|
|
440
|
-
if (!reported || reported.error !== error) {
|
|
441
|
-
const completionError = reported && error instanceof AggregateError && error.errors[0] === reported.error ? error.errors[1] : error;
|
|
442
|
-
resolveErrorReporter(this.#app.getContainer()).report(completionError, reportContext);
|
|
443
|
-
}
|
|
444
|
-
throw error;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
/**
|
|
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.
|
|
456
|
-
*/
|
|
457
|
-
async queue(batch, env, ctx) {
|
|
458
|
-
assertCloudflareEnvironment(this.env, env);
|
|
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)));
|
|
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
|
-
*/
|
|
490
|
-
async close(signal) {
|
|
491
|
-
for (const controller of this.#scheduled.values()) controller.abort();
|
|
492
|
-
await Promise.allSettled(this.#scheduled.keys());
|
|
493
|
-
return this.#app.close(signal);
|
|
494
|
-
}
|
|
495
|
-
};
|
|
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
|
|
544
|
-
//#region src/cloudflare-factory.ts
|
|
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
|
-
*/
|
|
570
|
-
function cloudflareAdapter(options) {
|
|
571
|
-
const { env } = options;
|
|
572
|
-
return {
|
|
573
|
-
name: "cloudflare",
|
|
574
|
-
requestMiddleware: [async (context, next) => {
|
|
575
|
-
assertCloudflareEnvironment(env, context.env);
|
|
576
|
-
await next();
|
|
577
|
-
}],
|
|
578
|
-
invocationTransport: ({ app }) => (request) => Promise.resolve(app.fetch(request, env)),
|
|
579
|
-
getClientIp: (c) => getConnInfo(c).remote.address ?? null,
|
|
580
|
-
configureContainer: (container) => {
|
|
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);
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
}
|
|
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
|
-
*/
|
|
597
|
-
async function createCloudflareApp(rootModule, options) {
|
|
598
|
-
const velaApp = await VelaFactory.create(rootModule, {
|
|
599
|
-
globalPrefix: options.globalPrefix,
|
|
600
|
-
security: options.security,
|
|
601
|
-
middleware: options.middleware?.(options.env),
|
|
602
|
-
adapters: [cloudflareAdapter(options), ...options.adapters ?? []]
|
|
603
|
-
});
|
|
604
|
-
const app = new CloudflareApplication(velaApp, options.env);
|
|
605
|
-
app.scanInstances(velaApp.getInstances());
|
|
606
|
-
registerWebSocketRoutes(app.getHonoApp(), app.getWsGatewayRoutes(), velaApp.getContainer());
|
|
607
|
-
return app;
|
|
608
|
-
}
|
|
609
|
-
/**
|
|
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.
|
|
613
|
-
*/
|
|
614
|
-
function createCloudflareWorker(rootModule, options = {}) {
|
|
615
|
-
const applications = /* @__PURE__ */ new WeakMap();
|
|
616
|
-
const application = (env) => {
|
|
617
|
-
const existing = applications.get(env);
|
|
618
|
-
if (existing) return existing;
|
|
619
|
-
const pending = createCloudflareApp(rootModule, {
|
|
620
|
-
...options,
|
|
621
|
-
env
|
|
622
|
-
});
|
|
623
|
-
applications.set(env, pending);
|
|
624
|
-
pending.catch(() => {
|
|
625
|
-
if (applications.get(env) === pending) applications.delete(env);
|
|
626
|
-
});
|
|
627
|
-
return pending;
|
|
628
|
-
};
|
|
629
|
-
return {
|
|
630
|
-
async fetch(request, env, ctx) {
|
|
631
|
-
return (await application(env)).fetch(request, env, ctx);
|
|
632
|
-
},
|
|
633
|
-
async scheduled(event, env, ctx) {
|
|
634
|
-
return (await application(env)).scheduled(event, env, ctx);
|
|
635
|
-
},
|
|
636
|
-
async queue(batch, env, ctx) {
|
|
637
|
-
return (await application(env)).queue(batch, env, ctx);
|
|
638
|
-
}
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
|
-
//#endregion
|
|
642
|
-
//#region src/storage/storage.tokens.ts
|
|
643
|
-
const STORAGE_OPTIONS = new InjectionToken("STORAGE_OPTIONS");
|
|
644
|
-
//#endregion
|
|
645
|
-
//#region src/storage/storage-key-claim.ts
|
|
646
|
-
/** R2-compatible object-key claim bound. Keeps decode work predictably small. */
|
|
647
|
-
const MAX_STORAGE_KEY_BYTES = 1024;
|
|
648
|
-
const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
649
|
-
const ROOT_TOKEN_PATTERNS = {
|
|
650
|
-
date: "\\d{4}-\\d{2}-\\d{2}",
|
|
651
|
-
year: "\\d{4}",
|
|
652
|
-
month: "(?:0[1-9]|1[0-2])",
|
|
653
|
-
day: "(?:0[1-9]|[12]\\d|3[01])",
|
|
654
|
-
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}"
|
|
655
|
-
};
|
|
656
|
-
/** Encode an object key as an opaque, canonical base64url query claim. */
|
|
657
|
-
function encodeStorageKeyClaim(key) {
|
|
658
|
-
const bytes = new TextEncoder().encode(key);
|
|
659
|
-
if (bytes.byteLength === 0 || bytes.byteLength > 1024) throw new Error(`Storage key must be 1–${MAX_STORAGE_KEY_BYTES} UTF-8 bytes`);
|
|
660
|
-
let binary = "";
|
|
661
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
662
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
663
|
-
}
|
|
664
|
-
/** Decode exactly one canonical base64url layer. Malformed/non-UTF-8 claims return undefined. */
|
|
665
|
-
function decodeStorageKeyClaim(claim) {
|
|
666
|
-
try {
|
|
667
|
-
if (claim.length === 0 || claim.length > Math.ceil(4096 / 3) || !BASE64URL_RE.test(claim) || claim.length % 4 === 1) return;
|
|
668
|
-
const base64 = claim.replace(/-/g, "+").replace(/_/g, "/");
|
|
669
|
-
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
670
|
-
const binary = atob(padded);
|
|
671
|
-
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
672
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
673
|
-
if (bytes.byteLength === 0 || bytes.byteLength > 1024) return void 0;
|
|
674
|
-
const decoded = new TextDecoder("utf-8", {
|
|
675
|
-
fatal: true,
|
|
676
|
-
ignoreBOM: false
|
|
677
|
-
}).decode(bytes);
|
|
678
|
-
return encodeStorageKeyClaim(decoded) === claim ? decoded : void 0;
|
|
679
|
-
} catch {
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
function isDotSegment(segment) {
|
|
684
|
-
let decoded = segment;
|
|
685
|
-
for (let i = 0; i < 2; i++) {
|
|
686
|
-
if (decoded === "" || decoded === "." || decoded === "..") return true;
|
|
687
|
-
try {
|
|
688
|
-
const next = decodeURIComponent(decoded);
|
|
689
|
-
if (next === decoded) break;
|
|
690
|
-
decoded = next;
|
|
691
|
-
} catch {
|
|
692
|
-
break;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
return decoded === "" || decoded === "." || decoded === "..";
|
|
696
|
-
}
|
|
697
|
-
function rootSegmentPattern(segment) {
|
|
698
|
-
let pattern = "";
|
|
699
|
-
let cursor = 0;
|
|
700
|
-
for (const match of segment.matchAll(/\{(date|year|month|day|uuid)\}/g)) {
|
|
701
|
-
pattern += segment.slice(cursor, match.index).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
702
|
-
pattern += ROOT_TOKEN_PATTERNS[match[1]];
|
|
703
|
-
cursor = match.index + match[0].length;
|
|
704
|
-
}
|
|
705
|
-
pattern += segment.slice(cursor).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
706
|
-
return pattern;
|
|
707
|
-
}
|
|
708
|
-
/** Assert a canonical key is beneath a static or templated configured root. */
|
|
709
|
-
function isStorageKeyWithinRoot(key, root) {
|
|
710
|
-
const segments = (root ?? "").split(/[/\\]+/).filter((segment) => !isDotSegment(segment));
|
|
711
|
-
if (segments.length === 0) return key.length > 0;
|
|
712
|
-
const rootPattern = segments.map(rootSegmentPattern).join("/");
|
|
713
|
-
return new RegExp(`^(?:${rootPattern})(?:/|$)`).test(key);
|
|
714
|
-
}
|
|
715
|
-
//#endregion
|
|
716
|
-
//#region src/storage/r2-storage.driver.ts
|
|
717
|
-
/** Base path of the StorageController presign-proxy route. */
|
|
718
|
-
const STORAGE_ROUTE_BASE = "/storage";
|
|
719
|
-
/** {@link StorageDriver} over a Cloudflare R2 bucket. */
|
|
720
|
-
var R2StorageDriver = class {
|
|
721
|
-
config;
|
|
722
|
-
constructor(config) {
|
|
723
|
-
this.config = config;
|
|
724
|
-
}
|
|
725
|
-
async upload(body, path, options) {
|
|
726
|
-
await this.config.bucket.put(path, body, {
|
|
727
|
-
httpMetadata: options.mimeType ? { contentType: options.mimeType } : void 0,
|
|
728
|
-
customMetadata: options.metadata
|
|
729
|
-
});
|
|
730
|
-
return {
|
|
731
|
-
path,
|
|
732
|
-
disk: this.config.disk,
|
|
733
|
-
size: options.size,
|
|
734
|
-
mimeType: options.mimeType ?? "application/octet-stream",
|
|
735
|
-
uploadedAt: /* @__PURE__ */ new Date()
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
async download(path) {
|
|
739
|
-
const obj = await this.config.bucket.get(path);
|
|
740
|
-
if (!obj) throw new Error(`Storage object not found at "${path}".`);
|
|
741
|
-
return {
|
|
742
|
-
toStream: () => obj.body,
|
|
743
|
-
toArrayBuffer: () => obj.arrayBuffer(),
|
|
744
|
-
toText: () => obj.text(),
|
|
745
|
-
contentType: obj.httpMetadata?.contentType ?? "application/octet-stream",
|
|
746
|
-
size: obj.size,
|
|
747
|
-
metadata: obj.customMetadata
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
async delete(path) {
|
|
751
|
-
await this.config.bucket.delete(path);
|
|
752
|
-
}
|
|
753
|
-
async exists(path) {
|
|
754
|
-
return await this.config.bucket.head(path) !== null;
|
|
755
|
-
}
|
|
756
|
-
async getPresignedUrl(path, method, expiresIn) {
|
|
757
|
-
if (!this.config.secret) throw new Error("A signing secret is required for presigned URLs (set APP_SECRET).");
|
|
758
|
-
if (!Number.isSafeInteger(expiresIn) || expiresIn <= 0) throw new Error(`Invalid presigned URL expiry: ${expiresIn}s (must be a positive safe integer).`);
|
|
759
|
-
const claim = encodeStorageKeyClaim(path);
|
|
760
|
-
const routePath = `${STORAGE_ROUTE_BASE}/${encodeURIComponent(this.config.disk)}`;
|
|
761
|
-
const query = new URLSearchParams({
|
|
762
|
-
key: claim,
|
|
763
|
-
method
|
|
764
|
-
});
|
|
765
|
-
return {
|
|
766
|
-
url: await signUrl(`${routePath}?${query}`, this.config.secret, {
|
|
767
|
-
expiresIn,
|
|
768
|
-
method,
|
|
769
|
-
purpose: STORAGE_SIGNED_URL_PURPOSE
|
|
770
|
-
}),
|
|
771
|
-
method,
|
|
772
|
-
expiresIn,
|
|
773
|
-
expiresAt: new Date(Date.now() + expiresIn * 1e3)
|
|
774
|
-
};
|
|
775
|
-
}
|
|
776
|
-
};
|
|
777
|
-
//#endregion
|
|
778
|
-
//#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateMetadata.js
|
|
779
|
-
function __decorateMetadata(k, v) {
|
|
780
|
-
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
781
|
-
}
|
|
782
|
-
//#endregion
|
|
783
|
-
//#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateParam.js
|
|
784
|
-
function __decorateParam(paramIndex, decorator) {
|
|
785
|
-
return function(target, key) {
|
|
786
|
-
decorator(target, key, paramIndex);
|
|
787
|
-
};
|
|
788
|
-
}
|
|
789
|
-
//#endregion
|
|
790
|
-
//#region src/storage/storage-manager.service.ts
|
|
791
|
-
let StorageManagerService = class StorageManagerService {
|
|
792
|
-
options;
|
|
793
|
-
constructor(options) {
|
|
794
|
-
this.options = options;
|
|
795
|
-
}
|
|
796
|
-
hasDisk(disk) {
|
|
797
|
-
return this.options.disks.some((d) => d.disk === disk);
|
|
798
|
-
}
|
|
799
|
-
getDiskConfig(disk) {
|
|
800
|
-
const config = this.options.disks.find((d) => d.disk === disk);
|
|
801
|
-
if (!config) throw new Error(`Storage disk "${disk}" is not configured.`);
|
|
802
|
-
return config;
|
|
803
|
-
}
|
|
804
|
-
getDriver(disk) {
|
|
805
|
-
return new R2StorageDriver({
|
|
806
|
-
disk,
|
|
807
|
-
bucket: this.getDiskConfig(disk).bucket,
|
|
808
|
-
secret: this.options.secret
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
};
|
|
812
|
-
StorageManagerService = __decorate([
|
|
813
|
-
Injectable(),
|
|
814
|
-
__decorateParam(0, Inject(STORAGE_OPTIONS)),
|
|
815
|
-
__decorateMetadata("design:paramtypes", [Object])
|
|
816
|
-
], StorageManagerService);
|
|
817
|
-
//#endregion
|
|
818
|
-
//#region src/storage/storage.controller.ts
|
|
819
|
-
let StorageController = class StorageController {
|
|
820
|
-
manager;
|
|
821
|
-
options;
|
|
822
|
-
constructor(manager, options) {
|
|
823
|
-
this.manager = manager;
|
|
824
|
-
this.options = options;
|
|
825
|
-
}
|
|
826
|
-
async download(c) {
|
|
827
|
-
const secret = this.options.secret;
|
|
828
|
-
if (!secret) return new Response("Storage signing is not configured", { status: 500 });
|
|
829
|
-
if (!await verifySignedUrl(c.req.url, secret, {
|
|
830
|
-
method: c.req.method,
|
|
831
|
-
purpose: STORAGE_SIGNED_URL_PURPOSE
|
|
832
|
-
})) return new Response("Invalid or expired URL", { status: 403 });
|
|
833
|
-
const url = new URL(c.req.url);
|
|
834
|
-
if (url.searchParams.get("method") !== "GET") return new Response("URL is not scoped for reads", { status: 403 });
|
|
835
|
-
const disk = c.req.param("disk");
|
|
836
|
-
if (!disk || !this.manager.hasDisk(disk)) return new Response("Unknown disk", { status: 404 });
|
|
837
|
-
const claim = url.searchParams.get("key");
|
|
838
|
-
const decoded = claim ? decodeStorageKeyClaim(claim) : void 0;
|
|
839
|
-
if (!decoded) return new Response("Malformed storage key claim", { status: 400 });
|
|
840
|
-
const fullPath = joinStoragePath(void 0, decoded);
|
|
841
|
-
if (fullPath !== decoded) return new Response("Invalid storage key", { status: 403 });
|
|
842
|
-
if (!isStorageKeyWithinRoot(fullPath, this.manager.getDiskConfig(disk).root)) return new Response("Storage key is outside the configured root", { status: 403 });
|
|
843
|
-
try {
|
|
844
|
-
const result = await this.manager.getDriver(disk).download(fullPath);
|
|
845
|
-
const filename = fullPath.split("/").at(-1) || "download";
|
|
846
|
-
return new Response(result.toStream(), { headers: {
|
|
847
|
-
"content-type": result.contentType || "application/octet-stream",
|
|
848
|
-
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
849
|
-
"x-content-type-options": "nosniff"
|
|
850
|
-
} });
|
|
851
|
-
} catch {
|
|
852
|
-
return new Response("Not found", { status: 404 });
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
};
|
|
856
|
-
__decorate([
|
|
857
|
-
Get("/:disk"),
|
|
858
|
-
__decorateParam(0, Req()),
|
|
859
|
-
__decorateMetadata("design:type", Function),
|
|
860
|
-
__decorateMetadata("design:paramtypes", [Object]),
|
|
861
|
-
__decorateMetadata("design:returntype", Promise)
|
|
862
|
-
], StorageController.prototype, "download", null);
|
|
863
|
-
StorageController = __decorate([
|
|
864
|
-
Controller("storage"),
|
|
865
|
-
__decorateParam(0, Inject(StorageManagerService)),
|
|
866
|
-
__decorateParam(1, Inject(STORAGE_OPTIONS)),
|
|
867
|
-
__decorateMetadata("design:paramtypes", [typeof StorageManagerService === "undefined" ? Object : StorageManagerService, Object])
|
|
868
|
-
], StorageController);
|
|
869
|
-
//#endregion
|
|
870
|
-
//#region src/storage/storage.service.ts
|
|
871
|
-
const DEFAULT_PRESIGN = {
|
|
872
|
-
defaultExpiry: 3600,
|
|
873
|
-
maxExpiry: 86400
|
|
874
|
-
};
|
|
875
|
-
let StorageService = class StorageService {
|
|
876
|
-
options;
|
|
877
|
-
manager;
|
|
878
|
-
constructor(options, manager) {
|
|
879
|
-
this.options = options;
|
|
880
|
-
this.manager = manager;
|
|
881
|
-
}
|
|
882
|
-
put(relativePath, body, options = {}, disk) {
|
|
883
|
-
const name = this.resolveDisk(disk);
|
|
884
|
-
return this.manager.getDriver(name).upload(body, this.fullPath(relativePath, name), options);
|
|
885
|
-
}
|
|
886
|
-
get(relativePath, disk) {
|
|
887
|
-
const name = this.resolveDisk(disk);
|
|
888
|
-
return this.manager.getDriver(name).download(this.fullPath(relativePath, name));
|
|
889
|
-
}
|
|
890
|
-
delete(relativePath, disk) {
|
|
891
|
-
const name = this.resolveDisk(disk);
|
|
892
|
-
return this.manager.getDriver(name).delete(this.fullPath(relativePath, name));
|
|
893
|
-
}
|
|
894
|
-
exists(relativePath, disk) {
|
|
895
|
-
const name = this.resolveDisk(disk);
|
|
896
|
-
return this.manager.getDriver(name).exists(this.fullPath(relativePath, name));
|
|
897
|
-
}
|
|
898
|
-
url(relativePath, method = "GET", expiresIn, disk) {
|
|
899
|
-
const name = this.resolveDisk(disk);
|
|
900
|
-
return this.manager.getDriver(name).getPresignedUrl(this.fullPath(relativePath, name), method, this.validateExpiry(expiresIn));
|
|
901
|
-
}
|
|
902
|
-
resolveDisk(disk) {
|
|
903
|
-
const name = disk ?? this.options.defaultDisk;
|
|
904
|
-
if (!this.manager.hasDisk(name)) throw new Error(`Storage disk "${name}" is not configured.`);
|
|
905
|
-
return name;
|
|
906
|
-
}
|
|
907
|
-
fullPath(relativePath, disk) {
|
|
908
|
-
return joinStoragePath(this.manager.getDiskConfig(disk).root, relativePath);
|
|
909
|
-
}
|
|
910
|
-
validateExpiry(expiresIn) {
|
|
911
|
-
const cfg = this.options.presignedUrl ?? DEFAULT_PRESIGN;
|
|
912
|
-
const value = expiresIn ?? cfg.defaultExpiry;
|
|
913
|
-
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).`);
|
|
914
|
-
return value;
|
|
915
|
-
}
|
|
916
|
-
};
|
|
917
|
-
StorageService = __decorate([
|
|
918
|
-
Injectable(),
|
|
919
|
-
__decorateParam(0, Inject(STORAGE_OPTIONS)),
|
|
920
|
-
__decorateParam(1, Inject(StorageManagerService)),
|
|
921
|
-
__decorateMetadata("design:paramtypes", [Object, typeof StorageManagerService === "undefined" ? Object : StorageManagerService])
|
|
922
|
-
], StorageService);
|
|
923
|
-
//#endregion
|
|
924
|
-
//#region src/storage/storage.module.ts
|
|
925
|
-
const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({
|
|
926
|
-
moduleName: "Storage",
|
|
927
|
-
optionsInjectionToken: STORAGE_OPTIONS
|
|
928
|
-
}).build();
|
|
929
|
-
let StorageModule = class StorageModule extends ConfigurableModuleClass {};
|
|
930
|
-
StorageModule = __decorate([Module({
|
|
931
|
-
providers: [StorageManagerService, StorageService],
|
|
932
|
-
controllers: [StorageController],
|
|
933
|
-
exports: [
|
|
934
|
-
StorageService,
|
|
935
|
-
StorageManagerService,
|
|
936
|
-
STORAGE_OPTIONS
|
|
937
|
-
]
|
|
938
|
-
})], StorageModule);
|
|
939
|
-
//#endregion
|
|
940
|
-
//#region src/services/kv-cache.store.ts
|
|
941
|
-
/**
|
|
942
|
-
* Native KV JSON value store. Metadata retains logical expiry even when KV's
|
|
943
|
-
* physical retention rounds up to its 60-second minimum. Legacy values without
|
|
944
|
-
* metadata remain readable, but cannot safely backfill another tier.
|
|
945
|
-
*/
|
|
946
|
-
var KVCacheStore = class {
|
|
947
|
-
ns;
|
|
948
|
-
constructor(ns) {
|
|
949
|
-
this.ns = ns;
|
|
950
|
-
}
|
|
951
|
-
async get(key) {
|
|
952
|
-
return (await this.getEntry(key))?.value;
|
|
953
|
-
}
|
|
954
|
-
async getEntry(key) {
|
|
955
|
-
const { value, metadata } = await this.ns.getWithMetadata(key, "json");
|
|
956
|
-
if (value === null) return void 0;
|
|
957
|
-
if (typeof metadata === "object" && metadata !== null && "velaCacheExpiresAt" in metadata) {
|
|
958
|
-
const expiresAt = metadata.velaCacheExpiresAt;
|
|
959
|
-
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) return void 0;
|
|
960
|
-
return {
|
|
961
|
-
value,
|
|
962
|
-
expiresAt
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
return { value };
|
|
966
|
-
}
|
|
967
|
-
async set(key, value, ttl) {
|
|
968
|
-
if (ttl !== void 0 && (!Number.isFinite(ttl) || ttl < 0)) throw new TypeError("Cache TTL must be finite and nonnegative.");
|
|
969
|
-
if (ttl !== void 0) return this.setEntry(key, {
|
|
970
|
-
value,
|
|
971
|
-
expiresAt: Date.now() + ttl * 1e3
|
|
972
|
-
});
|
|
973
|
-
await this.ns.put(key, JSON.stringify(value));
|
|
974
|
-
}
|
|
975
|
-
async setEntry(key, entry) {
|
|
976
|
-
if (!Number.isFinite(entry.expiresAt)) throw new TypeError("Cache expiry must be finite.");
|
|
977
|
-
const remaining = (entry.expiresAt - Date.now()) / 1e3;
|
|
978
|
-
if (remaining <= 0) return this.del(key);
|
|
979
|
-
await this.ns.put(key, JSON.stringify(entry.value), {
|
|
980
|
-
expirationTtl: Math.max(60, Math.ceil(remaining)),
|
|
981
|
-
metadata: { velaCacheExpiresAt: entry.expiresAt }
|
|
982
|
-
});
|
|
983
|
-
}
|
|
984
|
-
async del(key) {
|
|
985
|
-
await this.ns.delete(key);
|
|
986
|
-
}
|
|
987
|
-
/** Namespace-wide, best effort. Use a dedicated value namespace; never use for scoped invalidation. */
|
|
988
|
-
async clear() {
|
|
989
|
-
let cursor;
|
|
990
|
-
do {
|
|
991
|
-
const list = await this.ns.list(cursor ? { cursor } : void 0);
|
|
992
|
-
await Promise.all(list.keys.map((entry) => this.ns.delete(entry.name)));
|
|
993
|
-
cursor = list.list_complete ? void 0 : list.cursor;
|
|
994
|
-
} while (cursor);
|
|
995
|
-
}
|
|
996
|
-
};
|
|
997
|
-
/**
|
|
998
|
-
* Optional, eventually consistent generation store. Use a dedicated KV namespace
|
|
999
|
-
* without TTL/lifecycle cleanup. Never delete/reset generations while entries can
|
|
1000
|
-
* survive. Concurrent writes and cached/negative reads prevent strong invalidation;
|
|
1001
|
-
* this is unsuitable for strict read-after-write or authorization revocation.
|
|
1002
|
-
*/
|
|
1003
|
-
var KVCacheInvalidationStore = class {
|
|
1004
|
-
ns;
|
|
1005
|
-
constructor(ns) {
|
|
1006
|
-
this.ns = ns;
|
|
1007
|
-
}
|
|
1008
|
-
async getVersion(key) {
|
|
1009
|
-
const value = await this.ns.get(key, "json");
|
|
1010
|
-
if (value === null) return "initial";
|
|
1011
|
-
if (typeof value !== "string" || value.length === 0 || value.length > 2048) throw new TypeError("Invalid cache generation.");
|
|
1012
|
-
return value;
|
|
1013
|
-
}
|
|
1014
|
-
async invalidate(key) {
|
|
1015
|
-
await this.ns.put(key, JSON.stringify(crypto.randomUUID()));
|
|
1016
|
-
}
|
|
1017
|
-
};
|
|
1018
|
-
//#endregion
|
|
1019
|
-
//#region src/services/flagship-flag.driver.ts
|
|
1020
|
-
/**
|
|
1021
|
-
* {@link FeatureFlagDriver} backed by a Cloudflare Flagship binding.
|
|
1022
|
-
*
|
|
1023
|
-
* A thin, honest wrapper: each contract method maps 1:1 onto the binding's
|
|
1024
|
-
* corresponding value method, forwarding the caller's `fallback` (the binding's
|
|
1025
|
-
* `defaultValue`) and evaluation context. The binding resolves the fallback on
|
|
1026
|
-
* evaluation errors; anything the binding *rejects* with (e.g. a `remote: true`
|
|
1027
|
-
* dev-proxy tunnel dropping) propagates — `@velajs/feature-flags`'s service owns
|
|
1028
|
-
* the never-throw guarantee.
|
|
1029
|
-
*
|
|
1030
|
-
* Build the driver inside a provider factory with the native environment:
|
|
1031
|
-
*
|
|
1032
|
-
* ```ts
|
|
1033
|
-
* FeatureFlagsModule.forRootAsync({
|
|
1034
|
-
* inject: [ENV],
|
|
1035
|
-
* useFactory: (env: WorkerEnv) => ({ drivers: [flagshipFlagDriver(env.FLAGS)] }),
|
|
1036
|
-
* });
|
|
1037
|
-
* ```
|
|
1038
|
-
*
|
|
1039
|
-
* Evaluation ergonomics (the 1:1 binding-method mapping) are ported from the
|
|
1040
|
-
* Stratal feature-flags service (MIT, © Temitayo Fadojutimi), reshaped as a
|
|
1041
|
-
* bare driver.
|
|
1042
|
-
*/
|
|
1043
|
-
var FlagshipFlagDriver = class {
|
|
1044
|
-
name;
|
|
1045
|
-
resolve;
|
|
1046
|
-
constructor(binding, options = {}) {
|
|
1047
|
-
this.resolve = typeof binding === "function" ? binding : () => binding;
|
|
1048
|
-
this.name = options.name ?? "flagship";
|
|
1049
|
-
}
|
|
1050
|
-
getBoolean(key, fallback, ctx) {
|
|
1051
|
-
return this.resolve().getBooleanValue(key, fallback, ctx);
|
|
1052
|
-
}
|
|
1053
|
-
getString(key, fallback, ctx) {
|
|
1054
|
-
return this.resolve().getStringValue(key, fallback, ctx);
|
|
1055
|
-
}
|
|
1056
|
-
getNumber(key, fallback, ctx) {
|
|
1057
|
-
return this.resolve().getNumberValue(key, fallback, ctx);
|
|
1058
|
-
}
|
|
1059
|
-
getObject(key, fallback, ctx) {
|
|
1060
|
-
return this.resolve().getObjectValue(key, fallback, ctx);
|
|
1061
|
-
}
|
|
1062
|
-
};
|
|
1063
|
-
/** Convenience factory for {@link FlagshipFlagDriver}. */
|
|
1064
|
-
function flagshipFlagDriver(binding, options) {
|
|
1065
|
-
return new FlagshipFlagDriver(binding, options);
|
|
1066
|
-
}
|
|
1067
|
-
//#endregion
|
|
1068
|
-
//#region src/services/kv-flag.driver.ts
|
|
1069
|
-
/**
|
|
1070
|
-
* Cloudflare KV-backed {@link FeatureFlagDriver}. Flags are stored as JSON
|
|
1071
|
-
* values under an optional key prefix and read with `get(key, 'json')`. Reads
|
|
1072
|
-
* are type-checked against the requested type: a missing key or a value of the
|
|
1073
|
-
* wrong JSON type returns the caller's `fallback`. KV has no targeting, so the
|
|
1074
|
-
* evaluation context is ignored.
|
|
1075
|
-
*
|
|
1076
|
-
* The driver stays honest — it does **not** swallow errors. A KV failure (or a
|
|
1077
|
-
* `SyntaxError` from a malformed stored value) propagates; the never-throw
|
|
1078
|
-
* guarantee lives in `@velajs/feature-flags`'s service layer.
|
|
1079
|
-
*
|
|
1080
|
-
* Placed like {@link KVCacheStore}: construct it in a wiring factory over a
|
|
1081
|
-
* resolved {@link KVNamespace}.
|
|
1082
|
-
*
|
|
1083
|
-
* ```ts
|
|
1084
|
-
* FeatureFlagsModule.forRootAsync({
|
|
1085
|
-
* inject: [ENV],
|
|
1086
|
-
* useFactory: (env: WorkerEnv) => ({ drivers: [new KvFlagDriver(env.CACHE, { prefix: 'flag:' })] }),
|
|
1087
|
-
* });
|
|
1088
|
-
* ```
|
|
1089
|
-
*/
|
|
1090
|
-
var KvFlagDriver = class {
|
|
1091
|
-
ns;
|
|
1092
|
-
name;
|
|
1093
|
-
prefix;
|
|
1094
|
-
constructor(ns, options = {}) {
|
|
1095
|
-
this.ns = ns;
|
|
1096
|
-
this.name = options.name ?? "kv";
|
|
1097
|
-
this.prefix = options.prefix ?? "";
|
|
1098
|
-
}
|
|
1099
|
-
getBoolean(key, fallback, _ctx) {
|
|
1100
|
-
return this.read(key, fallback, (v) => typeof v === "boolean");
|
|
1101
|
-
}
|
|
1102
|
-
getString(key, fallback, _ctx) {
|
|
1103
|
-
return this.read(key, fallback, (v) => typeof v === "string");
|
|
1104
|
-
}
|
|
1105
|
-
getNumber(key, fallback, _ctx) {
|
|
1106
|
-
return this.read(key, fallback, (v) => typeof v === "number");
|
|
1107
|
-
}
|
|
1108
|
-
getObject(key, fallback, _ctx) {
|
|
1109
|
-
return this.read(key, fallback, (v) => typeof v === "object" && v !== null);
|
|
1110
|
-
}
|
|
1111
|
-
/**
|
|
1112
|
-
* Reads and JSON-parses the (prefixed) key, returning the parsed value only
|
|
1113
|
-
* when `matches` accepts its type; otherwise the caller's fallback. A missing
|
|
1114
|
-
* key reads as `null` → fallback. Read/parse errors are left to propagate.
|
|
1115
|
-
*/
|
|
1116
|
-
async read(key, fallback, matches) {
|
|
1117
|
-
const value = await this.ns.get(this.prefix + key, "json");
|
|
1118
|
-
if (value === null || value === void 0) return fallback;
|
|
1119
|
-
return matches(value) ? value : fallback;
|
|
1120
|
-
}
|
|
1121
|
-
};
|
|
1122
|
-
/** Convenience factory for {@link KvFlagDriver}. */
|
|
1123
|
-
function kvFlagDriver(kv, options) {
|
|
1124
|
-
return new KvFlagDriver(kv, options);
|
|
1125
|
-
}
|
|
1126
|
-
//#endregion
|
|
1127
|
-
//#region src/decorators/queue-consumer.ts
|
|
1128
|
-
const QUEUE_CONSUMER_METADATA_KEY = "cloudflare:queue-consumer";
|
|
1129
|
-
registerEntrypointKind({
|
|
1130
|
-
kind: "cf:queue",
|
|
1131
|
-
metaKey: QUEUE_CONSUMER_METADATA_KEY,
|
|
1132
|
-
level: "method"
|
|
1133
|
-
});
|
|
1134
|
-
/**
|
|
1135
|
-
* Marks a method as a queue consumer handler.
|
|
1136
|
-
*
|
|
1137
|
-
* @example
|
|
1138
|
-
* ```ts
|
|
1139
|
-
* @Injectable()
|
|
1140
|
-
* class WorkerService {
|
|
1141
|
-
* @QueueConsumer('email-queue')
|
|
1142
|
-
* async processEmails(batch: MessageBatch) {
|
|
1143
|
-
* for (const msg of batch.messages) {
|
|
1144
|
-
* console.log('Processing:', msg.body);
|
|
1145
|
-
* msg.ack();
|
|
1146
|
-
* }
|
|
1147
|
-
* }
|
|
1148
|
-
* }
|
|
1149
|
-
* ```
|
|
1150
|
-
*/
|
|
1151
|
-
function QueueConsumer(queueName) {
|
|
1152
|
-
return (target, propertyKey, _descriptor) => {
|
|
1153
|
-
const existing = getMetadata(QUEUE_CONSUMER_METADATA_KEY, target.constructor) ?? [];
|
|
1154
|
-
existing.push({
|
|
1155
|
-
queueName,
|
|
1156
|
-
methodName: String(propertyKey)
|
|
1157
|
-
});
|
|
1158
|
-
defineMetadata(QUEUE_CONSUMER_METADATA_KEY, existing, target.constructor);
|
|
1159
|
-
};
|
|
1160
|
-
}
|
|
1161
|
-
//#endregion
|
|
1162
|
-
//#region src/websocket/cloudflare-websocket.module.ts
|
|
1163
|
-
/**
|
|
1164
|
-
* Cloudflare counterpart to the core `WebSocketModule.forRoot()`. Import this in
|
|
1165
|
-
* your `AppModule` instead: it provides the gateway dispatcher plus a late-bound
|
|
1166
|
-
* `WS_SERVER` (`WsServerHolder`) that the WebSocket Durable Object wires to a
|
|
1167
|
-
* ctx-backed server per instance. `useClass` ensures a fresh holder per DI
|
|
1168
|
-
* container so colocated DO instances never share a server.
|
|
1169
|
-
*/
|
|
1170
|
-
var CloudflareWebSocketModule = class CloudflareWebSocketModule {
|
|
1171
|
-
static forRoot() {
|
|
1172
|
-
const providers = [defineProvider(WS_SERVER, { useClass: WsServerHolder }), WsDispatcher];
|
|
1173
|
-
return {
|
|
1174
|
-
module: CloudflareWebSocketModule,
|
|
1175
|
-
providers,
|
|
1176
|
-
exports: [WS_SERVER, WsDispatcher]
|
|
1177
|
-
};
|
|
1178
|
-
}
|
|
1179
|
-
};
|
|
1180
|
-
//#endregion
|
|
1181
|
-
//#region src/websocket/broadcast.ts
|
|
1182
|
-
/**
|
|
1183
|
-
* Push to a room from a Worker HTTP handler / cron / queue consumer (server-
|
|
1184
|
-
* initiated emit). Resolves the room's Durable Object and calls its `broadcast`
|
|
1185
|
-
* RPC method — the same canonical room→DO mapping the upgrade route uses, so it
|
|
1186
|
-
* always reaches the DO holding those sockets.
|
|
1187
|
-
*
|
|
1188
|
-
* @example
|
|
1189
|
-
* ```ts
|
|
1190
|
-
* // In a controller — ns from the typed Worker environment
|
|
1191
|
-
* await broadcastToRoom(ns, '/orgs/:orgId/ws', `org:${id}`, 'order.created', order);
|
|
1192
|
-
* ```
|
|
1193
|
-
*/
|
|
1194
|
-
async function broadcastToRoom(ns, gatewayPath, room, event, data, options) {
|
|
1195
|
-
const cmd = {
|
|
1196
|
-
rooms: [room],
|
|
1197
|
-
exceptIds: options?.exceptIds,
|
|
1198
|
-
frame: JSON.stringify({
|
|
1199
|
-
event,
|
|
1200
|
-
data
|
|
1201
|
-
})
|
|
1202
|
-
};
|
|
1203
|
-
const maxFrameBytes = resolveMaxFrameBytes({ maxFrameBytes: options?.maxFrameBytes ?? DEFAULT_WS_MAX_FRAME_BYTES });
|
|
1204
|
-
assertBroadcastCommandFits(cmd, maxFrameBytes);
|
|
1205
|
-
await ns.get(roomToDurableId(ns, gatewayPath, room)).broadcast(cmd);
|
|
1206
|
-
}
|
|
1207
|
-
//#endregion
|
|
1208
|
-
//#region src/rate-limit/cloudflare-rate-limit.store.ts
|
|
1209
|
-
/**
|
|
1210
|
-
* Adapt a Cloudflare Workers Rate Limiting binding to Vela's throttler store.
|
|
1211
|
-
*
|
|
1212
|
-
* The platform binding makes the allow/deny decision. It does not expose exact
|
|
1213
|
-
* counters or reset timestamps, so this adapter intentionally omits `remaining`.
|
|
1214
|
-
*/
|
|
1215
|
-
function cloudflareRateLimitStore(binding, options) {
|
|
1216
|
-
if (!binding || typeof binding !== "function" && typeof binding.limit !== "function") throw new TypeError("A Cloudflare Rate Limiting binding is required");
|
|
1217
|
-
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");
|
|
1218
|
-
if (options.periodSeconds !== 10 && options.periodSeconds !== 60) throw new RangeError("Cloudflare rate-limit periods must be 10 or 60 seconds");
|
|
1219
|
-
const maxKeyBytes = options.maxKeyBytes ?? 1024;
|
|
1220
|
-
if (!Number.isSafeInteger(maxKeyBytes) || maxKeyBytes <= 0 || maxKeyBytes > 4096) throw new RangeError("maxKeyBytes must be between 1 and 4096");
|
|
1221
|
-
const ttlMs = options.periodSeconds * 1e3;
|
|
1222
|
-
const encoder = new TextEncoder();
|
|
1223
|
-
const resolveBinding = typeof binding === "function" ? binding : () => binding;
|
|
1224
|
-
return {
|
|
1225
|
-
async increment(key, requestedTtlMs) {
|
|
1226
|
-
if (requestedTtlMs !== ttlMs) throw new Error(`Cloudflare binding period mismatch: expected ${ttlMs}ms, received ${requestedTtlMs}ms`);
|
|
1227
|
-
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");
|
|
1228
|
-
const currentBinding = resolveBinding();
|
|
1229
|
-
if (!currentBinding || typeof currentBinding.limit !== "function") throw new Error("Cloudflare Rate Limiting binding is unavailable");
|
|
1230
|
-
const decision = await currentBinding.limit({ key });
|
|
1231
|
-
if (!decision || typeof decision.success !== "boolean") throw new Error("Cloudflare rate-limit binding returned an invalid decision");
|
|
1232
|
-
return {
|
|
1233
|
-
count: decision.success ? 0 : options.limit + 1,
|
|
1234
|
-
ttlMs,
|
|
1235
|
-
allowed: decision.success,
|
|
1236
|
-
enforcedLimit: options.limit
|
|
1237
|
-
};
|
|
1238
|
-
},
|
|
1239
|
-
reset() {
|
|
1240
|
-
throw new Error("Cloudflare Rate Limiting bindings do not support counter reset");
|
|
1241
|
-
}
|
|
1242
|
-
};
|
|
1243
|
-
}
|
|
1244
|
-
//#endregion
|
|
1245
|
-
//#region src/nonce/durable-object-nonce.store.ts
|
|
1246
|
-
const APP_NAMESPACE_PREFIX = "vela:nonce:v1:";
|
|
1247
|
-
const MAX_APP_NAMESPACE_BYTES = 128;
|
|
1248
|
-
/**
|
|
1249
|
-
* Strict, cross-isolate {@link NonceStore} backed by one SQLite Durable Object
|
|
1250
|
-
* per explicit application namespace.
|
|
1251
|
-
*
|
|
1252
|
-
* Invalid input, an unavailable/malformed binding, RPC failure, or a malformed
|
|
1253
|
-
* RPC result all deny the claim (`false`). Only the literal boolean `true` from
|
|
1254
|
-
* the Durable Object is accepted.
|
|
1255
|
-
*/
|
|
1256
|
-
function durableObjectNonceStore(options) {
|
|
1257
|
-
if (!options || typeof options !== "object") throw new TypeError("Durable Object nonce-store options are required");
|
|
1258
|
-
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`);
|
|
1259
|
-
if (typeof options.binding !== "function") throw new TypeError("A lazy Durable Object namespace binding resolver is required");
|
|
1260
|
-
const objectName = `${APP_NAMESPACE_PREFIX}${options.appNamespace}`;
|
|
1261
|
-
return { async claim(nonce, expEpochSeconds) {
|
|
1262
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
1263
|
-
if (!isCanonicalBoundedText(nonce, 512) || !isValidExpiry(expEpochSeconds, now)) return false;
|
|
1264
|
-
try {
|
|
1265
|
-
const namespace = await options.binding();
|
|
1266
|
-
const id = namespace.idFromName(objectName);
|
|
1267
|
-
return await namespace.get(id).claim(nonce, expEpochSeconds) === true;
|
|
1268
|
-
} catch {
|
|
1269
|
-
return false;
|
|
1270
|
-
}
|
|
1271
|
-
} };
|
|
1272
|
-
}
|
|
1273
|
-
//#endregion
|
|
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 };
|
|
1275
|
-
|
|
1276
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
import { durableObjectRoomName } from "./websocket/room-id.js";
|
|
2
|
+
import { CLOUDFLARE_SCHEDULED_EVENT } from "./scheduled-event.js";
|
|
3
|
+
import { CloudflareApplication } from "./cloudflare-application.js";
|
|
4
|
+
import { DoCursorLog, durableObjectCursorLog, durableObjectLive, liveInvalidateToRoom } from "./websocket/do-live.js";
|
|
5
|
+
import { cloudflareAdapter, createCloudflareApp, createCloudflareWorker } from "./cloudflare-factory.js";
|
|
6
|
+
import { STORAGE_OPTIONS } from "./storage/storage.tokens.js";
|
|
7
|
+
import { R2StorageDriver } from "./storage/r2-storage.driver.js";
|
|
8
|
+
import { StorageManagerService } from "./storage/storage-manager.service.js";
|
|
9
|
+
import { StorageController } from "./storage/storage.controller.js";
|
|
10
|
+
import { StorageService } from "./storage/storage.service.js";
|
|
11
|
+
import { StorageModule } from "./storage/storage.module.js";
|
|
12
|
+
import { KVCacheInvalidationStore, KVCacheStore } from "./services/kv-cache.store.js";
|
|
13
|
+
import { FlagshipFlagDriver, flagshipFlagDriver } from "./services/flagship-flag.driver.js";
|
|
14
|
+
import { KvFlagDriver, kvFlagDriver } from "./services/kv-flag.driver.js";
|
|
15
|
+
import { QueueConsumer } from "./decorators/queue-consumer.js";
|
|
16
|
+
import { DoPitrUnavailableError, armDoPitr, isDoPitrUnavailable, readDoPitrBookmark } from "./websocket/do-pitr.js";
|
|
17
|
+
import { CloudflareWebSocketModule } from "./websocket/cloudflare-websocket.module.js";
|
|
18
|
+
import { broadcastToRoom } from "./websocket/broadcast.js";
|
|
19
|
+
import { cloudflareRateLimitStore } from "./rate-limit/cloudflare-rate-limit.store.js";
|
|
20
|
+
import { durableObjectNonceStore } from "./nonce/durable-object-nonce.store.js";
|
|
21
|
+
export { CLOUDFLARE_SCHEDULED_EVENT, CloudflareApplication, CloudflareWebSocketModule, DoCursorLog, DoPitrUnavailableError, FlagshipFlagDriver, KVCacheInvalidationStore, KVCacheStore, KvFlagDriver, QueueConsumer, R2StorageDriver, STORAGE_OPTIONS, StorageController, StorageManagerService, StorageModule, StorageService, armDoPitr, broadcastToRoom, cloudflareAdapter, cloudflareRateLimitStore, createCloudflareApp, createCloudflareWorker, durableObjectCursorLog, durableObjectLive, durableObjectNonceStore, durableObjectRoomName, flagshipFlagDriver, isDoPitrUnavailable, kvFlagDriver, liveInvalidateToRoom, readDoPitrBookmark };
|