nuxt-cf-jobs 0.4.0 โ†’ 0.4.2

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/README.md CHANGED
@@ -1,14 +1,31 @@
1
- # nuxt-cf-jobs
2
-
3
- Typed Cloudflare Queue jobs for Nuxt.
4
-
5
- `nuxt-cf-jobs` scans your Nuxt server job files, generates a typed registry, and gives you small runtime helpers for:
6
-
7
- - building typed queue payloads
8
- - sending jobs to Cloudflare Queue bindings
9
- - consuming Cloudflare queue batches through Nitro hooks
10
- - persisting durable jobs in D1
11
- - testing queues without Cloudflare
1
+ <h1>nuxt-cf-jobs</h1>
2
+
3
+ [![npm version][npm-version-src]][npm-version-href]
4
+ [![npm downloads][npm-downloads-src]][npm-downloads-href]
5
+ [![License][license-src]][license-href]
6
+ [![Nuxt][nuxt-src]][nuxt-href]
7
+
8
+ Typed Cloudflare Queue jobs for Nuxt, with Laravel-style ergonomics.
9
+
10
+ <p align="center">
11
+ <table>
12
+ <tbody>
13
+ <td align="center">
14
+ <sub>Made possible by my <a href="https://github.com/sponsors/harlan-zw">Sponsor Program ๐Ÿ’–</a><br> Follow me <a href="https://twitter.com/harlan_zw">@harlan_zw</a> ๐Ÿฆ โ€ข Join <a href="https://discord.gg/275MBUBvgP">Discord</a> for help</sub><br>
15
+ </td>
16
+ </tbody>
17
+ </table>
18
+ </p>
19
+
20
+ ## Features
21
+
22
+ - ๐Ÿ“ **File-based jobs**: drop a `defineJob` in `server/jobs`, get a generated typed registry, no central list to maintain.
23
+ - ๐Ÿ”’ **End-to-end types**: `JobName` and `JobPayload` are inferred per job, so unknown names and wrong payloads fail at compile time.
24
+ - โ˜๏ธ **Cloudflare Queues**: send to queue bindings and consume batches through a Nitro hook, with per-queue routing.
25
+ - ๐Ÿ—„๏ธ **Durable D1 jobs**: persist a record before enqueue so work survives restarts and delivery gaps, with retry, release, and DLQ.
26
+ - โฐ **Scheduled tasks**: co-locate a cron with its handler; `nitro.tasks`, `scheduledTasks`, and Cloudflare `triggers.crons` are derived from it.
27
+ - ๐Ÿงช **Laravel-style testing**: run handlers inline, fake the queue, drain the outbox, or drive the whole `queue:work` loop on a virtual clock.
28
+ - ๐Ÿ› ๏ธ **`cf-jobs` CLI**: `artisan queue:*`-style status, retry, flush, and migrate against local or remote D1.
12
29
 
13
30
  ## Install
14
31
 
@@ -16,7 +33,7 @@ Typed Cloudflare Queue jobs for Nuxt.
16
33
  pnpm add nuxt-cf-jobs
17
34
  ```
18
35
 
19
- Add the module:
36
+ Add the module and map your logical queue names to Cloudflare bindings:
20
37
 
21
38
  ```ts
22
39
  // nuxt.config.ts
@@ -36,7 +53,7 @@ export default defineNuxtConfig({
36
53
  })
37
54
  ```
38
55
 
39
- `queues` maps your logical queue names to Cloudflare environment binding names. The string form uses the logical name as the Cloudflare queue name. The object form lets the Cloudflare queue name differ from the logical name.
56
+ The string form (`default`) uses the logical name as the Cloudflare queue name. The object form lets the Cloudflare queue name differ from the logical name.
40
57
 
41
58
  ## Define Jobs
42
59
 
@@ -60,27 +77,19 @@ export default defineJob({
60
77
  })
61
78
  ```
62
79
 
63
- Job names are derived from file paths for the generated registry, so this file is available as `sync/table`. Duplicate derived names fail during template generation.
64
-
65
- The module ignores private/test files by default:
80
+ Job names come from the file path, so this file registers as `sync/table`. Duplicate derived names fail during template generation. Private and test files are ignored by default:
66
81
 
67
82
  ```ts
68
83
  jobsIgnore: ['**/_*.ts', '**/*.d.ts', '**/*.test.ts', '**/*.spec.ts']
69
84
  ```
70
85
 
71
- ## Use The Typed Registry
86
+ ## Typed Registry
72
87
 
73
- The generated registry is available as `#cf-jobs/app` by default.
88
+ The generated registry is available as `#cf-jobs/app`:
74
89
 
75
90
  ```ts
76
91
  import type { JobName, JobPayload } from '#cf-jobs/app'
77
- import {
78
- buildJobPayload,
79
- getJobDefinition,
80
- getQueue,
81
-
82
- prepareJob
83
- } from '#cf-jobs/app'
92
+ import { buildJobPayload, getJobDefinition, getQueue, prepareJob } from '#cf-jobs/app'
84
93
 
85
94
  const name: JobName = 'sync/table'
86
95
 
@@ -95,11 +104,11 @@ const message = buildJobPayload(name, payload)
95
104
  const definition = getJobDefinition(name)
96
105
  ```
97
106
 
98
- Unknown job names and invalid payload shapes are rejected by TypeScript when the job payload type can be inferred from the job definition.
107
+ TypeScript rejects unknown job names and invalid payload shapes wherever the payload type can be inferred from the job definition.
99
108
 
100
109
  ## Send Jobs
101
110
 
102
- Use `getQueue(event, jobDefinition)` inside server routes, event handlers, Nitro plugins, or other server code where you have an `H3Event` or Cloudflare env-like source.
111
+ Use `getQueue(event, jobDefinition)` inside server routes, event handlers, or Nitro plugins, anywhere you have an `H3Event` or a Cloudflare env-like source:
103
112
 
104
113
  ```ts
105
114
  // server/api/sync.post.ts
@@ -121,9 +130,9 @@ export default defineEventHandler(async (event) => {
121
130
  })
122
131
  ```
123
132
 
124
- `queue.send()` returns `false` when the configured Cloudflare binding is unavailable. That lets development or unsupported runtimes fail explicitly without throwing.
133
+ `queue.send()` returns `false` when the configured Cloudflare binding is unavailable, so development and unsupported runtimes fail explicitly instead of throwing.
125
134
 
126
- In Nuxt dev, the module installs a dev-only Nitro plugin that creates in-memory queue bindings from your `cfJobs.queues` config and forwards messages to the `cloudflare:queue` hook.
135
+ In `nuxt dev`, the module installs a dev-only Nitro plugin that builds in-memory queue bindings from your `cfJobs.queues` config and forwards messages to the `cloudflare:queue` hook.
127
136
 
128
137
  ## Consume Queue Batches
129
138
 
@@ -161,9 +170,7 @@ export default defineNitroPlugin((nitroApp) => {
161
170
  })
162
171
  ```
163
172
 
164
- The generated `registerQueueConsumer()` wires the generated registry and `runtimeConfig.cfJobs.queues` for you. You provide only the application-specific context.
165
-
166
- Useful options:
173
+ `registerQueueConsumer()` wires the generated registry and `runtimeConfig.cfJobs.queues` for you; you provide the application-specific context. Other options:
167
174
 
168
175
  ```ts
169
176
  registerQueueConsumer(nitroApp, {
@@ -171,25 +178,21 @@ registerQueueConsumer(nitroApp, {
171
178
  getJobId: ({ payload }) => String(payload.jobId),
172
179
  getSiteId: payload => typeof payload.siteId === 'string' ? payload.siteId : null,
173
180
  getUserId: payload => typeof payload.userId === 'number' ? payload.userId : null,
174
- retryDelaySeconds: ({ error, job }) => 30,
181
+ retryDelaySeconds: () => 30,
175
182
  onInvalidPayload: input => console.warn(input.error),
176
183
  onDispatchError: input => console.error(input.error),
177
184
  })
178
185
  ```
179
186
 
180
- If you do not provide `getJobId`, the consumer uses `payload.jobId` when present, otherwise a stable serialized payload ID.
187
+ Without `getJobId`, the consumer uses `payload.jobId` when present, otherwise a stable serialized payload ID.
181
188
 
182
189
  ## Durable D1 Jobs
183
190
 
184
- For jobs that should survive process restarts or queue delivery issues, persist a durable job record first, then enqueue a lightweight queue message.
191
+ For work that should survive process restarts or queue delivery issues, persist a durable record first, then enqueue a lightweight queue message:
185
192
 
186
193
  ```ts
187
- import {
188
- createD1DurableJobRepository,
189
- createQueuePublisher,
190
- enqueueDurableJob,
191
- } from 'nuxt-cf-jobs/server'
192
194
  import { prepareJob } from '#cf-jobs/app'
195
+ import { createD1DurableJobRepository, createQueuePublisher, enqueueDurableJob } from '#cf-jobs/server'
193
196
 
194
197
  export default defineEventHandler(async (event) => {
195
198
  const env = event.context.cloudflare?.env as {
@@ -201,7 +204,7 @@ export default defineEventHandler(async (event) => {
201
204
  await repository.migrate()
202
205
 
203
206
  const publisher = createQueuePublisher(env, queue =>
204
- queue === 'default' ? 'QUEUE_DEFAULT' : undefined,)
207
+ queue === 'default' ? 'QUEUE_DEFAULT' : undefined)
205
208
 
206
209
  const record = await prepareJob({
207
210
  name: 'sync/table',
@@ -219,9 +222,8 @@ export default defineEventHandler(async (event) => {
219
222
  Consume durable queue messages with `runDurableJobMessage()` and the D1 repository:
220
223
 
221
224
  ```ts
222
- import { createD1DurableJobRepository } from 'nuxt-cf-jobs/d1'
223
- import { runDurableJobMessage } from 'nuxt-cf-jobs/durable'
224
225
  import { jobRegistry } from '#cf-jobs/app'
226
+ import { createD1DurableJobRepository, runDurableJobMessage } from '#cf-jobs/server'
225
227
 
226
228
  export default defineNitroPlugin((nitroApp) => {
227
229
  nitroApp.hooks.hook('cloudflare:queue', async ({ batch, env }) => {
@@ -262,22 +264,11 @@ export default defineNitroPlugin((nitroApp) => {
262
264
  })
263
265
  ```
264
266
 
265
- `createD1DurableJobRepository()` exposes:
266
-
267
- - `migrate()`
268
- - `insertJob()`
269
- - `claimJob()`
270
- - `completeJob()`
271
- - `failJob()`
272
- - `releaseJob()`
273
- - `findDispatchableJobs()`
274
- - `findStaleReservedJobs()`
275
- - `releaseStaleReservedJobs()`
276
- - `toDispatchableJob()`
267
+ `createD1DurableJobRepository()` exposes `migrate`, `insertJob`, `claimJob`, `completeJob`, `failJob`, `releaseJob`, `findDispatchableJobs`, `findStaleReservedJobs`, `releaseStaleReservedJobs`, and `toDispatchableJob`.
277
268
 
278
269
  ## Scheduled Tasks (cron)
279
270
 
280
- Queue jobs handle per-request deferred work; **scheduled tasks** handle cron work. `defineScheduledTask` co-locates the cron schedule with its handler, and the module derives `nitro.tasks`, `nitro.scheduledTasks`, and the Cloudflare `triggers.crons` from it โ€” so there is no central list to keep in sync (and no way for the three to silently drift apart).
271
+ Queue jobs handle per-request deferred work; **scheduled tasks** handle cron work. `defineScheduledTask` co-locates the cron schedule with its handler, and the module derives `nitro.tasks`, `nitro.scheduledTasks`, and the Cloudflare `triggers.crons` from it, so there is no central list to keep in sync (and no way for the three to drift apart).
281
272
 
282
273
  ```ts
283
274
  // server/tasks/cleanup.ts
@@ -300,16 +291,16 @@ export default defineNuxtConfig({
300
291
  // `true` โ†’ auto-discover `server/tasks` in the app AND every extended layer
301
292
  // (nuxt.options._layers), so a new layer with cron work needs no host config.
302
293
  tasksDir: true,
303
- // โ€ฆor be explicit: tasksDir: ['server/tasks', '../some-layer/server/tasks']
294
+ // ...or be explicit: tasksDir: ['server/tasks', '../some-layer/server/tasks']
304
295
  },
305
296
  })
306
297
  ```
307
298
 
308
299
  Notes:
309
300
 
310
- - `name` and `cron` must be **string literals** โ€” the module reads them statically at build time (without executing the file, which typically imports a DB/server utils that won't load outside nitro). Computed values are skipped with a warning.
311
- - Plain nitro `defineTask` files in the same dirs are still registered (so they're runnable via `runTask`), just not scheduled.
312
- - `nitro.scheduledTasks` is populated only outside dev by default (so crons don't fire locally); override with `cfJobs.scheduledTasks: true | false`. The deploy-only `triggers.crons` is always written.
301
+ - `name` and `cron` must be **string literals**. The module reads them statically at build time, without executing the file (which usually imports DB/server utils that won't load outside nitro). Computed values are skipped with a warning.
302
+ - Plain nitro `defineTask` files in the same dirs are still registered (runnable via `runTask`), just not scheduled.
303
+ - `nitro.scheduledTasks` is populated only outside dev by default, so crons don't fire locally. Override with `cfJobs.scheduledTasks: true | false`. The deploy-only `triggers.crons` is always written.
313
304
  - Opt-in: nothing is scanned or registered unless `tasksDir` is set.
314
305
 
315
306
  ## CLI
@@ -328,27 +319,21 @@ pnpm cf-jobs schedule # artisan schedule:list (cron + next r
328
319
  pnpm cf-jobs tasks # every discovered task
329
320
 
330
321
  # manage (prompt for confirmation; pass --yes to skip, required when non-interactive)
331
- pnpm cf-jobs retry <id> # artisan queue:retry โ€” re-queue a failed job
322
+ pnpm cf-jobs retry <id> # artisan queue:retry, re-queue a failed job
332
323
  pnpm cf-jobs retry --queue billing # re-queue a whole queue's failures
333
324
  pnpm cf-jobs forget <id> # artisan queue:forget
334
- pnpm cf-jobs flush # artisan queue:flush โ€” delete all failed jobs
335
- pnpm cf-jobs clear --state reserved # artisan queue:clear โ€” drop active jobs (e.g. stuck reservations)
325
+ pnpm cf-jobs flush # artisan queue:flush, delete all failed jobs
326
+ pnpm cf-jobs clear --state reserved # artisan queue:clear, drop active jobs (e.g. stuck reservations)
336
327
  pnpm cf-jobs migrate # create the job tables/indexes in D1
337
328
  ```
338
329
 
339
330
  Every command accepts `--config <wrangler path>`, `--db <binding>`, `--remote`, `--json`, and `--jobs-table` / `--failed-table` overrides. `status` flags queues whose oldest ready job is lagging and reservations stuck for more than five minutes (a crashed or timed-out consumer). Run `cf-jobs <command> --help` for the full argument list.
340
331
 
341
- `cf-jobs` shells out to `wrangler`; it resolves the binary from `node_modules/.bin`, falling back to `wrangler` on `PATH` (override with `CF_JOBS_WRANGLER_BIN`).
332
+ `cf-jobs` shells out to `wrangler`, resolving the binary from `node_modules/.bin` and falling back to `wrangler` on `PATH` (override with `CF_JOBS_WRANGLER_BIN`).
342
333
 
343
334
  ## Runtime Validation
344
335
 
345
- The generated registry validates jobs at startup. It fails loudly for:
346
-
347
- - invalid job definitions
348
- - duplicate job names
349
- - missing or invalid queue names
350
-
351
- Queue binding checks are available from `#cf-jobs/app`:
336
+ The generated registry validates jobs at startup and fails loudly for invalid definitions, duplicate names, and missing or invalid queue names. Queue binding checks are available from `#cf-jobs/app`:
352
337
 
353
338
  ```ts
354
339
  import { assertQueueBindings, validateQueueBindings } from '#cf-jobs/app'
@@ -357,39 +342,227 @@ const issues = validateQueueBindings()
357
342
  assertQueueBindings()
358
343
  ```
359
344
 
345
+ ## Testing Jobs
346
+
347
+ `createJobTestHarness(registry, options?)` gives Laravel-style ergonomics for testing jobs without a running queue or worker. It infers typed job names and payloads from the registry you pass.
348
+
349
+ ### Test setup
350
+
351
+ `createJobTestHarness` lives on the **`nuxt-cf-jobs/testing`** subpath, which is itself nitropack-free. What you pair it with is not: your generated registry (`#cf-jobs/app`) and `defineJob` / `defineJobRegistry` (from `nuxt-cf-jobs/server`) transitively import `nitropack/runtime`, which only resolves inside a built Nuxt app. So:
352
+
353
+ - **Under `@nuxt/test-utils`** (recommended): nothing to configure. `#cf-jobs/app`, `#cf-jobs/server`, and `nitropack/runtime` all resolve.
354
+ - **Plain vitest**: alias the generated registry to its prepared location and stub `nitropack/runtime` with identity exports:
355
+
356
+ ```ts
357
+ // vitest.config.ts
358
+ import { fileURLToPath } from 'node:url'
359
+
360
+ export default {
361
+ resolve: { alias: {
362
+ // run `nuxi prepare` first so the generated registry exists:
363
+ '#cf-jobs/app': fileURLToPath(new URL('./.nuxt/cf-jobs/registry.ts', import.meta.url)),
364
+ // identity stub re-exporting defineTask / defineNitroPlugin / useRuntimeConfig:
365
+ 'nitropack/runtime': fileURLToPath(new URL('./tests/stubs/nitropack-runtime.ts', import.meta.url)),
366
+ } },
367
+ }
368
+ ```
369
+
370
+ The module's own [`vitest.config.ts`](./vitest.config.ts) applies the same alias and stub pattern to source.
371
+
372
+ ### Build a harness
373
+
374
+ The fastest path is an **inline registry**, which needs no `#cf-jobs/app` resolution:
375
+
376
+ ```ts
377
+ import { defineJob, defineJobRegistry } from 'nuxt-cf-jobs/server'
378
+ import { createJobTestHarness } from 'nuxt-cf-jobs/testing'
379
+
380
+ const registry = defineJobRegistry([
381
+ defineJob({
382
+ name: 'order/ship',
383
+ queue: 'standard',
384
+ async handle(payload: { orderId: string }, ctx) { /* ... */ },
385
+ }),
386
+ ])
387
+
388
+ const h = createJobTestHarness(registry, {
389
+ env: {}, // opaque to the harness; surfaced as ctx.env
390
+ db: {}, // your test double / drizzle instance; surfaced as ctx.db
391
+ log: console,
392
+ })
393
+ ```
394
+
395
+ To test your real jobs, pass `jobRegistry` from `#cf-jobs/app` instead (with the setup above). `env` / `db` / `log` are opaque to the harness and handed to each handler's `ctx`.
396
+
397
+ ### Run a job inline (the `sync` driver)
398
+
399
+ `runInline` looks up the handler, builds the `_task` envelope, runs middleware and `handle`, and returns the result. Unhandled errors propagate so you can use `expect(...).rejects`. Assert on side effects (DB rows, sent mail, events):
400
+
401
+ ```ts
402
+ it('ships the order', async () => {
403
+ const res = await h.runInline('order/ship', { orderId: 'A1' })
404
+
405
+ expect(res.success).toBe(true)
406
+ expect(res.released).toBe(false) // handler did not call ctx.release()
407
+ expect(res.failed).toBe(false) // handler did not call ctx.fail()
408
+ // ...assert your side effects
409
+ })
410
+
411
+ // exercise retry / failure branches
412
+ const released = await h.runInline('order/ship', { orderId: 'A2' }, { attempt: 2 })
413
+ expect(released.delaySeconds).toBe(30)
414
+ ```
415
+
416
+ The harness records every `runInline` / `drainOutbox` outcome, so you can assert what ran after the fact (Laravel's `assertFailed` / `assertNothingFailed`):
417
+
418
+ ```ts
419
+ await h.runInline('order/ship', { orderId: 'A1' })
420
+ await h.runInline('order/ship', { orderId: 'A2', fail: true })
421
+
422
+ h.assertRan('order/ship') // ran and succeeded at least once
423
+ h.assertRan('order/ship', result => result.success)
424
+ h.assertFailed('order/ship') // a run called ctx.fail() (or threw)
425
+ h.assertReleased('order/ship') // a run called ctx.release()
426
+ // h.assertNothingFailed() // would throw here
427
+ ```
428
+
429
+ ### Assert what was dispatched (`Queue::fake()`)
430
+
431
+ `fakeJobs(bindings)` returns a recording fake env plus assertions. Spread `env` into whatever your producer reads from, run your code, then assert:
432
+
433
+ ```ts
434
+ it('queues a confirmation email', async () => {
435
+ const fake = h.fakeJobs(['QUEUE_STANDARD'])
436
+
437
+ await myEndpoint({ env: fake.env })
438
+
439
+ fake.assertSent('email/send')
440
+ fake.assertSent('email/send', payload => payload.orderId === 'A1')
441
+ fake.assertSentTimes('email/send', 1)
442
+ fake.assertSentOn('standard', 'email/send')
443
+ fake.assertSentWithDelay('email/send', 60) // queued with a 60s delay
444
+ fake.assertNotSent('order/ship')
445
+
446
+ // chains + batches (Laravel's assertPushedWithChain / Bus::assertBatched)
447
+ fake.assertChained('order/ship', ['email/send']) // `then` continuation chain
448
+ fake.assertBatched(names => names.length === 2) // jobs dispatched via sendBatch
449
+ })
450
+ ```
451
+
452
+ ### Drain the durable outbox once (`queue:work --once`)
453
+
454
+ `drainOutbox` claims durable records one at a time, runs each inline, and routes the outcome to `onComplete` / `onReleased` / `onFailed`. Wire `next` to your D1 (or in-memory) outbox; payloads are `JSON.parse`d by default:
455
+
456
+ The four callbacks are your own outbox functions (claim a record, then persist each outcome), not module exports:
457
+
458
+ ```ts
459
+ const summary = await h.drainOutbox({
460
+ next: () => claimNext(), // your "reserve the next durable record" query, or undefined when empty
461
+ onComplete: record => markComplete(record),
462
+ onReleased: (record, delaySeconds) => markReleased(record, delaySeconds),
463
+ onFailed: (record, error) => markFailed(record, String(error)),
464
+ })
465
+
466
+ expect(summary).toEqual({ processed: 3, completed: 2, released: 0, failed: 1 })
467
+ ```
468
+
469
+ ### Run the whole queue (`queue:work`)
470
+
471
+ `createQueueTestHarness` drives the full pipeline in-process on a **virtual clock**: dispatch onto a producer binding, `work()` a pass like `queue:work --once`, `advanceTime()` to fire delayed/released/backoff retries, and `runUntilEmpty()` to drain everything including chained continuations. No real timers, fully deterministic.
472
+
473
+ ```ts
474
+ import { createQueueTestHarness } from 'nuxt-cf-jobs/testing'
475
+
476
+ const q = createQueueTestHarness({
477
+ registry, // inline, or jobRegistry from #cf-jobs/app
478
+ queues: { critical: 'QUEUE_CRITICAL', standard: 'QUEUE_STANDARD' }, // logicalName: binding
479
+ })
480
+
481
+ // producer โ†’ queue โ†’ consumer โ†’ handler.
482
+ // A raw message body MUST carry `_task: <jobName>` alongside the payload fields;
483
+ // a message without `_task` is silently retried, not run.
484
+ q.env.QUEUE_CRITICAL.send({ _task: 'order/process', orderId: 'A1' })
485
+ await q.work()
486
+ q.assertProcessed('order/process')
487
+
488
+ // release/backoff redelivery
489
+ q.advanceTime(30)
490
+ await q.work()
491
+ q.assertReleased('order/process', { delay: 30 })
492
+ q.assertRetried('order/process', 1)
493
+
494
+ // drain a chain/continuation to completion
495
+ await q.runUntilEmpty()
496
+ q.assertNothingPending()
497
+ ```
498
+
499
+ `env[binding]` and `send(binding, ...)` use the **binding** name (`QUEUE_CRITICAL`); the assertions use the **job name** (`order/process`). `queues` maps logical-queue to binding, matching your `cfJobs.queues` config.
500
+
501
+ By default the harness dispatches through the registry, so `assertProcessed` / `assertFailed` / `assertReleased` reuse the inline run log. Pass `consumer` to drive **your own** `cloudflare:queue` batch processor instead. In that mode the run-log assertions throw a clear error (they have nothing to read), so assert via your durable store plus the queue-mechanics helpers (`assertRetried`, `assertDispatched`, `pending()`):
502
+
503
+ ```ts
504
+ const q = createQueueTestHarness({
505
+ registry: jobRegistry,
506
+ queues: { critical: 'QUEUE_CRITICAL' },
507
+ consumer: (batch, env) => myConsumer(batch, env), // your real telemetry/DLQ/retry
508
+ })
509
+ ```
510
+
511
+ The lower-level fakes (`createFakeQueue`, `createFakeQueueEnv`, `createQueueMessage`, `createQueueBatch`) live on the same `nuxt-cf-jobs/testing` subpath, for hand-wiring `processRegisteredQueueBatch`. Their producer contract (`send` / `sendBatch`, `delaySeconds`, per-message overrides) matches the dev polyfill (`createDevQueueRuntime`) and real Cloudflare Queues, so a passing fake-based test reflects dev and production producer behaviour. The module's own suite asserts that equivalence directly.
512
+
360
513
  ## Public Imports
361
514
 
362
- Use the narrow subpaths when you can:
515
+ Prefer the narrow subpaths:
363
516
 
364
517
  ```ts
365
518
  import { createD1DurableJobRepository } from 'nuxt-cf-jobs/d1'
366
- import { runDurableJobMessage } from 'nuxt-cf-jobs/durable'
367
519
  import { defineJob } from 'nuxt-cf-jobs/server'
368
- import { createFakeQueue } from 'nuxt-cf-jobs/testing'
520
+ import { createFakeQueue, createJobTestHarness } from 'nuxt-cf-jobs/testing'
369
521
  ```
370
522
 
371
523
  Available package subpaths:
372
524
 
373
- - `nuxt-cf-jobs` - Nuxt module
374
- - `nuxt-cf-jobs/server` - full server runtime barrel
375
- - `nuxt-cf-jobs/d1` - D1 durable repository adapter
376
- - `nuxt-cf-jobs/durable` - durable outbox helpers
377
- - `nuxt-cf-jobs/queue` - queue binding and consumer helpers
378
- - `nuxt-cf-jobs/schema` - Drizzle schema
379
- - `nuxt-cf-jobs/testing` - fake queue helpers
525
+ - `nuxt-cf-jobs`: the Nuxt module
526
+ - `nuxt-cf-jobs/server`: server runtime barrel (durable, queue, dispatch, registry)
527
+ - `nuxt-cf-jobs/testing`: test helpers (`createJobTestHarness`, `createQueueTestHarness`, `createFakeQueue*`), nitropack-free
528
+ - `nuxt-cf-jobs/d1`: D1 durable repository adapter (non-nuxt contexts)
529
+ - `nuxt-cf-jobs/schema`: Drizzle schema (non-nuxt contexts)
380
530
 
381
- Inside a Nuxt app, prefer the generated aliases:
382
-
383
- - `#cf-jobs/server` for server runtime helpers
384
- - `#cf-jobs/app` for your generated typed job registry
531
+ Inside a Nuxt app, prefer the generated aliases `#cf-jobs/server` (runtime helpers) and `#cf-jobs/app` (your typed registry).
385
532
 
386
533
  ## Tests
387
534
 
535
+ The suite runs as two vitest projects, plus an opt-in wrangler tier:
536
+
388
537
  ```bash
389
- pnpm test
538
+ pnpm test # unit project (happy-dom): runtime + test-helper specs
539
+ pnpm test:nitro # nitro project (*.nitro.test.ts): real Nuxt server via @nuxt/test-utils
390
540
  pnpm typecheck
391
541
  pnpm build
392
- pnpm test:e2e
542
+ pnpm test:e2e # wrangler/workerd round-trip (real queue consumer)
393
543
  ```
394
544
 
395
- `pnpm test:e2e` starts Wrangler fixtures and requires the local Cloudflare/Wrangler toolchain to be available.
545
+ Three tiers of increasing fidelity:
546
+
547
+ - **unit**: fakes and harness, plus a producer-contract parity check (the fakes behave like the dev polyfill and Cloudflare) and the dev-polyfill to consumer delivery loop.
548
+ - **`test:nitro`**: the generated registry driven through the real runtime inside a built Nuxt server (nitropack v2), via `@nuxt/test-utils`.
549
+ - **`test:e2e`**: the real Cloudflare Queues/D1 round-trip over workerd, including the consumer delivery path. `registerQueueConsumer`'s runtime-config resolution only works under the Cloudflare runtime, so this tier is where it runs end to end.
550
+
551
+ `pnpm test:e2e` starts Wrangler fixtures and needs the local Cloudflare/Wrangler toolchain.
552
+
553
+ ## License
554
+
555
+ Licensed under the [MIT license](https://github.com/harlan-zw/nuxt-cf-jobs/blob/main/LICENSE.md).
556
+
557
+ <!-- Badges -->
558
+ [npm-version-src]: https://img.shields.io/npm/v/nuxt-cf-jobs/latest.svg?style=flat&colorA=18181B&colorB=28CF8D
559
+ [npm-version-href]: https://npmjs.com/package/nuxt-cf-jobs
560
+
561
+ [npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-cf-jobs.svg?style=flat&colorA=18181B&colorB=28CF8D
562
+ [npm-downloads-href]: https://npmjs.com/package/nuxt-cf-jobs
563
+
564
+ [license-src]: https://img.shields.io/github/license/harlan-zw/nuxt-cf-jobs.svg?style=flat&colorA=18181B&colorB=28CF8D
565
+ [license-href]: https://github.com/harlan-zw/nuxt-cf-jobs/blob/main/LICENSE.md
566
+
567
+ [nuxt-src]: https://img.shields.io/badge/Nuxt-18181B?logo=nuxt
568
+ [nuxt-href]: https://nuxt.com
@@ -3,7 +3,7 @@ import process$1 from 'node:process';
3
3
  import { createInterface } from 'node:readline/promises';
4
4
  import { defineCommand, runMain } from 'citty';
5
5
  import { d1DurableJobMigrationSql } from '../../dist/runtime/server/d1.js';
6
- import { e as findWranglerConfig, p as parseWranglerConfig, c as collectTasks } from '../shared/nuxt-cf-jobs.C2yTYlMg.mjs';
6
+ import { e as findWranglerConfig, p as parseWranglerConfig, c as collectTasks } from '../shared/nuxt-cf-jobs.BkJA3gwQ.mjs';
7
7
  import { execFile } from 'node:child_process';
8
8
  import { existsSync } from 'node:fs';
9
9
  import { resolve } from 'node:path';
@@ -12,6 +12,8 @@ import 'node:fs/promises';
12
12
  import '@nuxt/kit';
13
13
 
14
14
  const execFileAsync = promisify(execFile);
15
+ const JSON_START_RE = /[[{]/;
16
+ const TRAILING_SEMICOLON_RE = /;\s*$/;
15
17
  class D1ResolutionError extends Error {
16
18
  }
17
19
  function selectD1Database(databases, binding) {
@@ -53,7 +55,7 @@ function extractJson(stdout) {
53
55
  try {
54
56
  return JSON.parse(trimmed);
55
57
  } catch {
56
- const start = trimmed.search(/[[{]/);
58
+ const start = trimmed.search(JSON_START_RE);
57
59
  const end = Math.max(trimmed.lastIndexOf("]"), trimmed.lastIndexOf("}"));
58
60
  if (start === -1 || end <= start)
59
61
  return void 0;
@@ -100,7 +102,7 @@ async function execD1(target, sql) {
100
102
  async function execD1Batch(target, sqls) {
101
103
  if (sqls.length === 0)
102
104
  return [];
103
- const entries = await runD1(target, sqls.map((s) => s.trim().replace(/;\s*$/, "")).join(";\n"));
105
+ const entries = await runD1(target, sqls.map((s) => s.trim().replace(TRAILING_SEMICOLON_RE, "")).join(";\n"));
104
106
  return sqls.map((_, i) => entries[i]?.results ?? []);
105
107
  }
106
108
 
@@ -352,6 +354,7 @@ function nextCronRun(expr, from) {
352
354
  return null;
353
355
  }
354
356
 
357
+ const CONFIRM_YES_RE = /^y(?:es)?$/i;
355
358
  const sharedArgs = {
356
359
  "cwd": { type: "string", description: "Project directory (default: current dir)" },
357
360
  "config": { type: "string", description: "Path to wrangler config (default: auto-detect)" },
@@ -384,7 +387,7 @@ async function confirm(message, skip) {
384
387
  const rl = createInterface({ input: process$1.stdin, output: process$1.stderr });
385
388
  const answer = await rl.question(`${message} ${color.dim("[y/N]")} `);
386
389
  rl.close();
387
- return /^y(?:es)?$/i.test(answer.trim());
390
+ return CONFIRM_YES_RE.test(answer.trim());
388
391
  }
389
392
  const status = defineCommand({
390
393
  meta: { name: "status", description: "Queue backpressure overview (ready/reserved/delayed, lag, failures)" },
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-cf-jobs",
3
3
  "configKey": "cfJobs",
4
- "version": "0.4.0",
4
+ "version": "0.4.2",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -4,7 +4,7 @@ import { resolve, relative, sep } from 'node:path';
4
4
  import { defineNuxtModule, createResolver, addServerImports, addTemplate, addTypeTemplate, updateTemplates, useLogger, addServerPlugin, resolveFiles } from '@nuxt/kit';
5
5
  import { parseModule } from 'magicast';
6
6
  import { cfJobsAppExportNames } from '../dist/runtime/server/app.js';
7
- import { c as collectTasks, f as findDuplicateTaskNames, b as buildCronUnion, a as buildScheduledTasks, g as renderSuggestedCronsToml, e as findWranglerConfig, p as parseWranglerConfig, d as crossCheckCrons, r as reconcileQueues } from './shared/nuxt-cf-jobs.C2yTYlMg.mjs';
7
+ import { c as collectTasks, f as findDuplicateTaskNames, b as buildCronUnion, a as buildScheduledTasks, g as renderSuggestedCronsToml, e as findWranglerConfig, p as parseWranglerConfig, d as crossCheckCrons, r as reconcileQueues } from './shared/nuxt-cf-jobs.BkJA3gwQ.mjs';
8
8
 
9
9
  function isNode(value) {
10
10
  return typeof value === "object" && value !== null && typeof value.type === "string";
@@ -73,6 +73,10 @@ function extractJobMeta(code) {
73
73
  continue;
74
74
  const value = prop.value;
75
75
  switch (name) {
76
+ case "name":
77
+ if (isNode(value) && value.type === "StringLiteral" && typeof value.value === "string")
78
+ meta.name = value.value;
79
+ break;
76
80
  case "queue":
77
81
  if (isNode(value) && value.type === "StringLiteral" && typeof value.value === "string")
78
82
  meta.queue = value.value;
@@ -104,6 +108,10 @@ function extractJobMeta(code) {
104
108
  return meta;
105
109
  }
106
110
 
111
+ const TS_EXTENSION_RE = /\.ts$/;
112
+ const WINDOWS_SLASH_RE = /\\/g;
113
+ const JOB_FILE_EXTENSION_RE = /\.[cm]?tsx?$/;
114
+ const INDEX_ROUTE_RE = /\/index$/;
107
115
  const module$1 = defineNuxtModule({
108
116
  meta: {
109
117
  name: "nuxt-cf-jobs",
@@ -248,11 +256,11 @@ See ${resolve(templateDir, "wrangler.suggested.toml")} for the expected blocks.`
248
256
  }
249
257
  async function generateRegistryTemplate(options, rootDir, templateDir) {
250
258
  const files = await resolveJobFiles(options, rootDir);
251
- assertUniqueGeneratedJobNames(files, options, rootDir);
259
+ await assertUniqueGeneratedJobNames(files, options, rootDir);
252
260
  const entryLines = await Promise.all(files.map(async (file) => {
253
- const name = toJobName(file, options, rootDir);
254
- const importPath = toImportPath(templateDir, file).replace(/\.ts$/, "");
255
261
  const meta = extractJobMeta(await readFile(file, "utf8").catch(() => ""));
262
+ const name = meta.name ?? toJobName(file, options, rootDir);
263
+ const importPath = toImportPath(templateDir, file).replace(TS_EXTENSION_RE, "");
256
264
  const fields = [`name: ${JSON.stringify(name)}`];
257
265
  if (meta.queue !== void 0)
258
266
  fields.push(`queue: ${JSON.stringify(meta.queue)}`);
@@ -291,7 +299,7 @@ async function generateRegistryTypesTemplate(options, rootDir, templateDir) {
291
299
  const files = await resolveJobFiles(options, rootDir);
292
300
  assertUniqueGeneratedJobNames(files, options, rootDir);
293
301
  const jobTypeLines = files.map((file) => {
294
- return ` typeof import(${JSON.stringify(toImportPath(templateDir, file).replace(/\.ts$/, ""))})['default'],`;
302
+ return ` typeof import(${JSON.stringify(toImportPath(templateDir, file).replace(TS_EXTENSION_RE, ""))})['default'],`;
295
303
  });
296
304
  return [
297
305
  "/* This file is generated by nuxt-cf-jobs. Do not edit directly. */",
@@ -326,11 +334,12 @@ async function resolveJobFiles(options, rootDir) {
326
334
  }));
327
335
  return files.flat();
328
336
  }
329
- function assertUniqueGeneratedJobNames(files, options, rootDir) {
337
+ async function assertUniqueGeneratedJobNames(files, options, rootDir) {
330
338
  const seen = /* @__PURE__ */ new Map();
331
339
  const duplicates = [];
332
340
  for (const file of files) {
333
- const name = toJobName(file, options, rootDir);
341
+ const meta = extractJobMeta(await readFile(file, "utf8").catch(() => ""));
342
+ const name = meta.name ?? toJobName(file, options, rootDir);
334
343
  const previous = seen.get(name);
335
344
  if (previous)
336
345
  duplicates.push(`${name} (${previous}, ${file})`);
@@ -357,8 +366,8 @@ function toJobName(file, options, rootDir) {
357
366
  const dirs = jobDirs.map((dir2) => resolve(rootDir, dir2)).sort((a, b) => b.length - a.length);
358
367
  const dir = dirs.find((dir2) => file.startsWith(`${dir2}/`) || file === dir2);
359
368
  const fallbackDir = jobDirs[0] ?? "server/jobs";
360
- const path = relative(dir ?? resolve(rootDir, fallbackDir), file).replace(/\\/g, "/").replace(/\.[cm]?tsx?$/, "");
361
- return path.replace(/\/index$/, "");
369
+ const path = relative(dir ?? resolve(rootDir, fallbackDir), file).replace(WINDOWS_SLASH_RE, "/").replace(JOB_FILE_EXTENSION_RE, "");
370
+ return path.replace(INDEX_ROUTE_RE, "");
362
371
  }
363
372
 
364
373
  export { module$1 as default, generateRegistryTemplate, generateRegistryTypesTemplate };
@@ -26,7 +26,8 @@ ${jobIssues.map((i) => ` - [job:${i.name}] ${i.reason}`).join("\n")}`);
26
26
  const env = resolveNitroTaskEnv();
27
27
  return env ? { context: { cloudflare: { env } } } : void 0;
28
28
  })();
29
- const runtimeConfig = resolvedSource && typeof resolvedSource === "object" && "context" in resolvedSource ? useRuntimeConfig(resolvedSource) : useRuntimeConfig();
29
+ const isH3Event = !!resolvedSource && typeof resolvedSource === "object" && "context" in resolvedSource && !!resolvedSource.context?.nitro;
30
+ const runtimeConfig = isH3Event ? useRuntimeConfig(resolvedSource) : useRuntimeConfig();
30
31
  return createJobQueue(resolvedSource, runtimeConfig.cfJobs.queues, job);
31
32
  }
32
33
  function buildJobPayload(name, payload) {
@@ -8,5 +8,4 @@ export type { JobQueuePublisher, QueueSource } from './queue.js';
8
8
  export * from './registry.js';
9
9
  export * from './scheduled.js';
10
10
  export * from './schema.js';
11
- export * from './testing.js';
12
11
  export * from './types.js';