@velajs/cloudflare 1.6.0 → 1.8.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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 (2026-07-04)
4
+
5
+ - `cloudflareAdapter()` exported (createCloudflareApp composes vela RuntimeAdapter); `@QueueConsumer`/`@Scheduled` declare open entrypoint kinds; queue/scheduled dispatch runs per-event in a request scope through PipelineRunner (consumer-scoped guards/interceptors/filters; request-scoped deps rebuild per batch); DO WebSocket reads `app.entrypoints`. Requires `@velajs/vela >=1.11.0`.
6
+
7
+
3
8
  ## 1.6.0 (2026-07-01)
4
9
 
5
10
  ### Added
@@ -41,8 +41,6 @@ export type MountOpenApiOptions = Parameters<VelaApplication['mountOpenApi']>[0]
41
41
  */
42
42
  export declare class CloudflareApplication {
43
43
  private app;
44
- private scheduledHandlers;
45
- private queueConsumers;
46
44
  private wsGatewayRoutes;
47
45
  constructor(app: VelaApplication);
48
46
  get fetch(): Hono['fetch'];
@@ -81,13 +79,20 @@ export declare class CloudflareApplication {
81
79
  * ```
82
80
  */
83
81
  mountOpenApi(options: MountOpenApiOptions): this;
84
- /** @internal — scans instances for @Scheduled, @Cron, and @QueueConsumer metadata */
82
+ /**
83
+ * @internal — scans instances for `@WebSocketGateway({ path, binding })`
84
+ * upgrade routes. Queue/scheduled handlers are NOT scanned anymore: they
85
+ * come from `app.entrypoints` (`cf:queue` / `cf:scheduled` / `cf:vela-cron`
86
+ * kinds) at dispatch time.
87
+ */
85
88
  scanInstances(instances: unknown[]): void;
86
89
  /** @internal — upgrade routes discovered from `@WebSocketGateway({ path, binding })`. */
87
90
  getWsGatewayRoutes(): WsGatewayRoute[];
88
91
  /**
89
92
  * Handle Cloudflare scheduled (cron) events.
90
- * Matches the event's cron expression to `@Scheduled()` and vela `@Cron()` handlers.
93
+ * Matches the event's cron expression to `@Scheduled()` and vela `@Cron()`
94
+ * handlers read from `app.entrypoints`; each handler runs inside a fresh
95
+ * request-scoped child (request-scoped providers rebuild per tick).
91
96
  */
92
97
  scheduled(event: {
93
98
  cron: string;
@@ -95,9 +100,20 @@ export declare class CloudflareApplication {
95
100
  }, env: CloudflareEnv, ctx: {
96
101
  waitUntil: (promise: Promise<unknown>) => void;
97
102
  }): Promise<void>;
103
+ /**
104
+ * Run one entrypoint handler inside a fresh request scope, through the
105
+ * shared guard → interceptor pipeline (components declared with
106
+ * `@UseGuards`/`@UseInterceptors`/`@UseFilters` on the consumer class or
107
+ * method). HTTP-global components deliberately do NOT apply — an HTTP auth
108
+ * guard has no business rejecting a queue batch. Unclaimed errors rethrow
109
+ * so the platform's retry semantics stay intact.
110
+ */
111
+ private dispatchEntrypoint;
98
112
  /**
99
113
  * Handle Cloudflare Queue consumer events.
100
- * Matches the batch queue name to `@QueueConsumer()` handlers.
114
+ * Matches the batch queue name to `@QueueConsumer()` handlers read from
115
+ * `app.entrypoints`; each batch is processed inside a fresh request-scoped
116
+ * child (request-scoped providers rebuild per batch — no boot-time captives).
101
117
  */
102
118
  queue(batch: {
103
119
  queue: string;
@@ -1,7 +1,14 @@
1
- import { CRON_METADATA, getMetadata } from "@velajs/vela";
2
- import { getScheduledMetadata } from "./decorators/scheduled.js";
3
- import { getQueueConsumerMetadata } from "./decorators/queue-consumer.js";
1
+ import { CRON_METADATA, PipelineRunner, buildEntrypointExecutionContext, registerEntrypointKind, runInEntrypointScope, shouldFilterCatch } from "@velajs/vela";
2
+ import { ComponentManager } from "@velajs/vela/internal";
4
3
  import { collectWsGatewayRoutes } from "./websocket/websocket-routing.js";
4
+ // vela's own @Cron jobs run via the same Workers cron trigger — declare an
5
+ // entrypoint kind over vela's metadata key (the open-kind system makes
6
+ // cross-package declarations first-class).
7
+ registerEntrypointKind({
8
+ kind: 'cf:vela-cron',
9
+ metaKey: CRON_METADATA,
10
+ level: 'method'
11
+ });
5
12
  function invoke(instance, methodName, args) {
6
13
  const method = instance[methodName];
7
14
  if (typeof method !== 'function') {
@@ -39,8 +46,6 @@ function invoke(instance, methodName, args) {
39
46
  * ```
40
47
  */ export class CloudflareApplication {
41
48
  app;
42
- scheduledHandlers = [];
43
- queueConsumers = [];
44
49
  wsGatewayRoutes = [];
45
50
  constructor(app){
46
51
  this.app = app;
@@ -88,32 +93,14 @@ function invoke(instance, methodName, args) {
88
93
  this.app.mountOpenApi(options);
89
94
  return this;
90
95
  }
91
- /** @internal — scans instances for @Scheduled, @Cron, and @QueueConsumer metadata */ scanInstances(instances) {
96
+ /**
97
+ * @internal — scans instances for `@WebSocketGateway({ path, binding })`
98
+ * upgrade routes. Queue/scheduled handlers are NOT scanned anymore: they
99
+ * come from `app.entrypoints` (`cf:queue` / `cf:scheduled` / `cf:vela-cron`
100
+ * kinds) at dispatch time.
101
+ */ scanInstances(instances) {
92
102
  for (const instance of instances){
93
103
  if (!instance || typeof instance !== 'object') continue;
94
- for (const meta of getScheduledMetadata(instance)){
95
- this.scheduledHandlers.push({
96
- instance,
97
- methodName: meta.methodName,
98
- cron: meta.cron
99
- });
100
- }
101
- // vela's @Cron jobs run via the same Workers cron trigger.
102
- const cronMeta = getMetadata(CRON_METADATA, instance.constructor) ?? [];
103
- for (const meta of cronMeta){
104
- this.scheduledHandlers.push({
105
- instance,
106
- methodName: meta.methodName,
107
- cron: meta.expression
108
- });
109
- }
110
- for (const meta of getQueueConsumerMetadata(instance)){
111
- this.queueConsumers.push({
112
- instance,
113
- methodName: meta.methodName,
114
- queueName: meta.queueName
115
- });
116
- }
117
104
  this.wsGatewayRoutes.push(...collectWsGatewayRoutes(instance));
118
105
  }
119
106
  }
@@ -122,21 +109,72 @@ function invoke(instance, methodName, args) {
122
109
  }
123
110
  /**
124
111
  * Handle Cloudflare scheduled (cron) events.
125
- * Matches the event's cron expression to `@Scheduled()` and vela `@Cron()` handlers.
112
+ * Matches the event's cron expression to `@Scheduled()` and vela `@Cron()`
113
+ * handlers read from `app.entrypoints`; each handler runs inside a fresh
114
+ * request-scoped child (request-scoped providers rebuild per tick).
126
115
  */ async scheduled(event, env, ctx) {
127
- const matching = this.scheduledHandlers.filter((h)=>h.cron === event.cron);
128
- await Promise.all(matching.map((h)=>invoke(h.instance, h.methodName, [
116
+ const handlers = [
117
+ ...this.app.entrypoints.ofKind('cf:scheduled').map((ep)=>({
118
+ ep,
119
+ cron: ep.meta.cron
120
+ })),
121
+ ...this.app.entrypoints.ofKind('cf:vela-cron').map((ep)=>({
122
+ ep,
123
+ cron: ep.meta.expression
124
+ }))
125
+ ].filter((h)=>h.cron === event.cron);
126
+ await Promise.all(handlers.map(({ ep })=>this.dispatchEntrypoint(ep, [
129
127
  event,
130
128
  env,
131
129
  ctx
132
130
  ])));
133
131
  }
134
132
  /**
133
+ * Run one entrypoint handler inside a fresh request scope, through the
134
+ * shared guard → interceptor pipeline (components declared with
135
+ * `@UseGuards`/`@UseInterceptors`/`@UseFilters` on the consumer class or
136
+ * method). HTTP-global components deliberately do NOT apply — an HTTP auth
137
+ * guard has no business rejecting a queue batch. Unclaimed errors rethrow
138
+ * so the platform's retry semantics stay intact.
139
+ */ async dispatchEntrypoint(ep, args) {
140
+ const targetClass = ep.token;
141
+ const methodName = String(ep.methodName);
142
+ const context = buildEntrypointExecutionContext(ep.kind, targetClass, methodName, args[0]);
143
+ await runInEntrypointScope(this.app.getContainer(), async (scope)=>{
144
+ const instance = scope.resolve(ep.token);
145
+ const guards = ComponentManager.resolveGuards(ComponentManager.getScopedComponents('guard', targetClass, methodName), scope);
146
+ const interceptors = ComponentManager.resolveInterceptors(ComponentManager.getScopedComponents('interceptor', targetClass, methodName), scope);
147
+ // Closest-first, mirroring the HTTP/WS dispatchers.
148
+ const filters = ComponentManager.resolveFilters([
149
+ ...ComponentManager.getScopedComponents('filter', targetClass, methodName)
150
+ ].reverse(), scope);
151
+ try {
152
+ await PipelineRunner.run({
153
+ context,
154
+ guards,
155
+ interceptors,
156
+ resolveArgs: async ()=>args,
157
+ invoke: async (resolved)=>invoke(instance, methodName, resolved)
158
+ });
159
+ } catch (error) {
160
+ for (const filter of filters){
161
+ if (shouldFilterCatch(filter, error)) {
162
+ await filter.catch(error, context);
163
+ return;
164
+ }
165
+ }
166
+ throw error;
167
+ }
168
+ });
169
+ }
170
+ /**
135
171
  * Handle Cloudflare Queue consumer events.
136
- * Matches the batch queue name to `@QueueConsumer()` handlers.
172
+ * Matches the batch queue name to `@QueueConsumer()` handlers read from
173
+ * `app.entrypoints`; each batch is processed inside a fresh request-scoped
174
+ * child (request-scoped providers rebuild per batch — no boot-time captives).
137
175
  */ async queue(batch, env, ctx) {
138
- const matching = this.queueConsumers.filter((h)=>h.queueName === batch.queue);
139
- await Promise.all(matching.map((h)=>invoke(h.instance, h.methodName, [
176
+ const handlers = this.app.entrypoints.ofKind('cf:queue').filter((ep)=>ep.meta.queueName === batch.queue);
177
+ await Promise.all(handlers.map((ep)=>this.dispatchEntrypoint(ep, [
140
178
  batch,
141
179
  env,
142
180
  ctx
@@ -1,5 +1,5 @@
1
1
  import type { MiddlewareHandler } from 'hono';
2
- import type { Type } from '@velajs/vela';
2
+ import type { RuntimeAdapter, Type } from '@velajs/vela';
3
3
  import { CloudflareApplication } from './cloudflare-application';
4
4
  /**
5
5
  * Options for {@link createCloudflareApp}.
@@ -71,4 +71,18 @@ export interface CreateCloudflareAppOptions {
71
71
  * });
72
72
  * ```
73
73
  */
74
+ /**
75
+ * The Cloudflare platform binding as a vela {@link RuntimeAdapter}: a one-time
76
+ * request middleware captures `c.env` on the first request and initializes
77
+ * every `BindingRef`/`EnvRef` (collected at `onBootstrap`, before any request
78
+ * can arrive). Adapter `requestMiddleware` is prepended to the global chain by
79
+ * `VelaFactory.create`, so user middleware can safely read binding refs.
80
+ *
81
+ * Exposed so consumers composing `VelaFactory.create` themselves can opt in:
82
+ *
83
+ * ```ts
84
+ * const app = await VelaFactory.create(AppModule, { adapters: [cloudflareAdapter()] });
85
+ * ```
86
+ */
87
+ export declare function cloudflareAdapter(): RuntimeAdapter;
74
88
  export declare function createCloudflareApp(rootModule: Type, options?: CreateCloudflareAppOptions): Promise<CloudflareApplication>;
@@ -48,31 +48,50 @@ function collectBindingRefs(container) {
48
48
  * ],
49
49
  * });
50
50
  * ```
51
- */ export async function createCloudflareApp(rootModule, options = {}) {
51
+ */ /**
52
+ * The Cloudflare platform binding as a vela {@link RuntimeAdapter}: a one-time
53
+ * request middleware captures `c.env` on the first request and initializes
54
+ * every `BindingRef`/`EnvRef` (collected at `onBootstrap`, before any request
55
+ * can arrive). Adapter `requestMiddleware` is prepended to the global chain by
56
+ * `VelaFactory.create`, so user middleware can safely read binding refs.
57
+ *
58
+ * Exposed so consumers composing `VelaFactory.create` themselves can opt in:
59
+ *
60
+ * ```ts
61
+ * const app = await VelaFactory.create(AppModule, { adapters: [cloudflareAdapter()] });
62
+ * ```
63
+ */ export function cloudflareAdapter() {
52
64
  let initialized = false;
53
- let refs;
54
- const bindingInit = async (c, next)=>{
55
- if (!initialized) {
56
- initialized = true;
57
- const env = c.env ?? {};
58
- for (const ref of refs){
59
- // EnvRef holds the whole env; every other ref holds one binding.
60
- if (ref instanceof EnvRef) ref._initialize(env);
61
- else ref._initialize(env[ref.bindingName]);
65
+ let refs = [];
66
+ return {
67
+ name: 'cloudflare',
68
+ requestMiddleware: [
69
+ async (c, next)=>{
70
+ if (!initialized) {
71
+ initialized = true;
72
+ const env = c.env ?? {};
73
+ for (const ref of refs){
74
+ // EnvRef holds the whole env; every other ref holds one binding.
75
+ if (ref instanceof EnvRef) ref._initialize(env);
76
+ else ref._initialize(env[ref.bindingName]);
77
+ }
78
+ }
79
+ await next();
62
80
  }
81
+ ],
82
+ onBootstrap: ({ container })=>{
83
+ refs = collectBindingRefs(container);
63
84
  }
64
- await next();
65
85
  };
66
- // IMPORTANT: keep the binding-init middleware FIRST so user middleware
67
- // can safely read binding refs / `c.env` derivatives on the first request.
86
+ }
87
+ export async function createCloudflareApp(rootModule, options = {}) {
68
88
  const velaApp = await VelaFactory.create(rootModule, {
69
89
  globalPrefix: options.globalPrefix,
70
- middleware: [
71
- bindingInit,
72
- ...options.middleware ?? []
90
+ middleware: options.middleware,
91
+ adapters: [
92
+ cloudflareAdapter()
73
93
  ]
74
94
  });
75
- refs = collectBindingRefs(velaApp.getContainer());
76
95
  const cfApp = new CloudflareApplication(velaApp);
77
96
  cfApp.scanInstances(velaApp.getInstances());
78
97
  // Register WebSocket upgrade routes for any @WebSocketGateway({ path, binding }).
@@ -1,5 +1,13 @@
1
- import { defineMetadata, getMetadata } from "@velajs/vela";
1
+ import { defineMetadata, getMetadata, registerEntrypointKind } from "@velajs/vela";
2
2
  const QUEUE_CONSUMER_METADATA_KEY = 'cloudflare:queue-consumer';
3
+ // Open entrypoint kind: any adapter can enumerate queue consumers via
4
+ // `app.entrypoints.ofKind('cf:queue')` — declared here, next to the decorator,
5
+ // with zero vela-core involvement.
6
+ registerEntrypointKind({
7
+ kind: 'cf:queue',
8
+ metaKey: QUEUE_CONSUMER_METADATA_KEY,
9
+ level: 'method'
10
+ });
3
11
  /**
4
12
  * Marks a method as a queue consumer handler.
5
13
  *
@@ -1,5 +1,12 @@
1
- import { defineMetadata, getMetadata } from "@velajs/vela";
1
+ import { defineMetadata, getMetadata, registerEntrypointKind } from "@velajs/vela";
2
2
  const SCHEDULED_METADATA_KEY = 'cloudflare:scheduled';
3
+ // Open entrypoint kind: adapters enumerate cron handlers via
4
+ // `app.entrypoints.ofKind('cf:scheduled')` — declared next to the decorator.
5
+ registerEntrypointKind({
6
+ kind: 'cf:scheduled',
7
+ metaKey: SCHEDULED_METADATA_KEY,
8
+ level: 'method'
9
+ });
3
10
  /**
4
11
  * Marks a method as a scheduled (cron) handler.
5
12
  *
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createCloudflareApp } from './cloudflare-factory';
1
+ export { cloudflareAdapter, createCloudflareApp } from './cloudflare-factory';
2
2
  export type { CreateCloudflareAppOptions } from './cloudflare-factory';
3
3
  export { CloudflareApplication } from './cloudflare-application';
4
4
  export type { MountOpenApiOptions } from './cloudflare-application';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Factory & Application
2
- export { createCloudflareApp } from "./cloudflare-factory.js";
2
+ export { cloudflareAdapter, createCloudflareApp } from "./cloudflare-factory.js";
3
3
  export { CloudflareApplication } from "./cloudflare-application.js";
4
4
  // Modules
5
5
  export { KVModule } from "./modules/kv.module.js";
@@ -7,6 +7,8 @@ export interface DoRuntime {
7
7
  dispatcher: WsDispatcher;
8
8
  registry: CfRoomRegistry;
9
9
  server: WsServer;
10
+ /** Gateway paths from `app.entrypoints.ofKind('websocket')` (discovery order). */
11
+ gatewayPaths: string[];
10
12
  close(signal?: string): Promise<void>;
11
13
  }
12
14
  /**
@@ -34,10 +34,17 @@ import { WsServerHolder } from "./ws-server-holder.js";
34
34
  app.setInstances(await loader.resolveAllInstances());
35
35
  await app.callOnModuleInit();
36
36
  await app.callOnApplicationBootstrap();
37
+ // The entrypoint registry is the transport contract: one 'websocket' entry
38
+ // per discovered gateway ({ meta: { path, dispatcher } }). Built by
39
+ // callOnApplicationBootstrap(), so this slim no-routes path has it too.
40
+ const wsEntrypoints = app.entrypoints.ofKind('websocket');
37
41
  return {
38
- dispatcher: app.get(WsDispatcher),
42
+ // Zero gateways still yields a live dispatcher (module imported, nothing
43
+ // decorated) — fall back to resolving it directly.
44
+ dispatcher: wsEntrypoints[0]?.meta.dispatcher ?? app.get(WsDispatcher),
39
45
  registry,
40
46
  server,
47
+ gatewayPaths: wsEntrypoints.map((ep)=>ep.meta.path),
41
48
  close: (signal)=>app.close(signal)
42
49
  };
43
50
  }
@@ -10,7 +10,8 @@ export declare class DoWebSocketHost {
10
10
  private readonly ctx;
11
11
  private readonly dispatcher;
12
12
  private readonly registry;
13
- constructor(ctx: DoStateLike, dispatcher: WsDispatcher, registry: CfRoomRegistry);
13
+ private readonly gatewayPaths;
14
+ constructor(ctx: DoStateLike, dispatcher: WsDispatcher, registry: CfRoomRegistry, gatewayPaths?: readonly string[]);
14
15
  /** The single registered gateway's path — a fallback when the Worker didn't forward `x-vela-path`. */
15
16
  defaultPath(): string | undefined;
16
17
  /**
@@ -8,13 +8,15 @@ import { connTag, roomTag } from "./room-id.js";
8
8
  ctx;
9
9
  dispatcher;
10
10
  registry;
11
- constructor(ctx, dispatcher, registry){
11
+ gatewayPaths;
12
+ constructor(ctx, dispatcher, registry, gatewayPaths = []){
12
13
  this.ctx = ctx;
13
14
  this.dispatcher = dispatcher;
14
15
  this.registry = registry;
16
+ this.gatewayPaths = gatewayPaths;
15
17
  }
16
18
  /** The single registered gateway's path — a fallback when the Worker didn't forward `x-vela-path`. */ defaultPath() {
17
- return this.dispatcher.gatewayPaths[0];
19
+ return this.gatewayPaths[0];
18
20
  }
19
21
  /**
20
22
  * Accept a hibernatable socket: tag it with its hub room + connection id,
@@ -28,7 +28,7 @@ const PONG = '{"event":"pong"}';
28
28
  }
29
29
  this.ready = ctx.blockConcurrencyWhile(async ()=>{
30
30
  const runtime = await buildDoRuntime(rootModule, ctx, env);
31
- this.host = new DoWebSocketHost(ctx, runtime.dispatcher, runtime.registry);
31
+ this.host = new DoWebSocketHost(ctx, runtime.dispatcher, runtime.registry, runtime.gatewayPaths);
32
32
  });
33
33
  }
34
34
  async fetch(request) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/cloudflare",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Cloudflare Workers integration for Vela framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -45,14 +45,14 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@cloudflare/workers-types": ">=4",
48
- "@velajs/vela": ">=1.10.0",
48
+ "@velajs/vela": ">=1.11.0",
49
49
  "hono": ">=4"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@cloudflare/workers-types": "^4.20260624.1",
53
53
  "@swc/cli": "^0.8.1",
54
54
  "@swc/core": "^1.15.43",
55
- "@velajs/vela": "^1.10.0",
55
+ "@velajs/vela": "^1.12.0",
56
56
  "hono": "^4.12.27",
57
57
  "typescript": "^6.0.3",
58
58
  "unplugin-swc": "^1.5.9",