weifuwu 0.25.2 → 0.27.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 +291 -2489
- package/ai/provider.ts +129 -0
- package/ai/stream.ts +63 -0
- package/cli.ts +55 -257
- package/core/cookie.ts +114 -0
- package/core/env.ts +142 -0
- package/core/logger.ts +72 -0
- package/core/router.ts +795 -0
- package/core/serve.ts +294 -0
- package/core/sse.ts +85 -0
- package/core/trace.ts +146 -0
- package/graphql.ts +267 -0
- package/hub.ts +133 -0
- package/index.ts +71 -0
- package/mailer.ts +81 -0
- package/middleware/compress.ts +103 -0
- package/middleware/cors.ts +81 -0
- package/middleware/csrf.ts +112 -0
- package/middleware/flash.ts +144 -0
- package/middleware/health.ts +44 -0
- package/middleware/helmet.ts +98 -0
- package/middleware/i18n.ts +175 -0
- package/middleware/rate-limit.ts +167 -0
- package/middleware/request-id.ts +60 -0
- package/middleware/static.ts +149 -0
- package/middleware/theme.ts +84 -0
- package/middleware/upload.ts +168 -0
- package/middleware/validate.ts +186 -0
- package/package.json +14 -36
- package/postgres/client.ts +132 -0
- package/postgres/index.ts +4 -0
- package/postgres/module.ts +37 -0
- package/postgres/schema/columns.ts +186 -0
- package/postgres/schema/index.ts +36 -0
- package/postgres/schema/sql.ts +39 -0
- package/postgres/schema/table.ts +548 -0
- package/postgres/schema/where.ts +99 -0
- package/postgres/types.ts +48 -0
- package/queue/cron.ts +90 -0
- package/queue/index.ts +654 -0
- package/queue/types.ts +60 -0
- package/redis/client.ts +24 -0
- package/{dist/redis/index.d.ts → redis/index.ts} +2 -2
- package/redis/types.ts +28 -0
- package/types.ts +78 -0
- package/cli/template/app.ts +0 -22
- package/cli/template/index.ts +0 -10
- package/cli/template/locales/en.json +0 -13
- package/cli/template/locales/zh-CN.json +0 -13
- package/cli/template/locales/zh-TW.json +0 -13
- package/cli/template/locales/zh.json +0 -13
- package/cli/template/ui/app/globals.css +0 -2
- package/cli/template/ui/app/layout.tsx +0 -15
- package/cli/template/ui/app/page.tsx +0 -124
- package/cli/template/ui/components/Greeting.tsx +0 -3
- package/dist/agent/client.d.ts +0 -2
- package/dist/agent/index.d.ts +0 -2
- package/dist/agent/rest.d.ts +0 -14
- package/dist/agent/run.d.ts +0 -19
- package/dist/agent/types.d.ts +0 -55
- package/dist/ai/provider.d.ts +0 -45
- package/dist/ai/utils.d.ts +0 -5
- package/dist/ai/workflow.d.ts +0 -17
- package/dist/ai-sdk.d.ts +0 -2
- package/dist/ai.d.ts +0 -13
- package/dist/analytics.d.ts +0 -45
- package/dist/auth.d.ts +0 -22
- package/dist/cache.d.ts +0 -74
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -302
- package/dist/client-locale.d.ts +0 -25
- package/dist/client-pref.d.ts +0 -3
- package/dist/client-router.d.ts +0 -300
- package/dist/client-state.d.ts +0 -22
- package/dist/client-theme.d.ts +0 -36
- package/dist/compile.d.ts +0 -15
- package/dist/compress.d.ts +0 -20
- package/dist/cookie.d.ts +0 -36
- package/dist/cors.d.ts +0 -25
- package/dist/cron-utils.d.ts +0 -73
- package/dist/csrf.d.ts +0 -47
- package/dist/deploy/config.d.ts +0 -2
- package/dist/deploy/gateway.d.ts +0 -2
- package/dist/deploy/index.d.ts +0 -4
- package/dist/deploy/manager.d.ts +0 -16
- package/dist/deploy/process.d.ts +0 -14
- package/dist/deploy/types.d.ts +0 -53
- package/dist/env.d.ts +0 -69
- package/dist/error-boundary.d.ts +0 -2
- package/dist/flash.d.ts +0 -90
- package/dist/fts.d.ts +0 -36
- package/dist/graphql.d.ts +0 -16
- package/dist/head.d.ts +0 -6
- package/dist/health.d.ts +0 -24
- package/dist/helmet.d.ts +0 -33
- package/dist/html-shell.d.ts +0 -1
- package/dist/hub.d.ts +0 -37
- package/dist/i18n.d.ts +0 -39
- package/dist/iii/client.d.ts +0 -2
- package/dist/iii/index.d.ts +0 -4
- package/dist/iii/register-worker.d.ts +0 -9
- package/dist/iii/rest.d.ts +0 -3
- package/dist/iii/stream.d.ts +0 -82
- package/dist/iii/types.d.ts +0 -121
- package/dist/iii/worker.d.ts +0 -2
- package/dist/iii/ws.d.ts +0 -22
- package/dist/index.d.ts +0 -101
- package/dist/index.js +0 -12752
- package/dist/kb/index.d.ts +0 -3
- package/dist/kb/types.d.ts +0 -72
- package/dist/layout.d.ts +0 -2
- package/dist/live.d.ts +0 -7
- package/dist/logdb/client.d.ts +0 -2
- package/dist/logdb/index.d.ts +0 -2
- package/dist/logdb/rest.d.ts +0 -5
- package/dist/logdb/types.d.ts +0 -27
- package/dist/logger.d.ts +0 -16
- package/dist/mailer.d.ts +0 -51
- package/dist/mcp.d.ts +0 -34
- package/dist/messager/agent.d.ts +0 -11
- package/dist/messager/client.d.ts +0 -2
- package/dist/messager/index.d.ts +0 -2
- package/dist/messager/rest.d.ts +0 -15
- package/dist/messager/types.d.ts +0 -57
- package/dist/messager/ws.d.ts +0 -14
- package/dist/module-server.d.ts +0 -9
- package/dist/not-found.d.ts +0 -2
- package/dist/notifier/client.d.ts +0 -2
- package/dist/notifier/index.d.ts +0 -2
- package/dist/notifier/types.d.ts +0 -105
- package/dist/opencode/client.d.ts +0 -2
- package/dist/opencode/index.d.ts +0 -2
- package/dist/opencode/permissions.d.ts +0 -5
- package/dist/opencode/prompt.d.ts +0 -8
- package/dist/opencode/rest.d.ts +0 -16
- package/dist/opencode/run.d.ts +0 -13
- package/dist/opencode/session.d.ts +0 -26
- package/dist/opencode/skills.d.ts +0 -4
- package/dist/opencode/tools/bash.d.ts +0 -6
- package/dist/opencode/tools/edit.d.ts +0 -19
- package/dist/opencode/tools/glob.d.ts +0 -9
- package/dist/opencode/tools/grep.d.ts +0 -17
- package/dist/opencode/tools/index.d.ts +0 -12
- package/dist/opencode/tools/question.d.ts +0 -5
- package/dist/opencode/tools/read.d.ts +0 -16
- package/dist/opencode/tools/skill.d.ts +0 -18
- package/dist/opencode/tools/web.d.ts +0 -18
- package/dist/opencode/tools/write.d.ts +0 -13
- package/dist/opencode/types.d.ts +0 -90
- package/dist/opencode/ws.d.ts +0 -21
- package/dist/permissions.d.ts +0 -51
- package/dist/postgres/client.d.ts +0 -4
- package/dist/postgres/index.d.ts +0 -4
- package/dist/postgres/module.d.ts +0 -17
- package/dist/postgres/schema/columns.d.ts +0 -99
- package/dist/postgres/schema/index.d.ts +0 -6
- package/dist/postgres/schema/sql.d.ts +0 -22
- package/dist/postgres/schema/table.d.ts +0 -141
- package/dist/postgres/schema/where.d.ts +0 -29
- package/dist/postgres/types.d.ts +0 -50
- package/dist/queue/index.d.ts +0 -2
- package/dist/queue/types.d.ts +0 -62
- package/dist/rate-limit.d.ts +0 -45
- package/dist/react.d.ts +0 -14
- package/dist/react.js +0 -751
- package/dist/redis/client.d.ts +0 -2
- package/dist/redis/types.d.ts +0 -18
- package/dist/request-id.d.ts +0 -40
- package/dist/router.d.ts +0 -73
- package/dist/s3.d.ts +0 -68
- package/dist/seo.d.ts +0 -104
- package/dist/serve.d.ts +0 -38
- package/dist/server-registry.d.ts +0 -10
- package/dist/session.d.ts +0 -117
- package/dist/sse.d.ts +0 -47
- package/dist/ssr-entries.d.ts +0 -4
- package/dist/ssr.d.ts +0 -11
- package/dist/static.d.ts +0 -23
- package/dist/stream.d.ts +0 -24
- package/dist/tailwind.d.ts +0 -15
- package/dist/tenant/client.d.ts +0 -2
- package/dist/tenant/graphql.d.ts +0 -3
- package/dist/tenant/index.d.ts +0 -2
- package/dist/tenant/rest.d.ts +0 -3
- package/dist/tenant/schema.d.ts +0 -5
- package/dist/tenant/types.d.ts +0 -48
- package/dist/tenant/utils.d.ts +0 -9
- package/dist/test-utils.d.ts +0 -194
- package/dist/theme.d.ts +0 -31
- package/dist/trace.d.ts +0 -95
- package/dist/tsx-context.d.ts +0 -32
- package/dist/types.d.ts +0 -47
- package/dist/upload.d.ts +0 -55
- package/dist/use-action.d.ts +0 -42
- package/dist/use-agent-stream.d.ts +0 -49
- package/dist/use-flash-message.d.ts +0 -17
- package/dist/use-websocket.d.ts +0 -42
- package/dist/user/client.d.ts +0 -30
- package/dist/user/index.d.ts +0 -2
- package/dist/user/oauth-login.d.ts +0 -21
- package/dist/user/oauth2.d.ts +0 -31
- package/dist/user/types.d.ts +0 -178
- package/dist/validate.d.ts +0 -32
- package/dist/vendor.d.ts +0 -7
- package/dist/webhook.d.ts +0 -79
- package/opencode/ui/app/globals.css +0 -1
- package/opencode/ui/app/layout.tsx +0 -13
- package/opencode/ui/app/page.tsx +0 -523
package/queue/index.ts
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
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
|
+
}
|