@velajs/cloudflare 1.28.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.29.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 416650e: Cron triggers run core `@Cron()` jobs through `invokeScheduledJob`, the same primitive as the Node executor: the adapter runs every `@Cron` job whose expression is exactly the trigger string, in a fresh invocation scope, and the trigger settles after every matching job and its `EXECUTION_LIFETIME` work settle. Closing the application aborts the invocation signal of running jobs and waits for them.
8
+
9
+ Add `CLOUDFLARE_SCHEDULED_EVENT`, a request-scoped token seeded into each job's invocation scope. Its `CloudflareScheduledEvent` value carries the trigger's `cron`, `scheduledTime` and a `noRetry()` already bound to the native controller. The token provides itself as request-scoped in every container, so a class that injects it is request-scoped wherever the module graph boots, including a `VelaWebSocketDurableObject`, `vela` CLI commands and `Test.createTestingModule()`, and is constructed per invocation instead of at bootstrap; resolving it outside a scheduled invocation throws. `ScheduledEvent` (the input of `scheduled()`) now also accepts the controller's optional `noRetry`.
10
+
11
+ Signed `ScheduleModule` dispatch now works on Workers: the adapter's invocation transport re-enters the signed route, so its global guards run.
12
+
13
+ The adapter reports schedule declarations a cron trigger cannot honor through the diagnostics policy: a `@Cron` without a dialect whose weekday field has digits or whose day fields are both restricted, `dialect: 'unix'`, `timeZone: 'local'`, `@Interval` jobs, which never run on Workers, and `@UseGuards`, `@UseInterceptors` or `@UseFilters` declared for a cron job. The default `'log'` mode warns once per declaration and never fails the first event; `'throw'` fails bootstrap. `vela deploy check` rejects the cron declarations and `@Interval` jobs before deployment (`ambiguous-cron-dialect`, `incompatible-cron-options`, `unsupported-interval`).
14
+
15
+ The adapter provides `SCHEDULE_INVOCATION_SEED`: a cron job fired outside a trigger, such as by Studio's run-now, receives a synthetic `CLOUDFLARE_SCHEDULED_EVENT` whose `cron` is the job's expression, whose `scheduledTime` is the invocation's, and whose `noRetry()` does nothing.
16
+
17
+ **Behavior change:** `@Scheduled` and `parseScheduledMetadata` are removed, along with the `ScheduledMetadata`, `ScheduledController`, `ScheduledContext` and `ScheduledHandler` types and the `cf:scheduled` and `cf:vela-cron` entrypoint kinds. Replace `@Scheduled(expr)` with `@Cron(expr, { dialect: 'cloudflare' })` from `@velajs/vela`. Cron jobs appear only as `schedule:cron` entrypoints.
18
+
19
+ **Behavior change:** scheduled handlers receive only a `ScheduleInvocation` (`kind`, `expression` equal to the trigger string, `scheduledTime`, `signal`), identical to Node, instead of `(controller, env, ctx)`. Inject `ENV` for bindings, `CLOUDFLARE_SCHEDULED_EVENT` for `noRetry()`, and `EXECUTION_LIFETIME` for `waitUntil()`.
20
+
21
+ **Behavior change:** scheduled jobs no longer run interceptors or filters declared with `@UseInterceptors` or `@UseFilters`, matching the Node executor. A job that declares `@UseGuards` on its class, method or module, whose guards the adapter used to run on each trigger, is now refused instead of running unguarded: the trigger fails, the job is never constructed, and the refusal is reported through the exception reporter (guards do not run for directly dispatched scheduled jobs — use `ScheduleModule.forRoot({ dispatch: { kind: 'signed', ... } })` or remove the guard). Other jobs on the same trigger still run. Queue consumers keep their guards, interceptors and filters. Use signed `ScheduleModule` dispatch to run a job through a route's request pipeline, or remove the guard.
22
+
23
+ **Behavior change:** a `@Cron` job that declares `@UseGuards`, `@UseInterceptors` or `@UseFilters` on its class, method or module is reported through the diagnostics policy, because those components never run for scheduled jobs: the default `'log'` mode warns once and `'throw'` fails bootstrap. Move them to a signed `ScheduleModule` dispatch route.
24
+ - a3e2b38: The Cloudflare runtime seeds the native environment as the framework `ENV`, in the Worker and in every `VelaWebSocketDurableObject`, and types it with the environment `wrangler types` generates: the package augments `VelaEnv` with `Cloudflare.Env`, so `@InjectEnv() env: VelaEnv`, `inject: [ENV]` factories and `registerAs` factories see your bindings, variables and secrets typed. Run `wrangler types` (for example with `--include-runtime=false` alongside `@cloudflare/workers-types`) so `Cloudflare.Env` declares them. The per-environment application cache and the environment identity assertion are unchanged.
25
+
26
+ `createCloudflareWorker` and `createCloudflareApp` accept `adapters: RuntimeAdapter[]`, composed after the Cloudflare adapter for each application, so a Worker entry can stay `export default createCloudflareWorker(AppModule, { adapters: [...] })` without a hand-written per-environment cache.
27
+
28
+ **Behavior change:** the `envToken` option is removed from `createCloudflareWorker`, `createCloudflareApp`, `cloudflareAdapter`, `VelaWebSocketDurableObject` and `buildDoRuntime`, with no alias. Delete the application's environment `InjectionToken` and inject `ENV` from `@velajs/vela` instead: `createCloudflareWorker(AppModule)`, `VelaWebSocketDurableObject(AppModule)`, `cloudflareAdapter({ env })`. `CloudflareApplication` and `CloudflareRoot` are no longer generic; their environment type is `VelaEnv`.
29
+
30
+ **Behavior change:** the `@Env()` parameter decorator is removed. Inject the environment with `@InjectEnv()` in a constructor, or read a binding in a factory with `inject: [ENV]`.
31
+
32
+ **Behavior change:** ENV now carries every binding, variable and secret of the Worker, so framework readers pick up values such as `URL_SIGNING_SECRET` (URL and invocation signing) and `VELA_STUDIO_TOKEN` (Studio) automatically once they are set as variables or secrets. Values come from outside the program: validate each value your code reads before relying on it.
33
+ - 2ae8505: Add the `@velajs/cloudflare/queues` subpath with `cloudflareQueues()`, the Cloudflare Queues driver for `QueueModule`. Configure it once with `QueueModule.forRoot({ driver: cloudflareQueues() })` and register each queue where it is used with `QueueModule.registerQueue({ name: 'email', binding: 'EMAIL_QUEUE' })`. Each application gets its own driver, which reads the registered binding from that application's `ENV` when a job is added, checks that it has `send()`, and awaits the native send. `QueueClient.addBulk` uses `sendBatch`, split into calls of at most 100 messages and an estimated 256 KB; a job estimated over 128 KB is rejected before anything is sent, and a partial failure rejects with a `QueueBatchError` listing the accepted job ids.
34
+
35
+ Native delivery needs no mapping: the Worker's `queue()` handler gives batches that no `@QueueConsumer` claims to `QueueModule`, which routes every job by its logical `queue`, so several registered queues can share one physical queue. Every job goes through the module's dispatch policy, so signed dispatch re-enters the signed route and runs its global guards. A message that is not a job envelope, belongs to an unregistered queue, or fails stays unacknowledged, so Cloudflare retries it and then dead-letters it. `registerQueue({ name, consumer })` pins the queue to that physical queue: its jobs are accepted only from it, and it carries only the queues pinned to it. Bootstrap rejects a physical queue claimed by both `@QueueConsumer` and a pinned registration. A raw `@QueueConsumer` owns its physical queue and must not carry jobs of queues registered with `QueueModule`, which `cloudflareQueues()` delivers: when it receives such job envelopes, which reach their `@Processor` only if the raw handler dispatches them itself, the adapter warns once per physical and logical queue unless diagnostics are silent; the raw consumer still receives and settles the batch.
36
+
37
+ **Behavior change:** `cloudflareQueueDriver(bindings, { consumers, producerBindings })` and the `@velajs/cloudflare/queue` subpath are removed, together with the `CloudflareQueueBindings` and `CloudflareQueueDriverOptions` types. Replace `driver: cloudflareQueueDriver({ email: env.EMAIL_QUEUE }, { producerBindings: { email: 'EMAIL_QUEUE' } })` with `driver: cloudflareQueues()` plus `QueueModule.registerQueue({ name: 'email', binding: 'EMAIL_QUEUE' })`, and replace a `consumers: { 'email-production': 'email' }` mapping with `QueueModule.registerQueue({ name: 'email', consumer: 'email-production' })`, or with a plain `registerQueue({ name: 'email' })` when the physical queue needs no pin.
38
+
39
+ **Behavior change:** `consumeQueueBatch` moves to `@velajs/cloudflare/queues`. It accepts every job envelope by default instead of requiring the job's queue to equal the batch's physical queue; its `queue` option is replaced by `queues`, the list of logical queues to accept.
40
+
41
+ **Behavior change:** the driver publishes one `cf:queue:module` entrypoint per application with `{ consumers }` (the pinned physical queues) instead of one `{ queueName, logicalQueue }` entrypoint per mapping, and the `cf:queue:producer` entrypoint kind is removed: registered queues are published as `queue:registration` entrypoints by `QueueModule`.
42
+
43
+ **Behavior change:** a failure on the native `QueueModule` path is reported once to the exception handler instead of once by its processor and again, with the whole batch rejection, by the adapter; a message that is not a job envelope or belongs to an unregistered queue is still reported once, individually.
44
+ - 8a3016c: **Behavior change:** Workers and Durable Objects are built from static roots only. `createCloudflareWorker`, `createCloudflareApp` and `VelaWebSocketDurableObject` take a module class or a `DynamicModule` declared at module scope; `CloudflareRoot` is now `Type | DynamicModule`. The `{ create(env) }` and async `{ create: async (env) => ... }` roots are removed, with no alias, together with the per-(root, environment) resolution cache. Read bindings where each application is built instead: `Module.forRootAsync({ inject: [ENV], useFactory: (env) => ({ ... }) })`, `useFactory` providers that inject `ENV`, or `@InjectEnv()` constructors. These run for each application, so nothing built from one environment is shared with another, and constructing another application or Durable Object instance declares no new classes in the isolate. The per-environment application cache of `createCloudflareWorker` is unchanged.
45
+
46
+ WebSocket upgrade routes authenticate with the gateway's `authenticator`, resolved once per application from the module that declares the gateway, and read an `(env) => origins` allowlist from the Worker's `ENV`. Authentication still completes before the Durable Object id is derived, and client-supplied `x-vela-*` headers are still stripped first. `UpgradeAuthenticator`, `WebSocketUpgradeIdentity` and `WebSocketUpgradeAuthenticationContext` are re-exported from the package root.
47
+
48
+ `WsGatewayRoute` gains an optional `moduleId`: the module that declares the gateway, from which its authenticator resolves.
49
+ - 864735d: **Behavior change:** a WebSocket Durable Object now refuses to start when its module registers the core `WebSocketModule` instead of `CloudflareWebSocketModule`. The core module's `WS_SERVER` broadcasts through its own sync driver, which never reaches the Durable Object's sockets, so `@WebSocketServer()` pushes were silently lost. Import `CloudflareWebSocketModule.forRoot()` in modules a `VelaWebSocketDurableObject` bootstraps.
50
+
51
+ The Worker adapter now warns once per isolate when `LiveModule` runs the default `localLive()` driver in the Worker, whose invalidations never reach subscriptions held by the Durable Object. Pass `driver: () => durableObjectLive({ namespace, gatewayPath })`. The warning respects the `'silent'` diagnostics mode.
52
+
53
+ ### Patch Changes
54
+
55
+ - a01273b: A queue batch that no consumer claims now rejects with guidance: the error names the physical queue, points to `@QueueConsumer(name)` or `QueueModule.forRoot({ driver: cloudflareQueues() })` with a `QueueModule.registerQueue()` for each queue the batch carries, and states that the unacknowledged batch is retried and then dead-lettered by Cloudflare.
56
+ - e4f2008: A Durable Object WebSocket whose `handleConnection` hook broadcasts to its room, for example `server.emit('system', { text: 'joined' })`, is now admitted. The broadcast reached the still-pending socket and rejected it, so every such upgrade failed with "Unable to persist authorized WebSocket state". Broadcasts now skip a socket while its connection hook runs and deliver to the room's active sockets; a pending socket that is not being admitted is still closed with 1008. When a socket is rejected while its hook runs, the error now says so.
57
+ - Updated dependencies [07d1713]
58
+ - Updated dependencies [db18d3a]
59
+ - Updated dependencies [07d1713]
60
+ - Updated dependencies [4071cb7]
61
+ - Updated dependencies [bacaacd]
62
+ - Updated dependencies [a814199]
63
+ - Updated dependencies [1838474]
64
+ - Updated dependencies [8a3016c]
65
+ - Updated dependencies [d803a49]
66
+ - Updated dependencies [b235935]
67
+ - Updated dependencies [08a81c8]
68
+ - Updated dependencies [5b5b81d]
69
+ - Updated dependencies [7daf4fc]
70
+ - Updated dependencies [35e8e0d]
71
+ - Updated dependencies [4420501]
72
+ - Updated dependencies [ff44b6a]
73
+ - Updated dependencies [6d4f0c0]
74
+ - Updated dependencies [e3bda2a]
75
+ - Updated dependencies [bd7e3c9]
76
+ - Updated dependencies [2b74880]
77
+ - Updated dependencies [5ba8635]
78
+ - Updated dependencies [db0c834]
79
+ - Updated dependencies [d6f6a65]
80
+ - Updated dependencies [8a3016c]
81
+ - Updated dependencies [d5a3ec8]
82
+ - Updated dependencies [0f7e8e7]
83
+ - Updated dependencies [41ec70d]
84
+ - Updated dependencies [b265297]
85
+ - Updated dependencies [bdfff47]
86
+ - Updated dependencies [28c7d07]
87
+ - Updated dependencies [8a3016c]
88
+ - Updated dependencies [44efdde]
89
+ - @velajs/vela@1.29.0
90
+ - @velajs/feature-flags@1.29.0
91
+
3
92
  ## 1.28.0
4
93
 
5
94
  ### Minor Changes
package/README.md CHANGED
@@ -6,26 +6,27 @@ platform's native types.
6
6
 
7
7
  ## Native environment and application lifetime
8
8
 
9
- Define one typed token for your generated Workers environment. Inject that token
10
- wherever bindings or secrets are needed, including async provider factories.
9
+ The Worker's native environment is the framework `ENV` from `@velajs/vela`.
10
+ `createCloudflareWorker` seeds it for each environment before any provider is
11
+ constructed, so the Worker entry only exports. Inject it wherever bindings or
12
+ secrets are needed, including async provider factories (`inject: [ENV]`).
13
+
14
+ Types come from Wrangler. Run `wrangler types --include-runtime=false` (runtime
15
+ types stay with `@cloudflare/workers-types`) and include the generated
16
+ `worker-configuration.d.ts` in your tsconfig. It declares `Cloudflare.Env` from
17
+ the bindings and variables in your Wrangler file and the secret names in
18
+ `.dev.vars`; this package extends `VelaEnv` with it, so `ENV`, `forRootAsync`
19
+ factories and `registerAs` factories are typed without a hand-written interface.
20
+ Regenerate it whenever the Wrangler file changes.
11
21
 
12
22
  ```ts
13
- import { Controller, Get, Inject, InjectionToken, Module } from '@velajs/vela';
23
+ import { Controller, Get, InjectEnv, Module, type VelaEnv } from '@velajs/vela';
14
24
  import { createCloudflareWorker } from '@velajs/cloudflare';
15
25
 
16
- interface WorkerEnv {
17
- CACHE: KVNamespace;
18
- DB: D1Database;
19
- FILES: R2Bucket;
20
- JOBS: Queue<{ taskId: string }>;
21
- SERVICE_NAME: string;
22
- APP_SECRET: string;
23
- }
24
- export const ENV = new InjectionToken<WorkerEnv>('Worker environment');
25
-
26
26
  @Controller('/status')
27
27
  class StatusController {
28
- constructor(@Inject(ENV) private readonly env: WorkerEnv) {}
28
+ // CACHE is a KVNamespace in worker-configuration.d.ts.
29
+ constructor(@InjectEnv() private readonly env: VelaEnv) {}
29
30
 
30
31
  @Get()
31
32
  async status() {
@@ -36,7 +37,7 @@ class StatusController {
36
37
  @Module({ controllers: [StatusController] })
37
38
  class AppModule {}
38
39
 
39
- export default createCloudflareWorker(AppModule, { envToken: ENV });
40
+ export default createCloudflareWorker(AppModule);
40
41
  ```
41
42
 
42
43
  The worker exposes `fetch`, `queue`, and `scheduled`. Its first event builds an
@@ -45,37 +46,57 @@ environment object share construction. Different environment objects receive
45
46
  separate applications, including separate providers, lifecycle state, and live
46
47
  drivers. A failed construction is evicted and the next event retries.
47
48
 
48
- When module configuration itself needs bindings, pass `{ create: (env) => AppModule }`
49
- instead of a static class. Dynamic module roots and asynchronous factories are also
50
- supported. The callback receives the native environment inferred
51
- from `envToken` and runs once per successful environment bootstrap. The same form
52
- works with `VelaWebSocketDurableObject` for authenticated live gateways. See the
49
+ The root is static: a module class, or a `DynamicModule` such as
50
+ `AppModule.forRoot(...)`, declared once at module scope. `createCloudflareWorker`,
51
+ `createCloudflareApp` and `VelaWebSocketDurableObject` all take the same root.
52
+ When module configuration needs bindings, read them where each application is
53
+ built, from its own `ENV`:
54
+
55
+ ```ts
56
+ import { ENV, Module } from '@velajs/vela';
57
+
58
+ @Module({
59
+ imports: [
60
+ DatabaseModule.forRootAsync({
61
+ inject: [ENV],
62
+ useFactory: (env) => ({ database: env.DB }),
63
+ }),
64
+ ],
65
+ })
66
+ class AppModule {}
67
+ ```
68
+
69
+ `forRootAsync` factories, `useFactory` providers, `@InjectEnv()` constructors and
70
+ queue driver factories such as `cloudflareQueues()` run for each application, so
71
+ nothing built from one environment is shared with another. Because the root never
72
+ changes, building another application or Durable Object instance declares no new
73
+ classes in the isolate. See the
53
74
  [complete API starter](../../apps/api-starter/README.md) for D1, Better Auth, CRUD,
54
75
  the generated Hono client, live updates, and Studio inspection in one application.
55
76
 
56
- The cache uses weak object keys: it does not permanently retain replaced
57
- environments or secrets. Providers with request scope still rebuild per HTTP
58
- request or queue/cron dispatch. Do not retain request objects or authentication
59
- state in singleton providers.
77
+ The application cache uses weak object keys, so the cache itself does not keep a
78
+ replaced environment alive. Build secret-bearing values in `forRootAsync`
79
+ factories that inject `ENV` rather than capturing them in module options.
80
+ Providers with request scope still rebuild per HTTP request or queue/cron dispatch.
81
+ Do not retain request objects or authentication state in singleton providers.
60
82
 
61
83
  For explicit construction inside a platform event:
62
84
 
63
85
  ```ts
64
86
  const app = await createCloudflareApp(AppModule, {
65
87
  env,
66
- envToken: ENV,
67
88
  globalPrefix: '/api',
68
89
  middleware: (bindings) => [async (context, next) => {
69
90
  context.header('x-service', bindings.SERVICE_NAME);
70
91
  await next();
71
92
  }],
72
93
  });
73
- const bindings = app.get(ENV); // WorkerEnv, inferred from ENV
94
+ const bindings = app.get(ENV); // VelaEnv
74
95
  return app.fetch(request, env, executionContext);
75
96
  ```
76
97
 
77
- `env` is registered before provider factories and lifecycle hooks. Referencing a
78
- binding inside `middleware(env)` is typed from that same token; request callbacks
98
+ `env` is registered as `ENV` before provider factories and lifecycle hooks.
99
+ Bindings inside `middleware(env)` are typed as `VelaEnv` too; request callbacks
79
100
  capture the native environment without retyping Hono's context. Referencing a
80
101
  binding is safe during construction; platform I/O must still happen inside a
81
102
  Workers event or Durable Object context. An explicitly built application rejects
@@ -83,29 +104,114 @@ requests or events carrying another environment object, including calls through
83
104
  the underlying Hono app. Internal `ctx.run` reentry retains the application's
84
105
  environment.
85
106
 
86
- `cloudflareAdapter({ env, envToken })` provides the same bootstrap and request
87
- contract when composing `VelaFactory.create` directly.
107
+ `cloudflareAdapter({ env })` provides the same bootstrap and request contract
108
+ when composing `VelaFactory.create` directly. `createCloudflareWorker` and
109
+ `createCloudflareApp` accept `adapters: RuntimeAdapter[]`, composed after the
110
+ Cloudflare adapter for each application, so the Worker entry needs no
111
+ hand-written per-environment cache for them.
112
+
113
+ Because `ENV` carries every binding, variable and secret, framework features
114
+ read their secrets from it without extra wiring: a string `URL_SIGNING_SECRET`
115
+ signs URLs and invocations when no explicit secret is configured, and Studio
116
+ reads `VELA_STUDIO_TOKEN` and its `VELA_STUDIO_*_EDITABLE` flags. Set them with
117
+ `wrangler secret put`. Values come from outside the program, so validate each
118
+ value your own code reads before relying on it.
88
119
 
89
120
  ## Module-based queues, cron and RPC
90
121
 
91
- Import `QueueModule` and configure `cloudflareQueueDriver` consumer mappings to
92
- connect native batches directly to `@Processor`/`@Process` providers. Use
93
- `ScheduleModule.forRoot()` and `@Cron()` for native scheduled work. The
94
- [module guide](../../docs/module-workers.md) covers producer-only and consumer-only
95
- Workers, RPC modules, migration, and deployment checks. Native decorators remain
96
- available as escape hatches.
122
+ `QueueModule` from `@velajs/vela/queue` is the Workers queue API. Configure the
123
+ driver once in the root module and register each queue where it is used:
97
124
 
98
- ## Managed queue and cron work
125
+ ```ts
126
+ import { Injectable, Module } from '@velajs/vela';
127
+ import {
128
+ InjectQueue,
129
+ Process,
130
+ Processor,
131
+ QueueModule,
132
+ defineQueueJob,
133
+ type QueueClient,
134
+ type QueueJob,
135
+ } from '@velajs/vela/queue';
136
+ import { cloudflareQueues } from '@velajs/cloudflare/queues';
137
+ import { z } from 'zod';
138
+
139
+ const welcome = defineQueueJob('welcome', z.object({ userId: z.string() }));
140
+
141
+ @Injectable()
142
+ class Signup {
143
+ constructor(@InjectQueue('email') private readonly email: QueueClient) {}
144
+ invite(userId: string) {
145
+ return this.email.add(welcome, { userId });
146
+ }
147
+ }
148
+
149
+ @Processor('email')
150
+ class EmailProcessor {
151
+ @Process(welcome)
152
+ send(job: QueueJob<{ userId: string }>) {}
153
+ }
154
+
155
+ @Module({
156
+ imports: [QueueModule.registerQueue({ name: 'email', binding: 'EMAIL_QUEUE' })],
157
+ providers: [Signup, EmailProcessor],
158
+ })
159
+ class EmailModule {}
160
+
161
+ @Module({ imports: [QueueModule.forRoot({ driver: cloudflareQueues() }), EmailModule] })
162
+ class AppModule {}
163
+ ```
164
+
165
+ `binding` names a Wrangler `queues.producers[].binding`. The driver reads it
166
+ from the application's `ENV` when a job is added and awaits the native send.
167
+ `addBulk` uses `sendBatch`, split into calls of at most 100 messages and an
168
+ estimated 256 KB, and rejects a job estimated over 128 KB before sending
169
+ anything. A partial failure rejects with a `QueueBatchError` whose `accepted`
170
+ lists the job ids already sent.
171
+
172
+ The Worker's `queue()` handler gives each batch to the `@QueueConsumer` handlers
173
+ of its physical queue. Batches no `@QueueConsumer` claims go to `QueueModule`,
174
+ which routes every job by its logical queue, so several registered queues may
175
+ share one physical queue. Each job runs through the module's dispatch policy,
176
+ including signed dispatch and its global guards. A message is acknowledged
177
+ after its processors succeed; a message that is not a job envelope, belongs to
178
+ an unregistered queue, or fails stays unacknowledged, so Cloudflare retries it
179
+ and then dead-letters it. `registerQueue({ name, consumer: 'email-production' })`
180
+ pins the queue to that physical queue: its jobs are accepted only from it, and
181
+ it carries only the queues pinned to it. A physical queue cannot be both a
182
+ `@QueueConsumer` queue and a pinned consumer. A `@QueueConsumer` owns its
183
+ physical queue and must not carry jobs of registered queues: those reach their
184
+ `@Processor` only if the raw handler dispatches them itself, so the adapter
185
+ warns once when it sees them. Registered queues are delivered by
186
+ `cloudflareQueues()`. `dispatchQueueJob` is for tests and for transports other
187
+ than Cloudflare Queues; it applies the module's dispatch policy, signed dispatch
188
+ included.
189
+
190
+ Use `ScheduleModule.forRoot()` and `@Cron()` for native scheduled work. The
191
+ [queue guide](../../docs/queues.md) and [module guide](../../docs/module-workers.md)
192
+ cover producer-only and consumer-only Workers, RPC modules and deployment
193
+ checks. `@QueueConsumer` remains available for raw batches.
99
194
 
100
- Each matching queue/cron handler receives its original event and environment,
101
- plus a context whose `waitUntil(promise)` delegates to the platform and retains
102
- that handler's DI scope until the promise settles. Class/method guards,
103
- interceptors and filters resolve asynchronously from the handler's declaring
104
- module. The execution context exposes that same child via `getContainer()` and
105
- its owner via `getModuleId()`; `REQUEST_CONTEXT` remains HTTP-only.
195
+ ## Managed queue and cron work
106
196
 
107
- Inject `EXECUTION_LIFETIME` from `@velajs/vela` to schedule deferred callbacks
108
- with `lifetime.defer(work)` or register already-started work with
197
+ Each matching `@QueueConsumer` handler receives its batch and environment, plus
198
+ a context whose `waitUntil(promise)` delegates to the platform and retains that
199
+ handler's DI scope until the promise settles. Class/method guards, interceptors
200
+ and filters resolve asynchronously from the handler's declaring module. The
201
+ execution context exposes that same child via `getContainer()` and its owner via
202
+ `getModuleId()`; `REQUEST_CONTEXT` remains HTTP-only.
203
+
204
+ A `@Cron` job receives only its `CronInvocation`, with no environment or
205
+ context argument, and runs no guards, interceptors or filters: the adapter warns
206
+ once (fails bootstrap in `diagnostics: 'throw'`) when a job declares
207
+ `@UseGuards`, `@UseInterceptors` or `@UseFilters`, and a job that declares
208
+ guards is refused on every trigger instead of running unguarded. Use signed
209
+ `ScheduleModule` dispatch to run a job through a route's request pipeline, and
210
+ inject `ENV`, `CLOUDFLARE_SCHEDULED_EVENT` and `EXECUTION_LIFETIME` for what the
211
+ native handler arguments used to carry.
212
+
213
+ In both, inject `EXECUTION_LIFETIME` from `@velajs/vela` to schedule deferred
214
+ callbacks with `lifetime.defer(work)` or register already-started work with
109
215
  `lifetime.waitUntil(promise)`. The handler, managed work and asynchronous provider
110
216
  disposal finish before queue/cron dispatch returns. Unclaimed failures reject
111
217
  for the platform to observe; they are not silently converted into success. When
@@ -122,7 +228,7 @@ Bindings retain their full native API and generic parameters. There are no
122
228
  binding-name wrappers to initialize or cast.
123
229
 
124
230
  ```ts
125
- import { defineProvider, InjectionToken, Module } from '@velajs/vela';
231
+ import { defineProvider, ENV, InjectionToken, Module } from '@velajs/vela';
126
232
 
127
233
  const TASK_QUEUE = new InjectionToken<Queue<{ taskId: string }>>('task queue');
128
234
 
@@ -136,27 +242,29 @@ const TASK_QUEUE = new InjectionToken<Queue<{ taskId: string }>>('task queue');
136
242
  class JobsModule {}
137
243
  ```
138
244
 
139
- Every `useFactory` strategy declares its dependencies with `inject`, including
140
- `inject: []` for factories without dependencies. This also applies to
141
- `lazyProvider` and `forRootAsync` factory options.
245
+ A `useFactory` strategy declares its dependencies with `inject`; a factory
246
+ without parameters may omit it. This also applies to `lazyProvider` and
247
+ `forRootAsync` factory options.
142
248
 
143
249
  Use native `env.DB`, `env.CACHE`, `env.FILES`, `env.JOBS`, `env.AI`,
144
- `env.VECTORIZE`, or `env.HYPERDRIVE` directly. `@Env()` remains available for HTTP
145
- handler parameters; typed token injection also works outside HTTP.
250
+ `env.VECTORIZE`, or `env.HYPERDRIVE` directly. Inject `ENV` in constructors
251
+ (`@InjectEnv()`) and factories (`inject: [ENV]`); it works the same in HTTP,
252
+ queue, cron and Durable Object code.
146
253
 
147
254
  ## Queues and cron
148
255
 
149
256
  ```ts
150
- import { Inject, Injectable } from '@velajs/vela';
151
- import { QueueConsumer, Scheduled } from '@velajs/cloudflare';
257
+ import { Cron, InjectEnv, Injectable, type CronInvocation, type VelaEnv } from '@velajs/vela';
258
+ import { QueueConsumer } from '@velajs/cloudflare';
152
259
 
153
260
  @Injectable()
154
261
  class Jobs {
155
- constructor(@Inject(ENV) private readonly env: WorkerEnv) {}
262
+ constructor(@InjectEnv() private readonly env: VelaEnv) {}
156
263
 
157
- @Scheduled('0 * * * *')
158
- async refresh() {
159
- await this.env.CACHE.put('last-refresh', new Date().toISOString());
264
+ // Declare the same string under Wrangler `triggers.crons`.
265
+ @Cron('0 * * * *', { dialect: 'cloudflare' })
266
+ async refresh(tick: CronInvocation) {
267
+ await this.env.CACHE.put('last-refresh', new Date(tick.scheduledTime).toISOString());
160
268
  }
161
269
 
162
270
  @QueueConsumer('jobs')
@@ -168,17 +276,24 @@ class Jobs {
168
276
  }
169
277
  ```
170
278
 
171
- Core `@Cron()` also runs on Workers scheduled triggers. Consumers use fresh
172
- request scopes and their declared guards, interceptors, and filters. Unclaimed
173
- errors propagate to the platform for retry. Cold queue and cron events have the
174
- same native bindings and live invalidation capabilities as HTTP.
279
+ A cron trigger runs every core `@Cron()` job whose expression is exactly the
280
+ trigger string. Jobs receive only their `CronInvocation`, as on Node, in a fresh
281
+ request scope and without guards, interceptors or filters; inject
282
+ `CLOUDFLARE_SCHEDULED_EVENT` for the trigger's bound `noRetry()` and
283
+ `EXECUTION_LIFETIME` for background work. A job run outside a trigger (Studio's
284
+ run-now) receives a synthetic event whose `noRetry()` does nothing. Signed `ScheduleModule` dispatch runs
285
+ the signed route with its global guards. Queue consumers use fresh request
286
+ scopes and their declared guards, interceptors, and filters. Unclaimed errors
287
+ propagate to the platform for retry. Cold queue and cron events have the same
288
+ native bindings and live invalidation capabilities as HTTP. See
289
+ [scheduling](../../docs/scheduling.md).
175
290
 
176
291
  ## WebSockets, live queries, and Durable Objects
177
292
 
178
293
  Use the native Durable Object entrypoint only in your Worker entry file:
179
294
 
180
295
  ```ts
181
- import { InjectionToken, Module } from '@velajs/vela';
296
+ import { ENV, Module } from '@velajs/vela';
182
297
  import { LiveModule } from '@velajs/vela/live';
183
298
  import {
184
299
  CloudflareWebSocketModule,
@@ -188,14 +303,12 @@ import {
188
303
  } from '@velajs/cloudflare';
189
304
  import { VelaWebSocketDurableObject } from '@velajs/cloudflare/durable-objects';
190
305
 
191
- interface RoomEnv { ROOMS: DurableObjectNamespace<Room> }
192
- const ROOM_ENV = new InjectionToken<RoomEnv>('room environment');
193
-
194
306
  @Module({
195
307
  imports: [
196
308
  CloudflareWebSocketModule.forRoot(),
197
309
  LiveModule.forRootAsync({
198
- inject: [ROOM_ENV],
310
+ // ROOMS is typed DurableObjectNamespace<Room> by `wrangler types`.
311
+ inject: [ENV],
199
312
  useFactory: (env) => ({
200
313
  driver: () => durableObjectLive({
201
314
  namespace: env.ROOMS,
@@ -210,12 +323,19 @@ const ROOM_ENV = new InjectionToken<RoomEnv>('room environment');
210
323
  })
211
324
  class RoomModule {}
212
325
 
213
- export class Room extends VelaWebSocketDurableObject(RoomModule, { envToken: ROOM_ENV }) {}
214
- export default createCloudflareWorker(RoomModule, { envToken: ROOM_ENV });
326
+ export class Room extends VelaWebSocketDurableObject(RoomModule) {}
327
+ export default createCloudflareWorker(RoomModule);
215
328
  ```
216
329
 
217
330
  Declare gateways with `@WebSocketGateway({ path, roomParam, binding, ... })` and
218
- configure origins and upgrade authentication for your application. Upgrade
331
+ configure origins and upgrade authentication for your application.
332
+ `authenticator` names an `UpgradeAuthenticator` class that the Worker resolves
333
+ once per application from the module declaring the gateway, and
334
+ `allowedOrigins` may read the environment: `(env) => [env.APP_ORIGIN]`.
335
+ `BetterAuthUpgradeAuthenticator` (`@velajs/better-auth`) and
336
+ `CloudflareAccessUpgradeAuthenticator` (`@velajs/cloudflare-access/vela`) are
337
+ ready-made authenticators. A gateway without an authenticator refuses every
338
+ upgrade. Authentication finishes before the Durable Object id is derived. Upgrade
219
339
  routing consumes the core trusted request identity, checks conflicts with the
220
340
  upgrade credential, and forwards issuer, subject, tenant, and expiry to the DO.
221
341
  Client-supplied internal identity headers are stripped before authorization.
@@ -1,24 +1,25 @@
1
- import { l as VelaDoPitrRpc, p as CloudflareRoot, t as VelaNonceDurableObject } from "./nonce.durable-object-DffHMRnU.js";
2
- import { InjectionToken } from "@velajs/vela";
1
+ import "./vela-env-DFvyoNT3.js";
2
+ import { l as VelaDoPitrRpc, p as CloudflareRoot, t as VelaNonceDurableObject } from "./nonce.durable-object-Df4CZi-0.js";
3
+ import { VelaEnv } from "@velajs/vela";
3
4
  import { BroadcastCommand } from "@velajs/vela/websocket";
4
5
  import { CommitStamp, InvalidationCommand, LiveInspection } from "@velajs/vela/live";
5
6
  import { DurableObject } from "cloudflare:workers";
6
7
  //#region src/websocket/websocket.durable-object.d.ts
7
8
  /**
8
9
  * Base class for the WebSocket Durable Object. The user exports a named subclass
9
- * (matching their `wrangler.toml` `class_name`) built from their `AppModule`:
10
+ * (matching their `wrangler.toml` `class_name`) built from their `AppModule`, or
11
+ * from a `DynamicModule` declared at module scope:
10
12
  *
11
13
  * ```ts
12
- * export class ChatRoom extends VelaWebSocketDurableObject(AppModule, { envToken: ENV }) {}
14
+ * export class ChatRoom extends VelaWebSocketDurableObject(AppModule) {}
13
15
  * ```
14
16
  *
15
17
  * It owns the raw hibernation socket lifecycle (Hono's `upgradeWebSocket` cannot
16
18
  * bridge DO hibernation) and forwards every event into the runtime-agnostic
17
- * `WsDispatcher` via {@link DoWebSocketHost}.
19
+ * `WsDispatcher` via {@link DoWebSocketHost}. The DO's `env` is the
20
+ * application's ENV, as in the Worker.
18
21
  */
19
- export declare function VelaWebSocketDurableObject<T extends object>(rootModule: CloudflareRoot<NoInfer<T>>, options: {
20
- envToken: InjectionToken<T>;
21
- }): new (ctx: DurableObjectState, env: T) => DurableObject<T> & VelaDoPitrRpc & {
22
+ export declare function VelaWebSocketDurableObject(rootModule: CloudflareRoot): new (ctx: DurableObjectState, env: VelaEnv) => DurableObject<VelaEnv> & VelaDoPitrRpc & {
22
23
  broadcast(cmd: BroadcastCommand): Promise<void>;
23
24
  invalidate(cmd: InvalidationCommand): Promise<CommitStamp | undefined>;
24
25
  inspectLive(): Promise<LiveInspection>;
@@ -1,4 +1,4 @@
1
- import { S as connTag, _ as readDoPitrBookmark, d as rejectedAttachment, f as socketAttachment, h as armDoPitr, l as CfWsClient, n as isValidExpiry, o as initDoLive, p as WsServerHolder, s as initializeDoLiveResources, t as isCanonicalBoundedText, w as roomTag, x as registerCloudflareEnvironment, y as resolveCloudflareRoot } from "./nonce-validation-CeVihShV.js";
1
+ import { S as connTag, a as armDoPitr, f as initDoLive, g as CfWsClient, n as isValidExpiry, p as initializeDoLiveResources, r as WsServerHolder, s as readDoPitrBookmark, t as isCanonicalBoundedText, v as rejectedAttachment, w as roomTag, x as registerCloudflareEnvironment, y as socketAttachment } from "./nonce-validation-Dy8z05A9.js";
2
2
  import { VelaApplication, bootstrap } from "@velajs/vela";
3
3
  import { WS_SERVER, WebSocketSendGate, WsDispatcher, WsMessageQueue, WsServerImpl, assertBroadcastCommandFits, local, readWsEntrypointMeta } from "@velajs/vela/websocket";
4
4
  import { DurableObject } from "cloudflare:workers";
@@ -12,6 +12,7 @@ import { DurableObject } from "cloudflare:workers";
12
12
  var CfRoomRegistry = class {
13
13
  ctx;
14
14
  #sendGates = /* @__PURE__ */ new WeakMap();
15
+ #admitting = /* @__PURE__ */ new WeakSet();
15
16
  #sendPolicyForPath;
16
17
  setSendPolicyResolver(resolve) {
17
18
  this.#sendPolicyForPath = resolve;
@@ -28,6 +29,19 @@ var CfRoomRegistry = class {
28
29
  setFrameLimitResolver(resolver) {
29
30
  this.frameLimitForPath = resolver;
30
31
  }
32
+ /**
33
+ * Run `admit` (the connection hook) with `ws` marked as being admitted, so a
34
+ * broadcast issued meanwhile (for example `server.emit()` announcing the new
35
+ * connection) skips the still-pending socket instead of rejecting it.
36
+ */
37
+ async admitting(ws, admit) {
38
+ this.#admitting.add(ws);
39
+ try {
40
+ return await admit();
41
+ } finally {
42
+ this.#admitting.delete(ws);
43
+ }
44
+ }
31
45
  register(_client) {}
32
46
  leaveAll(_client) {}
33
47
  join(client, room) {
@@ -47,6 +61,7 @@ var CfRoomRegistry = class {
47
61
  const selected = [];
48
62
  for (const ws of targets) {
49
63
  const att = this.reconcileFrameLimit(ws, this.attachmentOf(ws));
64
+ if (att.state === "pending" && this.#admitting.has(ws)) continue;
50
65
  if (att.state !== "active" || att.expiresAtMs !== void 0 && (!Number.isSafeInteger(att.expiresAtMs) || att.expiresAtMs <= Date.now())) {
51
66
  try {
52
67
  att.state = "rejected";
@@ -191,8 +206,8 @@ var DoWebSocketHost = class {
191
206
  }
192
207
  const client = this.registry.clientFor(ws);
193
208
  try {
194
- await this.dispatcher.handleOpen(path, client);
195
- if (!this.transition(ws, "active", "pending")) throw new Error("Unable to persist authorized WebSocket state");
209
+ await this.registry.admitting(ws, () => this.dispatcher.handleOpen(path, client));
210
+ if (!this.transition(ws, "active", "pending")) throw new Error(socketAttachment(ws)?.state === "rejected" ? "WebSocket was rejected while OnGatewayConnection was running: a frame arrived before the connection hook completed, or the connection closed or failed" : "Unable to persist authorized WebSocket state");
196
211
  return true;
197
212
  } catch (err) {
198
213
  await this.dispatcher.handleError(path, client, err).catch(() => {});
@@ -284,16 +299,13 @@ var DoWebSocketHost = class {
284
299
  /**
285
300
  * Slim DI bootstrap for the Durable Object isolate: wires the container and runs
286
301
  * `OnModuleInit`/`OnApplicationBootstrap` (so `WsDispatcher` discovers gateways)
287
- * WITHOUT building the Hono app/routes the DO never serves. Cloudflare binding
288
- * refs are initialized straight from the DO's `env`, and the ctx-backed server
302
+ * WITHOUT building the Hono app/routes the DO never serves. The DO's `env` is
303
+ * seeded as the global ENV before providers construct, and the ctx-backed server
289
304
  * is bound before bootstrap lifecycle so gateway `afterInit`/handlers see it.
290
305
  */
291
306
  async function buildDoRuntime(rootModule, ctx, options) {
292
307
  const { container, routeManager, loader } = await bootstrap(rootModule, { configureContainer: (container) => {
293
- registerCloudflareEnvironment(container, {
294
- token: options.envToken,
295
- env: options.env
296
- });
308
+ registerCloudflareEnvironment(container, options.env);
297
309
  } });
298
310
  const registry = new CfRoomRegistry(ctx);
299
311
  const driver = local();
@@ -301,7 +313,8 @@ async function buildDoRuntime(rootModule, ctx, options) {
301
313
  const server = new WsServerImpl(driver);
302
314
  if (container.has(WS_SERVER)) {
303
315
  const holder = await container.resolveAsync(WS_SERVER);
304
- if (holder instanceof WsServerHolder) holder.setTarget(server);
316
+ if (!(holder instanceof WsServerHolder)) throw new Error("[vela] The WebSocket Durable Object found a WS_SERVER from the core WebSocketModule, which cannot reach Durable Object sockets. On Cloudflare, import CloudflareWebSocketModule.forRoot() instead of WebSocketModule.forRoot().");
317
+ holder.setTarget(server);
305
318
  }
306
319
  const app = new VelaApplication(container, routeManager);
307
320
  app.setInstances(await loader.resolveAllInstances());
@@ -337,17 +350,19 @@ function isIdentityField(value) {
337
350
  }
338
351
  /**
339
352
  * Base class for the WebSocket Durable Object. The user exports a named subclass
340
- * (matching their `wrangler.toml` `class_name`) built from their `AppModule`:
353
+ * (matching their `wrangler.toml` `class_name`) built from their `AppModule`, or
354
+ * from a `DynamicModule` declared at module scope:
341
355
  *
342
356
  * ```ts
343
- * export class ChatRoom extends VelaWebSocketDurableObject(AppModule, { envToken: ENV }) {}
357
+ * export class ChatRoom extends VelaWebSocketDurableObject(AppModule) {}
344
358
  * ```
345
359
  *
346
360
  * It owns the raw hibernation socket lifecycle (Hono's `upgradeWebSocket` cannot
347
361
  * bridge DO hibernation) and forwards every event into the runtime-agnostic
348
- * `WsDispatcher` via {@link DoWebSocketHost}.
362
+ * `WsDispatcher` via {@link DoWebSocketHost}. The DO's `env` is the
363
+ * application's ENV, as in the Worker.
349
364
  */
350
- function VelaWebSocketDurableObject(rootModule, options) {
365
+ function VelaWebSocketDurableObject(rootModule) {
351
366
  return class VelaWsDurableObject extends DurableObject {
352
367
  host;
353
368
  liveEngine;
@@ -358,10 +373,7 @@ function VelaWebSocketDurableObject(rootModule, options) {
358
373
  ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(PING, PONG));
359
374
  } catch {}
360
375
  this.ready = ctx.blockConcurrencyWhile(async () => {
361
- const runtime = await buildDoRuntime(await resolveCloudflareRoot(rootModule, env), ctx, {
362
- ...options,
363
- env
364
- });
376
+ const runtime = await buildDoRuntime(rootModule, ctx, { env });
365
377
  this.host = new DoWebSocketHost(ctx, runtime.dispatcher, runtime.registry, runtime.gatewayPaths);
366
378
  this.liveEngine = runtime.live;
367
379
  });