nuxt-cf-jobs 0.3.0 → 0.4.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/README.md CHANGED
@@ -275,6 +275,43 @@ export default defineNitroPlugin((nitroApp) => {
275
275
  - `releaseStaleReservedJobs()`
276
276
  - `toDispatchableJob()`
277
277
 
278
+ ## Scheduled Tasks (cron)
279
+
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).
281
+
282
+ ```ts
283
+ // server/tasks/cleanup.ts
284
+ export default defineScheduledTask({
285
+ name: 'db:cleanup', // nitro task name (also the runTask id)
286
+ cron: '0 3 * * *', // or cron: ['0 3 * * *', '0 */6 * * *']
287
+ description: 'Nightly cleanup',
288
+ run() {
289
+ // ...same shape as nitro defineTask's run
290
+ return { result: 'ok' }
291
+ },
292
+ })
293
+ ```
294
+
295
+ Enable scanning via `cfJobs.tasksDir`:
296
+
297
+ ```ts
298
+ export default defineNuxtConfig({
299
+ cfJobs: {
300
+ // `true` → auto-discover `server/tasks` in the app AND every extended layer
301
+ // (nuxt.options._layers), so a new layer with cron work needs no host config.
302
+ tasksDir: true,
303
+ // …or be explicit: tasksDir: ['server/tasks', '../some-layer/server/tasks']
304
+ },
305
+ })
306
+ ```
307
+
308
+ Notes:
309
+
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.
313
+ - Opt-in: nothing is scanned or registered unless `tasksDir` is set.
314
+
278
315
  ## CLI
279
316
 
280
317
  The package ships a `cf-jobs` binary, an `artisan queue:*`-style tool for inspecting and managing the durable D1 job tables. It queries D1 through `wrangler d1 execute`, so it works against both the local miniflare database (default) and production (`--remote`), auto-detecting the D1 binding from your wrangler config.
package/dist/module.d.mts CHANGED
@@ -106,6 +106,14 @@ declare module '@nuxt/schema' {
106
106
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
107
107
 
108
108
  declare function generateRegistryTemplate(options: ModuleOptions, rootDir: string, templateDir: string): Promise<string>;
109
+ /**
110
+ * Emits a `declare module '#cf-jobs/app'` augmentation. The data-only runtime
111
+ * `registry.ts` carries no hand-written types; this augmentation MERGES the
112
+ * type-only aliases into that module so consumers can `import type { JobName,
113
+ * JobPayload } from '#cf-jobs/app'`. Types are derived from each job's *full*
114
+ * default-export type via `typeof import(...)` — purely type-level, so no
115
+ * handler module is loaded to compute them.
116
+ */
109
117
  declare function generateRegistryTypesTemplate(options: ModuleOptions, rootDir: string, templateDir: string): Promise<string>;
110
118
 
111
119
  export { _default as default, generateRegistryTemplate, generateRegistryTypesTemplate };
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-cf-jobs",
3
3
  "configKey": "cfJobs",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,9 +1,108 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
2
3
  import { resolve, relative, sep } from 'node:path';
3
- import { defineNuxtModule, createResolver, addServerImports, addTemplate, updateTemplates, useLogger, addServerPlugin, resolveFiles } from '@nuxt/kit';
4
+ import { defineNuxtModule, createResolver, addServerImports, addTemplate, addTypeTemplate, updateTemplates, useLogger, addServerPlugin, resolveFiles } from '@nuxt/kit';
5
+ import { parseModule } from 'magicast';
4
6
  import { cfJobsAppExportNames } from '../dist/runtime/server/app.js';
5
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';
6
- import 'node:fs/promises';
8
+
9
+ function isNode(value) {
10
+ return typeof value === "object" && value !== null && typeof value.type === "string";
11
+ }
12
+ function emptyMeta() {
13
+ return { hasInput: false, hasUniqueId: false };
14
+ }
15
+ function findDefineJobCall(node) {
16
+ if (Array.isArray(node)) {
17
+ for (const child of node) {
18
+ const found = findDefineJobCall(child);
19
+ if (found)
20
+ return found;
21
+ }
22
+ return void 0;
23
+ }
24
+ if (!isNode(node))
25
+ return void 0;
26
+ if (node.type === "CallExpression") {
27
+ const callee = node.callee;
28
+ if (isNode(callee) && callee.type === "Identifier" && callee.name === "defineJob")
29
+ return node;
30
+ }
31
+ for (const key in node) {
32
+ if (key === "type")
33
+ continue;
34
+ const found = findDefineJobCall(node[key]);
35
+ if (found)
36
+ return found;
37
+ }
38
+ return void 0;
39
+ }
40
+ function propKeyName(prop) {
41
+ const key = prop.key;
42
+ if (!isNode(key))
43
+ return void 0;
44
+ if (key.type === "Identifier" && typeof key.name === "string")
45
+ return key.name;
46
+ if (key.type === "StringLiteral" && typeof key.value === "string")
47
+ return key.value;
48
+ return void 0;
49
+ }
50
+ function extractJobMeta(code) {
51
+ const meta = emptyMeta();
52
+ let ast;
53
+ try {
54
+ ast = parseModule(code).$ast;
55
+ } catch {
56
+ return meta;
57
+ }
58
+ const call = findDefineJobCall(ast);
59
+ if (!call)
60
+ return meta;
61
+ const args = call.arguments;
62
+ const arg = Array.isArray(args) ? args[0] : void 0;
63
+ if (!isNode(arg) || arg.type !== "ObjectExpression")
64
+ return meta;
65
+ const properties = Array.isArray(arg.properties) ? arg.properties : [];
66
+ for (const prop of properties) {
67
+ if (!isNode(prop) || prop.type !== "ObjectProperty")
68
+ continue;
69
+ if (prop.computed === true)
70
+ continue;
71
+ const name = propKeyName(prop);
72
+ if (!name)
73
+ continue;
74
+ const value = prop.value;
75
+ switch (name) {
76
+ case "queue":
77
+ if (isNode(value) && value.type === "StringLiteral" && typeof value.value === "string")
78
+ meta.queue = value.value;
79
+ break;
80
+ case "jobType":
81
+ if (isNode(value) && value.type === "StringLiteral" && typeof value.value === "string")
82
+ meta.jobType = value.value;
83
+ break;
84
+ case "maxAttempts":
85
+ if (isNode(value) && value.type === "NumericLiteral" && typeof value.value === "number")
86
+ meta.maxAttempts = value.value;
87
+ break;
88
+ case "tries":
89
+ if (isNode(value) && value.type === "NumericLiteral" && typeof value.value === "number")
90
+ meta.tries = value.value;
91
+ break;
92
+ case "unique":
93
+ if (isNode(value) && value.type === "BooleanLiteral" && typeof value.value === "boolean")
94
+ meta.unique = value.value;
95
+ break;
96
+ case "input":
97
+ meta.hasInput = true;
98
+ break;
99
+ case "uniqueId":
100
+ meta.hasUniqueId = true;
101
+ break;
102
+ }
103
+ }
104
+ return meta;
105
+ }
7
106
 
8
107
  const module$1 = defineNuxtModule({
9
108
  meta: {
@@ -33,16 +132,15 @@ const module$1 = defineNuxtModule({
33
132
  write: true,
34
133
  getContents: async () => generateRegistryTemplate(options, nuxt.options.rootDir, resolve(nuxt.options.buildDir, "cf-jobs"))
35
134
  });
36
- addTemplate({
37
- filename: "cf-jobs/registry.d.ts",
38
- write: true,
39
- getContents: async () => generateRegistryTypesTemplate(options, nuxt.options.rootDir, resolve(nuxt.options.buildDir, "cf-jobs"))
40
- });
41
135
  nuxt.options.alias[options.registryAlias ?? "#cf-jobs/app"] = registryTemplate.dst;
136
+ const typesTemplate = addTypeTemplate({
137
+ filename: "cf-jobs/registry-augmentation.d.ts",
138
+ getContents: async () => generateRegistryTypesTemplate(options, nuxt.options.rootDir, resolve(nuxt.options.buildDir, "cf-jobs"))
139
+ }, { nuxt: true, nitro: true });
42
140
  nuxt.hooks.hook("builder:watch", (async (_event, path) => {
43
141
  if (!isWatchedJobPath(path, options, nuxt.options.rootDir))
44
142
  return;
45
- await updateTemplates({ filter: (template) => template.dst === registryTemplate.dst });
143
+ await updateTemplates({ filter: (template) => template.dst === registryTemplate.dst || template.dst === typesTemplate.dst });
46
144
  }));
47
145
  if (options.defaultQueue && !options.queues[options.defaultQueue])
48
146
  useLogger("nuxt-cf-jobs").warn(`cfJobs.defaultQueue="${options.defaultQueue}" is not a key of cfJobs.queues`);
@@ -151,17 +249,36 @@ See ${resolve(templateDir, "wrangler.suggested.toml")} for the expected blocks.`
151
249
  async function generateRegistryTemplate(options, rootDir, templateDir) {
152
250
  const files = await resolveJobFiles(options, rootDir);
153
251
  assertUniqueGeneratedJobNames(files, options, rootDir);
154
- const imports = files.map((file, index) => {
155
- return `import job${index} from ${JSON.stringify(toImportPath(templateDir, file).replace(/\.ts$/, ""))}`;
156
- });
157
- const jobItems = files.map((_, index) => `job${index}`).join(", ");
252
+ 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
+ const meta = extractJobMeta(await readFile(file, "utf8").catch(() => ""));
256
+ const fields = [`name: ${JSON.stringify(name)}`];
257
+ if (meta.queue !== void 0)
258
+ fields.push(`queue: ${JSON.stringify(meta.queue)}`);
259
+ if (meta.jobType !== void 0)
260
+ fields.push(`jobType: ${JSON.stringify(meta.jobType)}`);
261
+ if (meta.maxAttempts !== void 0)
262
+ fields.push(`maxAttempts: ${meta.maxAttempts}`);
263
+ if (meta.tries !== void 0)
264
+ fields.push(`tries: ${meta.tries}`);
265
+ if (meta.unique !== void 0)
266
+ fields.push(`unique: ${meta.unique}`);
267
+ if (meta.hasInput)
268
+ fields.push(`hasInput: true`);
269
+ if (meta.hasUniqueId)
270
+ fields.push(`hasUniqueId: true`);
271
+ fields.push(`load: () => import(${JSON.stringify(importPath)}).then(m => m.default)`);
272
+ return ` { ${fields.join(", ")} },`;
273
+ }));
158
274
  return [
159
275
  "/* This file is generated by nuxt-cf-jobs. Do not edit directly. */",
160
276
  `import { useRuntimeConfig } from 'nitropack/runtime'`,
161
277
  `import { createGeneratedCfJobsApp } from 'nuxt-cf-jobs/server'`,
162
- ...imports,
163
278
  "",
164
- `export const jobs = [${jobItems}] as const`,
279
+ "export const jobs = [",
280
+ ...entryLines,
281
+ "]",
165
282
  `export const app = createGeneratedCfJobsApp(jobs, useRuntimeConfig, ${options.defaultQueue ? JSON.stringify(options.defaultQueue) : "undefined"})`,
166
283
  "",
167
284
  "export const {",
@@ -174,29 +291,26 @@ async function generateRegistryTypesTemplate(options, rootDir, templateDir) {
174
291
  const files = await resolveJobFiles(options, rootDir);
175
292
  assertUniqueGeneratedJobNames(files, options, rootDir);
176
293
  const jobTypeLines = files.map((file) => {
177
- return ` typeof import(${JSON.stringify(toImportPath(templateDir, file).replace(/\.ts$/, ""))})['default'],`;
294
+ return ` typeof import(${JSON.stringify(toImportPath(templateDir, file).replace(/\.ts$/, ""))})['default'],`;
178
295
  });
179
296
  return [
180
297
  "/* This file is generated by nuxt-cf-jobs. Do not edit directly. */",
181
- `import type { JobMessageByName, JobMessageByQueue, JobNameOf, JobPayloadByName, JobPayloadOf, JobQueueByName, QueueNameOf, QueueConsumerOptions } from 'nuxt-cf-jobs/server'`,
182
- `export type { QueueConsumerOptions }`,
298
+ `import type { JobMessageByName, JobMessageByQueue, JobNameOf, JobPayloadOf, JobQueueByName, QueueNameOf } from 'nuxt-cf-jobs/server'`,
183
299
  "",
184
- "export declare const jobs: readonly [",
300
+ `declare module '#cf-jobs/app' {`,
301
+ ` export type { QueueConsumerOptions } from 'nuxt-cf-jobs/server'`,
302
+ " type Jobs = readonly [",
185
303
  ...jobTypeLines,
186
- "]",
187
- "export declare const app: ReturnType<typeof import('nuxt-cf-jobs/server').createCfJobsApp<typeof jobs>>",
188
- "",
189
- "export type Jobs = typeof jobs",
190
- "export type JobsByName = { readonly [Job in Jobs[number] as Job['name']]: Job }",
191
- "export type JobName = keyof JobsByName & JobNameOf<Jobs>",
192
- "export type JobDefinitionOf<Name extends JobName> = JobsByName[Name]",
193
- "export type QueueName = QueueNameOf<Jobs>",
194
- "export type JobPayload<Name extends JobName> = JobPayloadOf<JobDefinitionOf<Name>>",
195
- "export type JobQueue<Name extends JobName> = JobQueueByName<Jobs, Name>",
196
- "export type JobMessage<Name extends JobName> = JobMessageByName<Jobs, Name>",
197
- "export type QueueMessage<Queue extends QueueName> = JobMessageByQueue<Jobs, Queue>",
198
- "",
199
- ...cfJobsAppExportNames.map((name) => `export declare const ${name}: typeof app.${name}`),
304
+ " ]",
305
+ " type JobsByName = { readonly [Job in Jobs[number] as Job['name']]: Job }",
306
+ " export type JobName = keyof JobsByName & JobNameOf<Jobs>",
307
+ " export type JobDefinitionOf<Name extends JobName> = JobsByName[Name]",
308
+ " export type QueueName = QueueNameOf<Jobs>",
309
+ " export type JobPayload<Name extends JobName> = JobPayloadOf<JobDefinitionOf<Name>>",
310
+ " export type JobQueue<Name extends JobName> = JobQueueByName<Jobs, Name>",
311
+ " export type JobMessage<Name extends JobName> = JobMessageByName<Jobs, Name>",
312
+ " export type QueueMessage<Queue extends QueueName> = JobMessageByQueue<Jobs, Queue>",
313
+ "}",
200
314
  ""
201
315
  ].join("\n");
202
316
  }
@@ -1,6 +1,6 @@
1
1
  import type { PrepareRegisteredDurableJobOptions } from './outbox.js';
2
2
  import type { QueueSource, RegisterRegisteredQueueConsumerOptions } from './queue.js';
3
- import type { AnyJobDefinition, JobNameOf, JobPayloadByName } from './registry.js';
3
+ import type { AnyJobDefinition, JobNameOf, JobPayloadByName, LazyJobEntry } from './registry.js';
4
4
  import type { QueueBindingsConfig } from './types.js';
5
5
  import { createJobQueue } from './queue.js';
6
6
  export interface CfJobsRuntimeConfig {
@@ -30,8 +30,8 @@ export declare function createCfJobsApp<const Jobs extends readonly AnyJobDefini
30
30
  jobs: Jobs;
31
31
  jobRegistry: {
32
32
  jobs: Jobs;
33
- handlers: Map<string, import("./types.js").JobHandler<unknown, unknown, unknown, unknown>>;
34
- getHandler(name: string): import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined;
33
+ getHandler: (name: string) => import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | Promise<import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined> | undefined;
34
+ loadJobDefinition: (name: string) => Promise<AnyJobDefinition | undefined>;
35
35
  getJobDefinition: {
36
36
  <Name extends JobNameOf<Jobs>>(name: Name): Extract<Jobs[number], {
37
37
  name: Name;
@@ -54,7 +54,8 @@ export declare function createCfJobsApp<const Jobs extends readonly AnyJobDefini
54
54
  extra: string[];
55
55
  };
56
56
  };
57
- getHandler: (name: string) => import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined;
57
+ getHandler: (name: string) => import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | Promise<import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined> | undefined;
58
+ loadJobDefinition: (name: string) => Promise<AnyJobDefinition | undefined>;
58
59
  getJobDefinition: {
59
60
  <Name extends JobNameOf<Jobs>>(name: Name): Extract<Jobs[number], {
60
61
  name: Name;
@@ -95,25 +96,25 @@ export declare function createCfJobsApp<const Jobs extends readonly AnyJobDefini
95
96
  * `any` param + cast) to the strictly-typed injectable. Keeping this here means
96
97
  * the template emits only dynamic data, and the cast is type-checked in source.
97
98
  */
98
- export declare function createGeneratedCfJobsApp<const Jobs extends readonly AnyJobDefinition[]>(jobs: Jobs, useRuntimeConfig: (...args: any[]) => any, defaultQueue?: string): {
99
- jobs: Jobs;
99
+ export declare function createGeneratedCfJobsApp<const Jobs extends readonly LazyJobEntry[]>(jobs: Jobs, useRuntimeConfig: (...args: any[]) => any, defaultQueue?: string): {
100
+ jobs: readonly AnyJobDefinition[];
100
101
  jobRegistry: {
101
- jobs: Jobs;
102
- handlers: Map<string, import("./types.js").JobHandler<unknown, unknown, unknown, unknown>>;
103
- getHandler(name: string): import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined;
102
+ jobs: readonly AnyJobDefinition[];
103
+ getHandler: (name: string) => import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | Promise<import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined> | undefined;
104
+ loadJobDefinition: (name: string) => Promise<AnyJobDefinition | undefined>;
104
105
  getJobDefinition: {
105
- <Name extends JobNameOf<Jobs>>(name: Name): Extract<Jobs[number], {
106
+ <Name extends string>(name: Name): Extract<AnyJobDefinition, {
106
107
  name: Name;
107
108
  }> | undefined;
108
109
  (name: string): AnyJobDefinition | undefined;
109
110
  };
110
111
  getJobQueue: {
111
- <Name extends JobNameOf<Jobs>>(name: Name): import("./registry.js").JobQueueByName<Jobs, Name> | undefined;
112
+ <Name extends string>(name: Name): import("./registry.js").JobQueueByName<readonly AnyJobDefinition[], Name> | undefined;
112
113
  (name: string): string | undefined;
113
114
  };
114
- buildPayload<Name extends JobNameOf<Jobs>>(name: Name, payload: JobPayloadByName<Jobs, Name>): {
115
+ buildPayload<Name extends string>(name: Name, payload: JobPayloadByName<readonly AnyJobDefinition[], Name>): {
115
116
  _task: Name;
116
- } & JobPayloadByName<Jobs, Name>;
117
+ } & JobPayloadByName<readonly AnyJobDefinition[], Name>;
117
118
  getJobRoute(name: string): {
118
119
  queue: string;
119
120
  jobType: string;
@@ -123,15 +124,16 @@ export declare function createGeneratedCfJobsApp<const Jobs extends readonly Any
123
124
  extra: string[];
124
125
  };
125
126
  };
126
- getHandler: (name: string) => import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined;
127
+ getHandler: (name: string) => import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | Promise<import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined> | undefined;
128
+ loadJobDefinition: (name: string) => Promise<AnyJobDefinition | undefined>;
127
129
  getJobDefinition: {
128
- <Name extends JobNameOf<Jobs>>(name: Name): Extract<Jobs[number], {
130
+ <Name extends string>(name: Name): Extract<AnyJobDefinition, {
129
131
  name: Name;
130
132
  }> | undefined;
131
133
  (name: string): AnyJobDefinition | undefined;
132
134
  };
133
135
  getJobQueue: {
134
- <Name extends JobNameOf<Jobs>>(name: Name): import("./registry.js").JobQueueByName<Jobs, Name> | undefined;
136
+ <Name extends string>(name: Name): import("./registry.js").JobQueueByName<readonly AnyJobDefinition[], Name> | undefined;
135
137
  (name: string): string | undefined;
136
138
  };
137
139
  getJobRoute: (name: string) => {
@@ -145,13 +147,13 @@ export declare function createGeneratedCfJobsApp<const Jobs extends readonly Any
145
147
  validateQueueBindings: (queues?: QueueBindingsConfig) => import("./queue.js").QueueBindingValidationIssue[];
146
148
  assertQueueBindings: (queues?: QueueBindingsConfig) => void;
147
149
  getQueue: {
148
- <const Job extends Jobs[number]>(job: Job): import("./queue.js").JobQueuePublisher<Job>;
149
- <const Job extends Jobs[number]>(source: QueueSource | undefined, job: Job): import("./queue.js").JobQueuePublisher<Job>;
150
+ <const Job extends AnyJobDefinition>(job: Job): import("./queue.js").JobQueuePublisher<Job>;
151
+ <const Job extends AnyJobDefinition>(source: QueueSource | undefined, job: Job): import("./queue.js").JobQueuePublisher<Job>;
150
152
  };
151
- buildJobPayload: <Name extends JobNameOf<Jobs>>(name: Name, payload: JobPayloadByName<Jobs, Name>) => {
153
+ buildJobPayload: <Name extends string>(name: Name, payload: JobPayloadByName<readonly AnyJobDefinition[], Name>) => {
152
154
  _task: Name;
153
- } & JobPayloadByName<Jobs, Name>;
154
- prepareJob: <Name extends JobNameOf<Jobs>>(opts: PrepareRegisteredDurableJobOptions<Jobs, Name>) => Promise<import("./outbox.js").DurableJobRecord<import("./registry.js").JobQueueByName<Jobs, Name>>>;
155
+ } & JobPayloadByName<readonly AnyJobDefinition[], Name>;
156
+ prepareJob: <Name extends string>(opts: PrepareRegisteredDurableJobOptions<readonly AnyJobDefinition[], Name>) => Promise<import("./outbox.js").DurableJobRecord<import("./registry.js").JobQueueByName<readonly AnyJobDefinition[], Name>>>;
155
157
  registerQueueConsumer: <Env extends Record<string, unknown>, Db, Logger>(nitroApp: {
156
158
  hooks: {
157
159
  hook: (name: any, handler: any) => void;
@@ -161,12 +163,11 @@ export declare function createGeneratedCfJobsApp<const Jobs extends readonly Any
161
163
  export type CfJobsApp<Jobs extends readonly AnyJobDefinition[]> = ReturnType<typeof createCfJobsApp<Jobs>>;
162
164
  /**
163
165
  * Authoritative list of `createCfJobsApp` members re-exported by the generated
164
- * `#cf-jobs/app` module. The build-time templates (`generateRegistryTemplate` /
165
- * `generateRegistryTypesTemplate`) map over this so the runtime destructure, the
166
- * `.d.ts` declarations, and the app's return shape can't drift apart.
166
+ * `#cf-jobs/app` module. `generateRegistryTemplate` maps over this so the runtime
167
+ * destructure and the app's return shape can't drift apart.
167
168
  *
168
169
  * `jobs` is exported separately by the template (as a `const` tuple), so it is
169
170
  * intentionally absent here.
170
171
  */
171
- export declare const cfJobsAppExportNames: readonly ["jobRegistry", "getHandler", "getJobDefinition", "getJobQueue", "getJobRoute", "validateRegistry", "validateQueueBindings", "assertQueueBindings", "getQueue", "buildJobPayload", "prepareJob", "registerQueueConsumer"];
172
+ export declare const cfJobsAppExportNames: readonly ["jobRegistry", "getHandler", "loadJobDefinition", "getJobDefinition", "getJobQueue", "getJobRoute", "validateRegistry", "validateQueueBindings", "assertQueueBindings", "getQueue", "buildJobPayload", "prepareJob", "registerQueueConsumer"];
172
173
  export type QueueConsumerOptions<Env extends Record<string, unknown>, Db, Logger> = CfJobsQueueConsumerOptions<Env, Db, Logger>;
@@ -69,6 +69,7 @@ ${issues.map((i) => ` - ${i}`).join("\n")}`);
69
69
  jobs: materialized,
70
70
  jobRegistry,
71
71
  getHandler: jobRegistry.getHandler,
72
+ loadJobDefinition: jobRegistry.loadJobDefinition,
72
73
  getJobDefinition: jobRegistry.getJobDefinition,
73
74
  getJobQueue: jobRegistry.getJobQueue,
74
75
  getJobRoute: jobRegistry.getJobRoute,
@@ -87,6 +88,7 @@ export function createGeneratedCfJobsApp(jobs, useRuntimeConfig, defaultQueue) {
87
88
  export const cfJobsAppExportNames = [
88
89
  "jobRegistry",
89
90
  "getHandler",
91
+ "loadJobDefinition",
90
92
  "getJobDefinition",
91
93
  "getJobQueue",
92
94
  "getJobRoute",
@@ -99,5 +101,7 @@ export const cfJobsAppExportNames = [
99
101
  "registerQueueConsumer"
100
102
  ];
101
103
  function isJobDefinition(value) {
102
- return !!value && typeof value === "object" && typeof value.name === "string" && typeof value.handle === "function";
104
+ if (!value || typeof value !== "object" || typeof value.name !== "string")
105
+ return false;
106
+ return typeof value.handle === "function" || typeof value.load === "function";
103
107
  }
@@ -1,7 +1,19 @@
1
1
  import type { DispatchableJob, DispatchResult, JobContext, JobControlResult, JobDefinition, JobHandler, JobMiddleware } from './types.js';
2
2
  export interface JobRegistryLike<Env, Db, Logger> {
3
- getHandler: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined;
3
+ /** May resolve asynchronously for lazily-loaded jobs; callers must await. */
4
+ getHandler: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined | Promise<JobHandler<unknown, Env, Db, Logger> | undefined>;
5
+ /**
6
+ * Returns the definition for routing/validation. For lazy entries this is
7
+ * static routing metadata typed as a full definition (its `handle`/`input` are
8
+ * absent at runtime); dispatch prefers `loadJobDefinition` for the real module.
9
+ */
4
10
  getJobDefinition?: (name: string) => JobDefinition<string, unknown, string, Env, Db, Logger> | undefined;
11
+ /**
12
+ * Loads the full definition (handler, `input`, `middleware`, `failed`) for a
13
+ * lazy entry. Preferred at dispatch so payload validation + failure hooks see
14
+ * the real module. Falls back to `getJobDefinition`/`getHandler` when absent.
15
+ */
16
+ loadJobDefinition?: (name: string) => Promise<JobDefinition<string, unknown, string, Env, Db, Logger> | undefined>;
5
17
  }
6
18
  export interface DispatchContextInput<Job extends DispatchableJob> {
7
19
  job: Job;
@@ -5,8 +5,8 @@ export async function dispatchRegisteredJob(opts) {
5
5
  if (typeof taskName !== "string" || taskName.length === 0) {
6
6
  return { success: false, error: "No _task in payload", handlerNotFound: true };
7
7
  }
8
- const definition = opts.registry.getJobDefinition?.(taskName);
9
- const handler = definition?.handle ?? opts.registry.getHandler(taskName);
8
+ const definition = opts.registry.loadJobDefinition ? await opts.registry.loadJobDefinition(taskName) : opts.registry.getJobDefinition?.(taskName);
9
+ const handler = definition?.handle ?? await opts.registry.getHandler(taskName);
10
10
  if (!handler) {
11
11
  return { success: false, error: `No handler for task: ${taskName}`, handlerNotFound: true };
12
12
  }
@@ -75,7 +75,8 @@ export interface DurableJobFailureRepository {
75
75
  recordFailure: (input: RecordDurableJobFailureInput) => Promise<void>;
76
76
  }
77
77
  export interface DurableJobRegistryLike<Env = unknown, Db = unknown, Logger = unknown> {
78
- getHandler?: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined;
78
+ /** May resolve asynchronously for lazily-loaded jobs. Unused on the producer path. */
79
+ getHandler?: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined | Promise<JobHandler<unknown, Env, Db, Logger> | undefined>;
79
80
  getJobDefinition?: (name: string) => JobDefinition<string, unknown, string, Env, Db, Logger> | undefined;
80
81
  getJobRoute?: (name: string) => DurableJobRoute<string> | undefined;
81
82
  }
@@ -1,6 +1,33 @@
1
1
  import type { JobBackoff, JobDefinition, JobFailedHandler, JobHandler, JobMiddleware, JobPayloadSchema } from './types.js';
2
2
  export type JobPayloadMap = Record<string, unknown>;
3
3
  export type AnyJobDefinition = JobDefinition<string, any, string, any, any, any>;
4
+ /**
5
+ * A build-time registry entry that defers loading the handler module. The
6
+ * static routing fields are AST-extracted from the job's `defineJob({...})`
7
+ * call so the producer/consumer can route, validate queues and resolve attempts
8
+ * WITHOUT importing (and evaluating) the handler — `load()` pulls the full
9
+ * definition only when a handler/`input`/`failed` is actually needed (dispatch,
10
+ * or a producer of a job that declares `input`/`unique`). This is what keeps a
11
+ * worker from evaluating all job modules to run one job.
12
+ */
13
+ export interface LazyJobEntry<Name extends string = string, Queue extends string = string> {
14
+ name: Name;
15
+ queue?: Queue;
16
+ jobType?: string;
17
+ maxAttempts?: number;
18
+ tries?: number;
19
+ unique?: boolean;
20
+ /** Whether the source `defineJob` declares an `input` schema (AST flag). */
21
+ hasInput?: boolean;
22
+ /** Whether the source `defineJob` declares a `uniqueId` fn (AST flag). */
23
+ hasUniqueId?: boolean;
24
+ load: () => Promise<AnyJobDefinition>;
25
+ }
26
+ /** Either an eagerly-constructed definition or a lazily-loaded entry. */
27
+ export type RegistryEntry = AnyJobDefinition | LazyJobEntry;
28
+ export declare function isLazyJobEntry(entry: RegistryEntry): entry is LazyJobEntry;
29
+ /** Static routing metadata shared by eager defs and lazy entries. */
30
+ export type JobStaticDefinition = Pick<AnyJobDefinition, 'name' | 'queue' | 'jobType' | 'maxAttempts' | 'tries' | 'unique'>;
4
31
  export type JobPayloadOf<Job extends AnyJobDefinition> = Job extends JobDefinition<string, infer Payload, string, any, any, any> ? Payload extends object ? Payload : never : never;
5
32
  export type JobMessageOf<Job extends AnyJobDefinition> = Job extends JobDefinition<infer Name, any, string, any, any, any> ? {
6
33
  _task: Name;
@@ -49,8 +76,8 @@ export declare function validateJobDefinitions(jobs: readonly unknown[]): JobDef
49
76
  export declare function assertJobDefinitions(jobs: readonly unknown[]): void;
50
77
  export declare function defineJobRegistry<const Jobs extends readonly AnyJobDefinition[]>(jobs: Jobs): {
51
78
  jobs: Jobs;
52
- handlers: Map<string, JobHandler<unknown, unknown, unknown, unknown>>;
53
- getHandler(name: string): JobHandler<unknown, unknown, unknown, unknown> | undefined;
79
+ getHandler: (name: string) => JobHandler<unknown, unknown, unknown, unknown> | Promise<JobHandler<unknown, unknown, unknown, unknown> | undefined> | undefined;
80
+ loadJobDefinition: (name: string) => Promise<AnyJobDefinition | undefined>;
54
81
  getJobDefinition: {
55
82
  <Name extends JobNameOf<Jobs>>(name: Name): JobDefinitionByName<Jobs, Name> | undefined;
56
83
  (name: string): AnyJobDefinition | undefined;
@@ -1,4 +1,17 @@
1
1
  import { buildJobPayload } from "./payload.js";
2
+ export function isLazyJobEntry(entry) {
3
+ return typeof entry.load === "function" && typeof entry.handle !== "function";
4
+ }
5
+ function toStaticDefinition(entry) {
6
+ return {
7
+ name: entry.name,
8
+ queue: entry.queue,
9
+ jobType: entry.jobType,
10
+ maxAttempts: entry.maxAttempts,
11
+ tries: entry.tries,
12
+ unique: entry.unique
13
+ };
14
+ }
2
15
  export function defineJob(opts) {
3
16
  return opts;
4
17
  }
@@ -23,7 +36,7 @@ export function validateJobDefinitions(jobs) {
23
36
  }
24
37
  const definition = job;
25
38
  const name = typeof definition.name === "string" && definition.name.length > 0 ? definition.name : "<unknown>";
26
- if (typeof definition.name !== "string" || definition.name.length === 0 || typeof definition.handle !== "function") {
39
+ if (typeof definition.name !== "string" || definition.name.length === 0 || typeof definition.handle !== "function" && typeof definition.load !== "function") {
27
40
  issues.push({ name, reason: "invalid-definition" });
28
41
  }
29
42
  if (typeof definition.queue !== "string" || definition.queue.length === 0)
@@ -43,35 +56,58 @@ export function assertJobDefinitions(jobs) {
43
56
  }
44
57
  export function defineJobRegistry(jobs) {
45
58
  assertJobDefinitions(jobs);
46
- const handlers = new Map(
47
- jobs.map((job) => [job.name, job.handle])
48
- );
59
+ const entries = jobs;
60
+ const byName = new Map(entries.map((job) => [job.name, job]));
61
+ const loaded = /* @__PURE__ */ new Map();
62
+ function loadJobDefinition(name) {
63
+ const entry = byName.get(name);
64
+ if (!entry)
65
+ return Promise.resolve(void 0);
66
+ if (!isLazyJobEntry(entry))
67
+ return Promise.resolve(entry);
68
+ let pending = loaded.get(name);
69
+ if (!pending) {
70
+ pending = entry.load();
71
+ loaded.set(name, pending);
72
+ }
73
+ return pending;
74
+ }
49
75
  function getJobDefinition(name) {
50
- return jobs.find((job) => job.name === name);
76
+ const entry = byName.get(name);
77
+ if (!entry)
78
+ return void 0;
79
+ return isLazyJobEntry(entry) ? toStaticDefinition(entry) : entry;
51
80
  }
52
81
  function getJobQueue(name) {
53
- return jobs.find((job) => job.name === name)?.queue;
82
+ return byName.get(name)?.queue;
83
+ }
84
+ function getHandler(name) {
85
+ const entry = byName.get(name);
86
+ if (!entry)
87
+ return void 0;
88
+ if (!isLazyJobEntry(entry))
89
+ return entry.handle;
90
+ return loadJobDefinition(name).then((def) => def?.handle);
54
91
  }
55
92
  return {
56
93
  jobs,
57
- handlers,
58
- getHandler(name) {
59
- return handlers.get(name);
60
- },
94
+ getHandler,
95
+ loadJobDefinition,
61
96
  getJobDefinition,
62
97
  getJobQueue,
63
98
  buildPayload(name, payload) {
64
- const definition = jobs.find((job) => job.name === name);
65
- if (!definition)
99
+ const entry = byName.get(name);
100
+ if (!entry)
66
101
  throw new Error(`Unknown task: ${name}`);
102
+ const definition = isLazyJobEntry(entry) ? toStaticDefinition(entry) : entry;
67
103
  return buildJobMessage(definition, payload);
68
104
  },
69
105
  getJobRoute(name) {
70
- const job = jobs.find((job2) => job2.name === name);
71
- return job ? { queue: job.queue, jobType: job.jobType ?? job.name } : void 0;
106
+ const entry = byName.get(name);
107
+ return entry ? { queue: entry.queue, jobType: entry.jobType ?? entry.name } : void 0;
72
108
  },
73
109
  validate(expectedTasks) {
74
- const registered = new Set(handlers.keys());
110
+ const registered = new Set(byName.keys());
75
111
  const expected = new Set(expectedTasks);
76
112
  return {
77
113
  missing: expectedTasks.filter((task) => !registered.has(task)),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-cf-jobs",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "description": "Nuxt module for typed Cloudflare queue jobs.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -61,8 +61,9 @@
61
61
  "dependencies": {
62
62
  "@nuxt/kit": "^4.4.6",
63
63
  "@nuxt/schema": "^4.4.6",
64
- "citty": "^0.1.6",
65
- "drizzle-orm": "1.0.0-rc.3"
64
+ "citty": "^0.2.2",
65
+ "drizzle-orm": "1.0.0-rc.3",
66
+ "magicast": "^0.5.3"
66
67
  },
67
68
  "devDependencies": {
68
69
  "@antfu/eslint-config": "latest",