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 +261 -88
- package/dist/cli/index.mjs +7 -4
- package/dist/module.json +1 -1
- package/dist/module.mjs +18 -9
- package/dist/runtime/server/app.js +2 -1
- package/dist/runtime/server/index.d.ts +0 -1
- package/dist/runtime/server/index.js +0 -1
- package/dist/runtime/server/queue.js +2 -1
- package/dist/runtime/server/scheduled.js +3 -5
- package/dist/runtime/server/testing.d.ts +200 -1
- package/dist/runtime/server/testing.js +393 -0
- package/dist/shared/{nuxt-cf-jobs.C2yTYlMg.mjs โ nuxt-cf-jobs.BkJA3gwQ.mjs} +41 -16
- package/package.json +14 -4
package/README.md
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
86
|
+
## Typed Registry
|
|
72
87
|
|
|
73
|
-
The generated registry is available as `#cf-jobs/app
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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: (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
311
|
-
- Plain nitro `defineTask` files in the same dirs are still registered (
|
|
312
|
-
- `nitro.scheduledTasks` is populated only outside dev by default
|
|
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
|
|
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
|
|
335
|
-
pnpm cf-jobs clear --state reserved # artisan queue:clear
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
374
|
-
- `nuxt-cf-jobs/server
|
|
375
|
-
- `nuxt-cf-jobs/
|
|
376
|
-
- `nuxt-cf-jobs/
|
|
377
|
-
- `nuxt-cf-jobs/
|
|
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
|
-
|
|
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
|
package/dist/cli/index.mjs
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
|
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
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.
|
|
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(
|
|
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
|
|
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(
|
|
361
|
-
return path.replace(
|
|
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
|
|
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) {
|