weifuwu 0.27.2 → 0.27.3

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.
Files changed (89) hide show
  1. package/dist/ai/provider.d.ts +45 -0
  2. package/dist/ai/stream.d.ts +13 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +131 -0
  5. package/dist/core/cookie.d.ts +36 -0
  6. package/dist/core/env.d.ts +69 -0
  7. package/dist/core/logger.d.ts +16 -0
  8. package/dist/core/router.d.ts +72 -0
  9. package/dist/core/serve.d.ts +38 -0
  10. package/dist/core/sse.d.ts +47 -0
  11. package/dist/core/trace.d.ts +95 -0
  12. package/dist/graphql.d.ts +16 -0
  13. package/dist/hub.d.ts +36 -0
  14. package/dist/index.d.ts +61 -0
  15. package/dist/index.js +3963 -0
  16. package/dist/mailer.d.ts +51 -0
  17. package/dist/middleware/compress.d.ts +20 -0
  18. package/dist/middleware/cors.d.ts +25 -0
  19. package/dist/middleware/csrf.d.ts +47 -0
  20. package/dist/middleware/flash.d.ts +90 -0
  21. package/dist/middleware/health.d.ts +24 -0
  22. package/dist/middleware/helmet.d.ts +33 -0
  23. package/dist/middleware/i18n.d.ts +39 -0
  24. package/dist/middleware/rate-limit.d.ts +44 -0
  25. package/dist/middleware/request-id.d.ts +40 -0
  26. package/dist/middleware/static.d.ts +23 -0
  27. package/dist/middleware/theme.d.ts +31 -0
  28. package/dist/middleware/upload.d.ts +55 -0
  29. package/dist/middleware/validate.d.ts +32 -0
  30. package/dist/postgres/client.d.ts +4 -0
  31. package/dist/postgres/index.d.ts +4 -0
  32. package/dist/postgres/module.d.ts +16 -0
  33. package/dist/postgres/schema/columns.d.ts +99 -0
  34. package/dist/postgres/schema/index.d.ts +6 -0
  35. package/dist/postgres/schema/sql.d.ts +22 -0
  36. package/dist/postgres/schema/table.d.ts +141 -0
  37. package/dist/postgres/schema/where.d.ts +29 -0
  38. package/dist/postgres/types.d.ts +49 -0
  39. package/dist/queue/cron.d.ts +9 -0
  40. package/dist/queue/index.d.ts +2 -0
  41. package/dist/queue/types.d.ts +61 -0
  42. package/dist/redis/client.d.ts +2 -0
  43. package/{redis/index.ts → dist/redis/index.d.ts} +2 -2
  44. package/dist/redis/types.d.ts +17 -0
  45. package/dist/test/test-utils.d.ts +193 -0
  46. package/dist/types.d.ts +50 -0
  47. package/package.json +10 -10
  48. package/ai/provider.ts +0 -129
  49. package/ai/stream.ts +0 -63
  50. package/cli.ts +0 -147
  51. package/core/cookie.ts +0 -114
  52. package/core/env.ts +0 -142
  53. package/core/logger.ts +0 -72
  54. package/core/router.ts +0 -795
  55. package/core/serve.ts +0 -294
  56. package/core/sse.ts +0 -85
  57. package/core/trace.ts +0 -146
  58. package/graphql.ts +0 -267
  59. package/hub.ts +0 -133
  60. package/index.ts +0 -71
  61. package/mailer.ts +0 -81
  62. package/middleware/compress.ts +0 -103
  63. package/middleware/cors.ts +0 -81
  64. package/middleware/csrf.ts +0 -112
  65. package/middleware/flash.ts +0 -144
  66. package/middleware/health.ts +0 -44
  67. package/middleware/helmet.ts +0 -98
  68. package/middleware/i18n.ts +0 -175
  69. package/middleware/rate-limit.ts +0 -167
  70. package/middleware/request-id.ts +0 -60
  71. package/middleware/static.ts +0 -149
  72. package/middleware/theme.ts +0 -84
  73. package/middleware/upload.ts +0 -168
  74. package/middleware/validate.ts +0 -186
  75. package/postgres/client.ts +0 -132
  76. package/postgres/index.ts +0 -4
  77. package/postgres/module.ts +0 -37
  78. package/postgres/schema/columns.ts +0 -186
  79. package/postgres/schema/index.ts +0 -36
  80. package/postgres/schema/sql.ts +0 -39
  81. package/postgres/schema/table.ts +0 -548
  82. package/postgres/schema/where.ts +0 -99
  83. package/postgres/types.ts +0 -48
  84. package/queue/cron.ts +0 -90
  85. package/queue/index.ts +0 -654
  86. package/queue/types.ts +0 -60
  87. package/redis/client.ts +0 -24
  88. package/redis/types.ts +0 -28
  89. package/types.ts +0 -78
package/queue/index.ts DELETED
@@ -1,654 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-unused-vars, no-console */
2
- import { Redis as IORedis } from 'ioredis'
3
- import crypto from 'node:crypto'
4
- import type { Context, Handler } from '../types.ts'
5
- import { Router } from '../core/router.ts'
6
- import type { Queue, QueueOptions, QueueJob, QueueJobWithError } from './types.ts'
7
- import { cronNext, parsePattern, matches } from './cron.ts'
8
-
9
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
- type JobHandler = (job: QueueJob<any>) => Promise<void>
11
-
12
- // ── Factory ─────────────────────────────────────────────────────────────────
13
-
14
- export function queue(opts?: QueueOptions): Queue {
15
- const store = opts?.store ?? 'memory'
16
-
17
- if (store === 'redis') return createRedisQueue(opts)
18
- if (store === 'pg') return createPgQueue(opts)
19
- return createMemoryQueue(opts)
20
- }
21
-
22
- // ═══════════════════════════════════════════════════════════════════════════════
23
- // Shared helpers
24
- // ═══════════════════════════════════════════════════════════════════════════════
25
-
26
- function escapeIdent(s: string): string {
27
- return '"' + s.replace(/"/g, '""') + '"'
28
- }
29
-
30
- /** Adds cron() to a queue instance: uses process + add with schedule internally. */
31
- function attachCron(q: Queue, handlers: Map<string, JobHandler>): void {
32
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
- ;(q as any).cron = function (pattern: string, handler: () => void | Promise<void>) {
34
- const id =
35
- '__cron_' + pattern.replace(/[^a-zA-Z0-9]/g, '_') + '_' + crypto.randomUUID().slice(0, 8)
36
- q.process(id, async () => {
37
- await handler()
38
- })
39
- q.add(id, {}, { schedule: pattern })
40
- return { stop: () => handlers.delete(id) }
41
- }
42
- }
43
-
44
- // ═══════════════════════════════════════════════════════════════════════════════
45
- // Memory mode
46
- // ═══════════════════════════════════════════════════════════════════════════════
47
-
48
- function createMemoryQueue(opts?: QueueOptions): Queue {
49
- const pollInterval = opts?.pollInterval ?? 200
50
- const handlers = new Map<string, JobHandler>()
51
- const pending: QueueJob[] = []
52
- const failed: QueueJobWithError[] = []
53
- const MAX_FAILED = 1000
54
- let running = false
55
- let pollTimer: ReturnType<typeof setTimeout> | null = null
56
- let _processed = 0
57
- let _failed = 0
58
- let inflight = 0
59
- const MAX_CONCURRENT = 16
60
-
61
- function insertJob(job: QueueJob): void {
62
- let i = 0
63
- while (i < pending.length && pending[i].runAt <= job.runAt) i++
64
- pending.splice(i, 0, job)
65
- }
66
-
67
- async function execute(job: QueueJob, handler: (job: QueueJob) => Promise<void>): Promise<void> {
68
- inflight++
69
- try {
70
- await handler(job)
71
- _processed++
72
- } catch (e) {
73
- _failed++
74
- failed.unshift({ ...job, error: (e as Error).message, failedAt: Date.now() })
75
- if (failed.length > MAX_FAILED) failed.length = MAX_FAILED
76
- } finally {
77
- inflight--
78
- }
79
- if (job.schedule) {
80
- try {
81
- insertJob({
82
- ...job,
83
- id: crypto.randomUUID(),
84
- runAt: cronNext(job.schedule),
85
- createdAt: Date.now(),
86
- })
87
- } catch (e) {
88
- console.error('[queue] cron re-queue failed:', (e as Error).message)
89
- }
90
- }
91
- }
92
-
93
- async function poll(): Promise<void> {
94
- if (!running) return
95
- const now = Date.now()
96
- while (running && inflight < MAX_CONCURRENT && pending.length > 0 && pending[0].runAt <= now) {
97
- const job = pending.shift()!
98
- const handler = handlers.get(job.type)
99
- if (handler) execute(job, handler)
100
- }
101
- if (running) pollTimer = setTimeout(poll, pollInterval)
102
- }
103
-
104
- const mw = ((req: Request, ctx: Context, next: Handler) => {
105
- ctx.queue = q
106
- return next(req, ctx)
107
- }) as unknown as Queue
108
-
109
- const q: Queue = mw
110
- mw.add = function add<T>(
111
- type: string,
112
- payload: T,
113
- opts?: { delay?: number; schedule?: string },
114
- ): Promise<string> {
115
- const id = crypto.randomUUID()
116
- let runAt: number
117
- if (opts?.schedule) {
118
- try {
119
- const f = parsePattern(opts.schedule)
120
- runAt = matches(f, new Date()) ? Date.now() : cronNext(opts.schedule)
121
- } catch {
122
- runAt = cronNext(opts.schedule)
123
- }
124
- } else if (opts?.delay) {
125
- runAt = Date.now() + opts.delay
126
- } else {
127
- runAt = Date.now()
128
- }
129
- const job: QueueJob<T> = { id, type, payload, createdAt: Date.now(), runAt }
130
- if (opts?.schedule) job.schedule = opts.schedule
131
- insertJob(job)
132
- return Promise.resolve(id)
133
- }
134
- mw.process = function process<T>(
135
- type: string,
136
- handler: (job: QueueJob<T>) => Promise<void>,
137
- ): void {
138
- handlers.set(type, handler)
139
- }
140
- mw.run = async function run(): Promise<void> {
141
- if (running) return
142
- running = true
143
- poll()
144
- }
145
- mw.close = async function close(): Promise<void> {
146
- running = false
147
- if (pollTimer) {
148
- clearTimeout(pollTimer)
149
- pollTimer = null
150
- }
151
- while (inflight > 0) await new Promise((r) => setTimeout(r, 50))
152
- }
153
- mw.jobs = async function (limit?: number): Promise<QueueJob[]> {
154
- return pending.slice(0, limit ?? 50)
155
- }
156
- mw.failedJobs = async function failedJobs(limit?: number): Promise<QueueJobWithError[]> {
157
- return failed.slice(0, limit ?? 50)
158
- }
159
- mw.retryFailed = async function retry(jobId: string): Promise<boolean> {
160
- const idx = failed.findIndex((j) => j.id === jobId)
161
- if (idx < 0) return false
162
- const [entry] = failed.splice(idx, 1)
163
- _failed--
164
- insertJob({ ...entry, runAt: Date.now() })
165
- return true
166
- }
167
- mw.retryAllFailed = async function retryAll(type?: string): Promise<number> {
168
- let count = 0
169
- for (let i = failed.length - 1; i >= 0; i--) {
170
- if (type && failed[i].type !== type) continue
171
- const [entry] = failed.splice(i, 1)
172
- _failed--
173
- insertJob({ ...entry, runAt: Date.now() })
174
- count++
175
- }
176
- return count
177
- }
178
- mw.dashboard = function dashboard(): Router {
179
- return buildDashboard(q)
180
- }
181
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
182
- ;(mw as any).stats = () => ({
183
- running,
184
- inflight,
185
- processed: _processed,
186
- failed: _failed,
187
- handlers: handlers.size,
188
- maxConcurrent: MAX_CONCURRENT,
189
- })
190
-
191
- attachCron(q, handlers)
192
- return q
193
- }
194
-
195
- // ═══════════════════════════════════════════════════════════════════════════════
196
- // PostgreSQL mode
197
- // ═══════════════════════════════════════════════════════════════════════════════
198
-
199
- function createPgQueue(opts?: QueueOptions): Queue {
200
- const sql = opts!.pg!.sql
201
- const pollInterval = opts?.pollInterval ?? 200
202
- const table = (opts?.prefix ?? 'queue') + '_jobs'
203
- const handlers = new Map<string, JobHandler>()
204
- let running = false,
205
- pollTimer: ReturnType<typeof setTimeout> | null = null
206
- let _processed = 0,
207
- _failed = 0,
208
- inflight = 0,
209
- ready = false
210
- const MAX_CONCURRENT = 16
211
- const MAX_FAILED = 1000
212
-
213
- async function ensureTable(): Promise<void> {
214
- if (ready) return
215
- await sql.unsafe(
216
- `CREATE TABLE IF NOT EXISTS ${escapeIdent(table)} (id UUID PRIMARY KEY, type TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}', run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), schedule TEXT, status TEXT NOT NULL DEFAULT 'pending', error TEXT, failed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`,
217
- )
218
- await sql.unsafe(
219
- `CREATE INDEX IF NOT EXISTS ${escapeIdent(table + '_run_at_idx')} ON ${escapeIdent(table)} (run_at, status)`,
220
- )
221
- ready = true
222
- }
223
-
224
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
225
- async function processJob(job: any, handler: (job: any) => Promise<void>): Promise<void> {
226
- inflight++
227
- try {
228
- await handler(job)
229
- _processed++
230
- await sql.unsafe(`DELETE FROM ${escapeIdent(table)} WHERE id = $1`, [job.id])
231
- } catch (e) {
232
- _failed++
233
- const msg = (e as Error).message
234
- console.error('[queue] handler error:', msg)
235
- await sql.unsafe(
236
- `UPDATE ${escapeIdent(table)} SET status = 'failed', error = $2, failed_at = NOW() WHERE id = $1`,
237
- [job.id, msg],
238
- )
239
- } finally {
240
- inflight--
241
- }
242
- if (job.schedule) {
243
- try {
244
- const nextRun = cronNext(job.schedule)
245
- await sql.unsafe(
246
- `INSERT INTO ${escapeIdent(table)} (id, type, payload, run_at, schedule) VALUES ($1, $2, $3::jsonb, $4, $5)`,
247
- [
248
- crypto.randomUUID(),
249
- job.type,
250
- JSON.stringify(job.payload),
251
- new Date(nextRun).toISOString(),
252
- job.schedule,
253
- ],
254
- )
255
- } catch (e) {
256
- console.error('[queue] cron re-queue failed:', (e as Error).message)
257
- }
258
- }
259
- }
260
-
261
- async function poll(): Promise<void> {
262
- if (!running) return
263
- try {
264
- while (running && inflight < MAX_CONCURRENT) {
265
- const rows = (await sql.unsafe(
266
- `UPDATE ${escapeIdent(table)} SET status = 'running' WHERE id = (SELECT id FROM ${escapeIdent(table)} WHERE run_at <= NOW() AND status = 'pending' ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING *`,
267
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
268
- )) as any[]
269
- if (rows.length === 0) break
270
- const row = rows[0]
271
- const job = {
272
- id: row.id,
273
- type: row.type,
274
- payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
275
- createdAt: new Date(row.created_at).getTime(),
276
- runAt: new Date(row.run_at).getTime(),
277
- schedule: row.schedule || undefined,
278
- }
279
- const handler = handlers.get(job.type)
280
- if (handler) processJob(job, handler)
281
- }
282
- } catch (e) {
283
- const msg = (e as Error).message
284
- if (msg.includes('CONNECTION_ENDED') || msg.includes('Connection terminated')) {
285
- running = false
286
- return
287
- }
288
- console.error('[queue] poll error:', msg)
289
- }
290
- if (running) pollTimer = setTimeout(poll, pollInterval)
291
- }
292
-
293
- const mw = ((req: Request, ctx: Context, next: Handler) => {
294
- ctx.queue = q
295
- return next(req, ctx)
296
- }) as unknown as Queue
297
- const q: Queue = mw
298
- mw.add = function add<T>(
299
- type: string,
300
- payload: T,
301
- opts?: { delay?: number; schedule?: string },
302
- ): Promise<string> {
303
- return (async () => {
304
- const id = crypto.randomUUID()
305
- let runAt: Date
306
- if (opts?.schedule) {
307
- try {
308
- const f = parsePattern(opts.schedule)
309
- runAt = matches(f, new Date()) ? new Date() : new Date(cronNext(opts.schedule))
310
- } catch {
311
- runAt = new Date(cronNext(opts.schedule))
312
- }
313
- } else if (opts?.delay) {
314
- runAt = new Date(Date.now() + opts.delay)
315
- } else {
316
- runAt = new Date()
317
- }
318
- await sql.unsafe(
319
- `INSERT INTO ${escapeIdent(table)} (id, type, payload, run_at, schedule) VALUES ($1, $2, $3::jsonb, $4, $5)`,
320
- [id, type, JSON.stringify(payload), runAt.toISOString(), opts?.schedule || null],
321
- )
322
- return id
323
- })()
324
- }
325
- mw.process = function process<T>(
326
- type: string,
327
- handler: (job: QueueJob<T>) => Promise<void>,
328
- ): void {
329
- handlers.set(type, handler)
330
- }
331
- mw.migrate = ensureTable
332
- mw.run = async function run(): Promise<void> {
333
- if (running) return
334
- await ensureTable()
335
- running = true
336
- poll()
337
- }
338
- mw.close = async function close(): Promise<void> {
339
- running = false
340
- if (pollTimer) {
341
- clearTimeout(pollTimer)
342
- pollTimer = null
343
- }
344
- while (inflight > 0) await new Promise((r) => setTimeout(r, 50))
345
- }
346
- mw.jobs = async function jobs(limit?: number): Promise<QueueJob[]> {
347
- const rows = (await sql.unsafe(
348
- `SELECT * FROM ${escapeIdent(table)} WHERE status = 'pending' ORDER BY run_at LIMIT $1`,
349
- [limit ?? 50],
350
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
351
- )) as any[]
352
- return rows.map((r) => ({
353
- id: r.id,
354
- type: r.type,
355
- payload: typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload,
356
- createdAt: new Date(r.created_at).getTime(),
357
- runAt: new Date(r.run_at).getTime(),
358
- schedule: r.schedule || undefined,
359
- }))
360
- }
361
- mw.failedJobs = async function failedJobs(limit?: number): Promise<QueueJobWithError[]> {
362
- const rows = (await sql.unsafe(
363
- `SELECT * FROM ${escapeIdent(table)} WHERE status = 'failed' ORDER BY failed_at DESC LIMIT $1`,
364
- [limit ?? 50],
365
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
366
- )) as any[]
367
- return rows.map((r) => ({
368
- id: r.id,
369
- type: r.type,
370
- payload: typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload,
371
- createdAt: new Date(r.created_at).getTime(),
372
- runAt: new Date(r.run_at).getTime(),
373
- schedule: r.schedule || undefined,
374
- error: r.error || '',
375
- failedAt: new Date(r.failed_at).getTime(),
376
- }))
377
- }
378
- mw.retryFailed = async function retryFailed(jobId: string): Promise<boolean> {
379
- const result = (await sql.unsafe(
380
- `UPDATE ${escapeIdent(table)} SET status = 'pending', error = NULL, failed_at = NULL, run_at = NOW() WHERE id = $1 AND status = 'failed' RETURNING id`,
381
- [jobId],
382
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
383
- )) as any[]
384
- return result.length > 0
385
- }
386
- mw.retryAllFailed = async function retryAllFailed(type?: string): Promise<number> {
387
- const result = (await sql.unsafe(
388
- type
389
- ? `UPDATE ${escapeIdent(table)} SET status = 'pending', error = NULL, failed_at = NULL, run_at = NOW() WHERE status = 'failed' AND type = $1 RETURNING id`
390
- : `UPDATE ${escapeIdent(table)} SET status = 'pending', error = NULL, failed_at = NULL, run_at = NOW() WHERE status = 'failed' RETURNING id`,
391
- type ? [type] : [],
392
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
393
- )) as any[]
394
- return result.length
395
- }
396
- mw.dashboard = function dashboard(): Router {
397
- return buildDashboard(q)
398
- }
399
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
400
- ;(mw as any).stats = () => ({
401
- running,
402
- inflight,
403
- processed: _processed,
404
- failed: _failed,
405
- handlers: handlers.size,
406
- maxConcurrent: MAX_CONCURRENT,
407
- })
408
-
409
- attachCron(q, handlers)
410
- return q
411
- }
412
-
413
- // ═══════════════════════════════════════════════════════════════════════════════
414
- // Redis mode
415
- // ═══════════════════════════════════════════════════════════════════════════════
416
-
417
- function createRedisQueue(opts?: QueueOptions): Queue {
418
- const redis =
419
- opts?.redis ?? new IORedis(opts?.url ?? process.env.REDIS_URL ?? 'redis://localhost:6379')
420
- const prefix = opts?.prefix ?? 'queue'
421
- const pollInterval = opts?.pollInterval ?? 200
422
- const handlers = new Map<string, JobHandler>()
423
- let running = false,
424
- pollTimer: ReturnType<typeof setTimeout> | null = null,
425
- epoch = 0
426
- let _processed = 0,
427
- _failed = 0,
428
- inflight = 0
429
- const jobKey = prefix + ':jobs',
430
- failedKey = prefix + ':failed',
431
- MAX_FAILED = 1000,
432
- MAX_CONCURRENT = 16
433
-
434
- async function processJob(
435
- job: QueueJob,
436
- handler: (job: QueueJob) => Promise<void>,
437
- ): Promise<void> {
438
- inflight++
439
- try {
440
- await handler(job)
441
- _processed++
442
- } catch (e) {
443
- _failed++
444
- const msg = (e as Error).message
445
- console.error('[queue] handler error:', msg)
446
- await redis.lpush(failedKey, JSON.stringify({ ...job, error: msg, failedAt: Date.now() }))
447
- await redis.ltrim(failedKey, 0, MAX_FAILED - 1)
448
- } finally {
449
- inflight--
450
- }
451
- if (job.schedule) {
452
- try {
453
- const nextRun = cronNext(job.schedule)
454
- await redis.zadd(
455
- jobKey,
456
- nextRun,
457
- JSON.stringify({
458
- ...job,
459
- id: crypto.randomUUID(),
460
- runAt: nextRun,
461
- createdAt: Date.now(),
462
- }),
463
- )
464
- } catch (e) {
465
- console.error('[queue] cron re-queue failed:', (e as Error).message)
466
- }
467
- }
468
- }
469
-
470
- async function poll(): Promise<void> {
471
- const currentEpoch = epoch
472
- if (!running) return
473
- try {
474
- const now = Date.now()
475
- while (running && inflight < MAX_CONCURRENT) {
476
- const result = await redis.zpopmin(jobKey)
477
- if (result.length < 2) break
478
- const raw = result[0],
479
- score = parseInt(result[1], 10)
480
- if (score > now) {
481
- await redis.zadd(jobKey, score, raw)
482
- break
483
- }
484
- let job: QueueJob
485
- try {
486
- job = JSON.parse(raw)
487
- } catch {
488
- continue
489
- }
490
- const handler = handlers.get(job.type)
491
- if (handler) processJob(job, handler)
492
- }
493
- } catch (e) {
494
- console.error('[queue] poll error:', (e as Error).message)
495
- }
496
- if (running && currentEpoch === epoch) pollTimer = setTimeout(poll, pollInterval)
497
- }
498
-
499
- const mw = ((req: Request, ctx: Context, next: Handler) => {
500
- ctx.queue = q
501
- return next(req, ctx)
502
- }) as unknown as Queue
503
- const q: Queue = mw
504
- mw.add = function add<T>(
505
- type: string,
506
- payload: T,
507
- opts?: { delay?: number; schedule?: string },
508
- ): Promise<string> {
509
- const id = crypto.randomUUID()
510
- let runAt: number
511
- if (opts?.schedule) {
512
- runAt = cronNext(opts.schedule)
513
- } else if (opts?.delay) {
514
- runAt = Date.now() + opts.delay
515
- } else {
516
- runAt = Date.now()
517
- }
518
- const job: QueueJob<T> = { id, type, payload, createdAt: Date.now(), runAt }
519
- if (opts?.schedule) job.schedule = opts.schedule
520
- return redis.zadd(jobKey, runAt, JSON.stringify(job)).then(() => id)
521
- }
522
- mw.process = function process<T>(
523
- type: string,
524
- handler: (job: QueueJob<T>) => Promise<void>,
525
- ): void {
526
- handlers.set(type, handler)
527
- }
528
- mw.run = async function run(): Promise<void> {
529
- if (running) return
530
- running = true
531
- poll()
532
- }
533
- mw.close = async function close(): Promise<void> {
534
- running = false
535
- epoch++
536
- if (pollTimer) {
537
- clearTimeout(pollTimer)
538
- pollTimer = null
539
- }
540
- while (inflight > 0) await new Promise((r) => setTimeout(r, 50))
541
- redis.disconnect()
542
- }
543
- mw.jobs = async function jobs(limit?: number): Promise<QueueJob[]> {
544
- const raw = await redis.zrevrange(jobKey, 0, (limit ?? 50) - 1)
545
- return raw
546
- .map((r: string) => {
547
- try {
548
- return JSON.parse(r)
549
- } catch {
550
- return null
551
- }
552
- })
553
- .filter(Boolean)
554
- }
555
- mw.failedJobs = async function failedJobs(limit?: number): Promise<QueueJobWithError[]> {
556
- const raw = await redis.lrange(failedKey, 0, (limit ?? 50) - 1)
557
- return raw
558
- .map((r: string) => {
559
- try {
560
- return JSON.parse(r)
561
- } catch {
562
- return null
563
- }
564
- })
565
- .filter(Boolean)
566
- }
567
- mw.retryFailed = async function retryFailed(jobId: string): Promise<boolean> {
568
- const raw = await redis.lrange(failedKey, 0, -1)
569
- for (const entry of raw) {
570
- try {
571
- const job = JSON.parse(entry)
572
- if (job.id === jobId) {
573
- await redis.lrem(failedKey, 1, entry)
574
- const reJob = { ...job, runAt: Date.now() }
575
- delete reJob.error
576
- delete reJob.failedAt
577
- await redis.zadd(jobKey, reJob.runAt, JSON.stringify(reJob))
578
- _failed--
579
- return true
580
- }
581
- } catch {}
582
- }
583
- return false
584
- }
585
- mw.retryAllFailed = async function retryAllFailed(type?: string): Promise<number> {
586
- let count = 0
587
- const raw = await redis.lrange(failedKey, 0, -1)
588
- for (const entry of raw) {
589
- try {
590
- const job = JSON.parse(entry)
591
- if (type && job.type !== type) continue
592
- await redis.lrem(failedKey, 1, entry)
593
- const reJob = { ...job, runAt: Date.now() }
594
- delete reJob.error
595
- delete reJob.failedAt
596
- await redis.zadd(jobKey, reJob.runAt, JSON.stringify(reJob))
597
- _failed--
598
- count++
599
- } catch {}
600
- }
601
- return count
602
- }
603
- mw.dashboard = function dashboard(): Router {
604
- return buildDashboard(q)
605
- }
606
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
607
- ;(mw as any).stats = () => ({
608
- running,
609
- inflight,
610
- processed: _processed,
611
- failed: _failed,
612
- handlers: handlers.size,
613
- maxConcurrent: MAX_CONCURRENT,
614
- })
615
-
616
- attachCron(q, handlers)
617
- return q
618
- }
619
-
620
- // ═══════════════════════════════════════════════════════════════════════════════
621
- // Shared dashboard
622
- // ═══════════════════════════════════════════════════════════════════════════════
623
-
624
- function buildDashboard(q: Queue): Router {
625
- const r = new Router()
626
- r.get('/', async () => {
627
- const s = q.stats()
628
- const pending = await q.jobs(100)
629
- const byType: Record<string, { pending: number; failed: number }> = {}
630
- for (const j of pending) {
631
- if (!byType[j.type]) byType[j.type] = { pending: 0, failed: 0 }
632
- byType[j.type].pending++
633
- }
634
- const failed = await q.failedJobs(1000)
635
- for (const j of failed) {
636
- if (!byType[j.type]) byType[j.type] = { pending: 0, failed: 0 }
637
- byType[j.type].failed++
638
- }
639
- return Response.json({ stats: s, types: byType, failedCount: failed.length })
640
- })
641
- r.get('/:type/failed', async (req, ctx) => {
642
- const failed = await q.failedJobs(100)
643
- return Response.json({ jobs: failed.filter((j) => j.type === ctx.params.type) })
644
- })
645
- r.post('/:type/retry', async (req, ctx) => {
646
- return Response.json({ retried: await q.retryAllFailed(ctx.params.type) })
647
- })
648
- r.post('/retry/:id', async (req, ctx) => {
649
- const ok = await q.retryFailed(ctx.params.id)
650
- if (!ok) return new Response('Not found', { status: 404 })
651
- return Response.json({ ok: true })
652
- })
653
- return r
654
- }