weifuwu 0.27.28 → 0.28.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/index.d.ts +8 -0
- package/index.js +26 -0
- package/package.json +9 -52
- package/README.md +0 -711
- package/dist/ai/provider.d.ts +0 -45
- package/dist/ai/stream.d.ts +0 -13
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -126
- package/dist/core/cookie.d.ts +0 -36
- package/dist/core/env.d.ts +0 -69
- package/dist/core/logger.d.ts +0 -16
- package/dist/core/router.d.ts +0 -88
- package/dist/core/serve.d.ts +0 -38
- package/dist/core/sse.d.ts +0 -47
- package/dist/core/trace.d.ts +0 -95
- package/dist/graphql.d.ts +0 -16
- package/dist/hub.d.ts +0 -36
- package/dist/index.d.ts +0 -51
- package/dist/index.js +0 -3249
- package/dist/middleware/compress.d.ts +0 -20
- package/dist/middleware/cors.d.ts +0 -25
- package/dist/middleware/health.d.ts +0 -24
- package/dist/middleware/helmet.d.ts +0 -33
- package/dist/middleware/rate-limit.d.ts +0 -44
- package/dist/middleware/request-id.d.ts +0 -40
- package/dist/middleware/static.d.ts +0 -23
- package/dist/middleware/upload.d.ts +0 -55
- package/dist/middleware/validate.d.ts +0 -32
- package/dist/postgres/client.d.ts +0 -4
- package/dist/postgres/index.d.ts +0 -3
- package/dist/postgres/module.d.ts +0 -12
- package/dist/postgres/types.d.ts +0 -42
- package/dist/queue/cron.d.ts +0 -9
- package/dist/queue/index.d.ts +0 -2
- package/dist/queue/types.d.ts +0 -61
- package/dist/redis/client.d.ts +0 -2
- package/dist/redis/index.d.ts +0 -2
- package/dist/redis/types.d.ts +0 -17
- package/dist/test/test-utils.d.ts +0 -193
- package/dist/types.d.ts +0 -76
package/dist/index.js
DELETED
|
@@ -1,3249 +0,0 @@
|
|
|
1
|
-
// types.ts
|
|
2
|
-
var HttpError = class extends Error {
|
|
3
|
-
status;
|
|
4
|
-
constructor(message, status) {
|
|
5
|
-
super(message);
|
|
6
|
-
this.name = "HttpError";
|
|
7
|
-
this.status = status;
|
|
8
|
-
}
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
// core/trace.ts
|
|
12
|
-
import crypto from "node:crypto";
|
|
13
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
-
var als = new AsyncLocalStorage();
|
|
15
|
-
function currentTraceId() {
|
|
16
|
-
return als.getStore()?.traceId;
|
|
17
|
-
}
|
|
18
|
-
function currentTrace() {
|
|
19
|
-
return als.getStore();
|
|
20
|
-
}
|
|
21
|
-
function runWithTrace(incomingTraceId, fn) {
|
|
22
|
-
const traceId = incomingTraceId || crypto.randomUUID();
|
|
23
|
-
const startTime = Date.now();
|
|
24
|
-
return als.run({ traceId, startTime }, fn);
|
|
25
|
-
}
|
|
26
|
-
function traceElapsed() {
|
|
27
|
-
const ctx = als.getStore();
|
|
28
|
-
if (!ctx) return 0;
|
|
29
|
-
return Date.now() - ctx.startTime;
|
|
30
|
-
}
|
|
31
|
-
function trace(options) {
|
|
32
|
-
const header = options?.header ?? "X-Request-ID";
|
|
33
|
-
const gen = options?.generator ?? (() => crypto.randomUUID());
|
|
34
|
-
return async (req, ctx, next) => {
|
|
35
|
-
const existing = req.headers.get(header);
|
|
36
|
-
const requestId2 = existing ?? gen();
|
|
37
|
-
const tc = als.getStore();
|
|
38
|
-
ctx.trace = {
|
|
39
|
-
requestId: requestId2,
|
|
40
|
-
traceId: tc?.traceId ?? requestId2,
|
|
41
|
-
startTime: tc?.startTime ?? Date.now(),
|
|
42
|
-
elapsed: () => {
|
|
43
|
-
const t = als.getStore();
|
|
44
|
-
return t ? Date.now() - t.startTime : 0;
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
const res = await next(req, ctx);
|
|
48
|
-
if (res.headers.has(header)) return res;
|
|
49
|
-
const h = new Headers(res.headers);
|
|
50
|
-
h.set(header, requestId2);
|
|
51
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// core/env.ts
|
|
56
|
-
import { readFileSync } from "node:fs";
|
|
57
|
-
import { resolve } from "node:path";
|
|
58
|
-
var PUBLIC_PREFIX = "WEIFUWU_PUBLIC_";
|
|
59
|
-
function getPublicEnv() {
|
|
60
|
-
const result = {};
|
|
61
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
62
|
-
if (key.startsWith(PUBLIC_PREFIX) && value !== void 0) {
|
|
63
|
-
result[key.slice(PUBLIC_PREFIX.length)] = value;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return result;
|
|
67
|
-
}
|
|
68
|
-
function isBundled() {
|
|
69
|
-
return typeof __WFW_BUNDLED__ !== "undefined" ? __WFW_BUNDLED__ : false;
|
|
70
|
-
}
|
|
71
|
-
function isDev() {
|
|
72
|
-
const env2 = process.env.NODE_ENV;
|
|
73
|
-
return env2 !== "production" && env2 !== "test";
|
|
74
|
-
}
|
|
75
|
-
function isProd() {
|
|
76
|
-
return process.env.NODE_ENV === "production";
|
|
77
|
-
}
|
|
78
|
-
function loadEnv(path) {
|
|
79
|
-
const filePath = resolve(process.cwd(), path ?? ".env");
|
|
80
|
-
let content;
|
|
81
|
-
try {
|
|
82
|
-
content = readFileSync(filePath, "utf-8");
|
|
83
|
-
} catch {
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
for (const line of content.split("\n")) {
|
|
87
|
-
const trimmed = line.trim();
|
|
88
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
89
|
-
const eqIdx = trimmed.indexOf("=");
|
|
90
|
-
if (eqIdx === -1) continue;
|
|
91
|
-
const key = trimmed.slice(0, eqIdx).trim();
|
|
92
|
-
if (!key) continue;
|
|
93
|
-
if (process.env[key] !== void 0) continue;
|
|
94
|
-
let value = trimmed.slice(eqIdx + 1).trim();
|
|
95
|
-
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
96
|
-
value = value.slice(1, -1);
|
|
97
|
-
} else {
|
|
98
|
-
const commentIdx = value.search(/\s#/);
|
|
99
|
-
if (commentIdx !== -1) {
|
|
100
|
-
value = value.slice(0, commentIdx).trimEnd();
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
process.env[key] = value;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
function env() {
|
|
107
|
-
const entries = getPublicEnv();
|
|
108
|
-
return async (req, ctx, next) => {
|
|
109
|
-
;
|
|
110
|
-
ctx.env = entries;
|
|
111
|
-
return next(req, ctx);
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// core/serve.ts
|
|
116
|
-
import http from "node:http";
|
|
117
|
-
var DEFAULT_MAX_BODY = 10 * 1024 * 1024;
|
|
118
|
-
async function readBody(req, maxSize) {
|
|
119
|
-
const limit = maxSize ?? DEFAULT_MAX_BODY;
|
|
120
|
-
if (limit > 0) {
|
|
121
|
-
const cl = parseInt(req.headers["content-length"] ?? "0", 10);
|
|
122
|
-
if (cl > limit) throw new HttpError("Request body too large", 413);
|
|
123
|
-
}
|
|
124
|
-
const chunks = [];
|
|
125
|
-
let total = 0;
|
|
126
|
-
for await (const chunk of req) {
|
|
127
|
-
total += chunk.byteLength;
|
|
128
|
-
if (limit > 0 && total > limit) throw new HttpError("Request body too large", 413);
|
|
129
|
-
chunks.push(chunk);
|
|
130
|
-
}
|
|
131
|
-
return Buffer.concat(chunks);
|
|
132
|
-
}
|
|
133
|
-
function createRequest(req, body) {
|
|
134
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
135
|
-
const query = Object.fromEntries(url.searchParams);
|
|
136
|
-
const headers = {};
|
|
137
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
138
|
-
if (value !== void 0) {
|
|
139
|
-
headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
const request = new Request(url.href, {
|
|
143
|
-
method: req.method?.toUpperCase() ?? "GET",
|
|
144
|
-
headers,
|
|
145
|
-
body: req.method !== "GET" && req.method !== "HEAD" && body.length > 0 ? body : null
|
|
146
|
-
});
|
|
147
|
-
return [request, query];
|
|
148
|
-
}
|
|
149
|
-
async function sendResponse(res, response, opts) {
|
|
150
|
-
const headers = {};
|
|
151
|
-
response.headers.forEach((value, key) => {
|
|
152
|
-
if (key.toLowerCase() === "set-cookie") {
|
|
153
|
-
const existing = headers[key];
|
|
154
|
-
headers[key] = existing ? Array.isArray(existing) ? [...existing, value] : [existing, value] : value;
|
|
155
|
-
} else {
|
|
156
|
-
headers[key] = value;
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
if (opts?.traceId && !headers["x-trace-id"]) {
|
|
160
|
-
headers["x-trace-id"] = opts.traceId;
|
|
161
|
-
}
|
|
162
|
-
res.writeHead(response.status, response.statusText, headers);
|
|
163
|
-
if (response.body) {
|
|
164
|
-
const reader = response.body.getReader();
|
|
165
|
-
try {
|
|
166
|
-
while (true) {
|
|
167
|
-
const { done, value } = await reader.read();
|
|
168
|
-
if (done) break;
|
|
169
|
-
res.write(value);
|
|
170
|
-
}
|
|
171
|
-
res.end();
|
|
172
|
-
} catch (err) {
|
|
173
|
-
if (!res.destroyed) {
|
|
174
|
-
res.destroy(err instanceof Error ? err : void 0);
|
|
175
|
-
}
|
|
176
|
-
} finally {
|
|
177
|
-
reader.releaseLock();
|
|
178
|
-
}
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
res.end();
|
|
182
|
-
}
|
|
183
|
-
async function createTestServer(handler, options) {
|
|
184
|
-
const server = serve(handler, { ...options, port: options?.port ?? 0, shutdown: false });
|
|
185
|
-
await server.ready;
|
|
186
|
-
return { server, url: `http://localhost:${server.port}` };
|
|
187
|
-
}
|
|
188
|
-
function serve(handler, options) {
|
|
189
|
-
const port = options?.port ?? 0;
|
|
190
|
-
const hostname = options?.hostname ?? "0.0.0.0";
|
|
191
|
-
const server = http.createServer(async (req, res) => {
|
|
192
|
-
const incomingTrace = req.headers["x-trace-id"] || req.headers["traceparent"]?.split("-")[1] || null;
|
|
193
|
-
await runWithTrace(incomingTrace, async () => {
|
|
194
|
-
try {
|
|
195
|
-
const body = await readBody(req, options?.maxBodySize);
|
|
196
|
-
const [request, query] = createRequest(req, body);
|
|
197
|
-
const response = await handler(request, { params: {}, query });
|
|
198
|
-
await sendResponse(res, response, { traceId: currentTraceId() });
|
|
199
|
-
} catch (err) {
|
|
200
|
-
if (err instanceof HttpError && err.status === 413) {
|
|
201
|
-
res.writeHead(413, { "Content-Type": "text/plain" });
|
|
202
|
-
res.end("Request Body Too Large");
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
206
|
-
console.error(`[${currentTraceId()}] unhandled error: ${msg}`);
|
|
207
|
-
if (err instanceof Error && err.stack) console.error(err.stack);
|
|
208
|
-
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
209
|
-
res.end("Internal Server Error");
|
|
210
|
-
}
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
server.timeout = options?.timeout ?? 3e4;
|
|
214
|
-
server.keepAliveTimeout = options?.keepAliveTimeout ?? 5e3;
|
|
215
|
-
server.headersTimeout = options?.headersTimeout ?? 6e3;
|
|
216
|
-
if (options?.websocket) {
|
|
217
|
-
server.on("upgrade", options.websocket);
|
|
218
|
-
}
|
|
219
|
-
let resolveReady;
|
|
220
|
-
const ready = new Promise((r) => {
|
|
221
|
-
resolveReady = r;
|
|
222
|
-
});
|
|
223
|
-
let shutdownHandler = null;
|
|
224
|
-
if (options?.shutdown !== false) {
|
|
225
|
-
let shuttingDown = false;
|
|
226
|
-
const shutdown = () => {
|
|
227
|
-
if (shuttingDown) return;
|
|
228
|
-
shuttingDown = true;
|
|
229
|
-
server.close();
|
|
230
|
-
const timer = setTimeout(() => {
|
|
231
|
-
server.closeAllConnections();
|
|
232
|
-
process.exit(0);
|
|
233
|
-
}, 1e4);
|
|
234
|
-
server.on("close", () => {
|
|
235
|
-
clearTimeout(timer);
|
|
236
|
-
process.exit(0);
|
|
237
|
-
});
|
|
238
|
-
};
|
|
239
|
-
shutdownHandler = shutdown;
|
|
240
|
-
process.on("SIGTERM", shutdown);
|
|
241
|
-
process.on("SIGINT", shutdown);
|
|
242
|
-
}
|
|
243
|
-
let _cachedPort = 0;
|
|
244
|
-
let _cachedHostname = "";
|
|
245
|
-
if (options?.signal) {
|
|
246
|
-
if (options.signal.aborted) {
|
|
247
|
-
_cachedPort = 0;
|
|
248
|
-
_cachedHostname = "";
|
|
249
|
-
server.close();
|
|
250
|
-
resolveReady();
|
|
251
|
-
return {
|
|
252
|
-
stop: () => Promise.resolve(),
|
|
253
|
-
close: () => Promise.resolve(),
|
|
254
|
-
ready,
|
|
255
|
-
get port() {
|
|
256
|
-
return 0;
|
|
257
|
-
},
|
|
258
|
-
get hostname() {
|
|
259
|
-
return hostname;
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
options.signal.addEventListener(
|
|
264
|
-
"abort",
|
|
265
|
-
() => {
|
|
266
|
-
server.close();
|
|
267
|
-
},
|
|
268
|
-
{ once: true }
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
server.on("error", (err) => {
|
|
272
|
-
console.error("Failed to start server:", err.message);
|
|
273
|
-
server.close();
|
|
274
|
-
_cachedPort = 0;
|
|
275
|
-
resolveReady();
|
|
276
|
-
});
|
|
277
|
-
server.listen(port, hostname, () => {
|
|
278
|
-
const addr = server.address();
|
|
279
|
-
if (addr && typeof addr !== "string") {
|
|
280
|
-
_cachedPort = addr.port;
|
|
281
|
-
_cachedHostname = addr.address;
|
|
282
|
-
}
|
|
283
|
-
resolveReady();
|
|
284
|
-
const displayHost = _cachedHostname === "0.0.0.0" ? "localhost" : _cachedHostname || "localhost";
|
|
285
|
-
console.log(`weifuwu listening on http://${displayHost}:${_cachedPort}`);
|
|
286
|
-
});
|
|
287
|
-
async function stop(timeoutMs = 1e4) {
|
|
288
|
-
if (shutdownHandler) {
|
|
289
|
-
process.off("SIGTERM", shutdownHandler);
|
|
290
|
-
process.off("SIGINT", shutdownHandler);
|
|
291
|
-
shutdownHandler = null;
|
|
292
|
-
}
|
|
293
|
-
if (!server.listening) return;
|
|
294
|
-
server.close();
|
|
295
|
-
server.closeIdleConnections();
|
|
296
|
-
return new Promise((resolve3) => {
|
|
297
|
-
const timer = setTimeout(() => {
|
|
298
|
-
server.closeAllConnections();
|
|
299
|
-
resolve3();
|
|
300
|
-
}, timeoutMs);
|
|
301
|
-
server.on("close", () => {
|
|
302
|
-
clearTimeout(timer);
|
|
303
|
-
resolve3();
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
return {
|
|
308
|
-
close: stop,
|
|
309
|
-
stop,
|
|
310
|
-
ready,
|
|
311
|
-
get port() {
|
|
312
|
-
if (!server.listening) return 0;
|
|
313
|
-
return _cachedPort;
|
|
314
|
-
},
|
|
315
|
-
get hostname() {
|
|
316
|
-
if (!server.listening) return hostname;
|
|
317
|
-
return _cachedHostname || hostname;
|
|
318
|
-
}
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// core/router.ts
|
|
323
|
-
import { WebSocketServer } from "ws";
|
|
324
|
-
|
|
325
|
-
// hub.ts
|
|
326
|
-
function createHub(opts) {
|
|
327
|
-
const prefix = opts?.prefix ?? "hub:";
|
|
328
|
-
const channels = /* @__PURE__ */ new Map();
|
|
329
|
-
const wsKeys = /* @__PURE__ */ new Map();
|
|
330
|
-
let redisPub;
|
|
331
|
-
let redisSub = null;
|
|
332
|
-
if (opts?.redis) {
|
|
333
|
-
redisPub = opts.redis;
|
|
334
|
-
redisSub = opts.redis.duplicate();
|
|
335
|
-
redisSub.on("message", (rawChannel, rawData) => {
|
|
336
|
-
if (!rawChannel.startsWith(prefix)) return;
|
|
337
|
-
const key = rawChannel.slice(prefix.length);
|
|
338
|
-
const members = channels.get(key);
|
|
339
|
-
if (!members) return;
|
|
340
|
-
for (const ws of members) {
|
|
341
|
-
try {
|
|
342
|
-
ws.send(rawData);
|
|
343
|
-
} catch {
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
function join2(key, ws) {
|
|
349
|
-
if (!channels.has(key)) {
|
|
350
|
-
channels.set(key, /* @__PURE__ */ new Set());
|
|
351
|
-
redisSub?.subscribe(`${prefix}${key}`);
|
|
352
|
-
}
|
|
353
|
-
channels.get(key).add(ws);
|
|
354
|
-
let keys = wsKeys.get(ws);
|
|
355
|
-
if (!keys) {
|
|
356
|
-
keys = /* @__PURE__ */ new Set();
|
|
357
|
-
wsKeys.set(ws, keys);
|
|
358
|
-
}
|
|
359
|
-
keys.add(key);
|
|
360
|
-
if (typeof ws.addEventListener === "function") {
|
|
361
|
-
ws.addEventListener("close", () => removeFromChannels(ws));
|
|
362
|
-
ws.addEventListener("error", () => removeFromChannels(ws));
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
function removeFromChannels(ws) {
|
|
366
|
-
const keys = wsKeys.get(ws);
|
|
367
|
-
if (keys) {
|
|
368
|
-
for (const key of keys) {
|
|
369
|
-
const members = channels.get(key);
|
|
370
|
-
if (members) {
|
|
371
|
-
members.delete(ws);
|
|
372
|
-
if (members.size === 0) channels.delete(key);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
wsKeys.delete(ws);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
function leave(ws) {
|
|
379
|
-
removeFromChannels(ws);
|
|
380
|
-
}
|
|
381
|
-
function broadcast(key, data) {
|
|
382
|
-
const msg = JSON.stringify(data);
|
|
383
|
-
const members = channels.get(key);
|
|
384
|
-
if (members) {
|
|
385
|
-
const dead = [];
|
|
386
|
-
for (const ws of members) {
|
|
387
|
-
try {
|
|
388
|
-
ws.send(msg);
|
|
389
|
-
} catch {
|
|
390
|
-
dead.push(ws);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
for (const ws of dead) removeFromChannels(ws);
|
|
394
|
-
}
|
|
395
|
-
redisPub?.publish(`${prefix}${key}`, msg);
|
|
396
|
-
}
|
|
397
|
-
async function close() {
|
|
398
|
-
for (const ws of wsKeys.keys()) {
|
|
399
|
-
removeFromChannels(ws);
|
|
400
|
-
}
|
|
401
|
-
channels.clear();
|
|
402
|
-
wsKeys.clear();
|
|
403
|
-
if (redisSub) {
|
|
404
|
-
redisSub.removeAllListeners("message");
|
|
405
|
-
await redisSub.quit();
|
|
406
|
-
}
|
|
407
|
-
redisPub = void 0;
|
|
408
|
-
redisSub = null;
|
|
409
|
-
}
|
|
410
|
-
return { join: join2, leave, broadcast, close };
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// core/router.ts
|
|
414
|
-
var createTrieNode = () => ({
|
|
415
|
-
children: /* @__PURE__ */ new Map(),
|
|
416
|
-
handlers: /* @__PURE__ */ new Map(),
|
|
417
|
-
middlewares: /* @__PURE__ */ new Map(),
|
|
418
|
-
pathMws: []
|
|
419
|
-
});
|
|
420
|
-
var createWsNode = () => ({
|
|
421
|
-
children: /* @__PURE__ */ new Map(),
|
|
422
|
-
middlewares: []
|
|
423
|
-
});
|
|
424
|
-
function createParamChild(node, segment, createNode) {
|
|
425
|
-
const paramName = segment.slice(1);
|
|
426
|
-
if (!node.children.has(":")) {
|
|
427
|
-
const child2 = createNode();
|
|
428
|
-
child2.param = paramName;
|
|
429
|
-
node.children.set(":", child2);
|
|
430
|
-
}
|
|
431
|
-
const child = node.children.get(":");
|
|
432
|
-
if (child.param !== paramName) {
|
|
433
|
-
throw new Error(
|
|
434
|
-
`Param name conflict: ":${child.param}" already registered, cannot register ":"${paramName}"`
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
return child;
|
|
438
|
-
}
|
|
439
|
-
function getOrCreateChild(node, segment, createNode, allowWildcard) {
|
|
440
|
-
if (allowWildcard && segment === "*") {
|
|
441
|
-
node.wildcard = true;
|
|
442
|
-
return node;
|
|
443
|
-
}
|
|
444
|
-
if (segment.startsWith(":")) return createParamChild(node, segment, createNode);
|
|
445
|
-
if (!node.children.has(segment)) node.children.set(segment, createNode());
|
|
446
|
-
return node.children.get(segment);
|
|
447
|
-
}
|
|
448
|
-
function matchChild(node, segment, params, allowWildcard = false) {
|
|
449
|
-
if (node.children.has(segment)) return node.children.get(segment);
|
|
450
|
-
if (node.children.has(":")) {
|
|
451
|
-
const child = node.children.get(":");
|
|
452
|
-
if (child.param) params[child.param] = segment;
|
|
453
|
-
return child;
|
|
454
|
-
}
|
|
455
|
-
if (allowWildcard && node.wildcard) return node;
|
|
456
|
-
return null;
|
|
457
|
-
}
|
|
458
|
-
var Router = class _Router {
|
|
459
|
-
root = createTrieNode();
|
|
460
|
-
wsRoot = createWsNode();
|
|
461
|
-
globalMws = [];
|
|
462
|
-
errorHandler;
|
|
463
|
-
_hasWildcard = false;
|
|
464
|
-
_hub;
|
|
465
|
-
_wss;
|
|
466
|
-
/** Track which ctx fields have been injected so far (for dependency checking). */
|
|
467
|
-
_ctxFields = /* @__PURE__ */ new Set();
|
|
468
|
-
get wss() {
|
|
469
|
-
if (!this._wss) this._wss = new WebSocketServer({ noServer: true });
|
|
470
|
-
return this._wss;
|
|
471
|
-
}
|
|
472
|
-
get hub() {
|
|
473
|
-
if (!this._hub) this._hub = createHub();
|
|
474
|
-
return this._hub;
|
|
475
|
-
}
|
|
476
|
-
/** Inject a custom hub (e.g. with Redis for cross-process broadcast). */
|
|
477
|
-
wsHub(hub) {
|
|
478
|
-
this._hub = hub;
|
|
479
|
-
return this;
|
|
480
|
-
}
|
|
481
|
-
use(arg1, arg2) {
|
|
482
|
-
if (typeof arg1 === "string") {
|
|
483
|
-
if (arg2 instanceof _Router) {
|
|
484
|
-
this._mountRouter(arg1, arg2);
|
|
485
|
-
} else if (typeof arg2 === "function") {
|
|
486
|
-
let node = this.root;
|
|
487
|
-
for (const segment of this.splitPath(arg1)) {
|
|
488
|
-
node = getOrCreateChild(node, segment, createTrieNode, false);
|
|
489
|
-
}
|
|
490
|
-
node.pathMws.push(arg2);
|
|
491
|
-
this._checkMiddlewareMeta(arg2, `${arg1}`);
|
|
492
|
-
}
|
|
493
|
-
} else if (typeof arg1 === "function") {
|
|
494
|
-
this.globalMws.push(arg1);
|
|
495
|
-
this._checkMiddlewareMeta(arg1, "global");
|
|
496
|
-
} else if (typeof arg1 === "object" && arg1 !== null && "middleware" in arg1 && // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
497
|
-
typeof arg1.middleware === "function" && arg1 instanceof _Router) {
|
|
498
|
-
const mod = arg1;
|
|
499
|
-
const mw = mod.middleware();
|
|
500
|
-
this.globalMws.push(mw);
|
|
501
|
-
this._checkMiddlewareMeta(mw, "global (auto-registered)");
|
|
502
|
-
this._mountRouter("/", mod);
|
|
503
|
-
}
|
|
504
|
-
return this;
|
|
505
|
-
}
|
|
506
|
-
/**
|
|
507
|
-
* Check a middleware's dependency metadata and emit warnings if
|
|
508
|
-
* required fields haven't been injected yet.
|
|
509
|
-
* Attach __meta to a middleware function:
|
|
510
|
-
*
|
|
511
|
-
* ```ts
|
|
512
|
-
* mw.__meta = { injects: ['sql'], depends: ['session'] }
|
|
513
|
-
* ```
|
|
514
|
-
*/
|
|
515
|
-
_checkMiddlewareMeta(mw, location) {
|
|
516
|
-
const meta = mw.__meta ?? (typeof mw === "object" && mw && "middleware" in mw ? mw.middleware().__meta : void 0);
|
|
517
|
-
if (!meta) return;
|
|
518
|
-
for (const dep of meta.depends) {
|
|
519
|
-
if (!this._ctxFields.has(dep)) {
|
|
520
|
-
console.warn(
|
|
521
|
-
`[weifuwu] Middleware at "${location}" depends on ctx.${dep} but it hasn't been registered yet.
|
|
522
|
-
Register the provider before this middleware:
|
|
523
|
-
app.use(${dep}()) // add before this middleware
|
|
524
|
-
Current ctx fields: [${[...this._ctxFields].join(", ")}]`
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
for (const field of meta.injects) {
|
|
529
|
-
this._ctxFields.add(field);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
// Route registration — returns Router<T> unchanged.
|
|
533
|
-
// Route-level middleware and handlers get Context<T>.
|
|
534
|
-
get(path, ...args) {
|
|
535
|
-
return this._route("GET", path, ...args);
|
|
536
|
-
}
|
|
537
|
-
post(path, ...args) {
|
|
538
|
-
return this._route("POST", path, ...args);
|
|
539
|
-
}
|
|
540
|
-
put(path, ...args) {
|
|
541
|
-
return this._route("PUT", path, ...args);
|
|
542
|
-
}
|
|
543
|
-
delete(path, ...args) {
|
|
544
|
-
return this._route("DELETE", path, ...args);
|
|
545
|
-
}
|
|
546
|
-
patch(path, ...args) {
|
|
547
|
-
return this._route("PATCH", path, ...args);
|
|
548
|
-
}
|
|
549
|
-
head(path, ...args) {
|
|
550
|
-
return this._route("HEAD", path, ...args);
|
|
551
|
-
}
|
|
552
|
-
options(path, ...args) {
|
|
553
|
-
return this._route("OPTIONS", path, ...args);
|
|
554
|
-
}
|
|
555
|
-
all(path, ...args) {
|
|
556
|
-
return this._route("*", path, ...args);
|
|
557
|
-
}
|
|
558
|
-
onError(handler) {
|
|
559
|
-
this.errorHandler = handler;
|
|
560
|
-
return this;
|
|
561
|
-
}
|
|
562
|
-
_route(method, path, ...args) {
|
|
563
|
-
return this._routeImpl(method, path, args);
|
|
564
|
-
}
|
|
565
|
-
/** Internal route registration — no type constraints (used by _mountRouter). */
|
|
566
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
567
|
-
_routeImpl(method, path, args) {
|
|
568
|
-
const last = args[args.length - 1];
|
|
569
|
-
if (last instanceof _Router) {
|
|
570
|
-
this._mountRouter(path, last, args.slice(0, -1));
|
|
571
|
-
return this;
|
|
572
|
-
}
|
|
573
|
-
const handler = args.pop();
|
|
574
|
-
const middlewares = args;
|
|
575
|
-
const segments = this.splitPath(path);
|
|
576
|
-
let node = this.root;
|
|
577
|
-
for (const segment of segments) {
|
|
578
|
-
if (segment === "*") {
|
|
579
|
-
this._hasWildcard = true;
|
|
580
|
-
const remaining = segments.indexOf("*") < segments.length - 1;
|
|
581
|
-
if (remaining) {
|
|
582
|
-
console.warn(`Route "${path}": segments after "*" are ignored`);
|
|
583
|
-
}
|
|
584
|
-
node.wildcard = true;
|
|
585
|
-
node.handlers.set(method, handler);
|
|
586
|
-
if (middlewares.length > 0) node.middlewares.set(method, middlewares);
|
|
587
|
-
return this;
|
|
588
|
-
}
|
|
589
|
-
node = getOrCreateChild(node, segment, createTrieNode, false);
|
|
590
|
-
}
|
|
591
|
-
if (!isProd() && node.handlers.has(method)) {
|
|
592
|
-
console.warn(`[router] route conflict: ${method} ${path} overwrites existing handler`);
|
|
593
|
-
}
|
|
594
|
-
node.handlers.set(method, handler);
|
|
595
|
-
if (middlewares.length > 0) node.middlewares.set(method, middlewares);
|
|
596
|
-
return this;
|
|
597
|
-
}
|
|
598
|
-
ws(path, ...args) {
|
|
599
|
-
const handler = args.pop();
|
|
600
|
-
const middlewares = args;
|
|
601
|
-
const segments = this.splitPath(path);
|
|
602
|
-
let node = this.wsRoot;
|
|
603
|
-
for (const segment of segments) {
|
|
604
|
-
node = getOrCreateChild(node, segment, createWsNode, true);
|
|
605
|
-
}
|
|
606
|
-
node.handler = handler;
|
|
607
|
-
node.middlewares = middlewares;
|
|
608
|
-
return this;
|
|
609
|
-
}
|
|
610
|
-
handler() {
|
|
611
|
-
return (req, ctx) => {
|
|
612
|
-
const url = new URL(req.url);
|
|
613
|
-
return this.handle(req, ctx, this.splitPath(url.pathname));
|
|
614
|
-
};
|
|
615
|
-
}
|
|
616
|
-
/** Returns a human-readable list of all registered routes. Useful for debugging. */
|
|
617
|
-
routes() {
|
|
618
|
-
const result = [];
|
|
619
|
-
if (this.globalMws.length > 0) {
|
|
620
|
-
result.push(`MIDDLEWARE [${this.globalMws.length} global]`);
|
|
621
|
-
}
|
|
622
|
-
this._collectRoutes(this.root, "", result);
|
|
623
|
-
this._collectWsRoutes(this.wsRoot, "", result);
|
|
624
|
-
return result;
|
|
625
|
-
}
|
|
626
|
-
_collectRoutes(node, prefix, result) {
|
|
627
|
-
for (const [method] of node.handlers) {
|
|
628
|
-
const m = method === "*" ? "ANY" : method;
|
|
629
|
-
const path = (prefix || "/") + (node.wildcard ? "/*" : "");
|
|
630
|
-
const middlewares = node.middlewares.get(method);
|
|
631
|
-
const mwCount = middlewares ? ` (+${middlewares.length} mw)` : "";
|
|
632
|
-
result.push(`${m.padEnd(7)} ${path}${mwCount}`);
|
|
633
|
-
}
|
|
634
|
-
for (const [seg, child] of node.children) {
|
|
635
|
-
const segment = seg === ":" ? `:${child.param}` : seg;
|
|
636
|
-
this._collectRoutes(child, prefix + "/" + segment, result);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
_collectWsRoutes(node, prefix, result) {
|
|
640
|
-
if (node.handler) {
|
|
641
|
-
const path = prefix || "/";
|
|
642
|
-
const mwCount = node.middlewares.length ? ` (+${node.middlewares.length} mw)` : "";
|
|
643
|
-
result.push(`WS ${path}${mwCount}`);
|
|
644
|
-
}
|
|
645
|
-
for (const [seg, child] of node.children) {
|
|
646
|
-
const segment = seg === ":" ? `:${child.param}` : seg;
|
|
647
|
-
this._collectWsRoutes(child, prefix + "/" + segment, result);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
websocketHandler() {
|
|
651
|
-
const wsRoot = this.wsRoot;
|
|
652
|
-
const router = this;
|
|
653
|
-
return (req, socket, head) => {
|
|
654
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
655
|
-
const segments = url.pathname.split("/").filter(Boolean);
|
|
656
|
-
const match = router.matchWsTrie(wsRoot, segments);
|
|
657
|
-
if (!match) {
|
|
658
|
-
socket.destroy();
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
const query = Object.fromEntries(url.searchParams);
|
|
662
|
-
const ctx = { params: match.params, query };
|
|
663
|
-
const allMws = router.globalMws.length === 0 && match.middlewares.length === 0 ? [] : [...router.globalMws, ...match.middlewares];
|
|
664
|
-
if (allMws.length === 0) {
|
|
665
|
-
upgradeSocket(router.wss, req, socket, head, match.handler, ctx, router.hub);
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
const finalHandler = () => {
|
|
669
|
-
try {
|
|
670
|
-
upgradeSocket(router.wss, req, socket, head, match.handler, ctx, router.hub);
|
|
671
|
-
} catch {
|
|
672
|
-
socket.destroy();
|
|
673
|
-
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
674
|
-
}
|
|
675
|
-
return new Response(null, { status: 200 });
|
|
676
|
-
};
|
|
677
|
-
const webReq = new Request(url.href, {
|
|
678
|
-
method: req.method ?? "GET",
|
|
679
|
-
headers: nodeReqHeadersToRecord(req.headers)
|
|
680
|
-
});
|
|
681
|
-
void router.runChain(allMws, finalHandler, webReq, ctx).then((result) => {
|
|
682
|
-
if (result.status >= 400) {
|
|
683
|
-
sendHttpResponseOnSocket(socket, result);
|
|
684
|
-
}
|
|
685
|
-
}).catch(() => {
|
|
686
|
-
socket.destroy();
|
|
687
|
-
});
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
_mountRouter(prefix, sub, extraMws = []) {
|
|
691
|
-
const base = prefix === "/" ? "" : prefix.replace(/\/$/, "");
|
|
692
|
-
const mountMw = (req, ctx, next) => {
|
|
693
|
-
ctx.mountPath = (ctx.mountPath || "") + base;
|
|
694
|
-
return next(req, ctx);
|
|
695
|
-
};
|
|
696
|
-
const allExtra = extraMws.length === 0 && sub.globalMws.length === 0 ? [mountMw] : [mountMw, ...extraMws, ...sub.globalMws];
|
|
697
|
-
const routes = [];
|
|
698
|
-
this._collect(sub.root, "", routes, []);
|
|
699
|
-
for (const { method, path, handler, middlewares } of routes) {
|
|
700
|
-
this._routeImpl(method, base + path, [...allExtra, ...middlewares, handler]);
|
|
701
|
-
}
|
|
702
|
-
const wsRoutes = [];
|
|
703
|
-
this._collectWs(sub.wsRoot, "", wsRoutes);
|
|
704
|
-
for (const { path, handler, middlewares } of wsRoutes) {
|
|
705
|
-
this.ws(
|
|
706
|
-
base + path,
|
|
707
|
-
...allExtra,
|
|
708
|
-
...middlewares,
|
|
709
|
-
handler
|
|
710
|
-
);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
mergeMws(base, extra) {
|
|
714
|
-
if (base.length === 0) return extra.length === 0 ? base : extra;
|
|
715
|
-
if (extra.length === 0) return base;
|
|
716
|
-
return [...base, ...extra];
|
|
717
|
-
}
|
|
718
|
-
_collect(node, prefix, result, pathMwsAcc) {
|
|
719
|
-
const mws = this.mergeMws(pathMwsAcc, node.pathMws);
|
|
720
|
-
for (const [method, handler] of node.handlers) {
|
|
721
|
-
const rmws = node.middlewares.get(method) || [];
|
|
722
|
-
const suffix = node.wildcard ? "/*" : "";
|
|
723
|
-
result.push({
|
|
724
|
-
method,
|
|
725
|
-
path: (prefix || "/") + suffix,
|
|
726
|
-
handler,
|
|
727
|
-
middlewares: this.mergeMws(mws, rmws)
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
for (const [seg, child] of node.children) {
|
|
731
|
-
const next = seg === ":" ? `/:${child.param}` : `/${seg}`;
|
|
732
|
-
this._collect(child, prefix + next, result, mws);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
_collectWs(node, prefix, result, pathMwsAcc = []) {
|
|
736
|
-
const mws = this.mergeMws(pathMwsAcc, node.middlewares);
|
|
737
|
-
if (node.handler) result.push({ path: prefix || "/", handler: node.handler, middlewares: mws });
|
|
738
|
-
for (const [seg, child] of node.children) {
|
|
739
|
-
const next = seg === ":" ? `/:${child.param}` : `/${seg}`;
|
|
740
|
-
this._collectWs(child, prefix + next, result, mws);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
splitPath(path) {
|
|
744
|
-
return path.split("/").filter(Boolean);
|
|
745
|
-
}
|
|
746
|
-
matchTrie(method, segments) {
|
|
747
|
-
let node = this.root;
|
|
748
|
-
const params = {};
|
|
749
|
-
const pathMws = [];
|
|
750
|
-
let wildcardHandler = null;
|
|
751
|
-
let wildcardMws = [];
|
|
752
|
-
let wildcardIdx = -1;
|
|
753
|
-
for (let i = 0; i < segments.length; i++) {
|
|
754
|
-
pathMws.push(...node.pathMws);
|
|
755
|
-
if (this._hasWildcard && node.wildcard) {
|
|
756
|
-
const h = node.handlers.get("*") || node.handlers.get(method);
|
|
757
|
-
if (h) {
|
|
758
|
-
wildcardHandler = h;
|
|
759
|
-
wildcardMws = node.middlewares.get(method) || node.middlewares.get("*") || [];
|
|
760
|
-
wildcardIdx = i;
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
const segment = segments[i];
|
|
764
|
-
const next = matchChild(node, segment, params, false);
|
|
765
|
-
if (!next) {
|
|
766
|
-
if (wildcardHandler) {
|
|
767
|
-
params["*"] = segments.slice(wildcardIdx).join("/");
|
|
768
|
-
return { handler: wildcardHandler, middlewares: wildcardMws, pathMws, params };
|
|
769
|
-
}
|
|
770
|
-
return null;
|
|
771
|
-
}
|
|
772
|
-
node = next;
|
|
773
|
-
}
|
|
774
|
-
pathMws.push(...node.pathMws);
|
|
775
|
-
const handler = node.handlers.get(method) || node.handlers.get("*");
|
|
776
|
-
if (handler) {
|
|
777
|
-
if (node.wildcard) params["*"] = segments.slice(segments.length).join("/");
|
|
778
|
-
return {
|
|
779
|
-
handler,
|
|
780
|
-
middlewares: node.middlewares.get(method) || node.middlewares.get("*") || [],
|
|
781
|
-
pathMws,
|
|
782
|
-
params
|
|
783
|
-
};
|
|
784
|
-
}
|
|
785
|
-
if (wildcardHandler) {
|
|
786
|
-
params["*"] = segments.slice(wildcardIdx).join("/");
|
|
787
|
-
return { handler: wildcardHandler, middlewares: wildcardMws, pathMws, params };
|
|
788
|
-
}
|
|
789
|
-
if (node.handlers.size > 0) {
|
|
790
|
-
return {
|
|
791
|
-
middlewares: [],
|
|
792
|
-
pathMws,
|
|
793
|
-
params,
|
|
794
|
-
allowedMethods: [...node.handlers.keys()].filter((k) => k !== "*")
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
return null;
|
|
798
|
-
}
|
|
799
|
-
matchWsTrie(root, segments) {
|
|
800
|
-
let node = root;
|
|
801
|
-
const params = {};
|
|
802
|
-
for (const segment of segments) {
|
|
803
|
-
const next = matchChild(node, segment, params, true);
|
|
804
|
-
if (!next) return null;
|
|
805
|
-
node = next;
|
|
806
|
-
}
|
|
807
|
-
return node.handler ? { handler: node.handler, middlewares: node.middlewares, params } : null;
|
|
808
|
-
}
|
|
809
|
-
async handleError(e, req, ctx) {
|
|
810
|
-
const err = e instanceof Error ? e : new Error(String(e));
|
|
811
|
-
console.error(err);
|
|
812
|
-
return this.errorHandler ? await this.errorHandler(err, req, ctx) : new Response("Internal Server Error", { status: 500 });
|
|
813
|
-
}
|
|
814
|
-
async handle(req, ctx, segments) {
|
|
815
|
-
const match = this.matchTrie(req.method, segments);
|
|
816
|
-
if (match) {
|
|
817
|
-
Object.assign(ctx.params, match.params);
|
|
818
|
-
if (match.handler) {
|
|
819
|
-
const { handler, middlewares: routeMws, pathMws } = match;
|
|
820
|
-
const mws = this.mergeMws(this.mergeMws(this.globalMws, pathMws), routeMws);
|
|
821
|
-
try {
|
|
822
|
-
return await this.runChain(mws, handler, req, ctx);
|
|
823
|
-
} catch (e) {
|
|
824
|
-
return this.handleError(e, req, ctx);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
if (match.allowedMethods && match.allowedMethods.length > 0) {
|
|
828
|
-
if (this.globalMws.length > 0) {
|
|
829
|
-
try {
|
|
830
|
-
return await this.runChain(
|
|
831
|
-
this.globalMws,
|
|
832
|
-
() => new Response("Method Not Allowed", {
|
|
833
|
-
status: 405,
|
|
834
|
-
headers: { Allow: match.allowedMethods.join(", ") }
|
|
835
|
-
}),
|
|
836
|
-
req,
|
|
837
|
-
ctx
|
|
838
|
-
);
|
|
839
|
-
} catch (e) {
|
|
840
|
-
return this.handleError(e, req, ctx);
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
return new Response("Method Not Allowed", {
|
|
844
|
-
status: 405,
|
|
845
|
-
headers: { Allow: match.allowedMethods.join(", ") }
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
if (this.globalMws.length > 0) {
|
|
850
|
-
try {
|
|
851
|
-
return await this.runChain(
|
|
852
|
-
this.globalMws,
|
|
853
|
-
() => {
|
|
854
|
-
if (!isProd()) {
|
|
855
|
-
return Response.json(
|
|
856
|
-
{ error: "Not Found", path: "/" + segments.join("/"), method: req.method },
|
|
857
|
-
{ status: 404 }
|
|
858
|
-
);
|
|
859
|
-
}
|
|
860
|
-
return new Response("Not Found", { status: 404 });
|
|
861
|
-
},
|
|
862
|
-
req,
|
|
863
|
-
ctx
|
|
864
|
-
);
|
|
865
|
-
} catch (e) {
|
|
866
|
-
return this.handleError(e, req, ctx);
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
if (!isProd()) {
|
|
870
|
-
return Response.json(
|
|
871
|
-
{
|
|
872
|
-
error: "Not Found",
|
|
873
|
-
path: "/" + segments.join("/"),
|
|
874
|
-
method: req.method
|
|
875
|
-
},
|
|
876
|
-
{ status: 404 }
|
|
877
|
-
);
|
|
878
|
-
}
|
|
879
|
-
return new Response("Not Found", { status: 404 });
|
|
880
|
-
}
|
|
881
|
-
async runChain(middlewares, finalHandler, req, ctx) {
|
|
882
|
-
if (middlewares.length === 0) return await finalHandler(req, ctx);
|
|
883
|
-
return await runChainLoop(middlewares, 0, finalHandler, req, ctx);
|
|
884
|
-
}
|
|
885
|
-
};
|
|
886
|
-
function runChainLoop(middlewares, index, finalHandler, req, ctx) {
|
|
887
|
-
if (index < middlewares.length) {
|
|
888
|
-
const mw = middlewares[index];
|
|
889
|
-
let called = false;
|
|
890
|
-
const dispatch = (r, c) => {
|
|
891
|
-
if (called) {
|
|
892
|
-
console.warn(
|
|
893
|
-
"[router] next() called more than once in middleware \u2014 ignoring duplicate call"
|
|
894
|
-
);
|
|
895
|
-
return Promise.resolve(new Response("Internal Server Error", { status: 500 }));
|
|
896
|
-
}
|
|
897
|
-
called = true;
|
|
898
|
-
return runChainLoop(middlewares, index + 1, finalHandler, r, c);
|
|
899
|
-
};
|
|
900
|
-
return Promise.resolve(mw(req, ctx, dispatch));
|
|
901
|
-
}
|
|
902
|
-
return Promise.resolve(finalHandler(req, ctx));
|
|
903
|
-
}
|
|
904
|
-
function upgradeSocket(wss, req, socket, head, handler, ctx, hub) {
|
|
905
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
906
|
-
const connCtx = { ...ctx, params: { ...ctx.params }, query: { ...ctx.query } };
|
|
907
|
-
const wsState = {};
|
|
908
|
-
connCtx.ws = {
|
|
909
|
-
get state() {
|
|
910
|
-
return wsState;
|
|
911
|
-
},
|
|
912
|
-
json(data) {
|
|
913
|
-
ws.send(JSON.stringify(data));
|
|
914
|
-
},
|
|
915
|
-
join(room) {
|
|
916
|
-
hub.join(room, ws);
|
|
917
|
-
},
|
|
918
|
-
leave(_room) {
|
|
919
|
-
hub.leave(ws);
|
|
920
|
-
},
|
|
921
|
-
sendRoom(room, data) {
|
|
922
|
-
hub.broadcast(room, data);
|
|
923
|
-
}
|
|
924
|
-
};
|
|
925
|
-
if (handler.open) {
|
|
926
|
-
handler.open(ws, connCtx);
|
|
927
|
-
}
|
|
928
|
-
ws.on("message", (data) => {
|
|
929
|
-
handler.message?.(ws, connCtx, data);
|
|
930
|
-
});
|
|
931
|
-
ws.on("close", () => {
|
|
932
|
-
hub.leave(ws);
|
|
933
|
-
handler.close?.(ws, connCtx);
|
|
934
|
-
});
|
|
935
|
-
ws.on("error", (err) => {
|
|
936
|
-
handler.error?.(ws, connCtx, err);
|
|
937
|
-
});
|
|
938
|
-
});
|
|
939
|
-
}
|
|
940
|
-
function nodeReqHeadersToRecord(headers) {
|
|
941
|
-
const result = {};
|
|
942
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
943
|
-
if (v !== void 0) result[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
944
|
-
}
|
|
945
|
-
return result;
|
|
946
|
-
}
|
|
947
|
-
function sendHttpResponseOnSocket(socket, response) {
|
|
948
|
-
const statusLine = `HTTP/1.1 ${response.status} ${response.statusText}`;
|
|
949
|
-
const headerLines = [statusLine];
|
|
950
|
-
response.headers.forEach((value, key) => {
|
|
951
|
-
headerLines.push(`${key}: ${value}`);
|
|
952
|
-
});
|
|
953
|
-
headerLines.push("Connection: close");
|
|
954
|
-
headerLines.push("");
|
|
955
|
-
const headerStr = headerLines.join("\r\n");
|
|
956
|
-
response.arrayBuffer().then((buf) => {
|
|
957
|
-
socket.write(headerStr + "\r\n");
|
|
958
|
-
if (buf.byteLength > 0) socket.write(Buffer.from(buf));
|
|
959
|
-
socket.end();
|
|
960
|
-
}).catch(() => {
|
|
961
|
-
socket.write(headerStr + "\r\n");
|
|
962
|
-
socket.end();
|
|
963
|
-
});
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
// core/logger.ts
|
|
967
|
-
function emit(event) {
|
|
968
|
-
event.traceId = event.traceId ?? currentTraceId();
|
|
969
|
-
event.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
970
|
-
process.stderr.write(JSON.stringify(event) + "\n");
|
|
971
|
-
}
|
|
972
|
-
function logger(options) {
|
|
973
|
-
const format = options?.format ?? "short";
|
|
974
|
-
return async (req, ctx, next) => {
|
|
975
|
-
const start = Date.now();
|
|
976
|
-
const url = new URL(req.url);
|
|
977
|
-
try {
|
|
978
|
-
const res = await next(req, ctx);
|
|
979
|
-
const ms = Date.now() - start;
|
|
980
|
-
const pathAndQuery = format === "combined" ? url.pathname + url.search : url.pathname;
|
|
981
|
-
if (format === "json") {
|
|
982
|
-
emit({
|
|
983
|
-
level: "info",
|
|
984
|
-
message: "request",
|
|
985
|
-
method: req.method,
|
|
986
|
-
path: pathAndQuery,
|
|
987
|
-
status: res.status,
|
|
988
|
-
elapsed_ms: ms
|
|
989
|
-
});
|
|
990
|
-
} else {
|
|
991
|
-
console.log(`${req.method} ${pathAndQuery} ${res.status} ${ms}ms`);
|
|
992
|
-
}
|
|
993
|
-
return res;
|
|
994
|
-
} catch (err) {
|
|
995
|
-
const ms = Date.now() - start;
|
|
996
|
-
const pathAndQuery = format === "combined" ? url.pathname + url.search : url.pathname;
|
|
997
|
-
if (format === "json") {
|
|
998
|
-
emit({
|
|
999
|
-
level: "error",
|
|
1000
|
-
message: err instanceof Error ? err.message : String(err),
|
|
1001
|
-
method: req.method,
|
|
1002
|
-
path: pathAndQuery,
|
|
1003
|
-
status: 500,
|
|
1004
|
-
elapsed_ms: ms
|
|
1005
|
-
});
|
|
1006
|
-
} else {
|
|
1007
|
-
console.log(`${req.method} ${pathAndQuery} 500 ${ms}ms`);
|
|
1008
|
-
}
|
|
1009
|
-
throw err;
|
|
1010
|
-
}
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
// middleware/cors.ts
|
|
1015
|
-
function cors(options) {
|
|
1016
|
-
const opts = {
|
|
1017
|
-
origin: "*",
|
|
1018
|
-
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"],
|
|
1019
|
-
allowedHeaders: ["Content-Type", "Authorization"],
|
|
1020
|
-
...options
|
|
1021
|
-
};
|
|
1022
|
-
function resolveOrigin(requestOrigin) {
|
|
1023
|
-
if (typeof opts.origin === "string") {
|
|
1024
|
-
if (opts.origin === "*") {
|
|
1025
|
-
return opts.credentials ? requestOrigin : "*";
|
|
1026
|
-
}
|
|
1027
|
-
return opts.origin;
|
|
1028
|
-
}
|
|
1029
|
-
if (Array.isArray(opts.origin)) {
|
|
1030
|
-
return opts.origin.includes(requestOrigin) ? requestOrigin : "";
|
|
1031
|
-
}
|
|
1032
|
-
const result = opts.origin(requestOrigin);
|
|
1033
|
-
if (typeof result === "boolean") return result ? requestOrigin : "";
|
|
1034
|
-
if (typeof result === "string") return result;
|
|
1035
|
-
return "";
|
|
1036
|
-
}
|
|
1037
|
-
function setCORSHeaders(res, acao) {
|
|
1038
|
-
if (!acao) return res;
|
|
1039
|
-
const headers = new Headers(res.headers);
|
|
1040
|
-
headers.set("Access-Control-Allow-Origin", acao);
|
|
1041
|
-
if (opts.credentials) headers.set("Access-Control-Allow-Credentials", "true");
|
|
1042
|
-
if (opts.exposedHeaders?.length)
|
|
1043
|
-
headers.set("Access-Control-Expose-Headers", opts.exposedHeaders.join(", "));
|
|
1044
|
-
if (acao !== "*") headers.set("Vary", "Origin");
|
|
1045
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
1046
|
-
}
|
|
1047
|
-
return (req, ctx, next) => {
|
|
1048
|
-
const requestOrigin = req.headers.get("origin") ?? "";
|
|
1049
|
-
const acao = resolveOrigin(requestOrigin);
|
|
1050
|
-
if (req.method === "OPTIONS" && acao) {
|
|
1051
|
-
const headers = new Headers();
|
|
1052
|
-
headers.set("Access-Control-Allow-Origin", acao);
|
|
1053
|
-
headers.set("Access-Control-Allow-Methods", opts.methods.join(", "));
|
|
1054
|
-
headers.set("Access-Control-Allow-Headers", opts.allowedHeaders.join(", "));
|
|
1055
|
-
if (opts.credentials) headers.set("Access-Control-Allow-Credentials", "true");
|
|
1056
|
-
if (opts.maxAge != null) headers.set("Access-Control-Max-Age", String(opts.maxAge));
|
|
1057
|
-
if (acao !== "*") headers.set("Vary", "Origin");
|
|
1058
|
-
return new Response(null, { status: 204, headers });
|
|
1059
|
-
}
|
|
1060
|
-
if (!acao) return next(req, ctx);
|
|
1061
|
-
return Promise.resolve(next(req, ctx)).then((res) => setCORSHeaders(res, acao));
|
|
1062
|
-
};
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
// middleware/static.ts
|
|
1066
|
-
import { open, realpath } from "node:fs/promises";
|
|
1067
|
-
import { extname, resolve as resolve2, normalize, sep } from "node:path";
|
|
1068
|
-
import { Readable } from "node:stream";
|
|
1069
|
-
function serveStatic(root, options) {
|
|
1070
|
-
const rootDir = resolve2(root);
|
|
1071
|
-
const opts = options ?? {};
|
|
1072
|
-
return async (req, ctx) => {
|
|
1073
|
-
const relativePath = ctx.params["*"] ?? new URL(req.url).pathname.slice(1);
|
|
1074
|
-
const decoded = decodeURIComponent(relativePath);
|
|
1075
|
-
if (decoded.includes("..") || decoded.includes("\0")) {
|
|
1076
|
-
return new Response("Forbidden", { status: 403 });
|
|
1077
|
-
}
|
|
1078
|
-
let filePath = normalize(resolve2(rootDir, decoded));
|
|
1079
|
-
if (!filePath.startsWith(rootDir + sep) && filePath !== rootDir) {
|
|
1080
|
-
return new Response("Forbidden", { status: 403 });
|
|
1081
|
-
}
|
|
1082
|
-
let fileHandle;
|
|
1083
|
-
try {
|
|
1084
|
-
fileHandle = await open(filePath, "r");
|
|
1085
|
-
let stat = await fileHandle.stat();
|
|
1086
|
-
const realPath = await realpath(filePath);
|
|
1087
|
-
if (!realPath.startsWith(rootDir + sep) && realPath !== rootDir) {
|
|
1088
|
-
await fileHandle.close();
|
|
1089
|
-
return new Response("Forbidden", { status: 403 });
|
|
1090
|
-
}
|
|
1091
|
-
if (stat.isDirectory()) {
|
|
1092
|
-
await fileHandle.close();
|
|
1093
|
-
const indexFile = opts.index ?? "index.html";
|
|
1094
|
-
filePath = resolve2(filePath, indexFile);
|
|
1095
|
-
if (!filePath.startsWith(rootDir + sep)) {
|
|
1096
|
-
return new Response("Forbidden", { status: 403 });
|
|
1097
|
-
}
|
|
1098
|
-
fileHandle = await open(filePath, "r");
|
|
1099
|
-
stat = await fileHandle.stat();
|
|
1100
|
-
if (!stat.isFile()) {
|
|
1101
|
-
await fileHandle.close();
|
|
1102
|
-
return new Response("Not Found", { status: 404 });
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
const mimeType = MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
1106
|
-
const etag = `"${stat.ino}-${stat.size}-${stat.mtimeMs}"`;
|
|
1107
|
-
const ifNoneMatch = req.headers.get("if-none-match");
|
|
1108
|
-
if (ifNoneMatch === etag) {
|
|
1109
|
-
await fileHandle.close();
|
|
1110
|
-
return new Response(null, { status: 304 });
|
|
1111
|
-
}
|
|
1112
|
-
const ifModifiedSince = req.headers.get("if-modified-since");
|
|
1113
|
-
if (ifModifiedSince && stat.mtimeMs <= new Date(ifModifiedSince).getTime()) {
|
|
1114
|
-
await fileHandle.close();
|
|
1115
|
-
return new Response(null, { status: 304 });
|
|
1116
|
-
}
|
|
1117
|
-
const headers = {
|
|
1118
|
-
"Content-Type": mimeType,
|
|
1119
|
-
"Content-Length": String(stat.size),
|
|
1120
|
-
ETag: etag,
|
|
1121
|
-
"Last-Modified": stat.mtime.toUTCString(),
|
|
1122
|
-
"Cache-Control": opts.immutable ? `public, max-age=${opts.maxAge ?? 31536e3}, immutable` : `public, max-age=${opts.maxAge ?? 0}`
|
|
1123
|
-
};
|
|
1124
|
-
const readStream = fileHandle.createReadStream();
|
|
1125
|
-
const cleanup = () => fileHandle.close().catch(() => {
|
|
1126
|
-
});
|
|
1127
|
-
readStream.on("close", cleanup);
|
|
1128
|
-
readStream.on("error", cleanup);
|
|
1129
|
-
const webStream = Readable.toWeb(readStream);
|
|
1130
|
-
return new Response(webStream, { headers });
|
|
1131
|
-
} catch (err) {
|
|
1132
|
-
if (fileHandle) await fileHandle.close().catch(() => {
|
|
1133
|
-
});
|
|
1134
|
-
if (err?.code === "ENOENT") {
|
|
1135
|
-
return new Response("Not Found", { status: 404 });
|
|
1136
|
-
}
|
|
1137
|
-
return new Response("Internal Server Error", { status: 500 });
|
|
1138
|
-
}
|
|
1139
|
-
};
|
|
1140
|
-
}
|
|
1141
|
-
var MIME_TYPES = {
|
|
1142
|
-
".html": "text/html; charset=utf-8",
|
|
1143
|
-
".htm": "text/html; charset=utf-8",
|
|
1144
|
-
".css": "text/css; charset=utf-8",
|
|
1145
|
-
".js": "text/javascript; charset=utf-8",
|
|
1146
|
-
".mjs": "text/javascript; charset=utf-8",
|
|
1147
|
-
".json": "application/json",
|
|
1148
|
-
".png": "image/png",
|
|
1149
|
-
".jpg": "image/jpeg",
|
|
1150
|
-
".jpeg": "image/jpeg",
|
|
1151
|
-
".gif": "image/gif",
|
|
1152
|
-
".svg": "image/svg+xml",
|
|
1153
|
-
".ico": "image/x-icon",
|
|
1154
|
-
".webp": "image/webp",
|
|
1155
|
-
".avif": "image/avif",
|
|
1156
|
-
".woff": "font/woff",
|
|
1157
|
-
".woff2": "font/woff2",
|
|
1158
|
-
".ttf": "font/ttf",
|
|
1159
|
-
".otf": "font/otf",
|
|
1160
|
-
".eot": "application/vnd.ms-fontobject",
|
|
1161
|
-
".txt": "text/plain; charset=utf-8",
|
|
1162
|
-
".xml": "application/xml",
|
|
1163
|
-
".pdf": "application/pdf",
|
|
1164
|
-
".zip": "application/zip",
|
|
1165
|
-
".wasm": "application/wasm",
|
|
1166
|
-
".map": "application/json",
|
|
1167
|
-
".ts": "application/x-typescript",
|
|
1168
|
-
".tsx": "application/x-typescript",
|
|
1169
|
-
".md": "text/markdown; charset=utf-8",
|
|
1170
|
-
".yaml": "application/x-yaml",
|
|
1171
|
-
".yml": "application/x-yaml",
|
|
1172
|
-
".csv": "text/csv; charset=utf-8",
|
|
1173
|
-
".mp4": "video/mp4",
|
|
1174
|
-
".mp3": "audio/mpeg",
|
|
1175
|
-
".wav": "audio/wav"
|
|
1176
|
-
};
|
|
1177
|
-
|
|
1178
|
-
// middleware/validate.ts
|
|
1179
|
-
function parseFormBody(text) {
|
|
1180
|
-
const params = new URLSearchParams(text);
|
|
1181
|
-
const result = {};
|
|
1182
|
-
for (const [key, value] of params) {
|
|
1183
|
-
result[key] = value;
|
|
1184
|
-
}
|
|
1185
|
-
return result;
|
|
1186
|
-
}
|
|
1187
|
-
function parseBody(text, ct) {
|
|
1188
|
-
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
1189
|
-
return parseFormBody(text);
|
|
1190
|
-
}
|
|
1191
|
-
const isExplicitJson = ct.includes("application/json") || ct.includes("+json") || ct.includes("text/") || ct.includes("*/json");
|
|
1192
|
-
const isNotSpecialMultipart = !ct.includes("multipart/form-data") && !ct.includes("application/x-www-form-urlencoded");
|
|
1193
|
-
if (isExplicitJson || isNotSpecialMultipart) {
|
|
1194
|
-
try {
|
|
1195
|
-
return JSON.parse(text);
|
|
1196
|
-
} catch {
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
return text;
|
|
1200
|
-
}
|
|
1201
|
-
function validate(schemas) {
|
|
1202
|
-
const mw = async (req, ctx, next) => {
|
|
1203
|
-
const parsed = {};
|
|
1204
|
-
const issues = [];
|
|
1205
|
-
if (schemas?.params) {
|
|
1206
|
-
const result = schemas.params.safeParse(ctx.params);
|
|
1207
|
-
if (result.success) {
|
|
1208
|
-
parsed.params = result.data;
|
|
1209
|
-
} else {
|
|
1210
|
-
issues.push(
|
|
1211
|
-
...result.error.issues.map((i) => ({
|
|
1212
|
-
path: ["params", ...i.path.map(String)],
|
|
1213
|
-
message: i.message
|
|
1214
|
-
}))
|
|
1215
|
-
);
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
if (schemas?.query) {
|
|
1219
|
-
const result = schemas.query.safeParse(ctx.query);
|
|
1220
|
-
if (result.success) {
|
|
1221
|
-
parsed.query = result.data;
|
|
1222
|
-
} else {
|
|
1223
|
-
issues.push(
|
|
1224
|
-
...result.error.issues.map((i) => ({
|
|
1225
|
-
path: ["query", ...i.path.map(String)],
|
|
1226
|
-
message: i.message
|
|
1227
|
-
}))
|
|
1228
|
-
);
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
if (schemas?.headers) {
|
|
1232
|
-
const rawHeaders = {};
|
|
1233
|
-
req.headers.forEach((v, k) => {
|
|
1234
|
-
rawHeaders[k] = v;
|
|
1235
|
-
});
|
|
1236
|
-
const result = schemas.headers.safeParse(rawHeaders);
|
|
1237
|
-
if (result.success) {
|
|
1238
|
-
parsed.headers = result.data;
|
|
1239
|
-
} else {
|
|
1240
|
-
issues.push(
|
|
1241
|
-
...result.error.issues.map((i) => ({
|
|
1242
|
-
path: ["headers", ...i.path.map(String)],
|
|
1243
|
-
message: i.message
|
|
1244
|
-
}))
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1249
|
-
const ct = req.headers.get("content-type") ?? "";
|
|
1250
|
-
const isForm = ct.includes("application/x-www-form-urlencoded");
|
|
1251
|
-
if (schemas?.body || isForm) {
|
|
1252
|
-
if (req.body === null) {
|
|
1253
|
-
if (schemas?.body) {
|
|
1254
|
-
issues.push({ path: ["body"], message: "Request body is required" });
|
|
1255
|
-
}
|
|
1256
|
-
} else {
|
|
1257
|
-
const bodyText = await req.text();
|
|
1258
|
-
if (!bodyText) {
|
|
1259
|
-
if (schemas?.body) {
|
|
1260
|
-
issues.push({ path: ["body"], message: "Request body is required" });
|
|
1261
|
-
}
|
|
1262
|
-
} else {
|
|
1263
|
-
const bodyValue = parseBody(bodyText, ct);
|
|
1264
|
-
if (schemas?.body) {
|
|
1265
|
-
const result = schemas.body.safeParse(bodyValue);
|
|
1266
|
-
if (result.success) {
|
|
1267
|
-
parsed.body = result.data;
|
|
1268
|
-
} else {
|
|
1269
|
-
issues.push(
|
|
1270
|
-
...result.error.issues.map((i) => ({
|
|
1271
|
-
path: ["body", ...i.path.map(String)],
|
|
1272
|
-
message: i.message
|
|
1273
|
-
}))
|
|
1274
|
-
);
|
|
1275
|
-
}
|
|
1276
|
-
} else {
|
|
1277
|
-
parsed.body = bodyValue;
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
if (issues.length > 0) {
|
|
1284
|
-
return Response.json({ error: "Validation failed", issues }, { status: 400 });
|
|
1285
|
-
}
|
|
1286
|
-
ctx.parsed = { ...ctx.parsed, ...parsed };
|
|
1287
|
-
return next(req, ctx);
|
|
1288
|
-
};
|
|
1289
|
-
mw.__meta = { injects: ["parsed"], depends: [] };
|
|
1290
|
-
return mw;
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
// core/cookie.ts
|
|
1294
|
-
function getCookies(req) {
|
|
1295
|
-
const header = req.headers.get("cookie");
|
|
1296
|
-
if (!header) return {};
|
|
1297
|
-
const cookies = {};
|
|
1298
|
-
for (const pair of header.split(";")) {
|
|
1299
|
-
const idx = pair.indexOf("=");
|
|
1300
|
-
if (idx === -1) continue;
|
|
1301
|
-
let name = pair.slice(0, idx).trim();
|
|
1302
|
-
let value = pair.slice(idx + 1).trim();
|
|
1303
|
-
if (!name) continue;
|
|
1304
|
-
try {
|
|
1305
|
-
name = decodeURIComponent(name);
|
|
1306
|
-
} catch {
|
|
1307
|
-
}
|
|
1308
|
-
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
1309
|
-
value = value.slice(1, -1);
|
|
1310
|
-
}
|
|
1311
|
-
try {
|
|
1312
|
-
cookies[name] = decodeURIComponent(value);
|
|
1313
|
-
} catch {
|
|
1314
|
-
cookies[name] = value;
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
return cookies;
|
|
1318
|
-
}
|
|
1319
|
-
function serializeCookie(name, value, options) {
|
|
1320
|
-
if (/[\x00-\x1F\x7F-\x9F;,]/.test(name) || /[\x00-\x1F\x7F-\x9F;,]/.test(value)) {
|
|
1321
|
-
throw new Error(`Invalid cookie name or value: contains control characters or special chars`);
|
|
1322
|
-
}
|
|
1323
|
-
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
1324
|
-
if (options?.maxAge != null) parts.push(`Max-Age=${options.maxAge}`);
|
|
1325
|
-
if (options?.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
1326
|
-
if (options?.domain) parts.push(`Domain=${options.domain}`);
|
|
1327
|
-
if (options?.path) parts.push(`Path=${options.path}`);
|
|
1328
|
-
if (options?.httpOnly) parts.push("HttpOnly");
|
|
1329
|
-
if (options?.secure) parts.push("Secure");
|
|
1330
|
-
if (options?.sameSite) parts.push(`SameSite=${options.sameSite}`);
|
|
1331
|
-
return parts.join("; ");
|
|
1332
|
-
}
|
|
1333
|
-
function setCookie(res, name, value, options) {
|
|
1334
|
-
const headers = new Headers(res.headers);
|
|
1335
|
-
headers.append("Set-Cookie", serializeCookie(name, value, options));
|
|
1336
|
-
return new Response(res.body, {
|
|
1337
|
-
status: res.status,
|
|
1338
|
-
statusText: res.statusText,
|
|
1339
|
-
headers
|
|
1340
|
-
});
|
|
1341
|
-
}
|
|
1342
|
-
function deleteCookie(res, name, options) {
|
|
1343
|
-
const headers = new Headers(res.headers);
|
|
1344
|
-
headers.append(
|
|
1345
|
-
"Set-Cookie",
|
|
1346
|
-
serializeCookie(name, "", {
|
|
1347
|
-
...options,
|
|
1348
|
-
maxAge: 0,
|
|
1349
|
-
expires: /* @__PURE__ */ new Date(0)
|
|
1350
|
-
})
|
|
1351
|
-
);
|
|
1352
|
-
return new Response(res.body, {
|
|
1353
|
-
status: res.status,
|
|
1354
|
-
statusText: res.statusText,
|
|
1355
|
-
headers
|
|
1356
|
-
});
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
// middleware/upload.ts
|
|
1360
|
-
import { writeFile, mkdir } from "node:fs/promises";
|
|
1361
|
-
import { randomUUID } from "node:crypto";
|
|
1362
|
-
import { join, extname as extname2 } from "node:path";
|
|
1363
|
-
var extensionMimeMap = {
|
|
1364
|
-
".jpg": "image/jpeg",
|
|
1365
|
-
".jpeg": "image/jpeg",
|
|
1366
|
-
".png": "image/png",
|
|
1367
|
-
".gif": "image/gif",
|
|
1368
|
-
".webp": "image/webp",
|
|
1369
|
-
".svg": "image/svg+xml",
|
|
1370
|
-
".pdf": "application/pdf",
|
|
1371
|
-
".doc": "application/msword",
|
|
1372
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1373
|
-
".xls": "application/vnd.ms-excel",
|
|
1374
|
-
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1375
|
-
".zip": "application/zip",
|
|
1376
|
-
".gz": "application/gzip",
|
|
1377
|
-
".mp4": "video/mp4",
|
|
1378
|
-
".mp3": "audio/mpeg",
|
|
1379
|
-
".wav": "audio/wav",
|
|
1380
|
-
".json": "application/json",
|
|
1381
|
-
".csv": "text/csv",
|
|
1382
|
-
".txt": "text/plain",
|
|
1383
|
-
".html": "text/html",
|
|
1384
|
-
".css": "text/css",
|
|
1385
|
-
".js": "text/javascript",
|
|
1386
|
-
".ts": "application/x-typescript",
|
|
1387
|
-
".tsx": "application/x-typescript"
|
|
1388
|
-
};
|
|
1389
|
-
function detectMimeFromExtension(filename) {
|
|
1390
|
-
return extensionMimeMap[extname2(filename).toLowerCase()];
|
|
1391
|
-
}
|
|
1392
|
-
function upload(options) {
|
|
1393
|
-
const saveDir = options?.dir;
|
|
1394
|
-
const mw = async (req, ctx, next) => {
|
|
1395
|
-
const ct = req.headers.get("content-type") ?? "";
|
|
1396
|
-
if (!ct.includes("multipart/form-data")) return next(req, ctx);
|
|
1397
|
-
try {
|
|
1398
|
-
if (saveDir) await mkdir(saveDir, { recursive: true });
|
|
1399
|
-
} catch (e) {
|
|
1400
|
-
console.error("upload: failed to create directory", saveDir, e);
|
|
1401
|
-
return Response.json({ error: "Server configuration error" }, { status: 500 });
|
|
1402
|
-
}
|
|
1403
|
-
let formData;
|
|
1404
|
-
try {
|
|
1405
|
-
formData = await req.formData();
|
|
1406
|
-
} catch {
|
|
1407
|
-
return Response.json({ error: "Invalid multipart data" }, { status: 400 });
|
|
1408
|
-
}
|
|
1409
|
-
const files = {};
|
|
1410
|
-
const fields = {};
|
|
1411
|
-
for (const [key, value] of formData) {
|
|
1412
|
-
if (value instanceof File) {
|
|
1413
|
-
if (options?.allowedTypes) {
|
|
1414
|
-
const clientOk = options.allowedTypes.includes(value.type);
|
|
1415
|
-
const extType = detectMimeFromExtension(value.name);
|
|
1416
|
-
const extOk = extType ? options.allowedTypes.includes(extType) : false;
|
|
1417
|
-
if (!clientOk && !extOk) {
|
|
1418
|
-
return Response.json({ error: `File type not allowed: ${value.type}` }, { status: 415 });
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
if (options?.maxFileSize && value.size > options.maxFileSize) {
|
|
1422
|
-
return Response.json({ error: `File too large: ${value.name}` }, { status: 413 });
|
|
1423
|
-
}
|
|
1424
|
-
const buf = Buffer.from(await value.arrayBuffer());
|
|
1425
|
-
const uf = {
|
|
1426
|
-
name: value.name,
|
|
1427
|
-
type: value.type,
|
|
1428
|
-
size: buf.byteLength,
|
|
1429
|
-
buffer: saveDir ? void 0 : buf
|
|
1430
|
-
};
|
|
1431
|
-
if (saveDir) {
|
|
1432
|
-
const safeName = value.name.replace(/[/\\\0]/g, "_").replace(/\.\./g, "_");
|
|
1433
|
-
const filePath = join(saveDir, `${randomUUID()}-${safeName}`);
|
|
1434
|
-
await writeFile(filePath, buf);
|
|
1435
|
-
uf.path = filePath;
|
|
1436
|
-
}
|
|
1437
|
-
if (files[key]) {
|
|
1438
|
-
const existing = files[key];
|
|
1439
|
-
files[key] = Array.isArray(existing) ? [...existing, uf] : [existing, uf];
|
|
1440
|
-
} else {
|
|
1441
|
-
files[key] = uf;
|
|
1442
|
-
}
|
|
1443
|
-
} else {
|
|
1444
|
-
fields[key] = value;
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
ctx.parsed = { ...ctx.parsed, files, fields };
|
|
1448
|
-
return next(req, ctx);
|
|
1449
|
-
};
|
|
1450
|
-
mw.__meta = { injects: ["parsed"], depends: [] };
|
|
1451
|
-
return mw;
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// middleware/rate-limit.ts
|
|
1455
|
-
function defaultKey(_req, _ctx) {
|
|
1456
|
-
const forwarded = _req.headers.get("x-forwarded-for");
|
|
1457
|
-
if (forwarded) return forwarded.split(",")[0].trim();
|
|
1458
|
-
const realIp = _req.headers.get("x-real-ip");
|
|
1459
|
-
if (realIp) return realIp;
|
|
1460
|
-
const cfIp = _req.headers.get("cf-connecting-ip");
|
|
1461
|
-
if (cfIp) return cfIp;
|
|
1462
|
-
return "global";
|
|
1463
|
-
}
|
|
1464
|
-
function rateLimit(options) {
|
|
1465
|
-
const max = options?.max ?? 100;
|
|
1466
|
-
const window = options?.window ?? 6e4;
|
|
1467
|
-
const getKey = options?.key ?? defaultKey;
|
|
1468
|
-
const message = options?.message ?? "Too Many Requests";
|
|
1469
|
-
const storeType = options?.store ?? "memory";
|
|
1470
|
-
if (storeType === "redis" && !options?.redis) {
|
|
1471
|
-
throw new Error('rateLimit: redis client required when store: "redis"');
|
|
1472
|
-
}
|
|
1473
|
-
const redis2 = options?.redis ?? null;
|
|
1474
|
-
const keyPrefix = options?.prefix ?? "ratelimit:";
|
|
1475
|
-
const MAX_ENTRIES = 1e4;
|
|
1476
|
-
const hits = /* @__PURE__ */ new Map();
|
|
1477
|
-
const interval = storeType === "memory" ? setInterval(
|
|
1478
|
-
() => {
|
|
1479
|
-
const now = Date.now();
|
|
1480
|
-
for (const [key, entry] of hits) {
|
|
1481
|
-
if (entry.reset < now) hits.delete(key);
|
|
1482
|
-
}
|
|
1483
|
-
if (hits.size > MAX_ENTRIES) {
|
|
1484
|
-
const toDelete = hits.size - MAX_ENTRIES;
|
|
1485
|
-
let deleted = 0;
|
|
1486
|
-
for (const key of hits.keys()) {
|
|
1487
|
-
if (deleted >= toDelete) break;
|
|
1488
|
-
hits.delete(key);
|
|
1489
|
-
deleted++;
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
},
|
|
1493
|
-
Math.min(window, 3e4)
|
|
1494
|
-
) : null;
|
|
1495
|
-
if (interval?.unref) interval.unref();
|
|
1496
|
-
async function checkAndIncrement(key) {
|
|
1497
|
-
const now = Date.now();
|
|
1498
|
-
if (storeType === "redis" && redis2) {
|
|
1499
|
-
const redisKey = `${keyPrefix}${key}`;
|
|
1500
|
-
const count = await redis2.incr(redisKey);
|
|
1501
|
-
if (count === 1) {
|
|
1502
|
-
await redis2.pexpire(redisKey, window);
|
|
1503
|
-
}
|
|
1504
|
-
const pttl = await redis2.pttl(redisKey);
|
|
1505
|
-
const reset = pttl > 0 ? now + pttl : now + window;
|
|
1506
|
-
return { count, reset };
|
|
1507
|
-
}
|
|
1508
|
-
const entry = hits.get(key);
|
|
1509
|
-
if (!entry || entry.reset < now) {
|
|
1510
|
-
hits.set(key, { count: 1, reset: now + window });
|
|
1511
|
-
return { count: 1, reset: now + window };
|
|
1512
|
-
}
|
|
1513
|
-
entry.count++;
|
|
1514
|
-
return { count: entry.count, reset: entry.reset };
|
|
1515
|
-
}
|
|
1516
|
-
const mw = async (req, ctx, next) => {
|
|
1517
|
-
const key = getKey(req, ctx);
|
|
1518
|
-
const now = Date.now();
|
|
1519
|
-
const { count, reset } = await checkAndIncrement(key);
|
|
1520
|
-
if (count > max) {
|
|
1521
|
-
return new Response(message, {
|
|
1522
|
-
status: 429,
|
|
1523
|
-
headers: {
|
|
1524
|
-
"Retry-After": String(Math.ceil((reset - now) / 1e3)),
|
|
1525
|
-
"X-RateLimit-Limit": String(max),
|
|
1526
|
-
"X-RateLimit-Remaining": "0",
|
|
1527
|
-
"X-RateLimit-Reset": String(Math.ceil(reset / 1e3))
|
|
1528
|
-
}
|
|
1529
|
-
});
|
|
1530
|
-
}
|
|
1531
|
-
const remaining = max - count;
|
|
1532
|
-
const res = await next(req, ctx);
|
|
1533
|
-
return addRateLimitHeaders(res, max, remaining, reset);
|
|
1534
|
-
};
|
|
1535
|
-
mw.__meta = { injects: [], depends: [] };
|
|
1536
|
-
mw.close = async () => {
|
|
1537
|
-
if (interval) clearInterval(interval);
|
|
1538
|
-
hits.clear();
|
|
1539
|
-
};
|
|
1540
|
-
mw.stats = () => ({
|
|
1541
|
-
store: storeType,
|
|
1542
|
-
entries: storeType === "memory" ? hits.size : void 0,
|
|
1543
|
-
maxEntries: MAX_ENTRIES
|
|
1544
|
-
});
|
|
1545
|
-
return mw;
|
|
1546
|
-
}
|
|
1547
|
-
function addRateLimitHeaders(res, limit, remaining, reset) {
|
|
1548
|
-
const headers = new Headers(res.headers);
|
|
1549
|
-
headers.set("X-RateLimit-Limit", String(limit));
|
|
1550
|
-
headers.set("X-RateLimit-Remaining", String(remaining));
|
|
1551
|
-
headers.set("X-RateLimit-Reset", String(Math.ceil(reset / 1e3)));
|
|
1552
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
// middleware/compress.ts
|
|
1556
|
-
import { constants, brotliCompress, gzip, deflate } from "node:zlib";
|
|
1557
|
-
import { promisify } from "node:util";
|
|
1558
|
-
var brotliCompressAsync = promisify(brotliCompress);
|
|
1559
|
-
var gzipAsync = promisify(gzip);
|
|
1560
|
-
var deflateAsync = promisify(deflate);
|
|
1561
|
-
function compress(options) {
|
|
1562
|
-
const level = options?.level ?? 6;
|
|
1563
|
-
const threshold = options?.threshold ?? 1024;
|
|
1564
|
-
return async (req, ctx, next) => {
|
|
1565
|
-
const accept = req.headers.get("accept-encoding") ?? "";
|
|
1566
|
-
const encoding = accept.includes("br") ? "br" : accept.includes("gzip") ? "gzip" : accept.includes("deflate") ? "deflate" : "";
|
|
1567
|
-
if (!encoding) return next(req, ctx);
|
|
1568
|
-
const res = await next(req, ctx);
|
|
1569
|
-
if (res.status === 304 || res.status === 204 || res.status === 206 || res.status < 200 || res.status >= 300) {
|
|
1570
|
-
return res;
|
|
1571
|
-
}
|
|
1572
|
-
if (res.headers.get("content-encoding")) return res;
|
|
1573
|
-
const ct = res.headers.get("content-type") ?? "";
|
|
1574
|
-
if (!ct || ct.startsWith("audio/") || ct.startsWith("video/") || ct.startsWith("image/") || ct === "application/zip") {
|
|
1575
|
-
return res;
|
|
1576
|
-
}
|
|
1577
|
-
if (!res.body) return res;
|
|
1578
|
-
const body = await res.bytes();
|
|
1579
|
-
if (body.byteLength < threshold) return res;
|
|
1580
|
-
let compressed;
|
|
1581
|
-
try {
|
|
1582
|
-
if (encoding === "br") {
|
|
1583
|
-
compressed = await brotliCompressAsync(body, {
|
|
1584
|
-
params: { [constants.BROTLI_PARAM_QUALITY]: Math.min(level, 11) }
|
|
1585
|
-
});
|
|
1586
|
-
} else if (encoding === "gzip") {
|
|
1587
|
-
compressed = await gzipAsync(body, { level: Math.min(level, 9) });
|
|
1588
|
-
} else {
|
|
1589
|
-
compressed = await deflateAsync(body, { level: Math.min(level, 9) });
|
|
1590
|
-
}
|
|
1591
|
-
} catch {
|
|
1592
|
-
return res;
|
|
1593
|
-
}
|
|
1594
|
-
const headers = new Headers(res.headers);
|
|
1595
|
-
headers.set("Content-Encoding", encoding);
|
|
1596
|
-
headers.set("Content-Length", String(compressed.byteLength));
|
|
1597
|
-
headers.delete("Content-Range");
|
|
1598
|
-
const existingVary = headers.get("Vary");
|
|
1599
|
-
headers.set("Vary", existingVary ? `${existingVary}, Accept-Encoding` : "Accept-Encoding");
|
|
1600
|
-
return new Response(compressed, {
|
|
1601
|
-
status: res.status,
|
|
1602
|
-
statusText: res.statusText,
|
|
1603
|
-
headers
|
|
1604
|
-
});
|
|
1605
|
-
};
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
// middleware/helmet.ts
|
|
1609
|
-
var HEADER_MAP = {
|
|
1610
|
-
"Content-Security-Policy": "contentSecurityPolicy",
|
|
1611
|
-
"Cross-Origin-Embedder-Policy": "crossOriginEmbedderPolicy",
|
|
1612
|
-
"Cross-Origin-Opener-Policy": "crossOriginOpenerPolicy",
|
|
1613
|
-
"Cross-Origin-Resource-Policy": "crossOriginResourcePolicy",
|
|
1614
|
-
"Origin-Agent-Cluster": "originAgentCluster",
|
|
1615
|
-
"Referrer-Policy": "referrerPolicy",
|
|
1616
|
-
"Strict-Transport-Security": "strictTransportSecurity",
|
|
1617
|
-
"X-Content-Type-Options": "xContentTypeOptions",
|
|
1618
|
-
"X-DNS-Prefetch-Control": "xDnsPrefetchControl",
|
|
1619
|
-
"X-Download-Options": "xDownloadOptions",
|
|
1620
|
-
"X-Frame-Options": "xFrameOptions",
|
|
1621
|
-
"X-Permitted-Cross-Domain-Policies": "xPermittedCrossDomainPolicies",
|
|
1622
|
-
"X-XSS-Protection": "xXssProtection",
|
|
1623
|
-
"Permissions-Policy": "permissionsPolicy"
|
|
1624
|
-
};
|
|
1625
|
-
function helmet(options) {
|
|
1626
|
-
const opts = { ...DEFAULTS, ...options };
|
|
1627
|
-
const headers = new Headers();
|
|
1628
|
-
for (const [header, key] of Object.entries(HEADER_MAP)) {
|
|
1629
|
-
const val = opts[key];
|
|
1630
|
-
if (val !== false && val !== void 0) headers.set(header, val);
|
|
1631
|
-
}
|
|
1632
|
-
return async (req, ctx, next) => {
|
|
1633
|
-
const res = await next(req, ctx);
|
|
1634
|
-
const h = new Headers(res.headers);
|
|
1635
|
-
for (const [k, v] of headers) {
|
|
1636
|
-
if (!h.has(k)) h.set(k, v);
|
|
1637
|
-
}
|
|
1638
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
|
|
1639
|
-
};
|
|
1640
|
-
}
|
|
1641
|
-
var DEFAULTS = {
|
|
1642
|
-
contentSecurityPolicy: "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests",
|
|
1643
|
-
crossOriginEmbedderPolicy: "require-corp",
|
|
1644
|
-
crossOriginOpenerPolicy: "same-origin",
|
|
1645
|
-
crossOriginResourcePolicy: "same-origin",
|
|
1646
|
-
originAgentCluster: "?1",
|
|
1647
|
-
referrerPolicy: "no-referrer",
|
|
1648
|
-
strictTransportSecurity: "max-age=15552000; includeSubDomains",
|
|
1649
|
-
xContentTypeOptions: "nosniff",
|
|
1650
|
-
xDnsPrefetchControl: "off",
|
|
1651
|
-
xDownloadOptions: "noopen",
|
|
1652
|
-
xFrameOptions: "SAMEORIGIN",
|
|
1653
|
-
xPermittedCrossDomainPolicies: "none",
|
|
1654
|
-
xXssProtection: "0",
|
|
1655
|
-
permissionsPolicy: "camera=(),display-capture=(),fullscreen=(),geolocation=(),microphone=()"
|
|
1656
|
-
};
|
|
1657
|
-
|
|
1658
|
-
// middleware/request-id.ts
|
|
1659
|
-
import crypto2 from "node:crypto";
|
|
1660
|
-
function requestId(options) {
|
|
1661
|
-
const header = options?.header ?? "X-Request-ID";
|
|
1662
|
-
const gen = options?.generator ?? (() => crypto2.randomUUID());
|
|
1663
|
-
const mw = async (req, ctx, next) => {
|
|
1664
|
-
const existing = req.headers.get(header);
|
|
1665
|
-
const id = existing ?? gen();
|
|
1666
|
-
ctx.requestId = id;
|
|
1667
|
-
const res = await next(req, ctx);
|
|
1668
|
-
if (res.headers.has(header)) return res;
|
|
1669
|
-
const h = new Headers(res.headers);
|
|
1670
|
-
h.set(header, id);
|
|
1671
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
|
|
1672
|
-
};
|
|
1673
|
-
mw.__meta = { injects: ["requestId"], depends: [] };
|
|
1674
|
-
return mw;
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
// core/sse.ts
|
|
1678
|
-
var encoder = new TextEncoder();
|
|
1679
|
-
function formatSSE(event, data) {
|
|
1680
|
-
return `event: ${event}
|
|
1681
|
-
data: ${JSON.stringify(data)}
|
|
1682
|
-
|
|
1683
|
-
`;
|
|
1684
|
-
}
|
|
1685
|
-
function formatSSEData(data) {
|
|
1686
|
-
return `data: ${JSON.stringify(data)}
|
|
1687
|
-
|
|
1688
|
-
`;
|
|
1689
|
-
}
|
|
1690
|
-
function createSSEStream(iterable, opts) {
|
|
1691
|
-
return new Response(
|
|
1692
|
-
new ReadableStream({
|
|
1693
|
-
async start(controller) {
|
|
1694
|
-
try {
|
|
1695
|
-
for await (const event of iterable) {
|
|
1696
|
-
const text = event.type ? formatSSE(event.type, event) : formatSSEData(event);
|
|
1697
|
-
controller.enqueue(encoder.encode(text));
|
|
1698
|
-
}
|
|
1699
|
-
} catch (e) {
|
|
1700
|
-
if (e instanceof Error && e.name !== "AbortError") {
|
|
1701
|
-
controller.enqueue(encoder.encode(formatSSE("error", { error: e.message })));
|
|
1702
|
-
}
|
|
1703
|
-
} finally {
|
|
1704
|
-
controller.close();
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
}),
|
|
1708
|
-
{
|
|
1709
|
-
status: opts?.status ?? 200,
|
|
1710
|
-
headers: {
|
|
1711
|
-
"Content-Type": "text/event-stream",
|
|
1712
|
-
"Cache-Control": "no-cache",
|
|
1713
|
-
Connection: "keep-alive",
|
|
1714
|
-
...opts?.headers
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
);
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
// test/test-utils.ts
|
|
1721
|
-
import { WebSocket as WSWebSocket } from "ws";
|
|
1722
|
-
var TestResponseImpl = class {
|
|
1723
|
-
response;
|
|
1724
|
-
constructor(response) {
|
|
1725
|
-
this.response = response;
|
|
1726
|
-
}
|
|
1727
|
-
get status() {
|
|
1728
|
-
return this.response.status;
|
|
1729
|
-
}
|
|
1730
|
-
get headers() {
|
|
1731
|
-
return this.response.headers;
|
|
1732
|
-
}
|
|
1733
|
-
async json() {
|
|
1734
|
-
return this.response.json();
|
|
1735
|
-
}
|
|
1736
|
-
async text() {
|
|
1737
|
-
return this.response.text();
|
|
1738
|
-
}
|
|
1739
|
-
async bytes() {
|
|
1740
|
-
return this.response.bytes();
|
|
1741
|
-
}
|
|
1742
|
-
async arrayBuffer() {
|
|
1743
|
-
return this.response.arrayBuffer();
|
|
1744
|
-
}
|
|
1745
|
-
};
|
|
1746
|
-
var TestRequest = class {
|
|
1747
|
-
headers = {};
|
|
1748
|
-
ctxMixin = {};
|
|
1749
|
-
bodyData = null;
|
|
1750
|
-
app;
|
|
1751
|
-
method;
|
|
1752
|
-
path;
|
|
1753
|
-
constructor(app, method, path) {
|
|
1754
|
-
this.app = app;
|
|
1755
|
-
this.method = method;
|
|
1756
|
-
this.path = path;
|
|
1757
|
-
}
|
|
1758
|
-
/** Set a request header */
|
|
1759
|
-
header(name, value) {
|
|
1760
|
-
this.headers[name.toLowerCase()] = value;
|
|
1761
|
-
return this;
|
|
1762
|
-
}
|
|
1763
|
-
/** Mix properties into ctx (simulating middleware injection) */
|
|
1764
|
-
with(mixin) {
|
|
1765
|
-
Object.assign(this.ctxMixin, mixin);
|
|
1766
|
-
return this;
|
|
1767
|
-
}
|
|
1768
|
-
/** Shortcut: set ctx.user */
|
|
1769
|
-
withUser(user) {
|
|
1770
|
-
;
|
|
1771
|
-
this.ctxMixin.user = user;
|
|
1772
|
-
return this;
|
|
1773
|
-
}
|
|
1774
|
-
/** Shortcut: set ctx.tenant */
|
|
1775
|
-
withTenant(tenant) {
|
|
1776
|
-
this.ctxMixin.tenant = tenant;
|
|
1777
|
-
return this;
|
|
1778
|
-
}
|
|
1779
|
-
/** Set JSON request body */
|
|
1780
|
-
body(data) {
|
|
1781
|
-
this.bodyData = JSON.stringify(data);
|
|
1782
|
-
this.headers["content-type"] = "application/json";
|
|
1783
|
-
return this;
|
|
1784
|
-
}
|
|
1785
|
-
/** Set raw text body */
|
|
1786
|
-
rawBody(data) {
|
|
1787
|
-
this.bodyData = data;
|
|
1788
|
-
return this;
|
|
1789
|
-
}
|
|
1790
|
-
/** Send the request and return the response */
|
|
1791
|
-
async send() {
|
|
1792
|
-
const url = `http://localhost${this.path}`;
|
|
1793
|
-
const query = {};
|
|
1794
|
-
const qIdx = this.path.indexOf("?");
|
|
1795
|
-
if (qIdx !== -1) {
|
|
1796
|
-
const searchParams = new URLSearchParams(this.path.slice(qIdx));
|
|
1797
|
-
for (const [k, v] of searchParams) {
|
|
1798
|
-
query[k] = v;
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
const request = new Request(url, {
|
|
1802
|
-
method: this.method,
|
|
1803
|
-
headers: this.headers,
|
|
1804
|
-
body: this.bodyData
|
|
1805
|
-
});
|
|
1806
|
-
const ctx = {
|
|
1807
|
-
params: {},
|
|
1808
|
-
query,
|
|
1809
|
-
...this.ctxMixin
|
|
1810
|
-
};
|
|
1811
|
-
const handler = this.app.handler();
|
|
1812
|
-
const response = await handler(request, ctx);
|
|
1813
|
-
return new TestResponseImpl(response);
|
|
1814
|
-
}
|
|
1815
|
-
};
|
|
1816
|
-
var TestApp = class {
|
|
1817
|
-
router;
|
|
1818
|
-
wsServer = null;
|
|
1819
|
-
wsConnections = [];
|
|
1820
|
-
constructor() {
|
|
1821
|
-
this.router = new Router();
|
|
1822
|
-
}
|
|
1823
|
-
/**
|
|
1824
|
-
* Register a WebSocket handler.
|
|
1825
|
-
*/
|
|
1826
|
-
ws(path, handler) {
|
|
1827
|
-
this.router.ws(path, handler);
|
|
1828
|
-
return this;
|
|
1829
|
-
}
|
|
1830
|
-
/** Get the raw Router (for advanced use). */
|
|
1831
|
-
get _router() {
|
|
1832
|
-
return this.router;
|
|
1833
|
-
}
|
|
1834
|
-
/** Add global middleware */
|
|
1835
|
-
use(mw) {
|
|
1836
|
-
this.router.use(mw);
|
|
1837
|
-
return this;
|
|
1838
|
-
}
|
|
1839
|
-
/** Register a GET route — supports route-level middleware via spread args. */
|
|
1840
|
-
get(path, ...args) {
|
|
1841
|
-
;
|
|
1842
|
-
this.router.get(path, ...args);
|
|
1843
|
-
return this;
|
|
1844
|
-
}
|
|
1845
|
-
/** Register a POST route. */
|
|
1846
|
-
post(path, ...args) {
|
|
1847
|
-
;
|
|
1848
|
-
this.router.post(path, ...args);
|
|
1849
|
-
return this;
|
|
1850
|
-
}
|
|
1851
|
-
/** Register a PUT route. */
|
|
1852
|
-
put(path, ...args) {
|
|
1853
|
-
;
|
|
1854
|
-
this.router.put(path, ...args);
|
|
1855
|
-
return this;
|
|
1856
|
-
}
|
|
1857
|
-
/** Register a PATCH route. */
|
|
1858
|
-
patch(path, ...args) {
|
|
1859
|
-
;
|
|
1860
|
-
this.router.patch(path, ...args);
|
|
1861
|
-
return this;
|
|
1862
|
-
}
|
|
1863
|
-
/** Register a DELETE route. */
|
|
1864
|
-
delete(path, ...args) {
|
|
1865
|
-
;
|
|
1866
|
-
this.router.delete(path, ...args);
|
|
1867
|
-
return this;
|
|
1868
|
-
}
|
|
1869
|
-
/** Start building a GET request */
|
|
1870
|
-
getReq(path) {
|
|
1871
|
-
return new TestRequest(this, "GET", path);
|
|
1872
|
-
}
|
|
1873
|
-
/** Start building a POST request */
|
|
1874
|
-
postReq(path) {
|
|
1875
|
-
return new TestRequest(this, "POST", path);
|
|
1876
|
-
}
|
|
1877
|
-
/** Start building a PUT request */
|
|
1878
|
-
putReq(path) {
|
|
1879
|
-
return new TestRequest(this, "PUT", path);
|
|
1880
|
-
}
|
|
1881
|
-
/** Start building a PATCH request */
|
|
1882
|
-
patchReq(path) {
|
|
1883
|
-
return new TestRequest(this, "PATCH", path);
|
|
1884
|
-
}
|
|
1885
|
-
/** Start building a DELETE request */
|
|
1886
|
-
deleteReq(path) {
|
|
1887
|
-
return new TestRequest(this, "DELETE", path);
|
|
1888
|
-
}
|
|
1889
|
-
/** Get the underlying handler (for advanced usage) */
|
|
1890
|
-
handler() {
|
|
1891
|
-
return this.router.handler();
|
|
1892
|
-
}
|
|
1893
|
-
/** Start building a WebSocket connection to the given path. */
|
|
1894
|
-
wsReq(path) {
|
|
1895
|
-
return new TestWSRequest(this, path);
|
|
1896
|
-
}
|
|
1897
|
-
/**
|
|
1898
|
-
* Internal: ensure HTTP server is running for WebSocket connections.
|
|
1899
|
-
* Starts on a random port.
|
|
1900
|
-
*/
|
|
1901
|
-
/* @internal */
|
|
1902
|
-
async _ensureServer() {
|
|
1903
|
-
if (this.wsServer) {
|
|
1904
|
-
return `http://localhost:${this.wsServer.port}`;
|
|
1905
|
-
}
|
|
1906
|
-
const wsHandler = this.router.websocketHandler();
|
|
1907
|
-
if (!wsHandler) {
|
|
1908
|
-
throw new Error(
|
|
1909
|
-
"No WebSocket routes registered. Use app.ws(path, handler) before calling wsReq()."
|
|
1910
|
-
);
|
|
1911
|
-
}
|
|
1912
|
-
this.wsServer = serve(this.router.handler(), {
|
|
1913
|
-
websocket: wsHandler
|
|
1914
|
-
});
|
|
1915
|
-
await this.wsServer.ready;
|
|
1916
|
-
return `http://localhost:${this.wsServer.port}`;
|
|
1917
|
-
}
|
|
1918
|
-
/**
|
|
1919
|
-
* Internal: register a WS connection for cleanup.
|
|
1920
|
-
*/
|
|
1921
|
-
/* @internal */
|
|
1922
|
-
_trackConnection(conn) {
|
|
1923
|
-
this.wsConnections.push(conn);
|
|
1924
|
-
}
|
|
1925
|
-
/**
|
|
1926
|
-
* Cleanup all WebSocket connections and stop the server.
|
|
1927
|
-
*/
|
|
1928
|
-
async close() {
|
|
1929
|
-
for (const conn of this.wsConnections) {
|
|
1930
|
-
try {
|
|
1931
|
-
conn.close();
|
|
1932
|
-
} catch {
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
this.wsConnections = [];
|
|
1936
|
-
if (this.wsServer) {
|
|
1937
|
-
this.wsServer.close();
|
|
1938
|
-
this.wsServer = null;
|
|
1939
|
-
}
|
|
1940
|
-
}
|
|
1941
|
-
};
|
|
1942
|
-
var TestWSRequest = class {
|
|
1943
|
-
app;
|
|
1944
|
-
path;
|
|
1945
|
-
_timeout = 5e3;
|
|
1946
|
-
constructor(app, path) {
|
|
1947
|
-
this.app = app;
|
|
1948
|
-
this.path = path;
|
|
1949
|
-
}
|
|
1950
|
-
/** Set the timeout for operations (default: 5000ms). */
|
|
1951
|
-
timeout(ms) {
|
|
1952
|
-
this._timeout = ms;
|
|
1953
|
-
return this;
|
|
1954
|
-
}
|
|
1955
|
-
/**
|
|
1956
|
-
* Connect to the WebSocket endpoint.
|
|
1957
|
-
* Starts a real HTTP server (random port) if not already running.
|
|
1958
|
-
*/
|
|
1959
|
-
async connect() {
|
|
1960
|
-
const baseUrl = await this.app._ensureServer();
|
|
1961
|
-
const wsUrl = baseUrl.replace(/^http/, "ws") + this.path;
|
|
1962
|
-
const ws = new WSWebSocket(wsUrl, { handshakeTimeout: this._timeout });
|
|
1963
|
-
return new Promise((resolve3, reject) => {
|
|
1964
|
-
const timer = setTimeout(() => {
|
|
1965
|
-
reject(new Error(`WebSocket connection timed out after ${this._timeout}ms`));
|
|
1966
|
-
ws.close();
|
|
1967
|
-
}, this._timeout);
|
|
1968
|
-
ws.on("open", () => {
|
|
1969
|
-
clearTimeout(timer);
|
|
1970
|
-
const conn = new TestWSConnection(ws, this._timeout);
|
|
1971
|
-
this.app._trackConnection(conn);
|
|
1972
|
-
resolve3(conn);
|
|
1973
|
-
});
|
|
1974
|
-
ws.on("error", (err) => {
|
|
1975
|
-
clearTimeout(timer);
|
|
1976
|
-
reject(new Error(`WebSocket connection error: ${err.message}`));
|
|
1977
|
-
});
|
|
1978
|
-
ws.on("unexpected-response", (_req, res) => {
|
|
1979
|
-
clearTimeout(timer);
|
|
1980
|
-
let body = "";
|
|
1981
|
-
res.on("data", (chunk) => {
|
|
1982
|
-
body += chunk.toString();
|
|
1983
|
-
});
|
|
1984
|
-
res.on("end", () => {
|
|
1985
|
-
reject(new Error(`WebSocket upgrade rejected (${res.statusCode}): ${body.slice(0, 200)}`));
|
|
1986
|
-
});
|
|
1987
|
-
});
|
|
1988
|
-
});
|
|
1989
|
-
}
|
|
1990
|
-
};
|
|
1991
|
-
var TestWSConnection = class {
|
|
1992
|
-
ws;
|
|
1993
|
-
_timeout;
|
|
1994
|
-
messageQueue = [];
|
|
1995
|
-
resolveQueue = [];
|
|
1996
|
-
_closed = false;
|
|
1997
|
-
constructor(ws, timeout = 5e3) {
|
|
1998
|
-
this.ws = ws;
|
|
1999
|
-
this._timeout = timeout;
|
|
2000
|
-
ws.on("message", (data) => {
|
|
2001
|
-
const str = data.toString();
|
|
2002
|
-
if (this.resolveQueue.length > 0) {
|
|
2003
|
-
const resolve3 = this.resolveQueue.shift();
|
|
2004
|
-
resolve3(str);
|
|
2005
|
-
} else {
|
|
2006
|
-
this.messageQueue.push(str);
|
|
2007
|
-
}
|
|
2008
|
-
});
|
|
2009
|
-
ws.on("close", () => {
|
|
2010
|
-
this._closed = true;
|
|
2011
|
-
for (const _r of this.resolveQueue) {
|
|
2012
|
-
}
|
|
2013
|
-
});
|
|
2014
|
-
}
|
|
2015
|
-
/** Send a text message. */
|
|
2016
|
-
send(data) {
|
|
2017
|
-
this.ws.send(data);
|
|
2018
|
-
}
|
|
2019
|
-
/** Send a JSON message. */
|
|
2020
|
-
json(data) {
|
|
2021
|
-
this.ws.send(JSON.stringify(data));
|
|
2022
|
-
}
|
|
2023
|
-
/**
|
|
2024
|
-
* Wait for the next message. Returns the raw text.
|
|
2025
|
-
* Throws on timeout or if the connection is closed.
|
|
2026
|
-
*/
|
|
2027
|
-
async receive(timeout) {
|
|
2028
|
-
if (this.messageQueue.length > 0) {
|
|
2029
|
-
return this.messageQueue.shift();
|
|
2030
|
-
}
|
|
2031
|
-
if (this._closed) {
|
|
2032
|
-
throw new Error("WebSocket connection closed");
|
|
2033
|
-
}
|
|
2034
|
-
return new Promise((resolve3, reject) => {
|
|
2035
|
-
const timer = setTimeout(() => {
|
|
2036
|
-
const idx = this.resolveQueue.indexOf(resolve3);
|
|
2037
|
-
if (idx !== -1) this.resolveQueue.splice(idx, 1);
|
|
2038
|
-
reject(new Error(`WebSocket receive timed out after ${timeout ?? this._timeout}ms`));
|
|
2039
|
-
}, timeout ?? this._timeout);
|
|
2040
|
-
this.resolveQueue.push((msg) => {
|
|
2041
|
-
clearTimeout(timer);
|
|
2042
|
-
resolve3(msg);
|
|
2043
|
-
});
|
|
2044
|
-
});
|
|
2045
|
-
}
|
|
2046
|
-
/** Wait for the next message and parse as JSON. */
|
|
2047
|
-
async receiveJson() {
|
|
2048
|
-
const msg = await this.receive();
|
|
2049
|
-
return JSON.parse(msg);
|
|
2050
|
-
}
|
|
2051
|
-
/**
|
|
2052
|
-
* Assert that no message is received within the given silence period.
|
|
2053
|
-
* Useful for verifying that something did NOT happen.
|
|
2054
|
-
*/
|
|
2055
|
-
async expectSilent(ms) {
|
|
2056
|
-
return new Promise((resolve3, reject) => {
|
|
2057
|
-
if (this.messageQueue.length > 0) {
|
|
2058
|
-
reject(new Error(`Expected silence but got message: ${this.messageQueue[0].slice(0, 100)}`));
|
|
2059
|
-
return;
|
|
2060
|
-
}
|
|
2061
|
-
const timer = setTimeout(() => resolve3(), ms);
|
|
2062
|
-
const origPush = this.resolveQueue.push.bind(this.resolveQueue);
|
|
2063
|
-
this.resolveQueue.push = (_fn) => {
|
|
2064
|
-
clearTimeout(timer);
|
|
2065
|
-
reject(new Error("Expected silence but received a message"));
|
|
2066
|
-
return 0;
|
|
2067
|
-
};
|
|
2068
|
-
setTimeout(() => {
|
|
2069
|
-
this.resolveQueue.push = origPush;
|
|
2070
|
-
}, ms + 10).unref();
|
|
2071
|
-
});
|
|
2072
|
-
}
|
|
2073
|
-
/** Close the connection. */
|
|
2074
|
-
close() {
|
|
2075
|
-
this._closed = true;
|
|
2076
|
-
this.ws.close();
|
|
2077
|
-
}
|
|
2078
|
-
/** Whether the connection is closed. */
|
|
2079
|
-
get closed() {
|
|
2080
|
-
return this._closed;
|
|
2081
|
-
}
|
|
2082
|
-
};
|
|
2083
|
-
function testApp() {
|
|
2084
|
-
return new TestApp();
|
|
2085
|
-
}
|
|
2086
|
-
async function createTestDb(options) {
|
|
2087
|
-
const dbUrl = options?.url || process.env.TEST_DATABASE_URL || process.env.DATABASE_URL;
|
|
2088
|
-
if (!dbUrl) throw new Error("createTestDb: DATABASE_URL or TEST_DATABASE_URL required");
|
|
2089
|
-
const schema = options?.schema || `test_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2090
|
-
const { default: postgres2 } = await import("postgres");
|
|
2091
|
-
const adminSql = postgres2(dbUrl);
|
|
2092
|
-
await adminSql.unsafe('CREATE SCHEMA IF NOT EXISTS "' + schema.replace(/"/g, '""') + '"');
|
|
2093
|
-
const schemaUrl = new URL(dbUrl);
|
|
2094
|
-
schemaUrl.searchParams.set("search_path", schema);
|
|
2095
|
-
const sql = postgres2(schemaUrl.toString());
|
|
2096
|
-
await adminSql.end();
|
|
2097
|
-
return {
|
|
2098
|
-
sql,
|
|
2099
|
-
url: schemaUrl.toString(),
|
|
2100
|
-
schema,
|
|
2101
|
-
destroy: async () => {
|
|
2102
|
-
const destroySql = postgres2(dbUrl);
|
|
2103
|
-
await destroySql.unsafe('DROP SCHEMA IF EXISTS "' + schema.replace(/"/g, '""') + '" CASCADE');
|
|
2104
|
-
await destroySql.end();
|
|
2105
|
-
await sql.end();
|
|
2106
|
-
}
|
|
2107
|
-
};
|
|
2108
|
-
}
|
|
2109
|
-
async function withTestDb(optionsOrFn, fn) {
|
|
2110
|
-
let dbUrl;
|
|
2111
|
-
let callback;
|
|
2112
|
-
if (typeof optionsOrFn === "function") {
|
|
2113
|
-
callback = optionsOrFn;
|
|
2114
|
-
} else if (typeof optionsOrFn === "string") {
|
|
2115
|
-
dbUrl = optionsOrFn;
|
|
2116
|
-
callback = fn;
|
|
2117
|
-
} else {
|
|
2118
|
-
dbUrl = optionsOrFn?.url;
|
|
2119
|
-
callback = fn;
|
|
2120
|
-
}
|
|
2121
|
-
const resolvedUrl = dbUrl || process.env.TEST_DATABASE_URL || process.env.DATABASE_URL;
|
|
2122
|
-
if (!resolvedUrl) throw new Error("withTestDb: DATABASE_URL or TEST_DATABASE_URL required");
|
|
2123
|
-
const { default: postgres2 } = await import("postgres");
|
|
2124
|
-
const sql = postgres2(resolvedUrl);
|
|
2125
|
-
try {
|
|
2126
|
-
await sql.begin(async (txSql) => {
|
|
2127
|
-
await callback(txSql);
|
|
2128
|
-
throw void 0;
|
|
2129
|
-
});
|
|
2130
|
-
} catch {
|
|
2131
|
-
} finally {
|
|
2132
|
-
await sql.end();
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
|
-
// graphql.ts
|
|
2137
|
-
import {
|
|
2138
|
-
buildSchema,
|
|
2139
|
-
graphql as executeGraphQL,
|
|
2140
|
-
validate as validateQuery,
|
|
2141
|
-
parse
|
|
2142
|
-
} from "graphql";
|
|
2143
|
-
import { makeExecutableSchema } from "@graphql-tools/schema";
|
|
2144
|
-
function parseParamsFromGet(url) {
|
|
2145
|
-
const query = url.searchParams.get("query");
|
|
2146
|
-
if (!query) return null;
|
|
2147
|
-
let variables = {};
|
|
2148
|
-
const variablesStr = url.searchParams.get("variables");
|
|
2149
|
-
if (variablesStr) {
|
|
2150
|
-
try {
|
|
2151
|
-
variables = JSON.parse(variablesStr);
|
|
2152
|
-
} catch {
|
|
2153
|
-
return null;
|
|
2154
|
-
}
|
|
2155
|
-
}
|
|
2156
|
-
return { query, variables, operationName: url.searchParams.get("operationName") || void 0 };
|
|
2157
|
-
}
|
|
2158
|
-
async function parseParamsFromPost(req) {
|
|
2159
|
-
try {
|
|
2160
|
-
const body = await req.json();
|
|
2161
|
-
if (!body.query) return null;
|
|
2162
|
-
return { query: body.query, variables: body.variables || {}, operationName: body.operationName };
|
|
2163
|
-
} catch {
|
|
2164
|
-
return null;
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
function buildSchemaFromOptions(options) {
|
|
2168
|
-
try {
|
|
2169
|
-
if (typeof options.schema === "string") {
|
|
2170
|
-
return options.resolvers ? makeExecutableSchema({ typeDefs: options.schema, resolvers: options.resolvers }) : buildSchema(options.schema);
|
|
2171
|
-
}
|
|
2172
|
-
return options.schema;
|
|
2173
|
-
} catch (err) {
|
|
2174
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2175
|
-
console.error(`[graphql] schema build failed: ${msg}`);
|
|
2176
|
-
throw err;
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
function queryDepth(doc) {
|
|
2180
|
-
let max = 0;
|
|
2181
|
-
function walk(node, depth) {
|
|
2182
|
-
if (depth > max) max = depth;
|
|
2183
|
-
if (node.selectionSet) {
|
|
2184
|
-
for (const sel of node.selectionSet.selections) {
|
|
2185
|
-
walk(sel, depth + 1);
|
|
2186
|
-
}
|
|
2187
|
-
}
|
|
2188
|
-
}
|
|
2189
|
-
for (const def of doc.definitions) {
|
|
2190
|
-
if (def.kind === "OperationDefinition") {
|
|
2191
|
-
walk(def, 0);
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
return max;
|
|
2195
|
-
}
|
|
2196
|
-
async function executeQuery(schema, params, options, req, ctx) {
|
|
2197
|
-
const maxDepth = options.maxDepth ?? 10;
|
|
2198
|
-
if (maxDepth > 0) {
|
|
2199
|
-
try {
|
|
2200
|
-
const doc = parse(params.query);
|
|
2201
|
-
const depth = queryDepth(doc);
|
|
2202
|
-
if (depth > maxDepth) {
|
|
2203
|
-
return Response.json(
|
|
2204
|
-
{ errors: [{ message: `Query depth ${depth} exceeds limit ${maxDepth}` }] },
|
|
2205
|
-
{ status: 400 }
|
|
2206
|
-
);
|
|
2207
|
-
}
|
|
2208
|
-
const validationErrors = validateQuery(schema, doc);
|
|
2209
|
-
if (validationErrors.length > 0) {
|
|
2210
|
-
return Response.json(
|
|
2211
|
-
{ errors: validationErrors.map((e) => ({ message: e.message })) },
|
|
2212
|
-
{ status: 400 }
|
|
2213
|
-
);
|
|
2214
|
-
}
|
|
2215
|
-
} catch (err) {
|
|
2216
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2217
|
-
return Response.json({ errors: [{ message: `Parse error: ${msg}` }] }, { status: 400 });
|
|
2218
|
-
}
|
|
2219
|
-
}
|
|
2220
|
-
const timeout = options.timeout ?? 3e4;
|
|
2221
|
-
const contextValue = options.context ? await options.context(req, ctx) : ctx;
|
|
2222
|
-
try {
|
|
2223
|
-
const resultPromise = executeGraphQL({
|
|
2224
|
-
schema,
|
|
2225
|
-
source: params.query,
|
|
2226
|
-
rootValue: options.rootValue,
|
|
2227
|
-
contextValue,
|
|
2228
|
-
variableValues: params.variables,
|
|
2229
|
-
operationName: params.operationName
|
|
2230
|
-
});
|
|
2231
|
-
let result;
|
|
2232
|
-
if (timeout > 0) {
|
|
2233
|
-
let timer = null;
|
|
2234
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
2235
|
-
timer = setTimeout(() => reject(new Error("Query timeout")), timeout);
|
|
2236
|
-
});
|
|
2237
|
-
result = await Promise.race([resultPromise, timeoutPromise]);
|
|
2238
|
-
if (timer) clearTimeout(timer);
|
|
2239
|
-
} else {
|
|
2240
|
-
result = await resultPromise;
|
|
2241
|
-
}
|
|
2242
|
-
return Response.json(result, { status: result.errors ? 400 : 200 });
|
|
2243
|
-
} catch (err) {
|
|
2244
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2245
|
-
console.error(`[${currentTraceId()}] graphql execution failed: ${msg}`);
|
|
2246
|
-
return Response.json({ errors: [{ message: msg }] }, { status: 500 });
|
|
2247
|
-
}
|
|
2248
|
-
}
|
|
2249
|
-
function graphiqlHTML(endpoint) {
|
|
2250
|
-
const safeEndpoint = endpoint.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/</g, "\\x3C");
|
|
2251
|
-
return `<!doctype html>
|
|
2252
|
-
<html lang="en">
|
|
2253
|
-
<head>
|
|
2254
|
-
<meta charset="UTF-8" />
|
|
2255
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2256
|
-
<title>GraphiQL</title>
|
|
2257
|
-
<style>body { margin: 0; } #graphiql { height: 100dvh; }</style>
|
|
2258
|
-
<link rel="stylesheet" href="https://esm.sh/graphiql@5.2.2/dist/style.css" />
|
|
2259
|
-
<script type="importmap">
|
|
2260
|
-
{
|
|
2261
|
-
"imports": {
|
|
2262
|
-
"react": "https://esm.sh/react@19.2.5",
|
|
2263
|
-
"react/": "https://esm.sh/react@19.2.5/",
|
|
2264
|
-
"react-dom": "https://esm.sh/react-dom@19.2.5",
|
|
2265
|
-
"react-dom/": "https://esm.sh/react-dom@19.2.5/",
|
|
2266
|
-
"graphiql": "https://esm.sh/graphiql@5.2.2?standalone&external=react,react-dom,@graphiql/react,graphql",
|
|
2267
|
-
"graphiql/": "https://esm.sh/graphiql@5.2.2/",
|
|
2268
|
-
"@graphiql/react": "https://esm.sh/@graphiql/react@0.37.3?standalone&external=react,react-dom,graphql,@graphiql/toolkit,@emotion/is-prop-valid",
|
|
2269
|
-
"@graphiql/toolkit": "https://esm.sh/@graphiql/toolkit@0.11.3?standalone&external=graphql",
|
|
2270
|
-
"graphql": "https://esm.sh/graphql@16.13.2",
|
|
2271
|
-
"@emotion/is-prop-valid": "data:text/javascript,"
|
|
2272
|
-
}
|
|
2273
|
-
}
|
|
2274
|
-
</script>
|
|
2275
|
-
<script type="module">
|
|
2276
|
-
import React from 'react';
|
|
2277
|
-
import ReactDOM from 'react-dom/client';
|
|
2278
|
-
import { GraphiQL } from 'graphiql';
|
|
2279
|
-
import { createGraphiQLFetcher } from '@graphiql/toolkit';
|
|
2280
|
-
import 'graphiql/setup-workers/esm.sh';
|
|
2281
|
-
|
|
2282
|
-
const fetcher = createGraphiQLFetcher({ url: "${safeEndpoint}" });
|
|
2283
|
-
|
|
2284
|
-
function App() {
|
|
2285
|
-
return React.createElement(GraphiQL, { fetcher });
|
|
2286
|
-
}
|
|
2287
|
-
|
|
2288
|
-
const container = document.getElementById('graphiql');
|
|
2289
|
-
const root = ReactDOM.createRoot(container);
|
|
2290
|
-
root.render(React.createElement(App));
|
|
2291
|
-
</script>
|
|
2292
|
-
</head>
|
|
2293
|
-
<body>
|
|
2294
|
-
<div id="graphiql">Loading\u2026</div>
|
|
2295
|
-
</body>
|
|
2296
|
-
</html>`;
|
|
2297
|
-
}
|
|
2298
|
-
function graphql(handler) {
|
|
2299
|
-
const r = new Router();
|
|
2300
|
-
let cachedOptions = null;
|
|
2301
|
-
let cachedSchema = null;
|
|
2302
|
-
async function getSchema(req, ctx) {
|
|
2303
|
-
const options = await handler(req, ctx);
|
|
2304
|
-
if (cachedSchema && cachedOptions === options) {
|
|
2305
|
-
return { options, schema: cachedSchema };
|
|
2306
|
-
}
|
|
2307
|
-
const schema = buildSchemaFromOptions(options);
|
|
2308
|
-
cachedOptions = options;
|
|
2309
|
-
cachedSchema = schema;
|
|
2310
|
-
return { options, schema };
|
|
2311
|
-
}
|
|
2312
|
-
r.get("/", async (req, ctx) => {
|
|
2313
|
-
const { options, schema } = await getSchema(req, ctx);
|
|
2314
|
-
const url = new URL(req.url);
|
|
2315
|
-
if (options.graphiql && !url.searchParams.has("query")) {
|
|
2316
|
-
return new Response(graphiqlHTML(url.pathname), {
|
|
2317
|
-
status: 200,
|
|
2318
|
-
headers: { "Content-Type": "text/html" }
|
|
2319
|
-
});
|
|
2320
|
-
}
|
|
2321
|
-
const params = parseParamsFromGet(url);
|
|
2322
|
-
if (!params) {
|
|
2323
|
-
return Response.json({ errors: [{ message: "Missing query" }] }, { status: 400 });
|
|
2324
|
-
}
|
|
2325
|
-
return executeQuery(schema, params, options, req, ctx);
|
|
2326
|
-
});
|
|
2327
|
-
r.post("/", async (req, ctx) => {
|
|
2328
|
-
const { options, schema } = await getSchema(req, ctx);
|
|
2329
|
-
const params = await parseParamsFromPost(req);
|
|
2330
|
-
if (!params) {
|
|
2331
|
-
return Response.json({ errors: [{ message: "Missing query" }] }, { status: 400 });
|
|
2332
|
-
}
|
|
2333
|
-
return executeQuery(schema, params, options, req, ctx);
|
|
2334
|
-
});
|
|
2335
|
-
return r;
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
// ai/stream.ts
|
|
2339
|
-
var _ai = {};
|
|
2340
|
-
async function getStreamText() {
|
|
2341
|
-
if (!_ai.streamText) _ai.streamText = (await import("ai")).streamText;
|
|
2342
|
-
return _ai.streamText;
|
|
2343
|
-
}
|
|
2344
|
-
async function getStreamObject() {
|
|
2345
|
-
if (!_ai.streamObject) _ai.streamObject = (await import("ai")).streamObject;
|
|
2346
|
-
return _ai.streamObject;
|
|
2347
|
-
}
|
|
2348
|
-
async function aiStream(handler, provider) {
|
|
2349
|
-
const r = new Router();
|
|
2350
|
-
r.post("/", async (req, ctx) => {
|
|
2351
|
-
let options;
|
|
2352
|
-
try {
|
|
2353
|
-
options = await handler(req, ctx);
|
|
2354
|
-
} catch (err) {
|
|
2355
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2356
|
-
return new Response(JSON.stringify({ error: message }), {
|
|
2357
|
-
status: 500,
|
|
2358
|
-
headers: { "Content-Type": "application/json" }
|
|
2359
|
-
});
|
|
2360
|
-
}
|
|
2361
|
-
if (provider && !options.model) {
|
|
2362
|
-
options.model = provider.model();
|
|
2363
|
-
}
|
|
2364
|
-
if (options.schema) {
|
|
2365
|
-
const streamObject2 = await getStreamObject();
|
|
2366
|
-
const { schema, ...params } = options;
|
|
2367
|
-
const result2 = streamObject2({ ...params, schema, output: "object" });
|
|
2368
|
-
return result2.toTextStreamResponse();
|
|
2369
|
-
}
|
|
2370
|
-
const streamText2 = await getStreamText();
|
|
2371
|
-
const result = streamText2(options);
|
|
2372
|
-
return result.toTextStreamResponse();
|
|
2373
|
-
});
|
|
2374
|
-
return r;
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
// ai/provider.ts
|
|
2378
|
-
import { createOpenAI } from "@ai-sdk/openai";
|
|
2379
|
-
import {
|
|
2380
|
-
embed as aiEmbed,
|
|
2381
|
-
embedMany as aiEmbedMany,
|
|
2382
|
-
generateText as aiGenerateText,
|
|
2383
|
-
streamText as aiStreamText
|
|
2384
|
-
} from "ai";
|
|
2385
|
-
function aiProvider(options) {
|
|
2386
|
-
const baseURL = options?.baseURL ?? process.env.OPENAI_BASE_URL ?? "http://localhost:11434/v1";
|
|
2387
|
-
const apiKey = options?.apiKey ?? process.env.OPENAI_API_KEY ?? "ollama";
|
|
2388
|
-
const modelName = options?.model ?? process.env.OPENAI_MODEL ?? "qwen3:0.6b";
|
|
2389
|
-
const embedModelName = options?.embeddingModel ?? process.env.OPENAI_EMBEDDING_MODEL ?? "qwen3-embedding:0.6b";
|
|
2390
|
-
const dimension = options?.embeddingDimension ?? parseInt(process.env.EMBEDDING_DIMENSION || "1024", 10);
|
|
2391
|
-
const client = createOpenAI({ baseURL, apiKey });
|
|
2392
|
-
let _model;
|
|
2393
|
-
let _embedModel;
|
|
2394
|
-
const provider = {
|
|
2395
|
-
get dimension() {
|
|
2396
|
-
return dimension;
|
|
2397
|
-
},
|
|
2398
|
-
model(name) {
|
|
2399
|
-
const m = name ?? modelName;
|
|
2400
|
-
if (!_model) _model = client(m);
|
|
2401
|
-
return _model;
|
|
2402
|
-
},
|
|
2403
|
-
embeddingModel(name) {
|
|
2404
|
-
const m = name ?? embedModelName;
|
|
2405
|
-
if (!_embedModel) _embedModel = client.embedding(m);
|
|
2406
|
-
return _embedModel;
|
|
2407
|
-
},
|
|
2408
|
-
async embed(text) {
|
|
2409
|
-
const result = await aiEmbed({ model: this.embeddingModel(), value: text });
|
|
2410
|
-
return result.embedding;
|
|
2411
|
-
},
|
|
2412
|
-
async embedMany(texts) {
|
|
2413
|
-
const result = await aiEmbedMany({ model: this.embeddingModel(), values: texts });
|
|
2414
|
-
return result.embeddings;
|
|
2415
|
-
},
|
|
2416
|
-
generateText(params) {
|
|
2417
|
-
return aiGenerateText({ ...params, model: this.model() });
|
|
2418
|
-
},
|
|
2419
|
-
streamText(params) {
|
|
2420
|
-
return aiStreamText({ ...params, model: this.model() });
|
|
2421
|
-
}
|
|
2422
|
-
};
|
|
2423
|
-
const mw = async (req, ctx, next) => {
|
|
2424
|
-
;
|
|
2425
|
-
ctx.ai = provider;
|
|
2426
|
-
return next(req, ctx);
|
|
2427
|
-
};
|
|
2428
|
-
mw.__meta = { injects: ["ai"], depends: [] };
|
|
2429
|
-
return Object.assign(mw, provider);
|
|
2430
|
-
}
|
|
2431
|
-
|
|
2432
|
-
// index.ts
|
|
2433
|
-
import {
|
|
2434
|
-
streamText,
|
|
2435
|
-
generateText,
|
|
2436
|
-
generateObject,
|
|
2437
|
-
streamObject,
|
|
2438
|
-
tool,
|
|
2439
|
-
embed,
|
|
2440
|
-
embedMany,
|
|
2441
|
-
smoothStream
|
|
2442
|
-
} from "ai";
|
|
2443
|
-
import { openai, createOpenAI as createOpenAI2 } from "@ai-sdk/openai";
|
|
2444
|
-
|
|
2445
|
-
// postgres/client.ts
|
|
2446
|
-
import postgresFactory from "postgres";
|
|
2447
|
-
var MIGRATIONS_TABLE = "_weifuwu_migrations";
|
|
2448
|
-
var RETRYABLE_CODES = /* @__PURE__ */ new Set(["40P01", "40001"]);
|
|
2449
|
-
function isRetryable(err) {
|
|
2450
|
-
return err instanceof Error && "code" in err && RETRYABLE_CODES.has(err.code);
|
|
2451
|
-
}
|
|
2452
|
-
function postgres(opts) {
|
|
2453
|
-
const options = typeof opts === "string" ? { connection: opts } : opts ?? {};
|
|
2454
|
-
const connection = options.connection ?? process.env.DATABASE_URL;
|
|
2455
|
-
if (!connection) {
|
|
2456
|
-
throw new Error(
|
|
2457
|
-
"postgres: DATABASE_URL is not set. Pass a connection string or set the DATABASE_URL environment variable."
|
|
2458
|
-
);
|
|
2459
|
-
}
|
|
2460
|
-
const stmtTimeout = options.statementTimeout ?? 3e4;
|
|
2461
|
-
let connStr = typeof connection === "string" ? connection : "";
|
|
2462
|
-
if (stmtTimeout > 0 && typeof connection === "string") {
|
|
2463
|
-
const sep2 = connStr.includes("?") ? "&" : "?";
|
|
2464
|
-
connStr = `${connStr}${sep2}options=-c%20statement_timeout%3D${stmtTimeout}`;
|
|
2465
|
-
}
|
|
2466
|
-
const sql = postgresFactory(connStr, {
|
|
2467
|
-
max: options.max,
|
|
2468
|
-
ssl: options.ssl,
|
|
2469
|
-
idle_timeout: options.idle_timeout,
|
|
2470
|
-
connect_timeout: options.connect_timeout
|
|
2471
|
-
});
|
|
2472
|
-
if (options.signal) {
|
|
2473
|
-
options.signal.addEventListener(
|
|
2474
|
-
"abort",
|
|
2475
|
-
() => {
|
|
2476
|
-
sql.end();
|
|
2477
|
-
},
|
|
2478
|
-
{ once: true }
|
|
2479
|
-
);
|
|
2480
|
-
}
|
|
2481
|
-
const closeTimeout = options.closeTimeout ?? 5;
|
|
2482
|
-
const _active = 0;
|
|
2483
|
-
const _waiting = 0;
|
|
2484
|
-
const poolMax = options.max ?? 10;
|
|
2485
|
-
const mw = ((req, ctx, next) => {
|
|
2486
|
-
ctx.sql = sql;
|
|
2487
|
-
return next(req, ctx);
|
|
2488
|
-
});
|
|
2489
|
-
mw.__meta = { injects: ["sql"], depends: [] };
|
|
2490
|
-
mw.sql = sql;
|
|
2491
|
-
mw.migrate = async () => {
|
|
2492
|
-
await sql.unsafe(`
|
|
2493
|
-
CREATE TABLE IF NOT EXISTS "${MIGRATIONS_TABLE}" (
|
|
2494
|
-
name TEXT PRIMARY KEY,
|
|
2495
|
-
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
2496
|
-
)
|
|
2497
|
-
`);
|
|
2498
|
-
};
|
|
2499
|
-
mw.markMigrated = async (moduleName) => {
|
|
2500
|
-
await sql.unsafe(
|
|
2501
|
-
`INSERT INTO "${MIGRATIONS_TABLE}" (name) VALUES ($1) ON CONFLICT DO NOTHING`,
|
|
2502
|
-
[moduleName]
|
|
2503
|
-
);
|
|
2504
|
-
};
|
|
2505
|
-
mw.isMigrated = async (moduleName) => {
|
|
2506
|
-
const [row] = await sql.unsafe(`SELECT 1 FROM "${MIGRATIONS_TABLE}" WHERE name = $1`, [
|
|
2507
|
-
moduleName
|
|
2508
|
-
]);
|
|
2509
|
-
return !!row;
|
|
2510
|
-
};
|
|
2511
|
-
mw.transaction = (async (fn, retryOpts) => {
|
|
2512
|
-
const maxRetries = retryOpts?.maxRetries ?? 3;
|
|
2513
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
2514
|
-
try {
|
|
2515
|
-
const result = await sql.begin(fn);
|
|
2516
|
-
return result;
|
|
2517
|
-
} catch (err) {
|
|
2518
|
-
if (attempt < maxRetries && isRetryable(err)) {
|
|
2519
|
-
const delay = Math.min(100 * Math.pow(2, attempt - 1), 1e3);
|
|
2520
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
2521
|
-
continue;
|
|
2522
|
-
}
|
|
2523
|
-
throw err;
|
|
2524
|
-
}
|
|
2525
|
-
}
|
|
2526
|
-
throw new Error("transaction: max retries exceeded");
|
|
2527
|
-
});
|
|
2528
|
-
mw.poolStats = () => ({
|
|
2529
|
-
active: _active,
|
|
2530
|
-
idle: poolMax - _active - _waiting,
|
|
2531
|
-
waiting: _waiting,
|
|
2532
|
-
max: poolMax
|
|
2533
|
-
});
|
|
2534
|
-
mw.close = () => sql.end({ timeout: closeTimeout });
|
|
2535
|
-
return mw;
|
|
2536
|
-
}
|
|
2537
|
-
|
|
2538
|
-
// redis/client.ts
|
|
2539
|
-
import { Redis as IORedis } from "ioredis";
|
|
2540
|
-
function redis(opts) {
|
|
2541
|
-
const options = typeof opts === "string" ? { url: opts } : opts ?? {};
|
|
2542
|
-
const url = options.url ?? process.env.REDIS_URL ?? "redis://localhost:6379";
|
|
2543
|
-
const client = new IORedis(url, options);
|
|
2544
|
-
client.on("error", (err) => console.error("[redis]", err.message));
|
|
2545
|
-
const mw = ((req, ctx, next) => {
|
|
2546
|
-
ctx.redis = client;
|
|
2547
|
-
return next(req, ctx);
|
|
2548
|
-
});
|
|
2549
|
-
mw.__meta = { injects: ["redis"], depends: [] };
|
|
2550
|
-
mw.redis = client;
|
|
2551
|
-
mw.close = () => client.quit();
|
|
2552
|
-
return mw;
|
|
2553
|
-
}
|
|
2554
|
-
|
|
2555
|
-
// queue/index.ts
|
|
2556
|
-
import { Redis as IORedis2 } from "ioredis";
|
|
2557
|
-
import crypto3 from "node:crypto";
|
|
2558
|
-
|
|
2559
|
-
// queue/cron.ts
|
|
2560
|
-
function parseField(field, min, max) {
|
|
2561
|
-
const values = /* @__PURE__ */ new Set();
|
|
2562
|
-
for (const part of field.split(",")) {
|
|
2563
|
-
if (part === "*") {
|
|
2564
|
-
for (let i = min; i <= max; i++) values.add(i);
|
|
2565
|
-
} else if (part.includes("/")) {
|
|
2566
|
-
const [range, stepStr] = part.split("/");
|
|
2567
|
-
const step = parseInt(stepStr, 10);
|
|
2568
|
-
if (isNaN(step) || step < 1) throw new Error(`Invalid cron step: ${part}`);
|
|
2569
|
-
let start = min;
|
|
2570
|
-
let end = max;
|
|
2571
|
-
if (range !== "*") {
|
|
2572
|
-
const rangeParts = range.split("-");
|
|
2573
|
-
start = parseInt(rangeParts[0], 10);
|
|
2574
|
-
end = rangeParts.length > 1 ? parseInt(rangeParts[1], 10) : max;
|
|
2575
|
-
}
|
|
2576
|
-
for (let i = start; i <= end; i += step) values.add(i);
|
|
2577
|
-
} else if (part.includes("-")) {
|
|
2578
|
-
const [s, e] = part.split("-").map(Number);
|
|
2579
|
-
if (isNaN(s) || isNaN(e)) throw new Error(`Invalid cron range: ${part}`);
|
|
2580
|
-
for (let i = s; i <= e; i++) values.add(i);
|
|
2581
|
-
} else {
|
|
2582
|
-
const val = parseInt(part, 10);
|
|
2583
|
-
if (isNaN(val)) throw new Error(`Invalid cron value: ${part}`);
|
|
2584
|
-
values.add(val);
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
const result = /* @__PURE__ */ new Set();
|
|
2588
|
-
for (const v of values) {
|
|
2589
|
-
if (v >= min && v <= max) result.add(v);
|
|
2590
|
-
}
|
|
2591
|
-
return result;
|
|
2592
|
-
}
|
|
2593
|
-
var FIELD_RANGES = [
|
|
2594
|
-
[0, 59],
|
|
2595
|
-
[0, 23],
|
|
2596
|
-
[1, 31],
|
|
2597
|
-
[1, 12],
|
|
2598
|
-
[0, 6]
|
|
2599
|
-
];
|
|
2600
|
-
function parsePattern(pattern) {
|
|
2601
|
-
const fields = pattern.trim().split(/\s+/);
|
|
2602
|
-
if (fields.length !== 5) {
|
|
2603
|
-
throw new Error(`Invalid cron pattern "${pattern}": expected 5 fields, got ${fields.length}`);
|
|
2604
|
-
}
|
|
2605
|
-
return fields.map((f, i) => parseField(f, FIELD_RANGES[i][0], FIELD_RANGES[i][1]));
|
|
2606
|
-
}
|
|
2607
|
-
function matches(fields, date) {
|
|
2608
|
-
return fields[0].has(date.getMinutes()) && fields[1].has(date.getHours()) && fields[2].has(date.getDate()) && fields[3].has(date.getMonth() + 1) && fields[4].has(date.getDay());
|
|
2609
|
-
}
|
|
2610
|
-
function cronNext(expr, from = /* @__PURE__ */ new Date()) {
|
|
2611
|
-
const fields = parsePattern(expr);
|
|
2612
|
-
const candidate = new Date(from.getTime() + 6e4);
|
|
2613
|
-
candidate.setSeconds(0, 0);
|
|
2614
|
-
for (let i = 0; i < 525600; i++) {
|
|
2615
|
-
if (fields[4].has(candidate.getDay()) && fields[3].has(candidate.getMonth() + 1) && fields[2].has(candidate.getDate()) && fields[1].has(candidate.getHours()) && fields[0].has(candidate.getMinutes())) {
|
|
2616
|
-
return candidate.getTime();
|
|
2617
|
-
}
|
|
2618
|
-
candidate.setTime(candidate.getTime() + 6e4);
|
|
2619
|
-
}
|
|
2620
|
-
throw new Error(`No future date found for cron expression "${expr}"`);
|
|
2621
|
-
}
|
|
2622
|
-
|
|
2623
|
-
// queue/index.ts
|
|
2624
|
-
function queue(opts) {
|
|
2625
|
-
const store = opts?.store ?? "memory";
|
|
2626
|
-
if (store === "redis") return createRedisQueue(opts);
|
|
2627
|
-
if (store === "pg") return createPgQueue(opts);
|
|
2628
|
-
return createMemoryQueue(opts);
|
|
2629
|
-
}
|
|
2630
|
-
function escapeIdent(s) {
|
|
2631
|
-
return '"' + s.replace(/"/g, '""') + '"';
|
|
2632
|
-
}
|
|
2633
|
-
function attachCron(q, handlers) {
|
|
2634
|
-
;
|
|
2635
|
-
q.cron = function(pattern, handler) {
|
|
2636
|
-
const id = "__cron_" + pattern.replace(/[^a-zA-Z0-9]/g, "_") + "_" + crypto3.randomUUID().slice(0, 8);
|
|
2637
|
-
q.process(id, async () => {
|
|
2638
|
-
await handler();
|
|
2639
|
-
});
|
|
2640
|
-
q.add(id, {}, { schedule: pattern });
|
|
2641
|
-
return { stop: () => handlers.delete(id) };
|
|
2642
|
-
};
|
|
2643
|
-
}
|
|
2644
|
-
function createMemoryQueue(opts) {
|
|
2645
|
-
const pollInterval = opts?.pollInterval ?? 200;
|
|
2646
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
2647
|
-
const pending = [];
|
|
2648
|
-
const failed = [];
|
|
2649
|
-
const MAX_FAILED = 1e3;
|
|
2650
|
-
let running = false;
|
|
2651
|
-
let pollTimer = null;
|
|
2652
|
-
let _processed = 0;
|
|
2653
|
-
let _failed = 0;
|
|
2654
|
-
let inflight = 0;
|
|
2655
|
-
const MAX_CONCURRENT = 16;
|
|
2656
|
-
function insertJob(job) {
|
|
2657
|
-
let i = 0;
|
|
2658
|
-
while (i < pending.length && pending[i].runAt <= job.runAt) i++;
|
|
2659
|
-
pending.splice(i, 0, job);
|
|
2660
|
-
}
|
|
2661
|
-
async function execute(job, handler) {
|
|
2662
|
-
inflight++;
|
|
2663
|
-
try {
|
|
2664
|
-
await handler(job);
|
|
2665
|
-
_processed++;
|
|
2666
|
-
} catch (e) {
|
|
2667
|
-
_failed++;
|
|
2668
|
-
failed.unshift({ ...job, error: e.message, failedAt: Date.now() });
|
|
2669
|
-
if (failed.length > MAX_FAILED) failed.length = MAX_FAILED;
|
|
2670
|
-
} finally {
|
|
2671
|
-
inflight--;
|
|
2672
|
-
}
|
|
2673
|
-
if (job.schedule) {
|
|
2674
|
-
try {
|
|
2675
|
-
insertJob({
|
|
2676
|
-
...job,
|
|
2677
|
-
id: crypto3.randomUUID(),
|
|
2678
|
-
runAt: cronNext(job.schedule),
|
|
2679
|
-
createdAt: Date.now()
|
|
2680
|
-
});
|
|
2681
|
-
} catch (e) {
|
|
2682
|
-
console.error("[queue] cron re-queue failed:", e.message);
|
|
2683
|
-
}
|
|
2684
|
-
}
|
|
2685
|
-
}
|
|
2686
|
-
async function poll() {
|
|
2687
|
-
if (!running) return;
|
|
2688
|
-
const now = Date.now();
|
|
2689
|
-
while (running && inflight < MAX_CONCURRENT && pending.length > 0 && pending[0].runAt <= now) {
|
|
2690
|
-
const job = pending.shift();
|
|
2691
|
-
const handler = handlers.get(job.type);
|
|
2692
|
-
if (handler) execute(job, handler);
|
|
2693
|
-
}
|
|
2694
|
-
if (running) pollTimer = setTimeout(poll, pollInterval);
|
|
2695
|
-
}
|
|
2696
|
-
const mw = ((req, ctx, next) => {
|
|
2697
|
-
ctx.queue = q;
|
|
2698
|
-
return next(req, ctx);
|
|
2699
|
-
});
|
|
2700
|
-
const q = mw;
|
|
2701
|
-
mw.add = function add(type, payload, opts2) {
|
|
2702
|
-
const id = crypto3.randomUUID();
|
|
2703
|
-
let runAt;
|
|
2704
|
-
if (opts2?.schedule) {
|
|
2705
|
-
try {
|
|
2706
|
-
const f = parsePattern(opts2.schedule);
|
|
2707
|
-
runAt = matches(f, /* @__PURE__ */ new Date()) ? Date.now() : cronNext(opts2.schedule);
|
|
2708
|
-
} catch {
|
|
2709
|
-
runAt = cronNext(opts2.schedule);
|
|
2710
|
-
}
|
|
2711
|
-
} else if (opts2?.delay) {
|
|
2712
|
-
runAt = Date.now() + opts2.delay;
|
|
2713
|
-
} else {
|
|
2714
|
-
runAt = Date.now();
|
|
2715
|
-
}
|
|
2716
|
-
const job = { id, type, payload, createdAt: Date.now(), runAt };
|
|
2717
|
-
if (opts2?.schedule) job.schedule = opts2.schedule;
|
|
2718
|
-
insertJob(job);
|
|
2719
|
-
return Promise.resolve(id);
|
|
2720
|
-
};
|
|
2721
|
-
mw.process = function process2(type, handler) {
|
|
2722
|
-
handlers.set(type, handler);
|
|
2723
|
-
};
|
|
2724
|
-
mw.run = async function run() {
|
|
2725
|
-
if (running) return;
|
|
2726
|
-
running = true;
|
|
2727
|
-
poll();
|
|
2728
|
-
};
|
|
2729
|
-
mw.close = async function close() {
|
|
2730
|
-
running = false;
|
|
2731
|
-
if (pollTimer) {
|
|
2732
|
-
clearTimeout(pollTimer);
|
|
2733
|
-
pollTimer = null;
|
|
2734
|
-
}
|
|
2735
|
-
while (inflight > 0) await new Promise((r) => setTimeout(r, 50));
|
|
2736
|
-
};
|
|
2737
|
-
mw.jobs = async function(limit) {
|
|
2738
|
-
return pending.slice(0, limit ?? 50);
|
|
2739
|
-
};
|
|
2740
|
-
mw.failedJobs = async function failedJobs(limit) {
|
|
2741
|
-
return failed.slice(0, limit ?? 50);
|
|
2742
|
-
};
|
|
2743
|
-
mw.retryFailed = async function retry(jobId) {
|
|
2744
|
-
const idx = failed.findIndex((j) => j.id === jobId);
|
|
2745
|
-
if (idx < 0) return false;
|
|
2746
|
-
const [entry] = failed.splice(idx, 1);
|
|
2747
|
-
_failed--;
|
|
2748
|
-
insertJob({ ...entry, runAt: Date.now() });
|
|
2749
|
-
return true;
|
|
2750
|
-
};
|
|
2751
|
-
mw.retryAllFailed = async function retryAll(type) {
|
|
2752
|
-
let count = 0;
|
|
2753
|
-
for (let i = failed.length - 1; i >= 0; i--) {
|
|
2754
|
-
if (type && failed[i].type !== type) continue;
|
|
2755
|
-
const [entry] = failed.splice(i, 1);
|
|
2756
|
-
_failed--;
|
|
2757
|
-
insertJob({ ...entry, runAt: Date.now() });
|
|
2758
|
-
count++;
|
|
2759
|
-
}
|
|
2760
|
-
return count;
|
|
2761
|
-
};
|
|
2762
|
-
mw.dashboard = function dashboard() {
|
|
2763
|
-
return buildDashboard(q);
|
|
2764
|
-
};
|
|
2765
|
-
mw.stats = () => ({
|
|
2766
|
-
running,
|
|
2767
|
-
inflight,
|
|
2768
|
-
processed: _processed,
|
|
2769
|
-
failed: _failed,
|
|
2770
|
-
handlers: handlers.size,
|
|
2771
|
-
maxConcurrent: MAX_CONCURRENT
|
|
2772
|
-
});
|
|
2773
|
-
attachCron(q, handlers);
|
|
2774
|
-
return q;
|
|
2775
|
-
}
|
|
2776
|
-
function createPgQueue(opts) {
|
|
2777
|
-
const sql = opts.pg.sql;
|
|
2778
|
-
const pollInterval = opts?.pollInterval ?? 200;
|
|
2779
|
-
const table = (opts?.prefix ?? "queue") + "_jobs";
|
|
2780
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
2781
|
-
let running = false, pollTimer = null;
|
|
2782
|
-
let _processed = 0, _failed = 0, inflight = 0, ready = false;
|
|
2783
|
-
const MAX_CONCURRENT = 16;
|
|
2784
|
-
const MAX_FAILED = 1e3;
|
|
2785
|
-
async function ensureTable() {
|
|
2786
|
-
if (ready) return;
|
|
2787
|
-
await sql.unsafe(
|
|
2788
|
-
`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())`
|
|
2789
|
-
);
|
|
2790
|
-
await sql.unsafe(
|
|
2791
|
-
`CREATE INDEX IF NOT EXISTS ${escapeIdent(table + "_run_at_idx")} ON ${escapeIdent(table)} (run_at, status)`
|
|
2792
|
-
);
|
|
2793
|
-
ready = true;
|
|
2794
|
-
}
|
|
2795
|
-
async function processJob(job, handler) {
|
|
2796
|
-
inflight++;
|
|
2797
|
-
try {
|
|
2798
|
-
await handler(job);
|
|
2799
|
-
_processed++;
|
|
2800
|
-
await sql.unsafe(`DELETE FROM ${escapeIdent(table)} WHERE id = $1`, [job.id]);
|
|
2801
|
-
} catch (e) {
|
|
2802
|
-
_failed++;
|
|
2803
|
-
const msg = e.message;
|
|
2804
|
-
console.error("[queue] handler error:", msg);
|
|
2805
|
-
await sql.unsafe(
|
|
2806
|
-
`UPDATE ${escapeIdent(table)} SET status = 'failed', error = $2, failed_at = NOW() WHERE id = $1`,
|
|
2807
|
-
[job.id, msg]
|
|
2808
|
-
);
|
|
2809
|
-
} finally {
|
|
2810
|
-
inflight--;
|
|
2811
|
-
}
|
|
2812
|
-
if (job.schedule) {
|
|
2813
|
-
try {
|
|
2814
|
-
const nextRun = cronNext(job.schedule);
|
|
2815
|
-
await sql.unsafe(
|
|
2816
|
-
`INSERT INTO ${escapeIdent(table)} (id, type, payload, run_at, schedule) VALUES ($1, $2, $3::jsonb, $4, $5)`,
|
|
2817
|
-
[
|
|
2818
|
-
crypto3.randomUUID(),
|
|
2819
|
-
job.type,
|
|
2820
|
-
JSON.stringify(job.payload),
|
|
2821
|
-
new Date(nextRun).toISOString(),
|
|
2822
|
-
job.schedule
|
|
2823
|
-
]
|
|
2824
|
-
);
|
|
2825
|
-
} catch (e) {
|
|
2826
|
-
console.error("[queue] cron re-queue failed:", e.message);
|
|
2827
|
-
}
|
|
2828
|
-
}
|
|
2829
|
-
}
|
|
2830
|
-
async function poll() {
|
|
2831
|
-
if (!running) return;
|
|
2832
|
-
try {
|
|
2833
|
-
while (running && inflight < MAX_CONCURRENT) {
|
|
2834
|
-
const rows = await sql.unsafe(
|
|
2835
|
-
`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 *`
|
|
2836
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2837
|
-
);
|
|
2838
|
-
if (rows.length === 0) break;
|
|
2839
|
-
const row = rows[0];
|
|
2840
|
-
const job = {
|
|
2841
|
-
id: row.id,
|
|
2842
|
-
type: row.type,
|
|
2843
|
-
payload: typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload,
|
|
2844
|
-
createdAt: new Date(row.created_at).getTime(),
|
|
2845
|
-
runAt: new Date(row.run_at).getTime(),
|
|
2846
|
-
schedule: row.schedule || void 0
|
|
2847
|
-
};
|
|
2848
|
-
const handler = handlers.get(job.type);
|
|
2849
|
-
if (handler) processJob(job, handler);
|
|
2850
|
-
}
|
|
2851
|
-
} catch (e) {
|
|
2852
|
-
const msg = e.message;
|
|
2853
|
-
if (msg.includes("CONNECTION_ENDED") || msg.includes("Connection terminated")) {
|
|
2854
|
-
running = false;
|
|
2855
|
-
return;
|
|
2856
|
-
}
|
|
2857
|
-
console.error("[queue] poll error:", msg);
|
|
2858
|
-
}
|
|
2859
|
-
if (running) pollTimer = setTimeout(poll, pollInterval);
|
|
2860
|
-
}
|
|
2861
|
-
const mw = ((req, ctx, next) => {
|
|
2862
|
-
ctx.queue = q;
|
|
2863
|
-
return next(req, ctx);
|
|
2864
|
-
});
|
|
2865
|
-
const q = mw;
|
|
2866
|
-
mw.add = function add(type, payload, opts2) {
|
|
2867
|
-
return (async () => {
|
|
2868
|
-
const id = crypto3.randomUUID();
|
|
2869
|
-
let runAt;
|
|
2870
|
-
if (opts2?.schedule) {
|
|
2871
|
-
try {
|
|
2872
|
-
const f = parsePattern(opts2.schedule);
|
|
2873
|
-
runAt = matches(f, /* @__PURE__ */ new Date()) ? /* @__PURE__ */ new Date() : new Date(cronNext(opts2.schedule));
|
|
2874
|
-
} catch {
|
|
2875
|
-
runAt = new Date(cronNext(opts2.schedule));
|
|
2876
|
-
}
|
|
2877
|
-
} else if (opts2?.delay) {
|
|
2878
|
-
runAt = new Date(Date.now() + opts2.delay);
|
|
2879
|
-
} else {
|
|
2880
|
-
runAt = /* @__PURE__ */ new Date();
|
|
2881
|
-
}
|
|
2882
|
-
await sql.unsafe(
|
|
2883
|
-
`INSERT INTO ${escapeIdent(table)} (id, type, payload, run_at, schedule) VALUES ($1, $2, $3::jsonb, $4, $5)`,
|
|
2884
|
-
[id, type, JSON.stringify(payload), runAt.toISOString(), opts2?.schedule || null]
|
|
2885
|
-
);
|
|
2886
|
-
return id;
|
|
2887
|
-
})();
|
|
2888
|
-
};
|
|
2889
|
-
mw.process = function process2(type, handler) {
|
|
2890
|
-
handlers.set(type, handler);
|
|
2891
|
-
};
|
|
2892
|
-
mw.migrate = ensureTable;
|
|
2893
|
-
mw.run = async function run() {
|
|
2894
|
-
if (running) return;
|
|
2895
|
-
await ensureTable();
|
|
2896
|
-
running = true;
|
|
2897
|
-
poll();
|
|
2898
|
-
};
|
|
2899
|
-
mw.close = async function close() {
|
|
2900
|
-
running = false;
|
|
2901
|
-
if (pollTimer) {
|
|
2902
|
-
clearTimeout(pollTimer);
|
|
2903
|
-
pollTimer = null;
|
|
2904
|
-
}
|
|
2905
|
-
while (inflight > 0) await new Promise((r) => setTimeout(r, 50));
|
|
2906
|
-
};
|
|
2907
|
-
mw.jobs = async function jobs(limit) {
|
|
2908
|
-
const rows = await sql.unsafe(
|
|
2909
|
-
`SELECT * FROM ${escapeIdent(table)} WHERE status = 'pending' ORDER BY run_at LIMIT $1`,
|
|
2910
|
-
[limit ?? 50]
|
|
2911
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2912
|
-
);
|
|
2913
|
-
return rows.map((r) => ({
|
|
2914
|
-
id: r.id,
|
|
2915
|
-
type: r.type,
|
|
2916
|
-
payload: typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
|
|
2917
|
-
createdAt: new Date(r.created_at).getTime(),
|
|
2918
|
-
runAt: new Date(r.run_at).getTime(),
|
|
2919
|
-
schedule: r.schedule || void 0
|
|
2920
|
-
}));
|
|
2921
|
-
};
|
|
2922
|
-
mw.failedJobs = async function failedJobs(limit) {
|
|
2923
|
-
const rows = await sql.unsafe(
|
|
2924
|
-
`SELECT * FROM ${escapeIdent(table)} WHERE status = 'failed' ORDER BY failed_at DESC LIMIT $1`,
|
|
2925
|
-
[limit ?? 50]
|
|
2926
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2927
|
-
);
|
|
2928
|
-
return rows.map((r) => ({
|
|
2929
|
-
id: r.id,
|
|
2930
|
-
type: r.type,
|
|
2931
|
-
payload: typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
|
|
2932
|
-
createdAt: new Date(r.created_at).getTime(),
|
|
2933
|
-
runAt: new Date(r.run_at).getTime(),
|
|
2934
|
-
schedule: r.schedule || void 0,
|
|
2935
|
-
error: r.error || "",
|
|
2936
|
-
failedAt: new Date(r.failed_at).getTime()
|
|
2937
|
-
}));
|
|
2938
|
-
};
|
|
2939
|
-
mw.retryFailed = async function retryFailed(jobId) {
|
|
2940
|
-
const result = await sql.unsafe(
|
|
2941
|
-
`UPDATE ${escapeIdent(table)} SET status = 'pending', error = NULL, failed_at = NULL, run_at = NOW() WHERE id = $1 AND status = 'failed' RETURNING id`,
|
|
2942
|
-
[jobId]
|
|
2943
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2944
|
-
);
|
|
2945
|
-
return result.length > 0;
|
|
2946
|
-
};
|
|
2947
|
-
mw.retryAllFailed = async function retryAllFailed(type) {
|
|
2948
|
-
const result = await sql.unsafe(
|
|
2949
|
-
type ? `UPDATE ${escapeIdent(table)} SET status = 'pending', error = NULL, failed_at = NULL, run_at = NOW() WHERE status = 'failed' AND type = $1 RETURNING id` : `UPDATE ${escapeIdent(table)} SET status = 'pending', error = NULL, failed_at = NULL, run_at = NOW() WHERE status = 'failed' RETURNING id`,
|
|
2950
|
-
type ? [type] : []
|
|
2951
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2952
|
-
);
|
|
2953
|
-
return result.length;
|
|
2954
|
-
};
|
|
2955
|
-
mw.dashboard = function dashboard() {
|
|
2956
|
-
return buildDashboard(q);
|
|
2957
|
-
};
|
|
2958
|
-
mw.stats = () => ({
|
|
2959
|
-
running,
|
|
2960
|
-
inflight,
|
|
2961
|
-
processed: _processed,
|
|
2962
|
-
failed: _failed,
|
|
2963
|
-
handlers: handlers.size,
|
|
2964
|
-
maxConcurrent: MAX_CONCURRENT
|
|
2965
|
-
});
|
|
2966
|
-
attachCron(q, handlers);
|
|
2967
|
-
return q;
|
|
2968
|
-
}
|
|
2969
|
-
function createRedisQueue(opts) {
|
|
2970
|
-
const redis2 = opts?.redis ?? new IORedis2(opts?.url ?? process.env.REDIS_URL ?? "redis://localhost:6379");
|
|
2971
|
-
const prefix = opts?.prefix ?? "queue";
|
|
2972
|
-
const pollInterval = opts?.pollInterval ?? 200;
|
|
2973
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
2974
|
-
let running = false, pollTimer = null, epoch = 0;
|
|
2975
|
-
let _processed = 0, _failed = 0, inflight = 0;
|
|
2976
|
-
const jobKey = prefix + ":jobs", failedKey = prefix + ":failed", MAX_FAILED = 1e3, MAX_CONCURRENT = 16;
|
|
2977
|
-
async function processJob(job, handler) {
|
|
2978
|
-
inflight++;
|
|
2979
|
-
try {
|
|
2980
|
-
await handler(job);
|
|
2981
|
-
_processed++;
|
|
2982
|
-
} catch (e) {
|
|
2983
|
-
_failed++;
|
|
2984
|
-
const msg = e.message;
|
|
2985
|
-
console.error("[queue] handler error:", msg);
|
|
2986
|
-
await redis2.lpush(failedKey, JSON.stringify({ ...job, error: msg, failedAt: Date.now() }));
|
|
2987
|
-
await redis2.ltrim(failedKey, 0, MAX_FAILED - 1);
|
|
2988
|
-
} finally {
|
|
2989
|
-
inflight--;
|
|
2990
|
-
}
|
|
2991
|
-
if (job.schedule) {
|
|
2992
|
-
try {
|
|
2993
|
-
const nextRun = cronNext(job.schedule);
|
|
2994
|
-
await redis2.zadd(
|
|
2995
|
-
jobKey,
|
|
2996
|
-
nextRun,
|
|
2997
|
-
JSON.stringify({
|
|
2998
|
-
...job,
|
|
2999
|
-
id: crypto3.randomUUID(),
|
|
3000
|
-
runAt: nextRun,
|
|
3001
|
-
createdAt: Date.now()
|
|
3002
|
-
})
|
|
3003
|
-
);
|
|
3004
|
-
} catch (e) {
|
|
3005
|
-
console.error("[queue] cron re-queue failed:", e.message);
|
|
3006
|
-
}
|
|
3007
|
-
}
|
|
3008
|
-
}
|
|
3009
|
-
async function poll() {
|
|
3010
|
-
const currentEpoch = epoch;
|
|
3011
|
-
if (!running) return;
|
|
3012
|
-
try {
|
|
3013
|
-
const now = Date.now();
|
|
3014
|
-
while (running && inflight < MAX_CONCURRENT) {
|
|
3015
|
-
const result = await redis2.zpopmin(jobKey);
|
|
3016
|
-
if (result.length < 2) break;
|
|
3017
|
-
const raw = result[0], score = parseInt(result[1], 10);
|
|
3018
|
-
if (score > now) {
|
|
3019
|
-
await redis2.zadd(jobKey, score, raw);
|
|
3020
|
-
break;
|
|
3021
|
-
}
|
|
3022
|
-
let job;
|
|
3023
|
-
try {
|
|
3024
|
-
job = JSON.parse(raw);
|
|
3025
|
-
} catch {
|
|
3026
|
-
continue;
|
|
3027
|
-
}
|
|
3028
|
-
const handler = handlers.get(job.type);
|
|
3029
|
-
if (handler) processJob(job, handler);
|
|
3030
|
-
}
|
|
3031
|
-
} catch (e) {
|
|
3032
|
-
console.error("[queue] poll error:", e.message);
|
|
3033
|
-
}
|
|
3034
|
-
if (running && currentEpoch === epoch) pollTimer = setTimeout(poll, pollInterval);
|
|
3035
|
-
}
|
|
3036
|
-
const mw = ((req, ctx, next) => {
|
|
3037
|
-
ctx.queue = q;
|
|
3038
|
-
return next(req, ctx);
|
|
3039
|
-
});
|
|
3040
|
-
const q = mw;
|
|
3041
|
-
mw.add = function add(type, payload, opts2) {
|
|
3042
|
-
const id = crypto3.randomUUID();
|
|
3043
|
-
let runAt;
|
|
3044
|
-
if (opts2?.schedule) {
|
|
3045
|
-
runAt = cronNext(opts2.schedule);
|
|
3046
|
-
} else if (opts2?.delay) {
|
|
3047
|
-
runAt = Date.now() + opts2.delay;
|
|
3048
|
-
} else {
|
|
3049
|
-
runAt = Date.now();
|
|
3050
|
-
}
|
|
3051
|
-
const job = { id, type, payload, createdAt: Date.now(), runAt };
|
|
3052
|
-
if (opts2?.schedule) job.schedule = opts2.schedule;
|
|
3053
|
-
return redis2.zadd(jobKey, runAt, JSON.stringify(job)).then(() => id);
|
|
3054
|
-
};
|
|
3055
|
-
mw.process = function process2(type, handler) {
|
|
3056
|
-
handlers.set(type, handler);
|
|
3057
|
-
};
|
|
3058
|
-
mw.run = async function run() {
|
|
3059
|
-
if (running) return;
|
|
3060
|
-
running = true;
|
|
3061
|
-
poll();
|
|
3062
|
-
};
|
|
3063
|
-
mw.close = async function close() {
|
|
3064
|
-
running = false;
|
|
3065
|
-
epoch++;
|
|
3066
|
-
if (pollTimer) {
|
|
3067
|
-
clearTimeout(pollTimer);
|
|
3068
|
-
pollTimer = null;
|
|
3069
|
-
}
|
|
3070
|
-
while (inflight > 0) await new Promise((r) => setTimeout(r, 50));
|
|
3071
|
-
redis2.disconnect();
|
|
3072
|
-
};
|
|
3073
|
-
mw.jobs = async function jobs(limit) {
|
|
3074
|
-
const raw = await redis2.zrevrange(jobKey, 0, (limit ?? 50) - 1);
|
|
3075
|
-
return raw.map((r) => {
|
|
3076
|
-
try {
|
|
3077
|
-
return JSON.parse(r);
|
|
3078
|
-
} catch {
|
|
3079
|
-
return null;
|
|
3080
|
-
}
|
|
3081
|
-
}).filter(Boolean);
|
|
3082
|
-
};
|
|
3083
|
-
mw.failedJobs = async function failedJobs(limit) {
|
|
3084
|
-
const raw = await redis2.lrange(failedKey, 0, (limit ?? 50) - 1);
|
|
3085
|
-
return raw.map((r) => {
|
|
3086
|
-
try {
|
|
3087
|
-
return JSON.parse(r);
|
|
3088
|
-
} catch {
|
|
3089
|
-
return null;
|
|
3090
|
-
}
|
|
3091
|
-
}).filter(Boolean);
|
|
3092
|
-
};
|
|
3093
|
-
mw.retryFailed = async function retryFailed(jobId) {
|
|
3094
|
-
const raw = await redis2.lrange(failedKey, 0, -1);
|
|
3095
|
-
for (const entry of raw) {
|
|
3096
|
-
try {
|
|
3097
|
-
const job = JSON.parse(entry);
|
|
3098
|
-
if (job.id === jobId) {
|
|
3099
|
-
await redis2.lrem(failedKey, 1, entry);
|
|
3100
|
-
const reJob = { ...job, runAt: Date.now() };
|
|
3101
|
-
delete reJob.error;
|
|
3102
|
-
delete reJob.failedAt;
|
|
3103
|
-
await redis2.zadd(jobKey, reJob.runAt, JSON.stringify(reJob));
|
|
3104
|
-
_failed--;
|
|
3105
|
-
return true;
|
|
3106
|
-
}
|
|
3107
|
-
} catch {
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
return false;
|
|
3111
|
-
};
|
|
3112
|
-
mw.retryAllFailed = async function retryAllFailed(type) {
|
|
3113
|
-
let count = 0;
|
|
3114
|
-
const raw = await redis2.lrange(failedKey, 0, -1);
|
|
3115
|
-
for (const entry of raw) {
|
|
3116
|
-
try {
|
|
3117
|
-
const job = JSON.parse(entry);
|
|
3118
|
-
if (type && job.type !== type) continue;
|
|
3119
|
-
await redis2.lrem(failedKey, 1, entry);
|
|
3120
|
-
const reJob = { ...job, runAt: Date.now() };
|
|
3121
|
-
delete reJob.error;
|
|
3122
|
-
delete reJob.failedAt;
|
|
3123
|
-
await redis2.zadd(jobKey, reJob.runAt, JSON.stringify(reJob));
|
|
3124
|
-
_failed--;
|
|
3125
|
-
count++;
|
|
3126
|
-
} catch {
|
|
3127
|
-
}
|
|
3128
|
-
}
|
|
3129
|
-
return count;
|
|
3130
|
-
};
|
|
3131
|
-
mw.dashboard = function dashboard() {
|
|
3132
|
-
return buildDashboard(q);
|
|
3133
|
-
};
|
|
3134
|
-
mw.stats = () => ({
|
|
3135
|
-
running,
|
|
3136
|
-
inflight,
|
|
3137
|
-
processed: _processed,
|
|
3138
|
-
failed: _failed,
|
|
3139
|
-
handlers: handlers.size,
|
|
3140
|
-
maxConcurrent: MAX_CONCURRENT
|
|
3141
|
-
});
|
|
3142
|
-
attachCron(q, handlers);
|
|
3143
|
-
return q;
|
|
3144
|
-
}
|
|
3145
|
-
function buildDashboard(q) {
|
|
3146
|
-
const r = new Router();
|
|
3147
|
-
r.get("/", async () => {
|
|
3148
|
-
const s = q.stats();
|
|
3149
|
-
const pending = await q.jobs(100);
|
|
3150
|
-
const byType = {};
|
|
3151
|
-
for (const j of pending) {
|
|
3152
|
-
if (!byType[j.type]) byType[j.type] = { pending: 0, failed: 0 };
|
|
3153
|
-
byType[j.type].pending++;
|
|
3154
|
-
}
|
|
3155
|
-
const failed = await q.failedJobs(1e3);
|
|
3156
|
-
for (const j of failed) {
|
|
3157
|
-
if (!byType[j.type]) byType[j.type] = { pending: 0, failed: 0 };
|
|
3158
|
-
byType[j.type].failed++;
|
|
3159
|
-
}
|
|
3160
|
-
return Response.json({ stats: s, types: byType, failedCount: failed.length });
|
|
3161
|
-
});
|
|
3162
|
-
r.get("/:type/failed", async (req, ctx) => {
|
|
3163
|
-
const failed = await q.failedJobs(100);
|
|
3164
|
-
return Response.json({ jobs: failed.filter((j) => j.type === ctx.params.type) });
|
|
3165
|
-
});
|
|
3166
|
-
r.post("/:type/retry", async (req, ctx) => {
|
|
3167
|
-
return Response.json({ retried: await q.retryAllFailed(ctx.params.type) });
|
|
3168
|
-
});
|
|
3169
|
-
r.post("/retry/:id", async (req, ctx) => {
|
|
3170
|
-
const ok = await q.retryFailed(ctx.params.id);
|
|
3171
|
-
if (!ok) return new Response("Not found", { status: 404 });
|
|
3172
|
-
return Response.json({ ok: true });
|
|
3173
|
-
});
|
|
3174
|
-
return r;
|
|
3175
|
-
}
|
|
3176
|
-
|
|
3177
|
-
// middleware/health.ts
|
|
3178
|
-
function health(options) {
|
|
3179
|
-
const path = options?.path ?? "/__health";
|
|
3180
|
-
const r = new Router();
|
|
3181
|
-
const handler = async () => {
|
|
3182
|
-
try {
|
|
3183
|
-
await options?.check?.();
|
|
3184
|
-
return new Response("OK", { status: 200 });
|
|
3185
|
-
} catch {
|
|
3186
|
-
return new Response("Service Unavailable", { status: 503 });
|
|
3187
|
-
}
|
|
3188
|
-
};
|
|
3189
|
-
r.get(path, handler);
|
|
3190
|
-
r.head(path, handler);
|
|
3191
|
-
return r;
|
|
3192
|
-
}
|
|
3193
|
-
export {
|
|
3194
|
-
DEFAULT_MAX_BODY,
|
|
3195
|
-
HttpError,
|
|
3196
|
-
MIGRATIONS_TABLE,
|
|
3197
|
-
Router,
|
|
3198
|
-
TestApp,
|
|
3199
|
-
TestRequest,
|
|
3200
|
-
aiProvider,
|
|
3201
|
-
aiStream,
|
|
3202
|
-
compress,
|
|
3203
|
-
cors,
|
|
3204
|
-
createHub,
|
|
3205
|
-
createOpenAI2 as createOpenAI,
|
|
3206
|
-
createSSEStream,
|
|
3207
|
-
createTestDb,
|
|
3208
|
-
createTestServer,
|
|
3209
|
-
currentTrace,
|
|
3210
|
-
currentTraceId,
|
|
3211
|
-
deleteCookie,
|
|
3212
|
-
embed,
|
|
3213
|
-
embedMany,
|
|
3214
|
-
env,
|
|
3215
|
-
formatSSE,
|
|
3216
|
-
formatSSEData,
|
|
3217
|
-
generateObject,
|
|
3218
|
-
generateText,
|
|
3219
|
-
getCookies,
|
|
3220
|
-
getPublicEnv,
|
|
3221
|
-
graphql,
|
|
3222
|
-
health,
|
|
3223
|
-
helmet,
|
|
3224
|
-
isBundled,
|
|
3225
|
-
isDev,
|
|
3226
|
-
isProd,
|
|
3227
|
-
loadEnv,
|
|
3228
|
-
logger,
|
|
3229
|
-
openai,
|
|
3230
|
-
postgres,
|
|
3231
|
-
queue,
|
|
3232
|
-
rateLimit,
|
|
3233
|
-
redis,
|
|
3234
|
-
requestId,
|
|
3235
|
-
runWithTrace,
|
|
3236
|
-
serve,
|
|
3237
|
-
serveStatic,
|
|
3238
|
-
setCookie,
|
|
3239
|
-
smoothStream,
|
|
3240
|
-
streamObject,
|
|
3241
|
-
streamText,
|
|
3242
|
-
testApp,
|
|
3243
|
-
tool,
|
|
3244
|
-
trace,
|
|
3245
|
-
traceElapsed,
|
|
3246
|
-
upload,
|
|
3247
|
-
validate,
|
|
3248
|
-
withTestDb
|
|
3249
|
-
};
|