nuxt-cf-jobs 0.0.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 +331 -0
- package/dist/module.d.mts +73 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +380 -0
- package/dist/runtime/server/app.d.ts +80 -0
- package/dist/runtime/server/app.js +81 -0
- package/dist/runtime/server/d1.d.ts +119 -0
- package/dist/runtime/server/d1.js +259 -0
- package/dist/runtime/server/dev.d.ts +39 -0
- package/dist/runtime/server/dev.js +93 -0
- package/dist/runtime/server/dispatch.d.ts +25 -0
- package/dist/runtime/server/dispatch.js +66 -0
- package/dist/runtime/server/index.d.ts +12 -0
- package/dist/runtime/server/index.js +12 -0
- package/dist/runtime/server/outbox.d.ts +220 -0
- package/dist/runtime/server/outbox.js +246 -0
- package/dist/runtime/server/payload.d.ts +3 -0
- package/dist/runtime/server/payload.js +3 -0
- package/dist/runtime/server/plugins/dev-queues.d.ts +2 -0
- package/dist/runtime/server/plugins/dev-queues.js +25 -0
- package/dist/runtime/server/policy.d.ts +10 -0
- package/dist/runtime/server/policy.js +49 -0
- package/dist/runtime/server/queue.d.ts +211 -0
- package/dist/runtime/server/queue.js +495 -0
- package/dist/runtime/server/registry.d.ts +79 -0
- package/dist/runtime/server/registry.js +82 -0
- package/dist/runtime/server/schema.d.ts +965 -0
- package/dist/runtime/server/schema.js +80 -0
- package/dist/runtime/server/testing.d.ts +34 -0
- package/dist/runtime/server/testing.js +61 -0
- package/dist/runtime/server/types.d.ts +123 -0
- package/dist/runtime/server/types.js +0 -0
- package/dist/types.d.mts +3 -0
- package/package.json +109 -0
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, sep, relative } from 'node:path';
|
|
3
|
+
import { defineNuxtModule, createResolver, addServerImports, addTemplate, updateTemplates, useLogger, addServerPlugin, resolveFiles } from '@nuxt/kit';
|
|
4
|
+
|
|
5
|
+
const WRANGLER_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
|
|
6
|
+
function findWranglerConfig(rootDir) {
|
|
7
|
+
for (const name of WRANGLER_FILES) {
|
|
8
|
+
const full = resolve(rootDir, name);
|
|
9
|
+
if (existsSync(full))
|
|
10
|
+
return full;
|
|
11
|
+
}
|
|
12
|
+
return void 0;
|
|
13
|
+
}
|
|
14
|
+
function stripJsoncComments(source) {
|
|
15
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'\\])\/\/.*$/gm, (_match, prefix) => prefix);
|
|
16
|
+
}
|
|
17
|
+
function camelCase(key) {
|
|
18
|
+
return key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
19
|
+
}
|
|
20
|
+
function parseTomlQueueBlocks(source) {
|
|
21
|
+
const producers = [];
|
|
22
|
+
const consumers = [];
|
|
23
|
+
const blockRe = /^\s*\[\[\s*queues\.(producers|consumers)\s*\]\]\s*$/gm;
|
|
24
|
+
const matches = [];
|
|
25
|
+
let m;
|
|
26
|
+
while ((m = blockRe.exec(source)) !== null)
|
|
27
|
+
matches.push({ kind: m[1], index: m.index + m[0].length });
|
|
28
|
+
const stopRe = /^\s*\[\[?[^\]]+\]\]?\s*$/m;
|
|
29
|
+
for (let i = 0; i < matches.length; i++) {
|
|
30
|
+
const start = matches[i].index;
|
|
31
|
+
const end = i + 1 < matches.length ? matches[i + 1].index - (matches[i + 1]?.kind.length ?? 0) : source.length;
|
|
32
|
+
const slice = source.slice(start, end);
|
|
33
|
+
const nextHeader = stopRe.exec(slice);
|
|
34
|
+
const body = nextHeader ? slice.slice(0, nextHeader.index) : slice;
|
|
35
|
+
const entry = {};
|
|
36
|
+
for (const line of body.split("\n")) {
|
|
37
|
+
const kv = line.match(/^\s*([a-z_]+)\s*=\s*(.+?)\s*(?:#.*)?$/i);
|
|
38
|
+
if (!kv)
|
|
39
|
+
continue;
|
|
40
|
+
const key = camelCase(kv[1]);
|
|
41
|
+
const raw = kv[2].trim();
|
|
42
|
+
if (raw.startsWith('"') || raw.startsWith("'"))
|
|
43
|
+
entry[key] = raw.slice(1, -1);
|
|
44
|
+
else if (/^-?\d+(?:\.\d+)?$/.test(raw))
|
|
45
|
+
entry[key] = Number(raw);
|
|
46
|
+
else
|
|
47
|
+
entry[key] = raw;
|
|
48
|
+
}
|
|
49
|
+
if (matches[i].kind === "producers" && typeof entry.binding === "string" && typeof entry.queue === "string")
|
|
50
|
+
producers.push({ binding: entry.binding, queue: entry.queue });
|
|
51
|
+
if (matches[i].kind === "consumers" && typeof entry.queue === "string") {
|
|
52
|
+
const consumer = { queue: entry.queue };
|
|
53
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
54
|
+
if (k === "queue")
|
|
55
|
+
continue;
|
|
56
|
+
consumer[k] = v;
|
|
57
|
+
}
|
|
58
|
+
consumers.push(consumer);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { producers, consumers };
|
|
62
|
+
}
|
|
63
|
+
function parseJsoncQueues(source) {
|
|
64
|
+
const stripped = stripJsoncComments(source);
|
|
65
|
+
let parsed = {};
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(stripped);
|
|
68
|
+
} catch {
|
|
69
|
+
return { producers: [], consumers: [] };
|
|
70
|
+
}
|
|
71
|
+
const queues = parsed.queues ?? {};
|
|
72
|
+
const producers = Array.isArray(queues.producers) ? queues.producers : [];
|
|
73
|
+
const consumers = Array.isArray(queues.consumers) ? queues.consumers : [];
|
|
74
|
+
return {
|
|
75
|
+
producers: producers.filter((p) => !!p && typeof p === "object" && typeof p.binding === "string" && typeof p.queue === "string").map((p) => ({ binding: p.binding, queue: p.queue })),
|
|
76
|
+
consumers: consumers.filter((c) => !!c && typeof c === "object").map((c) => {
|
|
77
|
+
const out = { queue: String(c.queue ?? "") };
|
|
78
|
+
for (const [k, v] of Object.entries(c)) {
|
|
79
|
+
if (k === "queue")
|
|
80
|
+
continue;
|
|
81
|
+
out[camelCase(k)] = v;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}).filter((c) => c.queue.length > 0)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function parseWranglerConfig(path) {
|
|
88
|
+
const source = readFileSync(path, "utf8");
|
|
89
|
+
const isJson = path.endsWith(".jsonc") || path.endsWith(".json");
|
|
90
|
+
const parsed = isJson ? parseJsoncQueues(source) : parseTomlQueueBlocks(source);
|
|
91
|
+
return { path, producers: parsed.producers, consumers: parsed.consumers };
|
|
92
|
+
}
|
|
93
|
+
function crossCheckWrangler(wrangler, expectations) {
|
|
94
|
+
if (!wrangler)
|
|
95
|
+
return [];
|
|
96
|
+
const issues = [];
|
|
97
|
+
const producersByBinding = new Map(wrangler.producers.map((p) => [p.binding, p]));
|
|
98
|
+
const consumersByQueue = new Map(wrangler.consumers.map((c) => [c.queue, c]));
|
|
99
|
+
for (const exp of expectations) {
|
|
100
|
+
const producer = producersByBinding.get(exp.binding);
|
|
101
|
+
if (!producer) {
|
|
102
|
+
issues.push({
|
|
103
|
+
reason: "missing-producer",
|
|
104
|
+
logical: exp.logical,
|
|
105
|
+
detail: `no [[queues.producers]] with binding="${exp.binding}" in ${wrangler.path}`
|
|
106
|
+
});
|
|
107
|
+
} else if (exp.explicitQueueName && producer.queue !== exp.cfQueueName) {
|
|
108
|
+
issues.push({
|
|
109
|
+
reason: "producer-queue-mismatch",
|
|
110
|
+
logical: exp.logical,
|
|
111
|
+
detail: `producer binding="${exp.binding}" points at queue="${producer.queue}" but module expects "${exp.cfQueueName}"`
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (exp.hasConsumer !== false) {
|
|
115
|
+
const effectiveQueueName = producer && !exp.explicitQueueName ? producer.queue : exp.cfQueueName;
|
|
116
|
+
const consumer = consumersByQueue.get(effectiveQueueName);
|
|
117
|
+
if (!consumer) {
|
|
118
|
+
issues.push({
|
|
119
|
+
reason: "missing-consumer",
|
|
120
|
+
logical: exp.logical,
|
|
121
|
+
detail: `no [[queues.consumers]] for queue="${effectiveQueueName}" (handler is registered but wrangler won't deliver to it)`
|
|
122
|
+
});
|
|
123
|
+
} else if (exp.maxRetries !== void 0 && consumer.maxRetries !== void 0 && consumer.maxRetries < exp.maxRetries - 1) {
|
|
124
|
+
issues.push({
|
|
125
|
+
reason: "max-retries-too-low",
|
|
126
|
+
logical: exp.logical,
|
|
127
|
+
detail: `consumer max_retries=${consumer.maxRetries} is below tries=${exp.maxRetries} from job definitions (allows only ${consumer.maxRetries + 1} deliveries)`
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return issues;
|
|
133
|
+
}
|
|
134
|
+
function renderSuggestedWranglerToml(expectations) {
|
|
135
|
+
const lines = ["# Suggested wrangler.toml snippet for nuxt-cf-jobs queues.", "# Generated; merge into your existing wrangler config.", ""];
|
|
136
|
+
for (const exp of expectations) {
|
|
137
|
+
lines.push("[[queues.producers]]");
|
|
138
|
+
lines.push(`binding = "${exp.binding}"`);
|
|
139
|
+
lines.push(`queue = "${exp.cfQueueName}"`);
|
|
140
|
+
lines.push("");
|
|
141
|
+
}
|
|
142
|
+
for (const exp of expectations) {
|
|
143
|
+
if (exp.hasConsumer === false)
|
|
144
|
+
continue;
|
|
145
|
+
lines.push("[[queues.consumers]]");
|
|
146
|
+
lines.push(`queue = "${exp.cfQueueName}"`);
|
|
147
|
+
if (exp.maxRetries !== void 0)
|
|
148
|
+
lines.push(`max_retries = ${exp.maxRetries}`);
|
|
149
|
+
lines.push("");
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const module$1 = defineNuxtModule({
|
|
155
|
+
meta: {
|
|
156
|
+
name: "nuxt-cf-jobs",
|
|
157
|
+
configKey: "cfJobs"
|
|
158
|
+
},
|
|
159
|
+
defaults: {
|
|
160
|
+
queues: {},
|
|
161
|
+
jobsDir: "server/jobs",
|
|
162
|
+
jobsPattern: "**/*.ts",
|
|
163
|
+
jobsIgnore: ["**/_*.ts", "**/*.d.ts", "**/*.test.ts", "**/*.spec.ts"],
|
|
164
|
+
registryAlias: "#cf-jobs/app"
|
|
165
|
+
},
|
|
166
|
+
setup(options, nuxt) {
|
|
167
|
+
const resolver = createResolver(import.meta.url);
|
|
168
|
+
nuxt.options.alias["#cf-jobs/server"] = resolver.resolve("./runtime/server");
|
|
169
|
+
addServerImports([
|
|
170
|
+
{ name: "defineJob", from: resolver.resolve("./runtime/server/registry") }
|
|
171
|
+
]);
|
|
172
|
+
const registryTemplate = addTemplate({
|
|
173
|
+
filename: "cf-jobs/registry.ts",
|
|
174
|
+
write: true,
|
|
175
|
+
getContents: async () => generateRegistryTemplate(options, nuxt.options.rootDir, resolve(nuxt.options.buildDir, "cf-jobs"))
|
|
176
|
+
});
|
|
177
|
+
nuxt.options.alias[options.registryAlias ?? "#cf-jobs/app"] = registryTemplate.dst;
|
|
178
|
+
nuxt.hooks.hook("builder:watch", (async (_event, path) => {
|
|
179
|
+
if (!isWatchedJobPath(path, options, nuxt.options.rootDir))
|
|
180
|
+
return;
|
|
181
|
+
await updateTemplates({ filter: (template) => template.dst === registryTemplate.dst });
|
|
182
|
+
}));
|
|
183
|
+
if (options.defaultQueue && !options.queues[options.defaultQueue])
|
|
184
|
+
useLogger("nuxt-cf-jobs").warn(`cfJobs.defaultQueue="${options.defaultQueue}" is not a key of cfJobs.queues`);
|
|
185
|
+
nuxt.options.runtimeConfig.cfJobs = {
|
|
186
|
+
...nuxt.options.runtimeConfig.cfJobs ?? {},
|
|
187
|
+
queues: nuxt.options.runtimeConfig.cfJobs?.queues ?? options.queues,
|
|
188
|
+
defaultQueue: nuxt.options.runtimeConfig.cfJobs?.defaultQueue ?? options.defaultQueue
|
|
189
|
+
};
|
|
190
|
+
if (nuxt.options.dev)
|
|
191
|
+
addServerPlugin(resolver.resolve("./runtime/server/plugins/dev-queues"));
|
|
192
|
+
if (options.validateWrangler !== false)
|
|
193
|
+
runWranglerCrossCheck(options, nuxt.options.rootDir, resolve(nuxt.options.buildDir, "cf-jobs"), nuxt.options.nitro);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
function runWranglerCrossCheck(options, rootDir, templateDir, nitroOptions) {
|
|
197
|
+
const logger = useLogger("nuxt-cf-jobs");
|
|
198
|
+
const wranglerPath = options.wranglerPath ? resolve(rootDir, options.wranglerPath) : findWranglerConfig(rootDir);
|
|
199
|
+
const expectations = [];
|
|
200
|
+
for (const [logical, config] of Object.entries(options.queues ?? {})) {
|
|
201
|
+
const binding = typeof config === "string" ? config : config?.binding;
|
|
202
|
+
if (!binding)
|
|
203
|
+
continue;
|
|
204
|
+
const explicitQueueName = typeof config === "object" && !!config?.queueName;
|
|
205
|
+
const cfQueueName = explicitQueueName ? config.queueName : logical;
|
|
206
|
+
expectations.push({ logical, binding, cfQueueName, explicitQueueName });
|
|
207
|
+
}
|
|
208
|
+
if (expectations.length === 0)
|
|
209
|
+
return;
|
|
210
|
+
const suggested = renderSuggestedWranglerToml(expectations);
|
|
211
|
+
addTemplate({
|
|
212
|
+
filename: "cf-jobs/wrangler.suggested.toml",
|
|
213
|
+
write: true,
|
|
214
|
+
getContents: () => suggested
|
|
215
|
+
});
|
|
216
|
+
const nitroQueues = readNitroCloudflareQueues(nitroOptions);
|
|
217
|
+
const fileWrangler = wranglerPath ? parseWranglerConfig(wranglerPath) : void 0;
|
|
218
|
+
const merged = mergeWranglerSources(fileWrangler, nitroQueues, wranglerPath ?? rootDir);
|
|
219
|
+
if (!merged) {
|
|
220
|
+
logger.warn(`No wrangler.{toml,jsonc,json} found in ${rootDir} and no queues declared via nitro.cloudflare.wrangler. See ${resolve(templateDir, "wrangler.suggested.toml")} for the expected [[queues.producers]] / [[queues.consumers]] blocks.`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const issues = crossCheckWrangler(merged, expectations);
|
|
224
|
+
if (issues.length === 0)
|
|
225
|
+
return;
|
|
226
|
+
const lines = issues.map((i) => ` - [${i.logical}] ${i.reason}: ${i.detail}`);
|
|
227
|
+
logger.warn(`nuxt-cf-jobs / wrangler config drift in ${merged.path}:
|
|
228
|
+
${lines.join("\n")}
|
|
229
|
+
See ${resolve(templateDir, "wrangler.suggested.toml")} for the expected blocks.`);
|
|
230
|
+
}
|
|
231
|
+
function readNitroCloudflareQueues(nitroOptions) {
|
|
232
|
+
const cf = nitroOptions?.cloudflare;
|
|
233
|
+
const queues = cf?.wrangler?.queues ?? cf?.deploy?.configuration?.queues;
|
|
234
|
+
if (!queues)
|
|
235
|
+
return void 0;
|
|
236
|
+
const producers = [];
|
|
237
|
+
const consumers = [];
|
|
238
|
+
for (const p of queues.producers ?? []) {
|
|
239
|
+
if (!p || typeof p !== "object")
|
|
240
|
+
continue;
|
|
241
|
+
const obj = p;
|
|
242
|
+
if (typeof obj.binding === "string" && typeof obj.queue === "string")
|
|
243
|
+
producers.push({ binding: obj.binding, queue: obj.queue });
|
|
244
|
+
}
|
|
245
|
+
for (const c of queues.consumers ?? []) {
|
|
246
|
+
if (!c || typeof c !== "object")
|
|
247
|
+
continue;
|
|
248
|
+
const obj = c;
|
|
249
|
+
if (typeof obj.queue !== "string")
|
|
250
|
+
continue;
|
|
251
|
+
const consumer = { queue: obj.queue };
|
|
252
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
253
|
+
if (k === "queue")
|
|
254
|
+
continue;
|
|
255
|
+
consumer[k.replace(/_([a-z])/g, (_, c2) => c2.toUpperCase())] = v;
|
|
256
|
+
}
|
|
257
|
+
consumers.push(consumer);
|
|
258
|
+
}
|
|
259
|
+
return { producers, consumers };
|
|
260
|
+
}
|
|
261
|
+
function mergeWranglerSources(fileWrangler, nitroQueues, fallbackPath) {
|
|
262
|
+
if (!fileWrangler && !nitroQueues)
|
|
263
|
+
return void 0;
|
|
264
|
+
const producerKey = (p) => `${p.binding}::${p.queue}`;
|
|
265
|
+
const consumerKey = (c) => c.queue;
|
|
266
|
+
const producers = /* @__PURE__ */ new Map();
|
|
267
|
+
const consumers = /* @__PURE__ */ new Map();
|
|
268
|
+
for (const p of fileWrangler?.producers ?? [])
|
|
269
|
+
producers.set(producerKey(p), p);
|
|
270
|
+
for (const c of fileWrangler?.consumers ?? [])
|
|
271
|
+
consumers.set(consumerKey(c), c);
|
|
272
|
+
for (const p of nitroQueues?.producers ?? [])
|
|
273
|
+
producers.set(producerKey(p), p);
|
|
274
|
+
for (const c of nitroQueues?.consumers ?? [])
|
|
275
|
+
consumers.set(consumerKey(c), c);
|
|
276
|
+
return {
|
|
277
|
+
path: fileWrangler?.path ?? `${fallbackPath} (nitro.cloudflare.deploy.configuration)`,
|
|
278
|
+
producers: [...producers.values()],
|
|
279
|
+
consumers: [...consumers.values()]
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
async function generateRegistryTemplate(options, rootDir, templateDir) {
|
|
283
|
+
const files = await resolveJobFiles(options, rootDir);
|
|
284
|
+
assertUniqueGeneratedJobNames(files, options, rootDir);
|
|
285
|
+
const imports = files.map((file, index) => {
|
|
286
|
+
return `import job${index} from ${JSON.stringify(toImportPath(templateDir, file))}`;
|
|
287
|
+
});
|
|
288
|
+
const loaders = files.map((file) => {
|
|
289
|
+
return ` ${JSON.stringify(toJobName(file, options, rootDir))}: () => import(${JSON.stringify(toImportPath(templateDir, file))}).then(m => m.default),`;
|
|
290
|
+
});
|
|
291
|
+
const jobItems = files.map((_, index) => `job${index}`).join(", ");
|
|
292
|
+
return [
|
|
293
|
+
"/* This file is generated by nuxt-cf-jobs. Do not edit directly. */",
|
|
294
|
+
`import { createCfJobsApp } from '#cf-jobs/server'`,
|
|
295
|
+
`import { useRuntimeConfig } from '#imports'`,
|
|
296
|
+
`import type { JobDefinitionsByNameOfLoaders, JobMessageByName, JobMessageByQueue, JobNameOf, JobPayloadByName, JobPayloadOf, JobQueueByName, QueueNameOf } from '#cf-jobs/server'`,
|
|
297
|
+
`export type { QueueConsumerOptions } from '#cf-jobs/server'`,
|
|
298
|
+
...imports,
|
|
299
|
+
"",
|
|
300
|
+
"export const jobLoaders = {",
|
|
301
|
+
...loaders,
|
|
302
|
+
"} as const",
|
|
303
|
+
`export const jobs = [${jobItems}] as const`,
|
|
304
|
+
`export const app = createCfJobsApp(jobs, useRuntimeConfig as never, { defaultQueue: ${options.defaultQueue ? JSON.stringify(options.defaultQueue) : "undefined"} })`,
|
|
305
|
+
"export const jobRegistry = app.jobRegistry",
|
|
306
|
+
"",
|
|
307
|
+
"export type Jobs = typeof jobs",
|
|
308
|
+
"export type JobsByName = JobDefinitionsByNameOfLoaders<typeof jobLoaders>",
|
|
309
|
+
"export type JobName = keyof JobsByName & JobNameOf<Jobs>",
|
|
310
|
+
"export type JobDefinitionOf<Name extends JobName> = JobsByName[Name]",
|
|
311
|
+
"export type QueueName = QueueNameOf<Jobs>",
|
|
312
|
+
"export type JobPayload<Name extends JobName> = JobPayloadOf<JobDefinitionOf<Name>>",
|
|
313
|
+
"export type JobQueue<Name extends JobName> = JobQueueByName<Jobs, Name>",
|
|
314
|
+
"export type JobMessage<Name extends JobName> = JobMessageByName<Jobs, Name>",
|
|
315
|
+
"export type QueueMessage<Queue extends QueueName> = JobMessageByQueue<Jobs, Queue>",
|
|
316
|
+
"",
|
|
317
|
+
"export const {",
|
|
318
|
+
" getHandler,",
|
|
319
|
+
" getJobDefinition,",
|
|
320
|
+
" getJobQueue,",
|
|
321
|
+
" getJobRoute,",
|
|
322
|
+
" validateRegistry,",
|
|
323
|
+
" validateQueueBindings,",
|
|
324
|
+
" assertQueueBindings,",
|
|
325
|
+
" getQueue,",
|
|
326
|
+
" buildJobPayload,",
|
|
327
|
+
" prepareJob,",
|
|
328
|
+
" registerQueueConsumer,",
|
|
329
|
+
"} = app",
|
|
330
|
+
""
|
|
331
|
+
].join("\n");
|
|
332
|
+
}
|
|
333
|
+
async function resolveJobFiles(options, rootDir) {
|
|
334
|
+
const dirs = toArray(options.jobsDir ?? "server/jobs");
|
|
335
|
+
const pattern = options.jobsPattern ?? "**/*.ts";
|
|
336
|
+
const ignore = options.jobsIgnore ?? [];
|
|
337
|
+
const files = await Promise.all(dirs.map((dir) => {
|
|
338
|
+
const resolvedDir = resolve(rootDir, dir);
|
|
339
|
+
if (!existsSync(resolvedDir))
|
|
340
|
+
return [];
|
|
341
|
+
return resolveFiles(resolvedDir, pattern, { ignore });
|
|
342
|
+
}));
|
|
343
|
+
return files.flat();
|
|
344
|
+
}
|
|
345
|
+
function assertUniqueGeneratedJobNames(files, options, rootDir) {
|
|
346
|
+
const seen = /* @__PURE__ */ new Map();
|
|
347
|
+
const duplicates = [];
|
|
348
|
+
for (const file of files) {
|
|
349
|
+
const name = toJobName(file, options, rootDir);
|
|
350
|
+
const previous = seen.get(name);
|
|
351
|
+
if (previous)
|
|
352
|
+
duplicates.push(`${name} (${previous}, ${file})`);
|
|
353
|
+
else
|
|
354
|
+
seen.set(name, file);
|
|
355
|
+
}
|
|
356
|
+
if (duplicates.length > 0)
|
|
357
|
+
throw new Error(`Duplicate nuxt-cf-jobs generated job names: ${duplicates.join(", ")}`);
|
|
358
|
+
}
|
|
359
|
+
function isWatchedJobPath(path, options, rootDir) {
|
|
360
|
+
const dirs = toArray(options.jobsDir ?? "server/jobs").map((dir) => resolve(rootDir, dir));
|
|
361
|
+
const absolutePath = resolve(rootDir, path);
|
|
362
|
+
return dirs.some((dir) => absolutePath === dir || absolutePath.startsWith(dir + sep));
|
|
363
|
+
}
|
|
364
|
+
function toArray(value) {
|
|
365
|
+
return Array.isArray(value) ? value : [value];
|
|
366
|
+
}
|
|
367
|
+
function toImportPath(fromDir, file) {
|
|
368
|
+
const path = relative(fromDir, file).replace(/\\/g, "/").replace(/\.[cm]?tsx?$/, "");
|
|
369
|
+
return path.startsWith(".") ? path : `./${path}`;
|
|
370
|
+
}
|
|
371
|
+
function toJobName(file, options, rootDir) {
|
|
372
|
+
const jobDirs = toArray(options.jobsDir ?? "server/jobs");
|
|
373
|
+
const dirs = jobDirs.map((dir2) => resolve(rootDir, dir2)).sort((a, b) => b.length - a.length);
|
|
374
|
+
const dir = dirs.find((dir2) => file.startsWith(`${dir2}/`) || file === dir2);
|
|
375
|
+
const fallbackDir = jobDirs[0] ?? "server/jobs";
|
|
376
|
+
const path = relative(dir ?? resolve(rootDir, fallbackDir), file).replace(/\\/g, "/").replace(/\.[cm]?tsx?$/, "");
|
|
377
|
+
return path.replace(/\/index$/, "");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export { module$1 as default };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AnyJobDefinition, JobNameOf, JobPayloadByName, JobQueueByName } from './registry.js';
|
|
2
|
+
import type { PrepareRegisteredDurableJobOptions } from './outbox.js';
|
|
3
|
+
import type { QueueSource, RegisterRegisteredQueueConsumerOptions } from './queue.js';
|
|
4
|
+
import type { QueueBindingsConfig } from './types.js';
|
|
5
|
+
import { createJobQueue } from './queue.js';
|
|
6
|
+
export interface CfJobsRuntimeConfig {
|
|
7
|
+
cfJobs: {
|
|
8
|
+
queues: QueueBindingsConfig;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export type CfJobsQueueConsumerOptions<Env extends Record<string, unknown>, Db, Logger> = Omit<RegisterRegisteredQueueConsumerOptions<Env, Db, Logger>, 'registry' | 'queues'>;
|
|
12
|
+
export interface CreateCfJobsAppOptions {
|
|
13
|
+
/** Fallback queue applied to jobs whose `defineJob` omits `queue`. */
|
|
14
|
+
defaultQueue?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function createCfJobsApp<const Jobs extends readonly AnyJobDefinition[]>(jobs: Jobs, useRuntimeConfig: (event?: unknown) => CfJobsRuntimeConfig, appOpts?: CreateCfJobsAppOptions): {
|
|
17
|
+
jobs: Jobs;
|
|
18
|
+
jobRegistry: {
|
|
19
|
+
jobs: Jobs;
|
|
20
|
+
handlers: Map<string, import("./types.js").JobHandler<unknown, unknown, unknown, unknown>>;
|
|
21
|
+
getHandler(name: string): import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined;
|
|
22
|
+
getJobDefinition: {
|
|
23
|
+
<Name extends JobNameOf<Jobs>>(name: Name): Extract<Jobs[number], {
|
|
24
|
+
name: Name;
|
|
25
|
+
}> | undefined;
|
|
26
|
+
(name: string): AnyJobDefinition | undefined;
|
|
27
|
+
};
|
|
28
|
+
getJobQueue: {
|
|
29
|
+
<Name extends JobNameOf<Jobs>>(name: Name): JobQueueByName<Jobs, Name> | undefined;
|
|
30
|
+
(name: string): string | undefined;
|
|
31
|
+
};
|
|
32
|
+
buildPayload<Name extends JobNameOf<Jobs>>(name: Name, payload: JobPayloadByName<Jobs, Name>): {
|
|
33
|
+
_task: Name;
|
|
34
|
+
} & JobPayloadByName<Jobs, Name>;
|
|
35
|
+
getJobRoute(name: string): {
|
|
36
|
+
queue: string;
|
|
37
|
+
jobType: string;
|
|
38
|
+
} | undefined;
|
|
39
|
+
validate(expectedTasks: readonly string[]): {
|
|
40
|
+
missing: string[];
|
|
41
|
+
extra: string[];
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
getHandler: (name: string) => import("./types.js").JobHandler<unknown, unknown, unknown, unknown> | undefined;
|
|
45
|
+
getJobDefinition: {
|
|
46
|
+
<Name extends JobNameOf<Jobs>>(name: Name): Extract<Jobs[number], {
|
|
47
|
+
name: Name;
|
|
48
|
+
}> | undefined;
|
|
49
|
+
(name: string): AnyJobDefinition | undefined;
|
|
50
|
+
};
|
|
51
|
+
getJobQueue: {
|
|
52
|
+
<Name extends JobNameOf<Jobs>>(name: Name): JobQueueByName<Jobs, Name> | undefined;
|
|
53
|
+
(name: string): string | undefined;
|
|
54
|
+
};
|
|
55
|
+
getJobRoute: (name: string) => {
|
|
56
|
+
queue: string;
|
|
57
|
+
jobType: string;
|
|
58
|
+
} | undefined;
|
|
59
|
+
validateRegistry: (expectedTasks: readonly string[]) => {
|
|
60
|
+
missing: string[];
|
|
61
|
+
extra: string[];
|
|
62
|
+
};
|
|
63
|
+
validateQueueBindings: (queues?: QueueBindingsConfig) => import("./queue.js").QueueBindingValidationIssue[];
|
|
64
|
+
assertQueueBindings: (queues?: QueueBindingsConfig) => void;
|
|
65
|
+
getQueue: {
|
|
66
|
+
<const Job extends Jobs[number]>(job: Job): ReturnType<typeof createJobQueue<Job>>;
|
|
67
|
+
<const Job extends Jobs[number]>(source: QueueSource | undefined, job: Job): ReturnType<typeof createJobQueue<Job>>;
|
|
68
|
+
};
|
|
69
|
+
buildJobPayload: <Name extends JobNameOf<Jobs>>(name: Name, payload: JobPayloadByName<Jobs, Name>) => {
|
|
70
|
+
_task: Name;
|
|
71
|
+
} & JobPayloadByName<Jobs, Name>;
|
|
72
|
+
prepareJob: <Name extends JobNameOf<Jobs>>(opts: PrepareRegisteredDurableJobOptions<Jobs, Name>) => Promise<import("./outbox.js").DurableJobRecord<JobQueueByName<Jobs, Name>>>;
|
|
73
|
+
registerQueueConsumer: <Env extends Record<string, unknown>, Db, Logger>(nitroApp: {
|
|
74
|
+
hooks: {
|
|
75
|
+
hook: (name: any, handler: any) => void;
|
|
76
|
+
};
|
|
77
|
+
}, opts: CfJobsQueueConsumerOptions<Env, Db, Logger>) => void;
|
|
78
|
+
};
|
|
79
|
+
export type CfJobsApp<Jobs extends readonly AnyJobDefinition[]> = ReturnType<typeof createCfJobsApp<Jobs>>;
|
|
80
|
+
export type QueueConsumerOptions<Env extends Record<string, unknown>, Db, Logger> = CfJobsQueueConsumerOptions<Env, Db, Logger>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { defineJobRegistry } from "./registry.js";
|
|
2
|
+
import { prepareRegisteredDurableJob } from "./outbox.js";
|
|
3
|
+
import {
|
|
4
|
+
assertJobQueueBindings,
|
|
5
|
+
createJobQueue,
|
|
6
|
+
registerRegisteredQueueConsumer,
|
|
7
|
+
resolveNitroTaskEnv,
|
|
8
|
+
validateJobQueueBindings,
|
|
9
|
+
validateQueueBindingShape,
|
|
10
|
+
validateQueueConsumerConfig
|
|
11
|
+
} from "./queue.js";
|
|
12
|
+
import { validateJobDefinitions } from "./registry.js";
|
|
13
|
+
export function createCfJobsApp(jobs, useRuntimeConfig, appOpts = {}) {
|
|
14
|
+
const effectiveJobs = appOpts.defaultQueue ? jobs.map((job) => job.queue ? job : { ...job, queue: appOpts.defaultQueue }) : jobs;
|
|
15
|
+
const jobRegistry = defineJobRegistry(effectiveJobs);
|
|
16
|
+
function getQueue(sourceOrJob, maybeJob) {
|
|
17
|
+
const isJobOnly = maybeJob === void 0 && isJobDefinition(sourceOrJob);
|
|
18
|
+
const job = isJobOnly ? sourceOrJob : maybeJob;
|
|
19
|
+
const source = isJobOnly ? void 0 : sourceOrJob;
|
|
20
|
+
const resolvedSource = source ?? (() => {
|
|
21
|
+
const env = resolveNitroTaskEnv();
|
|
22
|
+
return env ? { context: { cloudflare: { env } } } : void 0;
|
|
23
|
+
})();
|
|
24
|
+
const runtimeConfig = resolvedSource && typeof resolvedSource === "object" && "context" in resolvedSource ? useRuntimeConfig(resolvedSource) : useRuntimeConfig();
|
|
25
|
+
return createJobQueue(resolvedSource, runtimeConfig.cfJobs.queues, job);
|
|
26
|
+
}
|
|
27
|
+
function buildJobPayload(name, payload) {
|
|
28
|
+
return jobRegistry.buildPayload(name, payload);
|
|
29
|
+
}
|
|
30
|
+
function prepareJob(opts) {
|
|
31
|
+
return prepareRegisteredDurableJob(jobRegistry, opts);
|
|
32
|
+
}
|
|
33
|
+
let startupLogged = false;
|
|
34
|
+
function logStartupWarnings(queues) {
|
|
35
|
+
if (startupLogged)
|
|
36
|
+
return;
|
|
37
|
+
startupLogged = true;
|
|
38
|
+
const issues = [];
|
|
39
|
+
for (const issue of validateJobDefinitions(effectiveJobs))
|
|
40
|
+
issues.push(`[job:${issue.name}] ${issue.reason}`);
|
|
41
|
+
for (const issue of validateQueueBindingShape(queues))
|
|
42
|
+
issues.push(`[queue:${issue.queue}] ${issue.reason}: ${issue.detail}`);
|
|
43
|
+
for (const issue of validateJobQueueBindings(queues, effectiveJobs))
|
|
44
|
+
issues.push(`[job:${issue.jobName}] missing binding for queue "${issue.queue}"`);
|
|
45
|
+
for (const issue of validateQueueConsumerConfig(queues, effectiveJobs))
|
|
46
|
+
issues.push(`[job:${issue.jobName ?? "?"}@${issue.queue}] ${issue.reason}: ${issue.detail}`);
|
|
47
|
+
if (issues.length === 0)
|
|
48
|
+
return;
|
|
49
|
+
console.warn(`[nuxt-cf-jobs] configuration warnings:
|
|
50
|
+
${issues.map((i) => ` - ${i}`).join("\n")}`);
|
|
51
|
+
}
|
|
52
|
+
function registerQueueConsumer(nitroApp, opts) {
|
|
53
|
+
const queues = useRuntimeConfig().cfJobs.queues;
|
|
54
|
+
logStartupWarnings(queues);
|
|
55
|
+
return registerRegisteredQueueConsumer(nitroApp, {
|
|
56
|
+
...opts,
|
|
57
|
+
registry: jobRegistry,
|
|
58
|
+
queues: () => useRuntimeConfig().cfJobs.queues
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const validateQueueBindings = (queues = useRuntimeConfig().cfJobs.queues) => validateJobQueueBindings(queues, effectiveJobs);
|
|
62
|
+
const assertQueueBindings = (queues = useRuntimeConfig().cfJobs.queues) => assertJobQueueBindings(queues, effectiveJobs);
|
|
63
|
+
return {
|
|
64
|
+
jobs: effectiveJobs,
|
|
65
|
+
jobRegistry,
|
|
66
|
+
getHandler: jobRegistry.getHandler,
|
|
67
|
+
getJobDefinition: jobRegistry.getJobDefinition,
|
|
68
|
+
getJobQueue: jobRegistry.getJobQueue,
|
|
69
|
+
getJobRoute: jobRegistry.getJobRoute,
|
|
70
|
+
validateRegistry: jobRegistry.validate,
|
|
71
|
+
validateQueueBindings,
|
|
72
|
+
assertQueueBindings,
|
|
73
|
+
getQueue,
|
|
74
|
+
buildJobPayload,
|
|
75
|
+
prepareJob,
|
|
76
|
+
registerQueueConsumer
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function isJobDefinition(value) {
|
|
80
|
+
return !!value && typeof value === "object" && typeof value.name === "string" && typeof value.handle === "function";
|
|
81
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { DurableJobFailureRepository, DurableJobLifecycle, DurableJobRecord, DurableJobRecoveryRepository, DurableJobRepository, ReleaseDurableJobOptions } from './outbox.js';
|
|
2
|
+
export interface D1PreparedStatementLike<T = unknown> {
|
|
3
|
+
bind: (...values: unknown[]) => D1PreparedStatementLike<T>;
|
|
4
|
+
run: () => Promise<{
|
|
5
|
+
success?: boolean;
|
|
6
|
+
meta?: {
|
|
7
|
+
changes?: number;
|
|
8
|
+
};
|
|
9
|
+
}>;
|
|
10
|
+
first: <Result = T>() => Promise<Result | null>;
|
|
11
|
+
all?: <Result = T>() => Promise<{
|
|
12
|
+
results?: Result[];
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
export interface D1DatabaseLike {
|
|
16
|
+
exec: (query: string) => Promise<unknown>;
|
|
17
|
+
prepare: <T = unknown>(query: string) => D1PreparedStatementLike<T>;
|
|
18
|
+
/** Optional batch API matching `D1Database.batch`. When absent, batched ops fall back to sequential `.run()`. */
|
|
19
|
+
batch?: (statements: D1PreparedStatementLike<unknown>[]) => Promise<Array<{
|
|
20
|
+
success?: boolean;
|
|
21
|
+
meta?: {
|
|
22
|
+
changes?: number;
|
|
23
|
+
};
|
|
24
|
+
}>>;
|
|
25
|
+
}
|
|
26
|
+
export interface D1DurableJobRecord<Queue extends string = string> {
|
|
27
|
+
id: string;
|
|
28
|
+
queue: Queue;
|
|
29
|
+
job_type: string;
|
|
30
|
+
batch_id: string | null;
|
|
31
|
+
user_id: number | null;
|
|
32
|
+
site_id: string | null;
|
|
33
|
+
partner_id: string | null;
|
|
34
|
+
trace_id: string | null;
|
|
35
|
+
unique_key: string | null;
|
|
36
|
+
payload: string;
|
|
37
|
+
attempts: number;
|
|
38
|
+
max_attempts: number;
|
|
39
|
+
reserved_at: number | null;
|
|
40
|
+
available_at: number;
|
|
41
|
+
created_at: number;
|
|
42
|
+
completed_at: number | null;
|
|
43
|
+
failed_at: number | null;
|
|
44
|
+
last_error: string | null;
|
|
45
|
+
retry_reasons?: string | null;
|
|
46
|
+
rows_fetched?: number | null;
|
|
47
|
+
rows_inserted?: number | null;
|
|
48
|
+
d1_rows_read?: number | null;
|
|
49
|
+
d1_rows_written?: number | null;
|
|
50
|
+
duration_ms?: number | null;
|
|
51
|
+
}
|
|
52
|
+
export interface D1FailedDurableJobRecord<Queue extends string = string> {
|
|
53
|
+
id: string;
|
|
54
|
+
queue: Queue;
|
|
55
|
+
job_type: string;
|
|
56
|
+
batch_id: string | null;
|
|
57
|
+
user_id: number | null;
|
|
58
|
+
site_id: string | null;
|
|
59
|
+
partner_id: string | null;
|
|
60
|
+
trace_id: string | null;
|
|
61
|
+
unique_key: string | null;
|
|
62
|
+
payload: string;
|
|
63
|
+
exception: string;
|
|
64
|
+
attempts: number;
|
|
65
|
+
max_attempts: number;
|
|
66
|
+
failed_at: number;
|
|
67
|
+
}
|
|
68
|
+
export interface D1DurableJobRepositoryOptions<Queue extends string = string> {
|
|
69
|
+
jobsTable?: string;
|
|
70
|
+
failedJobsTable?: string;
|
|
71
|
+
batchesTable?: string;
|
|
72
|
+
/** Fire-and-forget hook invoked after a successful claim. Errors are swallowed. */
|
|
73
|
+
onJobClaimed?: (input: {
|
|
74
|
+
job: D1DurableJobRecord<Queue>;
|
|
75
|
+
}) => void;
|
|
76
|
+
/** Fire-and-forget hook invoked after `completeJob` writes succeed. Errors are swallowed. */
|
|
77
|
+
onJobCompleted?: (input: {
|
|
78
|
+
job: D1DurableJobRecord<Queue>;
|
|
79
|
+
durationMs: number | null;
|
|
80
|
+
result?: unknown;
|
|
81
|
+
}) => void;
|
|
82
|
+
/** Fire-and-forget hook invoked after `failJob` writes succeed. Errors are swallowed. */
|
|
83
|
+
onJobFailed?: (input: {
|
|
84
|
+
job: D1DurableJobRecord<Queue>;
|
|
85
|
+
error: string;
|
|
86
|
+
}) => void;
|
|
87
|
+
/** Fire-and-forget hook invoked after `releaseJob` writes succeed. Errors are swallowed. */
|
|
88
|
+
onJobReleased?: (input: {
|
|
89
|
+
job: D1DurableJobRecord<Queue>;
|
|
90
|
+
opts: ReleaseDurableJobOptions | undefined;
|
|
91
|
+
}) => void;
|
|
92
|
+
}
|
|
93
|
+
export interface D1InsertJobsChunkResult {
|
|
94
|
+
ok: boolean;
|
|
95
|
+
ids: string[];
|
|
96
|
+
changes: number;
|
|
97
|
+
error?: unknown;
|
|
98
|
+
}
|
|
99
|
+
export interface D1InsertJobsResult<Queue extends string = string> {
|
|
100
|
+
inserted: Array<DurableJobRecord<Queue>>;
|
|
101
|
+
chunks: D1InsertJobsChunkResult[];
|
|
102
|
+
}
|
|
103
|
+
export type D1DurableJobRepository<Queue extends string = string> = DurableJobRepository<Queue, DurableJobRecord<Queue>> & DurableJobRecoveryRepository<Queue, D1DurableJobRecord<Queue>> & DurableJobLifecycle<D1DurableJobRecord<Queue>> & DurableJobFailureRepository & {
|
|
104
|
+
migrate: () => Promise<void>;
|
|
105
|
+
insertJobs: (records: readonly DurableJobRecord<Queue>[], opts?: {
|
|
106
|
+
batchSize?: number;
|
|
107
|
+
}) => Promise<D1InsertJobsResult<Queue>>;
|
|
108
|
+
toDispatchableJob: (job: D1DurableJobRecord<Queue>) => {
|
|
109
|
+
id: string;
|
|
110
|
+
queue: Queue;
|
|
111
|
+
payload: Record<string, unknown>;
|
|
112
|
+
attempts: number;
|
|
113
|
+
batchId: string | null;
|
|
114
|
+
siteId: string | null;
|
|
115
|
+
userId: number | null;
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
export declare const d1DurableJobMigrationSql: string[];
|
|
119
|
+
export declare function createD1DurableJobRepository<Queue extends string = string>(db: D1DatabaseLike, opts?: D1DurableJobRepositoryOptions<Queue>): D1DurableJobRepository<Queue>;
|