weifuwu 0.30.0 → 0.30.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -203
- package/dist/core/router.d.ts +21 -58
- package/dist/core/serve.d.ts +0 -4
- package/dist/core/ws.d.ts +28 -0
- package/dist/index.d.ts +2 -24
- package/dist/index.js +396 -1880
- package/dist/queue/index.d.ts +10 -0
- package/dist/queue/types.d.ts +5 -9
- package/package.json +4 -5
- 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 -178
- package/dist/core/cookie.d.ts +0 -36
- package/dist/core/env.d.ts +0 -69
- package/dist/core/html.d.ts +0 -34
- package/dist/core/sse.d.ts +0 -47
- package/dist/middleware/csrf.d.ts +0 -31
- package/dist/middleware/flash.d.ts +0 -44
- package/dist/middleware/health.d.ts +0 -24
- package/dist/middleware/i18n.d.ts +0 -39
- package/dist/middleware/request-id.d.ts +0 -40
- package/dist/middleware/theme.d.ts +0 -31
- package/dist/middleware/validate.d.ts +0 -32
- package/dist/react/index.js +0 -2573
- package/dist/test/test-utils.d.ts +0 -193
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ var HttpError = class extends Error {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
// src/core/trace.ts
|
|
12
|
-
import
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
13
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
14
|
var als = new AsyncLocalStorage();
|
|
15
15
|
function currentTraceId() {
|
|
@@ -19,7 +19,7 @@ function currentTrace() {
|
|
|
19
19
|
return als.getStore();
|
|
20
20
|
}
|
|
21
21
|
function runWithTrace(incomingTraceId, fn) {
|
|
22
|
-
const traceId = incomingTraceId ||
|
|
22
|
+
const traceId = incomingTraceId || crypto.randomUUID();
|
|
23
23
|
const startTime = Date.now();
|
|
24
24
|
return als.run({ traceId, startTime }, fn);
|
|
25
25
|
}
|
|
@@ -30,14 +30,14 @@ function traceElapsed() {
|
|
|
30
30
|
}
|
|
31
31
|
function trace(options) {
|
|
32
32
|
const header = options?.header ?? "X-Request-ID";
|
|
33
|
-
const gen = options?.generator ?? (() =>
|
|
33
|
+
const gen = options?.generator ?? (() => crypto.randomUUID());
|
|
34
34
|
return async (req, ctx, next) => {
|
|
35
35
|
const existing = req.headers.get(header);
|
|
36
|
-
const
|
|
36
|
+
const requestId = existing ?? gen();
|
|
37
37
|
const tc = als.getStore();
|
|
38
38
|
ctx.trace = {
|
|
39
|
-
requestId
|
|
40
|
-
traceId: tc?.traceId ??
|
|
39
|
+
requestId,
|
|
40
|
+
traceId: tc?.traceId ?? requestId,
|
|
41
41
|
startTime: tc?.startTime ?? Date.now(),
|
|
42
42
|
elapsed: () => {
|
|
43
43
|
const t = als.getStore();
|
|
@@ -47,71 +47,11 @@ function trace(options) {
|
|
|
47
47
|
const res = await next(req, ctx);
|
|
48
48
|
if (res.headers.has(header)) return res;
|
|
49
49
|
const h = new Headers(res.headers);
|
|
50
|
-
h.set(header,
|
|
50
|
+
h.set(header, requestId);
|
|
51
51
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// src/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
55
|
// src/core/serve.ts
|
|
116
56
|
import http from "node:http";
|
|
117
57
|
var DEFAULT_MAX_BODY = 10 * 1024 * 1024;
|
|
@@ -180,11 +120,6 @@ async function sendResponse(res, response, opts) {
|
|
|
180
120
|
}
|
|
181
121
|
res.end();
|
|
182
122
|
}
|
|
183
|
-
async function createTestServer(router, options) {
|
|
184
|
-
const server = serve(router, { ...options, port: options?.port ?? 0, shutdown: false });
|
|
185
|
-
await server.ready;
|
|
186
|
-
return { server, url: `http://localhost:${server.port}` };
|
|
187
|
-
}
|
|
188
123
|
function serve(router, options) {
|
|
189
124
|
const ws = router.websocketHandler();
|
|
190
125
|
const handler = router.handler();
|
|
@@ -204,9 +139,6 @@ function serve(router, options) {
|
|
|
204
139
|
res.end("Request Body Too Large");
|
|
205
140
|
return;
|
|
206
141
|
}
|
|
207
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
208
|
-
console.error(`[${currentTraceId()}] unhandled error: ${msg}`);
|
|
209
|
-
if (err instanceof Error && err.stack) console.error(err.stack);
|
|
210
142
|
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
211
143
|
res.end("Internal Server Error");
|
|
212
144
|
}
|
|
@@ -269,7 +201,7 @@ function serve(router, options) {
|
|
|
269
201
|
);
|
|
270
202
|
}
|
|
271
203
|
server.on("error", (err) => {
|
|
272
|
-
|
|
204
|
+
throw err;
|
|
273
205
|
server.close();
|
|
274
206
|
_cachedPort = 0;
|
|
275
207
|
resolveReady();
|
|
@@ -293,14 +225,14 @@ function serve(router, options) {
|
|
|
293
225
|
if (!server.listening) return;
|
|
294
226
|
server.close();
|
|
295
227
|
server.closeIdleConnections();
|
|
296
|
-
return new Promise((
|
|
228
|
+
return new Promise((resolve2) => {
|
|
297
229
|
const timer = setTimeout(() => {
|
|
298
230
|
server.closeAllConnections();
|
|
299
|
-
|
|
231
|
+
resolve2();
|
|
300
232
|
}, timeoutMs);
|
|
301
233
|
server.on("close", () => {
|
|
302
234
|
clearTimeout(timer);
|
|
303
|
-
|
|
235
|
+
resolve2();
|
|
304
236
|
});
|
|
305
237
|
});
|
|
306
238
|
}
|
|
@@ -345,7 +277,7 @@ function createHub(opts) {
|
|
|
345
277
|
}
|
|
346
278
|
});
|
|
347
279
|
}
|
|
348
|
-
function
|
|
280
|
+
function join2(key, ws) {
|
|
349
281
|
if (!channels.has(key)) {
|
|
350
282
|
channels.set(key, /* @__PURE__ */ new Set());
|
|
351
283
|
redisSub?.subscribe(`${prefix}${key}`);
|
|
@@ -407,7 +339,97 @@ function createHub(opts) {
|
|
|
407
339
|
redisPub = void 0;
|
|
408
340
|
redisSub = null;
|
|
409
341
|
}
|
|
410
|
-
return { join:
|
|
342
|
+
return { join: join2, leave, broadcast, close };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/core/ws.ts
|
|
346
|
+
function nodeReqHeadersToRecord(headers) {
|
|
347
|
+
const result = {};
|
|
348
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
349
|
+
if (v !== void 0) result[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
function sendHttpResponseOnSocket(socket, response) {
|
|
354
|
+
const statusLine = `HTTP/1.1 ${response.status} ${response.statusText}`;
|
|
355
|
+
const lines = [statusLine];
|
|
356
|
+
response.headers.forEach((v, k) => lines.push(`${k}: ${v}`));
|
|
357
|
+
lines.push("Connection: close", "");
|
|
358
|
+
const headerStr = lines.join("\r\n");
|
|
359
|
+
response.arrayBuffer().then((buf) => {
|
|
360
|
+
socket.write(headerStr + "\r\n");
|
|
361
|
+
if (buf.byteLength > 0) socket.write(Buffer.from(buf));
|
|
362
|
+
socket.end();
|
|
363
|
+
}).catch(() => {
|
|
364
|
+
socket.write(headerStr + "\r\n");
|
|
365
|
+
socket.end();
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
function upgradeSocket(wss, req, socket, head, handler, ctx, hub) {
|
|
369
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
370
|
+
const connCtx = { ...ctx, params: { ...ctx.params }, query: { ...ctx.query } };
|
|
371
|
+
const wsState = {};
|
|
372
|
+
connCtx.ws = {
|
|
373
|
+
get state() {
|
|
374
|
+
return wsState;
|
|
375
|
+
},
|
|
376
|
+
json(data) {
|
|
377
|
+
ws.send(JSON.stringify(data));
|
|
378
|
+
},
|
|
379
|
+
join(room) {
|
|
380
|
+
hub.join(room, ws);
|
|
381
|
+
},
|
|
382
|
+
leave() {
|
|
383
|
+
hub.leave(ws);
|
|
384
|
+
},
|
|
385
|
+
sendRoom(room, data) {
|
|
386
|
+
hub.broadcast(room, data);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
handler.open?.(ws, connCtx);
|
|
390
|
+
ws.on("message", (data) => handler.message?.(ws, connCtx, data));
|
|
391
|
+
ws.on("close", () => {
|
|
392
|
+
hub.leave(ws);
|
|
393
|
+
handler.close?.(ws, connCtx);
|
|
394
|
+
});
|
|
395
|
+
ws.on("error", (err) => handler.error?.(ws, connCtx, err));
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
function createWsUpgradeHandler(wss, hub, matchWsFn, globalMws, runChainFn) {
|
|
399
|
+
return (req, socket, head) => {
|
|
400
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
401
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
402
|
+
const match = matchWsFn(segments);
|
|
403
|
+
if (!match) {
|
|
404
|
+
socket.destroy();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const query = Object.fromEntries(url.searchParams);
|
|
408
|
+
const ctx = { params: match.params, query };
|
|
409
|
+
const mws = globalMws.length === 0 && match.middlewares.length === 0 ? [] : [...globalMws, ...match.middlewares];
|
|
410
|
+
if (mws.length === 0) {
|
|
411
|
+
upgradeSocket(wss, req, socket, head, match.handler, ctx, hub);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const finalHandler = () => {
|
|
415
|
+
try {
|
|
416
|
+
upgradeSocket(wss, req, socket, head, match.handler, ctx, hub);
|
|
417
|
+
} catch {
|
|
418
|
+
socket.destroy();
|
|
419
|
+
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
420
|
+
}
|
|
421
|
+
return new Response(null, { status: 200 });
|
|
422
|
+
};
|
|
423
|
+
const webReq = new Request(url.href, {
|
|
424
|
+
method: req.method ?? "GET",
|
|
425
|
+
headers: nodeReqHeadersToRecord(req.headers)
|
|
426
|
+
});
|
|
427
|
+
void runChainFn(mws, finalHandler, webReq, ctx).then((result) => {
|
|
428
|
+
if (result.status >= 400) sendHttpResponseOnSocket(socket, result);
|
|
429
|
+
}).catch(() => {
|
|
430
|
+
socket.destroy();
|
|
431
|
+
});
|
|
432
|
+
};
|
|
411
433
|
}
|
|
412
434
|
|
|
413
435
|
// src/core/router.ts
|
|
@@ -462,7 +484,6 @@ var Router = class _Router {
|
|
|
462
484
|
_hasWildcard = false;
|
|
463
485
|
_hub;
|
|
464
486
|
_wss;
|
|
465
|
-
/** Track which ctx fields have been injected so far (for dependency checking). */
|
|
466
487
|
_ctxFields = /* @__PURE__ */ new Set();
|
|
467
488
|
get wss() {
|
|
468
489
|
if (!this._wss) this._wss = new WebSocketServer({ noServer: true });
|
|
@@ -472,90 +493,88 @@ var Router = class _Router {
|
|
|
472
493
|
if (!this._hub) this._hub = createHub();
|
|
473
494
|
return this._hub;
|
|
474
495
|
}
|
|
475
|
-
/** Inject a custom hub (e.g. with Redis for cross-process broadcast). */
|
|
476
496
|
wsHub(hub) {
|
|
477
497
|
this._hub = hub;
|
|
478
498
|
return this;
|
|
479
499
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
this.
|
|
500
|
+
// ── Middleware & mounting ─────────────────────────────────
|
|
501
|
+
use(mw) {
|
|
502
|
+
this.globalMws.push(mw);
|
|
503
|
+
this._checkMiddlewareMeta(mw, "global");
|
|
483
504
|
return this;
|
|
484
505
|
}
|
|
485
|
-
/**
|
|
486
|
-
* Mount a sub-router at the given path prefix.
|
|
487
|
-
* All routes from the sub-router are registered with the prefix.
|
|
488
|
-
*
|
|
489
|
-
* ```ts
|
|
490
|
-
* const admin = new Router()
|
|
491
|
-
* admin.get('/dashboard', handler)
|
|
492
|
-
* app.mount('/admin', admin) // → GET /admin/dashboard
|
|
493
|
-
* ```
|
|
494
|
-
*/
|
|
495
506
|
mount(path, router) {
|
|
496
507
|
this._mountRouter(path, router);
|
|
497
508
|
return this;
|
|
498
509
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
* Attach __meta to a middleware function:
|
|
503
|
-
*
|
|
504
|
-
* ```ts
|
|
505
|
-
* mw.__meta = { injects: ['sql'], depends: ['session'] }
|
|
506
|
-
* ```
|
|
507
|
-
*/
|
|
508
|
-
_checkMiddlewareMeta(mw, location) {
|
|
509
|
-
const meta = mw.__meta ?? (typeof mw === "object" && mw && "middleware" in mw ? mw.middleware().__meta : void 0);
|
|
510
|
-
if (!meta) return;
|
|
511
|
-
for (const dep of meta.depends) {
|
|
512
|
-
if (!this._ctxFields.has(dep)) {
|
|
513
|
-
console.warn(
|
|
514
|
-
`[weifuwu] Middleware at "${location}" depends on ctx.${dep} but it hasn't been registered yet.
|
|
515
|
-
Register the provider before this middleware:
|
|
516
|
-
app.use(${dep}()) // add before this middleware
|
|
517
|
-
Current ctx fields: [${[...this._ctxFields].join(", ")}]`
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
for (const field of meta.injects) {
|
|
522
|
-
this._ctxFields.add(field);
|
|
523
|
-
}
|
|
510
|
+
onError(handler) {
|
|
511
|
+
this.errorHandler = handler;
|
|
512
|
+
return this;
|
|
524
513
|
}
|
|
525
|
-
// Route registration
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
return this._route("GET", path, ...args);
|
|
514
|
+
// ── Route registration ────────────────────────────────────
|
|
515
|
+
get(path, ...rest) {
|
|
516
|
+
return this._route("GET", path, ...rest);
|
|
529
517
|
}
|
|
530
|
-
post(path, ...
|
|
531
|
-
return this._route("POST", path, ...
|
|
518
|
+
post(path, ...rest) {
|
|
519
|
+
return this._route("POST", path, ...rest);
|
|
532
520
|
}
|
|
533
|
-
put(path, ...
|
|
534
|
-
return this._route("PUT", path, ...
|
|
521
|
+
put(path, ...rest) {
|
|
522
|
+
return this._route("PUT", path, ...rest);
|
|
535
523
|
}
|
|
536
|
-
delete(path, ...
|
|
537
|
-
return this._route("DELETE", path, ...
|
|
524
|
+
delete(path, ...rest) {
|
|
525
|
+
return this._route("DELETE", path, ...rest);
|
|
538
526
|
}
|
|
539
|
-
patch(path, ...
|
|
540
|
-
return this._route("PATCH", path, ...
|
|
527
|
+
patch(path, ...rest) {
|
|
528
|
+
return this._route("PATCH", path, ...rest);
|
|
541
529
|
}
|
|
542
|
-
head(path, ...
|
|
543
|
-
return this._route("HEAD", path, ...
|
|
530
|
+
head(path, ...rest) {
|
|
531
|
+
return this._route("HEAD", path, ...rest);
|
|
544
532
|
}
|
|
545
|
-
options(path, ...
|
|
546
|
-
return this._route("OPTIONS", path, ...
|
|
533
|
+
options(path, ...rest) {
|
|
534
|
+
return this._route("OPTIONS", path, ...rest);
|
|
547
535
|
}
|
|
548
|
-
all(path, ...
|
|
549
|
-
return this._route("*", path, ...
|
|
536
|
+
all(path, ...rest) {
|
|
537
|
+
return this._route("*", path, ...rest);
|
|
550
538
|
}
|
|
551
|
-
|
|
552
|
-
|
|
539
|
+
ws(path, ...args) {
|
|
540
|
+
const handler = args.pop();
|
|
541
|
+
const mws = args;
|
|
542
|
+
let node = this.wsRoot;
|
|
543
|
+
for (const segment of this.splitPath(path)) {
|
|
544
|
+
node = getOrCreateChild(node, segment, createWsNode, true);
|
|
545
|
+
}
|
|
546
|
+
node.handler = handler;
|
|
547
|
+
node.middlewares = mws;
|
|
553
548
|
return this;
|
|
554
549
|
}
|
|
550
|
+
// ── Handler compilation ────────────────────────────────────
|
|
551
|
+
handler() {
|
|
552
|
+
return (req, ctx) => {
|
|
553
|
+
const url = new URL(req.url);
|
|
554
|
+
return this.handle(req, ctx, this.splitPath(url.pathname));
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
websocketHandler() {
|
|
558
|
+
return createWsUpgradeHandler(
|
|
559
|
+
this.wss,
|
|
560
|
+
this.hub,
|
|
561
|
+
(segments) => this.matchWsTrie(this.wsRoot, segments),
|
|
562
|
+
this.globalMws,
|
|
563
|
+
(mws, h, req, ctx) => this.runChain(mws, h, req, ctx)
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
// ── Debug ──────────────────────────────────────────────────
|
|
567
|
+
routes() {
|
|
568
|
+
const result = [];
|
|
569
|
+
if (this.globalMws.length > 0) result.push(`MIDDLEWARE [${this.globalMws.length} global]`);
|
|
570
|
+
this._collectRoutes(this.root, "", result);
|
|
571
|
+
this._collectWsRoutes(this.wsRoot, "", result);
|
|
572
|
+
return result;
|
|
573
|
+
}
|
|
574
|
+
// ── Private: Route impl ────────────────────────────────────
|
|
555
575
|
_route(method, path, ...args) {
|
|
556
576
|
return this._routeImpl(method, path, args);
|
|
557
577
|
}
|
|
558
|
-
/** Internal route registration — no type constraints (used by _mountRouter). */
|
|
559
578
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
560
579
|
_routeImpl(method, path, args) {
|
|
561
580
|
const last = args[args.length - 1];
|
|
@@ -564,123 +583,26 @@ var Router = class _Router {
|
|
|
564
583
|
return this;
|
|
565
584
|
}
|
|
566
585
|
const handler = args.pop();
|
|
567
|
-
const
|
|
568
|
-
const segments = this.splitPath(path);
|
|
586
|
+
const mws = args;
|
|
569
587
|
let node = this.root;
|
|
570
|
-
for (const segment of
|
|
588
|
+
for (const segment of this.splitPath(path)) {
|
|
571
589
|
if (segment === "*") {
|
|
572
590
|
this._hasWildcard = true;
|
|
573
|
-
const remaining = segments.indexOf("*") < segments.length - 1;
|
|
574
|
-
if (remaining) {
|
|
575
|
-
console.warn(`Route "${path}": segments after "*" are ignored`);
|
|
576
|
-
}
|
|
577
591
|
node.wildcard = true;
|
|
578
592
|
node.handlers.set(method, handler);
|
|
579
|
-
if (
|
|
593
|
+
if (mws.length > 0) node.middlewares.set(method, mws);
|
|
580
594
|
return this;
|
|
581
595
|
}
|
|
582
596
|
node = getOrCreateChild(node, segment, createTrieNode, false);
|
|
583
597
|
}
|
|
584
|
-
if (
|
|
585
|
-
|
|
598
|
+
if (node.handlers.has(method)) {
|
|
599
|
+
throw new Error(`[router] route conflict: ${method} ${path} already registered`);
|
|
586
600
|
}
|
|
587
601
|
node.handlers.set(method, handler);
|
|
588
|
-
if (
|
|
589
|
-
return this;
|
|
590
|
-
}
|
|
591
|
-
ws(path, ...args) {
|
|
592
|
-
const handler = args.pop();
|
|
593
|
-
const middlewares = args;
|
|
594
|
-
const segments = this.splitPath(path);
|
|
595
|
-
let node = this.wsRoot;
|
|
596
|
-
for (const segment of segments) {
|
|
597
|
-
node = getOrCreateChild(node, segment, createWsNode, true);
|
|
598
|
-
}
|
|
599
|
-
node.handler = handler;
|
|
600
|
-
node.middlewares = middlewares;
|
|
602
|
+
if (mws.length > 0) node.middlewares.set(method, mws);
|
|
601
603
|
return this;
|
|
602
604
|
}
|
|
603
|
-
|
|
604
|
-
return (req, ctx) => {
|
|
605
|
-
const url = new URL(req.url);
|
|
606
|
-
return this.handle(req, ctx, this.splitPath(url.pathname));
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
|
-
/** Returns a human-readable list of all registered routes. Useful for debugging. */
|
|
610
|
-
routes() {
|
|
611
|
-
const result = [];
|
|
612
|
-
if (this.globalMws.length > 0) {
|
|
613
|
-
result.push(`MIDDLEWARE [${this.globalMws.length} global]`);
|
|
614
|
-
}
|
|
615
|
-
this._collectRoutes(this.root, "", result);
|
|
616
|
-
this._collectWsRoutes(this.wsRoot, "", result);
|
|
617
|
-
return result;
|
|
618
|
-
}
|
|
619
|
-
_collectRoutes(node, prefix, result) {
|
|
620
|
-
for (const [method] of node.handlers) {
|
|
621
|
-
const m = method === "*" ? "ANY" : method;
|
|
622
|
-
const path = (prefix || "/") + (node.wildcard ? "/*" : "");
|
|
623
|
-
const middlewares = node.middlewares.get(method);
|
|
624
|
-
const mwCount = middlewares ? ` (+${middlewares.length} mw)` : "";
|
|
625
|
-
result.push(`${m.padEnd(7)} ${path}${mwCount}`);
|
|
626
|
-
}
|
|
627
|
-
for (const [seg, child] of node.children) {
|
|
628
|
-
const segment = seg === ":" ? `:${child.param}` : seg;
|
|
629
|
-
this._collectRoutes(child, prefix + "/" + segment, result);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
_collectWsRoutes(node, prefix, result) {
|
|
633
|
-
if (node.handler) {
|
|
634
|
-
const path = prefix || "/";
|
|
635
|
-
const mwCount = node.middlewares.length ? ` (+${node.middlewares.length} mw)` : "";
|
|
636
|
-
result.push(`WS ${path}${mwCount}`);
|
|
637
|
-
}
|
|
638
|
-
for (const [seg, child] of node.children) {
|
|
639
|
-
const segment = seg === ":" ? `:${child.param}` : seg;
|
|
640
|
-
this._collectWsRoutes(child, prefix + "/" + segment, result);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
websocketHandler() {
|
|
644
|
-
const wsRoot = this.wsRoot;
|
|
645
|
-
const router = this;
|
|
646
|
-
return (req, socket, head) => {
|
|
647
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
648
|
-
const segments = url.pathname.split("/").filter(Boolean);
|
|
649
|
-
const match = router.matchWsTrie(wsRoot, segments);
|
|
650
|
-
if (!match) {
|
|
651
|
-
socket.destroy();
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
const query = Object.fromEntries(url.searchParams);
|
|
655
|
-
const ctx = { params: match.params, query };
|
|
656
|
-
const allMws = router.globalMws.length === 0 && match.middlewares.length === 0 ? [] : [...router.globalMws, ...match.middlewares];
|
|
657
|
-
if (allMws.length === 0) {
|
|
658
|
-
upgradeSocket(router.wss, req, socket, head, match.handler, ctx, router.hub);
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
const finalHandler = () => {
|
|
662
|
-
try {
|
|
663
|
-
upgradeSocket(router.wss, req, socket, head, match.handler, ctx, router.hub);
|
|
664
|
-
} catch {
|
|
665
|
-
socket.destroy();
|
|
666
|
-
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
667
|
-
}
|
|
668
|
-
return new Response(null, { status: 200 });
|
|
669
|
-
};
|
|
670
|
-
const webReq = new Request(url.href, {
|
|
671
|
-
method: req.method ?? "GET",
|
|
672
|
-
headers: nodeReqHeadersToRecord(req.headers)
|
|
673
|
-
});
|
|
674
|
-
void router.runChain(allMws, finalHandler, webReq, ctx).then((result) => {
|
|
675
|
-
if (result.status >= 400) {
|
|
676
|
-
sendHttpResponseOnSocket(socket, result);
|
|
677
|
-
}
|
|
678
|
-
}).catch((err) => {
|
|
679
|
-
console.error("[router] WS middleware chain error:", err);
|
|
680
|
-
socket.destroy();
|
|
681
|
-
});
|
|
682
|
-
};
|
|
683
|
-
}
|
|
605
|
+
// ── Private: Mount ─────────────────────────────────────────
|
|
684
606
|
_mountRouter(prefix, sub, extraMws = []) {
|
|
685
607
|
const base = prefix === "/" ? "" : prefix.replace(/\/$/, "");
|
|
686
608
|
const mountMw = (req, ctx, next) => {
|
|
@@ -696,92 +618,96 @@ var Router = class _Router {
|
|
|
696
618
|
const wsRoutes = [];
|
|
697
619
|
this._collectWs(sub.wsRoot, "", wsRoutes);
|
|
698
620
|
for (const { path, handler, middlewares } of wsRoutes) {
|
|
699
|
-
this.ws(
|
|
700
|
-
base + path,
|
|
701
|
-
...allExtra,
|
|
702
|
-
...middlewares,
|
|
703
|
-
handler
|
|
704
|
-
);
|
|
621
|
+
this.ws(base + path, ...allExtra, ...middlewares, handler);
|
|
705
622
|
}
|
|
706
623
|
}
|
|
707
624
|
_collect(node, prefix, result) {
|
|
708
625
|
for (const [method, handler] of node.handlers) {
|
|
709
626
|
const rmws = node.middlewares.get(method) || [];
|
|
710
627
|
const suffix = node.wildcard ? "/*" : "";
|
|
711
|
-
result.push({
|
|
712
|
-
method,
|
|
713
|
-
path: (prefix || "/") + suffix,
|
|
714
|
-
handler,
|
|
715
|
-
middlewares: [...rmws]
|
|
716
|
-
});
|
|
628
|
+
result.push({ method, path: (prefix || "/") + suffix, handler, middlewares: [...rmws] });
|
|
717
629
|
}
|
|
718
630
|
for (const [seg, child] of node.children) {
|
|
719
|
-
|
|
720
|
-
this._collect(child, prefix + next, result);
|
|
631
|
+
this._collect(child, prefix + "/" + (seg === ":" ? `:${child.param}` : seg), result);
|
|
721
632
|
}
|
|
722
633
|
}
|
|
723
634
|
_collectWs(node, prefix, result, mwsAcc = []) {
|
|
724
635
|
const mws = [...mwsAcc, ...node.middlewares];
|
|
725
636
|
if (node.handler) result.push({ path: prefix || "/", handler: node.handler, middlewares: mws });
|
|
726
637
|
for (const [seg, child] of node.children) {
|
|
727
|
-
|
|
728
|
-
this._collectWs(child, prefix + next, result, mws);
|
|
638
|
+
this._collectWs(child, prefix + "/" + (seg === ":" ? `:${child.param}` : seg), result, mws);
|
|
729
639
|
}
|
|
730
640
|
}
|
|
641
|
+
// ── Private: Matching ──────────────────────────────────────
|
|
731
642
|
splitPath(path) {
|
|
732
643
|
return path.split("/").filter(Boolean);
|
|
733
644
|
}
|
|
645
|
+
_collectRoutes(node, prefix, result) {
|
|
646
|
+
for (const [method] of node.handlers) {
|
|
647
|
+
const m = method === "*" ? "ANY" : method;
|
|
648
|
+
const path = (prefix || "/") + (node.wildcard ? "/*" : "");
|
|
649
|
+
const middlewares = node.middlewares.get(method);
|
|
650
|
+
const mwCount = middlewares ? ` (+${middlewares.length} mw)` : "";
|
|
651
|
+
result.push(`${m.padEnd(7)} ${path}${mwCount}`);
|
|
652
|
+
}
|
|
653
|
+
for (const [seg, child] of node.children) {
|
|
654
|
+
const segment = seg === ":" ? `:${child.param}` : seg;
|
|
655
|
+
this._collectRoutes(child, prefix + "/" + segment, result);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
_collectWsRoutes(node, prefix, result) {
|
|
659
|
+
if (node.handler) {
|
|
660
|
+
const path = prefix || "/";
|
|
661
|
+
const mwCount = node.middlewares.length ? ` (+${node.middlewares.length} mw)` : "";
|
|
662
|
+
result.push(`WS ${path}${mwCount}`);
|
|
663
|
+
}
|
|
664
|
+
for (const [seg, child] of node.children) {
|
|
665
|
+
const segment = seg === ":" ? `:${child.param}` : seg;
|
|
666
|
+
this._collectWsRoutes(child, prefix + "/" + segment, result);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
734
669
|
matchTrie(method, segments) {
|
|
735
670
|
let node = this.root;
|
|
736
671
|
const params = {};
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
672
|
+
for (const seg of segments) {
|
|
673
|
+
const next = matchChild(node, seg, params, false);
|
|
674
|
+
if (!next) return this._wildcardMatch(method, segments);
|
|
675
|
+
node = next;
|
|
676
|
+
}
|
|
677
|
+
return this._resolveMatch(node, method, params, segments.length);
|
|
678
|
+
}
|
|
679
|
+
_wildcardMatch(method, segments) {
|
|
680
|
+
if (!this._hasWildcard) return null;
|
|
681
|
+
let node = this.root;
|
|
682
|
+
const params = {};
|
|
740
683
|
for (let i = 0; i < segments.length; i++) {
|
|
741
|
-
if (
|
|
742
|
-
|
|
743
|
-
if (!h && method === "HEAD") {
|
|
744
|
-
h = node.handlers.get("GET");
|
|
745
|
-
}
|
|
684
|
+
if (node.wildcard) {
|
|
685
|
+
const h = node.handlers.get("*") || node.handlers.get(method) || (method === "HEAD" ? node.handlers.get("GET") : void 0);
|
|
746
686
|
if (h) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
wildcardIdx = i;
|
|
687
|
+
params["*"] = segments.slice(i).join("/");
|
|
688
|
+
return { kind: "route", handler: h, mws: node.middlewares.get(method) || node.middlewares.get("*") || [], params };
|
|
750
689
|
}
|
|
751
690
|
}
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
if (!next) {
|
|
755
|
-
if (wildcardHandler) {
|
|
756
|
-
params["*"] = segments.slice(wildcardIdx).join("/");
|
|
757
|
-
return { kind: "route", handler: wildcardHandler, mws: wildcardMws, params };
|
|
758
|
-
}
|
|
759
|
-
return null;
|
|
760
|
-
}
|
|
691
|
+
const next = matchChild(node, segments[i], params, false);
|
|
692
|
+
if (!next) return null;
|
|
761
693
|
node = next;
|
|
762
694
|
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
_resolveMatch(node, method, params, _segLen) {
|
|
763
698
|
let handler = node.handlers.get(method) || node.handlers.get("*");
|
|
764
|
-
if (!handler && method === "HEAD")
|
|
765
|
-
|
|
766
|
-
}
|
|
699
|
+
if (!handler && method === "HEAD") handler = node.handlers.get("GET");
|
|
700
|
+
if (node.wildcard) params["*"] = "";
|
|
767
701
|
if (handler) {
|
|
768
|
-
|
|
769
|
-
return {
|
|
770
|
-
kind: "route",
|
|
771
|
-
handler,
|
|
772
|
-
mws: node.middlewares.get(method) || node.middlewares.get("*") || [],
|
|
773
|
-
params
|
|
774
|
-
};
|
|
775
|
-
}
|
|
776
|
-
if (wildcardHandler) {
|
|
777
|
-
params["*"] = segments.slice(wildcardIdx).join("/");
|
|
778
|
-
return { kind: "route", handler: wildcardHandler, mws: wildcardMws, params };
|
|
702
|
+
return { kind: "route", handler, mws: node.middlewares.get(method) || node.middlewares.get("*") || [], params };
|
|
779
703
|
}
|
|
780
704
|
if (node.handlers.size > 0) {
|
|
781
705
|
return {
|
|
782
706
|
kind: "not-allowed",
|
|
783
|
-
|
|
784
|
-
|
|
707
|
+
handler: () => new Response("", { status: 405 }),
|
|
708
|
+
mws: [],
|
|
709
|
+
params,
|
|
710
|
+
methods: [...node.handlers.keys()].filter((k) => k !== "*")
|
|
785
711
|
};
|
|
786
712
|
}
|
|
787
713
|
return null;
|
|
@@ -789,167 +715,89 @@ var Router = class _Router {
|
|
|
789
715
|
matchWsTrie(root, segments) {
|
|
790
716
|
let node = root;
|
|
791
717
|
const params = {};
|
|
792
|
-
for (const
|
|
793
|
-
const next = matchChild(node,
|
|
718
|
+
for (const seg of segments) {
|
|
719
|
+
const next = matchChild(node, seg, params, true);
|
|
794
720
|
if (!next) return null;
|
|
795
721
|
node = next;
|
|
796
722
|
}
|
|
797
723
|
return node.handler ? { handler: node.handler, middlewares: node.middlewares, params } : null;
|
|
798
724
|
}
|
|
799
|
-
|
|
800
|
-
const err = e instanceof Error ? e : new Error(String(e));
|
|
801
|
-
console.error(err);
|
|
802
|
-
return this.errorHandler ? await this.errorHandler(err, req, ctx) : new Response("Internal Server Error", { status: 500 });
|
|
803
|
-
}
|
|
804
|
-
_notFoundResponse(method, segments) {
|
|
805
|
-
if (!isProd()) {
|
|
806
|
-
return Response.json(
|
|
807
|
-
{ error: "Not Found", path: "/" + segments.join("/"), method },
|
|
808
|
-
{ status: 404 }
|
|
809
|
-
);
|
|
810
|
-
}
|
|
811
|
-
return new Response("Not Found", { status: 404 });
|
|
812
|
-
}
|
|
725
|
+
// ── Private: Request handling ──────────────────────────────
|
|
813
726
|
async handle(req, ctx, segments) {
|
|
814
727
|
const match = this.matchTrie(req.method, segments);
|
|
815
728
|
if (match) {
|
|
816
729
|
Object.assign(ctx.params, match.params);
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
} catch (e) {
|
|
823
|
-
return this.handleError(e, req, ctx);
|
|
824
|
-
}
|
|
730
|
+
if (match.kind === "route") {
|
|
731
|
+
try {
|
|
732
|
+
return await this.runChain([...this.globalMws, ...match.mws], match.handler, req, ctx);
|
|
733
|
+
} catch (e) {
|
|
734
|
+
return this.handleError(e, req, ctx);
|
|
825
735
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
this.globalMws,
|
|
831
|
-
() => new Response("Method Not Allowed", {
|
|
832
|
-
status: 405,
|
|
833
|
-
headers: { Allow: match.methods.join(", ") }
|
|
834
|
-
}),
|
|
835
|
-
req,
|
|
836
|
-
ctx
|
|
837
|
-
);
|
|
838
|
-
} catch (e) {
|
|
839
|
-
return this.handleError(e, req, ctx);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
return new Response("Method Not Allowed", {
|
|
736
|
+
}
|
|
737
|
+
if (this.globalMws.length > 0) {
|
|
738
|
+
try {
|
|
739
|
+
return await this.runChain(this.globalMws, () => new Response("Method Not Allowed", {
|
|
843
740
|
status: 405,
|
|
844
|
-
headers: { Allow: match.methods.join(", ") }
|
|
845
|
-
});
|
|
741
|
+
headers: { Allow: (match.methods || []).join(", ") }
|
|
742
|
+
}), req, ctx);
|
|
743
|
+
} catch (e) {
|
|
744
|
+
return this.handleError(e, req, ctx);
|
|
846
745
|
}
|
|
847
746
|
}
|
|
747
|
+
return new Response("Method Not Allowed", { status: 405, headers: { Allow: (match.methods || []).join(", ") } });
|
|
848
748
|
}
|
|
749
|
+
const nf = () => Response.json({ error: "Not Found", path: "/" + segments.join("/"), method: req.method }, { status: 404 });
|
|
849
750
|
if (this.globalMws.length > 0) {
|
|
850
751
|
try {
|
|
851
|
-
return await this.runChain(
|
|
852
|
-
this.globalMws,
|
|
853
|
-
() => this._notFoundResponse(req.method, segments),
|
|
854
|
-
req,
|
|
855
|
-
ctx
|
|
856
|
-
);
|
|
752
|
+
return await this.runChain(this.globalMws, nf, req, ctx);
|
|
857
753
|
} catch (e) {
|
|
858
754
|
return this.handleError(e, req, ctx);
|
|
859
755
|
}
|
|
860
756
|
}
|
|
861
|
-
return
|
|
757
|
+
return nf();
|
|
862
758
|
}
|
|
863
|
-
async
|
|
864
|
-
|
|
865
|
-
return
|
|
759
|
+
async handleError(e, req, ctx) {
|
|
760
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
761
|
+
return this.errorHandler ? this.errorHandler(err, req, ctx) : new Response("Internal Server Error", { status: 500 });
|
|
866
762
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
let called = false;
|
|
763
|
+
// ── Private: Middleware chain ───────────────────────────────
|
|
764
|
+
async runChain(mws, finalHandler, req, ctx) {
|
|
765
|
+
if (mws.length === 0) return finalHandler(req, ctx);
|
|
766
|
+
let i = 0;
|
|
872
767
|
const dispatch = (r, c) => {
|
|
873
|
-
if (
|
|
874
|
-
|
|
875
|
-
|
|
768
|
+
if (i >= mws.length) return Promise.resolve(finalHandler(r, c));
|
|
769
|
+
const mw = mws[i++];
|
|
770
|
+
let called = false;
|
|
771
|
+
const next = (r2, c2) => {
|
|
772
|
+
if (called) throw new Error("[router] next() called more than once in middleware");
|
|
773
|
+
called = true;
|
|
774
|
+
return dispatch(r2, c2);
|
|
775
|
+
};
|
|
776
|
+
return Promise.resolve(mw(r, c, next));
|
|
777
|
+
};
|
|
778
|
+
return dispatch(req, ctx);
|
|
779
|
+
}
|
|
780
|
+
// ── Private: Meta checking ──────────────────────────────────
|
|
781
|
+
_checkMiddlewareMeta(mw, location) {
|
|
782
|
+
const meta = mw.__meta ?? (typeof mw === "object" && mw && "middleware" in mw ? mw.middleware().__meta : void 0);
|
|
783
|
+
if (!meta) return;
|
|
784
|
+
for (const dep of meta.depends) {
|
|
785
|
+
if (!this._ctxFields.has(dep)) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
`[weifuwu] Middleware at "${location}" depends on ctx.${dep} but it hasn't been registered.
|
|
788
|
+
Register the provider before this middleware: app.use(${dep}())`
|
|
876
789
|
);
|
|
877
|
-
return Promise.resolve(new Response("", { status: 499 }));
|
|
878
790
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
};
|
|
882
|
-
return Promise.resolve(mw(req, ctx, dispatch));
|
|
791
|
+
}
|
|
792
|
+
for (const field of meta.injects) this._ctxFields.add(field);
|
|
883
793
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
get state() {
|
|
892
|
-
return wsState;
|
|
893
|
-
},
|
|
894
|
-
json(data) {
|
|
895
|
-
ws.send(JSON.stringify(data));
|
|
896
|
-
},
|
|
897
|
-
join(room) {
|
|
898
|
-
hub.join(room, ws);
|
|
899
|
-
},
|
|
900
|
-
leave(_room) {
|
|
901
|
-
hub.leave(ws);
|
|
902
|
-
},
|
|
903
|
-
sendRoom(room, data) {
|
|
904
|
-
hub.broadcast(room, data);
|
|
905
|
-
}
|
|
906
|
-
};
|
|
907
|
-
if (handler.open) {
|
|
908
|
-
handler.open(ws, connCtx);
|
|
909
|
-
}
|
|
910
|
-
ws.on("message", (data) => {
|
|
911
|
-
handler.message?.(ws, connCtx, data);
|
|
912
|
-
});
|
|
913
|
-
ws.on("close", () => {
|
|
914
|
-
hub.leave(ws);
|
|
915
|
-
handler.close?.(ws, connCtx);
|
|
916
|
-
});
|
|
917
|
-
ws.on("error", (err) => {
|
|
918
|
-
handler.error?.(ws, connCtx, err);
|
|
919
|
-
});
|
|
920
|
-
});
|
|
921
|
-
}
|
|
922
|
-
function nodeReqHeadersToRecord(headers) {
|
|
923
|
-
const result = {};
|
|
924
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
925
|
-
if (v !== void 0) result[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
926
|
-
}
|
|
927
|
-
return result;
|
|
928
|
-
}
|
|
929
|
-
function sendHttpResponseOnSocket(socket, response) {
|
|
930
|
-
const statusLine = `HTTP/1.1 ${response.status} ${response.statusText}`;
|
|
931
|
-
const headerLines = [statusLine];
|
|
932
|
-
response.headers.forEach((value, key) => {
|
|
933
|
-
headerLines.push(`${key}: ${value}`);
|
|
934
|
-
});
|
|
935
|
-
headerLines.push("Connection: close");
|
|
936
|
-
headerLines.push("");
|
|
937
|
-
const headerStr = headerLines.join("\r\n");
|
|
938
|
-
response.arrayBuffer().then((buf) => {
|
|
939
|
-
socket.write(headerStr + "\r\n");
|
|
940
|
-
if (buf.byteLength > 0) socket.write(Buffer.from(buf));
|
|
941
|
-
socket.end();
|
|
942
|
-
}).catch(() => {
|
|
943
|
-
socket.write(headerStr + "\r\n");
|
|
944
|
-
socket.end();
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// src/core/logger.ts
|
|
949
|
-
function emit(event) {
|
|
950
|
-
event.traceId = event.traceId ?? currentTraceId();
|
|
951
|
-
event.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
952
|
-
process.stderr.write(JSON.stringify(event) + "\n");
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
// src/core/logger.ts
|
|
797
|
+
function emit(event) {
|
|
798
|
+
event.traceId = event.traceId ?? currentTraceId();
|
|
799
|
+
event.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
800
|
+
process.stderr.write(JSON.stringify(event) + "\n");
|
|
953
801
|
}
|
|
954
802
|
function logger(options) {
|
|
955
803
|
const format = options?.format ?? "short";
|
|
@@ -1046,10 +894,10 @@ function cors(options) {
|
|
|
1046
894
|
|
|
1047
895
|
// src/middleware/static.ts
|
|
1048
896
|
import { open, realpath } from "node:fs/promises";
|
|
1049
|
-
import { extname, resolve
|
|
897
|
+
import { extname, resolve, normalize, sep } from "node:path";
|
|
1050
898
|
import { Readable } from "node:stream";
|
|
1051
899
|
function serveStatic(root, options) {
|
|
1052
|
-
const rootDir =
|
|
900
|
+
const rootDir = resolve(root);
|
|
1053
901
|
const opts = options ?? {};
|
|
1054
902
|
return async (req, ctx) => {
|
|
1055
903
|
const relativePath = ctx.params["*"] ?? new URL(req.url).pathname.slice(1);
|
|
@@ -1057,50 +905,50 @@ function serveStatic(root, options) {
|
|
|
1057
905
|
if (decoded.includes("..") || decoded.includes("\0")) {
|
|
1058
906
|
return new Response("Forbidden", { status: 403 });
|
|
1059
907
|
}
|
|
1060
|
-
let filePath = normalize(
|
|
908
|
+
let filePath = normalize(resolve(rootDir, decoded));
|
|
1061
909
|
if (!filePath.startsWith(rootDir + sep) && filePath !== rootDir) {
|
|
1062
910
|
return new Response("Forbidden", { status: 403 });
|
|
1063
911
|
}
|
|
1064
912
|
let fileHandle;
|
|
1065
913
|
try {
|
|
1066
914
|
fileHandle = await open(filePath, "r");
|
|
1067
|
-
let
|
|
915
|
+
let stat = await fileHandle.stat();
|
|
1068
916
|
const realPath = await realpath(filePath);
|
|
1069
917
|
if (!realPath.startsWith(rootDir + sep) && realPath !== rootDir) {
|
|
1070
918
|
await fileHandle.close();
|
|
1071
919
|
return new Response("Forbidden", { status: 403 });
|
|
1072
920
|
}
|
|
1073
|
-
if (
|
|
921
|
+
if (stat.isDirectory()) {
|
|
1074
922
|
await fileHandle.close();
|
|
1075
923
|
const indexFile = opts.index ?? "index.html";
|
|
1076
|
-
filePath =
|
|
924
|
+
filePath = resolve(filePath, indexFile);
|
|
1077
925
|
if (!filePath.startsWith(rootDir + sep)) {
|
|
1078
926
|
return new Response("Forbidden", { status: 403 });
|
|
1079
927
|
}
|
|
1080
928
|
fileHandle = await open(filePath, "r");
|
|
1081
|
-
|
|
1082
|
-
if (!
|
|
929
|
+
stat = await fileHandle.stat();
|
|
930
|
+
if (!stat.isFile()) {
|
|
1083
931
|
await fileHandle.close();
|
|
1084
932
|
return new Response("Not Found", { status: 404 });
|
|
1085
933
|
}
|
|
1086
934
|
}
|
|
1087
935
|
const mimeType = MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
1088
|
-
const etag = `"${
|
|
936
|
+
const etag = `"${stat.ino}-${stat.size}-${stat.mtimeMs}"`;
|
|
1089
937
|
const ifNoneMatch = req.headers.get("if-none-match");
|
|
1090
938
|
if (ifNoneMatch === etag) {
|
|
1091
939
|
await fileHandle.close();
|
|
1092
940
|
return new Response(null, { status: 304 });
|
|
1093
941
|
}
|
|
1094
942
|
const ifModifiedSince = req.headers.get("if-modified-since");
|
|
1095
|
-
if (ifModifiedSince &&
|
|
943
|
+
if (ifModifiedSince && stat.mtimeMs <= new Date(ifModifiedSince).getTime()) {
|
|
1096
944
|
await fileHandle.close();
|
|
1097
945
|
return new Response(null, { status: 304 });
|
|
1098
946
|
}
|
|
1099
947
|
const headers = {
|
|
1100
948
|
"Content-Type": mimeType,
|
|
1101
|
-
"Content-Length": String(
|
|
949
|
+
"Content-Length": String(stat.size),
|
|
1102
950
|
ETag: etag,
|
|
1103
|
-
"Last-Modified":
|
|
951
|
+
"Last-Modified": stat.mtime.toUTCString(),
|
|
1104
952
|
"Cache-Control": opts.immutable ? `public, max-age=${opts.maxAge ?? 31536e3}, immutable` : `public, max-age=${opts.maxAge ?? 0}`
|
|
1105
953
|
};
|
|
1106
954
|
const readStream = fileHandle.createReadStream();
|
|
@@ -1157,187 +1005,6 @@ var MIME_TYPES = {
|
|
|
1157
1005
|
".wav": "audio/wav"
|
|
1158
1006
|
};
|
|
1159
1007
|
|
|
1160
|
-
// src/middleware/validate.ts
|
|
1161
|
-
function parseFormBody(text) {
|
|
1162
|
-
const params = new URLSearchParams(text);
|
|
1163
|
-
const result = {};
|
|
1164
|
-
for (const [key, value] of params) {
|
|
1165
|
-
result[key] = value;
|
|
1166
|
-
}
|
|
1167
|
-
return result;
|
|
1168
|
-
}
|
|
1169
|
-
function parseBody(text, ct) {
|
|
1170
|
-
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
1171
|
-
return parseFormBody(text);
|
|
1172
|
-
}
|
|
1173
|
-
const isExplicitJson = ct.includes("application/json") || ct.includes("+json") || ct.includes("text/") || ct.includes("*/json");
|
|
1174
|
-
const isNotSpecialMultipart = !ct.includes("multipart/form-data") && !ct.includes("application/x-www-form-urlencoded");
|
|
1175
|
-
if (isExplicitJson || isNotSpecialMultipart) {
|
|
1176
|
-
try {
|
|
1177
|
-
return JSON.parse(text);
|
|
1178
|
-
} catch {
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
return text;
|
|
1182
|
-
}
|
|
1183
|
-
function validate(schemas) {
|
|
1184
|
-
const mw = async (req, ctx, next) => {
|
|
1185
|
-
const parsed = {};
|
|
1186
|
-
const issues = [];
|
|
1187
|
-
if (schemas?.params) {
|
|
1188
|
-
const result = schemas.params.safeParse(ctx.params);
|
|
1189
|
-
if (result.success) {
|
|
1190
|
-
parsed.params = result.data;
|
|
1191
|
-
} else {
|
|
1192
|
-
issues.push(
|
|
1193
|
-
...result.error.issues.map((i) => ({
|
|
1194
|
-
path: ["params", ...i.path.map(String)],
|
|
1195
|
-
message: i.message
|
|
1196
|
-
}))
|
|
1197
|
-
);
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
if (schemas?.query) {
|
|
1201
|
-
const result = schemas.query.safeParse(ctx.query);
|
|
1202
|
-
if (result.success) {
|
|
1203
|
-
parsed.query = result.data;
|
|
1204
|
-
} else {
|
|
1205
|
-
issues.push(
|
|
1206
|
-
...result.error.issues.map((i) => ({
|
|
1207
|
-
path: ["query", ...i.path.map(String)],
|
|
1208
|
-
message: i.message
|
|
1209
|
-
}))
|
|
1210
|
-
);
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
if (schemas?.headers) {
|
|
1214
|
-
const rawHeaders = {};
|
|
1215
|
-
req.headers.forEach((v, k) => {
|
|
1216
|
-
rawHeaders[k] = v;
|
|
1217
|
-
});
|
|
1218
|
-
const result = schemas.headers.safeParse(rawHeaders);
|
|
1219
|
-
if (result.success) {
|
|
1220
|
-
parsed.headers = result.data;
|
|
1221
|
-
} else {
|
|
1222
|
-
issues.push(
|
|
1223
|
-
...result.error.issues.map((i) => ({
|
|
1224
|
-
path: ["headers", ...i.path.map(String)],
|
|
1225
|
-
message: i.message
|
|
1226
|
-
}))
|
|
1227
|
-
);
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1231
|
-
const ct = req.headers.get("content-type") ?? "";
|
|
1232
|
-
const isForm = ct.includes("application/x-www-form-urlencoded");
|
|
1233
|
-
if (schemas?.body || isForm) {
|
|
1234
|
-
if (req.body === null) {
|
|
1235
|
-
if (schemas?.body) {
|
|
1236
|
-
issues.push({ path: ["body"], message: "Request body is required" });
|
|
1237
|
-
}
|
|
1238
|
-
} else {
|
|
1239
|
-
const bodyText = await req.text();
|
|
1240
|
-
if (!bodyText) {
|
|
1241
|
-
if (schemas?.body) {
|
|
1242
|
-
issues.push({ path: ["body"], message: "Request body is required" });
|
|
1243
|
-
}
|
|
1244
|
-
} else {
|
|
1245
|
-
const bodyValue = parseBody(bodyText, ct);
|
|
1246
|
-
if (schemas?.body) {
|
|
1247
|
-
const result = schemas.body.safeParse(bodyValue);
|
|
1248
|
-
if (result.success) {
|
|
1249
|
-
parsed.body = result.data;
|
|
1250
|
-
} else {
|
|
1251
|
-
issues.push(
|
|
1252
|
-
...result.error.issues.map((i) => ({
|
|
1253
|
-
path: ["body", ...i.path.map(String)],
|
|
1254
|
-
message: i.message
|
|
1255
|
-
}))
|
|
1256
|
-
);
|
|
1257
|
-
}
|
|
1258
|
-
} else {
|
|
1259
|
-
parsed.body = bodyValue;
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
if (issues.length > 0) {
|
|
1266
|
-
return Response.json({ error: "Validation failed", issues }, { status: 400 });
|
|
1267
|
-
}
|
|
1268
|
-
ctx.parsed = { ...ctx.parsed, ...parsed };
|
|
1269
|
-
return next(req, ctx);
|
|
1270
|
-
};
|
|
1271
|
-
mw.__meta = { injects: ["parsed"], depends: [] };
|
|
1272
|
-
return mw;
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
// src/core/cookie.ts
|
|
1276
|
-
function getCookies(req) {
|
|
1277
|
-
const header = req.headers.get("cookie");
|
|
1278
|
-
if (!header) return {};
|
|
1279
|
-
const cookies = {};
|
|
1280
|
-
for (const pair of header.split(";")) {
|
|
1281
|
-
const idx = pair.indexOf("=");
|
|
1282
|
-
if (idx === -1) continue;
|
|
1283
|
-
let name = pair.slice(0, idx).trim();
|
|
1284
|
-
let value = pair.slice(idx + 1).trim();
|
|
1285
|
-
if (!name) continue;
|
|
1286
|
-
try {
|
|
1287
|
-
name = decodeURIComponent(name);
|
|
1288
|
-
} catch {
|
|
1289
|
-
}
|
|
1290
|
-
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
1291
|
-
value = value.slice(1, -1);
|
|
1292
|
-
}
|
|
1293
|
-
try {
|
|
1294
|
-
cookies[name] = decodeURIComponent(value);
|
|
1295
|
-
} catch {
|
|
1296
|
-
cookies[name] = value;
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
return cookies;
|
|
1300
|
-
}
|
|
1301
|
-
function serializeCookie(name, value, options) {
|
|
1302
|
-
if (/[\x00-\x1F\x7F-\x9F;,]/.test(name) || /[\x00-\x1F\x7F-\x9F;,]/.test(value)) {
|
|
1303
|
-
throw new Error(`Invalid cookie name or value: contains control characters or special chars`);
|
|
1304
|
-
}
|
|
1305
|
-
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
1306
|
-
if (options?.maxAge != null) parts.push(`Max-Age=${options.maxAge}`);
|
|
1307
|
-
if (options?.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
1308
|
-
if (options?.domain) parts.push(`Domain=${options.domain}`);
|
|
1309
|
-
if (options?.path) parts.push(`Path=${options.path}`);
|
|
1310
|
-
if (options?.httpOnly) parts.push("HttpOnly");
|
|
1311
|
-
if (options?.secure) parts.push("Secure");
|
|
1312
|
-
if (options?.sameSite) parts.push(`SameSite=${options.sameSite}`);
|
|
1313
|
-
return parts.join("; ");
|
|
1314
|
-
}
|
|
1315
|
-
function setCookie(res, name, value, options) {
|
|
1316
|
-
const headers = new Headers(res.headers);
|
|
1317
|
-
headers.append("Set-Cookie", serializeCookie(name, value, options));
|
|
1318
|
-
return new Response(res.body, {
|
|
1319
|
-
status: res.status,
|
|
1320
|
-
statusText: res.statusText,
|
|
1321
|
-
headers
|
|
1322
|
-
});
|
|
1323
|
-
}
|
|
1324
|
-
function deleteCookie(res, name, options) {
|
|
1325
|
-
const headers = new Headers(res.headers);
|
|
1326
|
-
headers.append(
|
|
1327
|
-
"Set-Cookie",
|
|
1328
|
-
serializeCookie(name, "", {
|
|
1329
|
-
...options,
|
|
1330
|
-
maxAge: 0,
|
|
1331
|
-
expires: /* @__PURE__ */ new Date(0)
|
|
1332
|
-
})
|
|
1333
|
-
);
|
|
1334
|
-
return new Response(res.body, {
|
|
1335
|
-
status: res.status,
|
|
1336
|
-
statusText: res.statusText,
|
|
1337
|
-
headers
|
|
1338
|
-
});
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
1008
|
// src/middleware/upload.ts
|
|
1342
1009
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
1343
1010
|
import { randomUUID } from "node:crypto";
|
|
@@ -1378,8 +1045,7 @@ function upload(options) {
|
|
|
1378
1045
|
if (!ct.includes("multipart/form-data")) return next(req, ctx);
|
|
1379
1046
|
try {
|
|
1380
1047
|
if (saveDir) await mkdir(saveDir, { recursive: true });
|
|
1381
|
-
} catch
|
|
1382
|
-
console.error("upload: failed to create directory", saveDir, e);
|
|
1048
|
+
} catch {
|
|
1383
1049
|
return Response.json({ error: "Server configuration error" }, { status: 500 });
|
|
1384
1050
|
}
|
|
1385
1051
|
let formData;
|
|
@@ -1637,482 +1303,6 @@ var DEFAULTS = {
|
|
|
1637
1303
|
permissionsPolicy: "camera=(),display-capture=(),fullscreen=(),geolocation=(),microphone=()"
|
|
1638
1304
|
};
|
|
1639
1305
|
|
|
1640
|
-
// src/middleware/request-id.ts
|
|
1641
|
-
import crypto3 from "node:crypto";
|
|
1642
|
-
function requestId(options) {
|
|
1643
|
-
const header = options?.header ?? "X-Request-ID";
|
|
1644
|
-
const gen = options?.generator ?? (() => crypto3.randomUUID());
|
|
1645
|
-
const mw = async (req, ctx, next) => {
|
|
1646
|
-
const existing = req.headers.get(header);
|
|
1647
|
-
const id = existing ?? gen();
|
|
1648
|
-
ctx.requestId = id;
|
|
1649
|
-
const res = await next(req, ctx);
|
|
1650
|
-
if (res.headers.has(header)) return res;
|
|
1651
|
-
const h = new Headers(res.headers);
|
|
1652
|
-
h.set(header, id);
|
|
1653
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
|
|
1654
|
-
};
|
|
1655
|
-
mw.__meta = { injects: ["requestId"], depends: [] };
|
|
1656
|
-
return mw;
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
// src/core/sse.ts
|
|
1660
|
-
var encoder = new TextEncoder();
|
|
1661
|
-
function formatSSE(event, data) {
|
|
1662
|
-
return `event: ${event}
|
|
1663
|
-
data: ${JSON.stringify(data)}
|
|
1664
|
-
|
|
1665
|
-
`;
|
|
1666
|
-
}
|
|
1667
|
-
function formatSSEData(data) {
|
|
1668
|
-
return `data: ${JSON.stringify(data)}
|
|
1669
|
-
|
|
1670
|
-
`;
|
|
1671
|
-
}
|
|
1672
|
-
function createSSEStream(iterable, opts) {
|
|
1673
|
-
return new Response(
|
|
1674
|
-
new ReadableStream({
|
|
1675
|
-
async start(controller) {
|
|
1676
|
-
try {
|
|
1677
|
-
for await (const event of iterable) {
|
|
1678
|
-
const text = event.type ? formatSSE(event.type, event) : formatSSEData(event);
|
|
1679
|
-
controller.enqueue(encoder.encode(text));
|
|
1680
|
-
}
|
|
1681
|
-
} catch (e) {
|
|
1682
|
-
if (e instanceof Error && e.name !== "AbortError") {
|
|
1683
|
-
controller.enqueue(encoder.encode(formatSSE("error", { error: e.message })));
|
|
1684
|
-
}
|
|
1685
|
-
} finally {
|
|
1686
|
-
controller.close();
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
}),
|
|
1690
|
-
{
|
|
1691
|
-
status: opts?.status ?? 200,
|
|
1692
|
-
headers: {
|
|
1693
|
-
"Content-Type": "text/event-stream",
|
|
1694
|
-
"Cache-Control": "no-cache",
|
|
1695
|
-
Connection: "keep-alive",
|
|
1696
|
-
...opts?.headers
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
);
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
// src/test/test-utils.ts
|
|
1703
|
-
import { WebSocket as WSWebSocket } from "ws";
|
|
1704
|
-
var TestResponseImpl = class {
|
|
1705
|
-
response;
|
|
1706
|
-
constructor(response) {
|
|
1707
|
-
this.response = response;
|
|
1708
|
-
}
|
|
1709
|
-
get status() {
|
|
1710
|
-
return this.response.status;
|
|
1711
|
-
}
|
|
1712
|
-
get headers() {
|
|
1713
|
-
return this.response.headers;
|
|
1714
|
-
}
|
|
1715
|
-
async json() {
|
|
1716
|
-
return this.response.json();
|
|
1717
|
-
}
|
|
1718
|
-
async text() {
|
|
1719
|
-
return this.response.text();
|
|
1720
|
-
}
|
|
1721
|
-
async bytes() {
|
|
1722
|
-
return this.response.bytes();
|
|
1723
|
-
}
|
|
1724
|
-
async arrayBuffer() {
|
|
1725
|
-
return this.response.arrayBuffer();
|
|
1726
|
-
}
|
|
1727
|
-
};
|
|
1728
|
-
var TestRequest = class {
|
|
1729
|
-
headers = {};
|
|
1730
|
-
ctxMixin = {};
|
|
1731
|
-
bodyData = null;
|
|
1732
|
-
app;
|
|
1733
|
-
method;
|
|
1734
|
-
path;
|
|
1735
|
-
constructor(app, method, path) {
|
|
1736
|
-
this.app = app;
|
|
1737
|
-
this.method = method;
|
|
1738
|
-
this.path = path;
|
|
1739
|
-
}
|
|
1740
|
-
/** Set a request header */
|
|
1741
|
-
header(name, value) {
|
|
1742
|
-
this.headers[name.toLowerCase()] = value;
|
|
1743
|
-
return this;
|
|
1744
|
-
}
|
|
1745
|
-
/** Mix properties into ctx (simulating middleware injection) */
|
|
1746
|
-
with(mixin) {
|
|
1747
|
-
Object.assign(this.ctxMixin, mixin);
|
|
1748
|
-
return this;
|
|
1749
|
-
}
|
|
1750
|
-
/** Shortcut: set ctx.user */
|
|
1751
|
-
withUser(user) {
|
|
1752
|
-
;
|
|
1753
|
-
this.ctxMixin.user = user;
|
|
1754
|
-
return this;
|
|
1755
|
-
}
|
|
1756
|
-
/** Shortcut: set ctx.tenant */
|
|
1757
|
-
withTenant(tenant) {
|
|
1758
|
-
this.ctxMixin.tenant = tenant;
|
|
1759
|
-
return this;
|
|
1760
|
-
}
|
|
1761
|
-
/** Set JSON request body */
|
|
1762
|
-
body(data) {
|
|
1763
|
-
this.bodyData = JSON.stringify(data);
|
|
1764
|
-
this.headers["content-type"] = "application/json";
|
|
1765
|
-
return this;
|
|
1766
|
-
}
|
|
1767
|
-
/** Set raw text body */
|
|
1768
|
-
rawBody(data) {
|
|
1769
|
-
this.bodyData = data;
|
|
1770
|
-
return this;
|
|
1771
|
-
}
|
|
1772
|
-
/** Send the request and return the response */
|
|
1773
|
-
async send() {
|
|
1774
|
-
const url = `http://localhost${this.path}`;
|
|
1775
|
-
const query = {};
|
|
1776
|
-
const qIdx = this.path.indexOf("?");
|
|
1777
|
-
if (qIdx !== -1) {
|
|
1778
|
-
const searchParams = new URLSearchParams(this.path.slice(qIdx));
|
|
1779
|
-
for (const [k, v] of searchParams) {
|
|
1780
|
-
query[k] = v;
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
const request = new Request(url, {
|
|
1784
|
-
method: this.method,
|
|
1785
|
-
headers: this.headers,
|
|
1786
|
-
body: this.bodyData
|
|
1787
|
-
});
|
|
1788
|
-
const ctx = {
|
|
1789
|
-
params: {},
|
|
1790
|
-
query,
|
|
1791
|
-
...this.ctxMixin
|
|
1792
|
-
};
|
|
1793
|
-
const handler = this.app.handler();
|
|
1794
|
-
const response = await handler(request, ctx);
|
|
1795
|
-
return new TestResponseImpl(response);
|
|
1796
|
-
}
|
|
1797
|
-
};
|
|
1798
|
-
var TestApp = class {
|
|
1799
|
-
router;
|
|
1800
|
-
wsServer = null;
|
|
1801
|
-
wsConnections = [];
|
|
1802
|
-
constructor() {
|
|
1803
|
-
this.router = new Router();
|
|
1804
|
-
}
|
|
1805
|
-
/**
|
|
1806
|
-
* Register a WebSocket handler.
|
|
1807
|
-
*/
|
|
1808
|
-
ws(path, handler) {
|
|
1809
|
-
this.router.ws(path, handler);
|
|
1810
|
-
return this;
|
|
1811
|
-
}
|
|
1812
|
-
/** Get the raw Router (for advanced use). */
|
|
1813
|
-
get _router() {
|
|
1814
|
-
return this.router;
|
|
1815
|
-
}
|
|
1816
|
-
/** Add global middleware */
|
|
1817
|
-
use(mw) {
|
|
1818
|
-
this.router.use(mw);
|
|
1819
|
-
return this;
|
|
1820
|
-
}
|
|
1821
|
-
/** Register a GET route — supports route-level middleware via spread args. */
|
|
1822
|
-
get(path, ...args) {
|
|
1823
|
-
;
|
|
1824
|
-
this.router.get(path, ...args);
|
|
1825
|
-
return this;
|
|
1826
|
-
}
|
|
1827
|
-
/** Register a POST route. */
|
|
1828
|
-
post(path, ...args) {
|
|
1829
|
-
;
|
|
1830
|
-
this.router.post(path, ...args);
|
|
1831
|
-
return this;
|
|
1832
|
-
}
|
|
1833
|
-
/** Register a PUT route. */
|
|
1834
|
-
put(path, ...args) {
|
|
1835
|
-
;
|
|
1836
|
-
this.router.put(path, ...args);
|
|
1837
|
-
return this;
|
|
1838
|
-
}
|
|
1839
|
-
/** Register a PATCH route. */
|
|
1840
|
-
patch(path, ...args) {
|
|
1841
|
-
;
|
|
1842
|
-
this.router.patch(path, ...args);
|
|
1843
|
-
return this;
|
|
1844
|
-
}
|
|
1845
|
-
/** Register a DELETE route. */
|
|
1846
|
-
delete(path, ...args) {
|
|
1847
|
-
;
|
|
1848
|
-
this.router.delete(path, ...args);
|
|
1849
|
-
return this;
|
|
1850
|
-
}
|
|
1851
|
-
/** Start building a GET request */
|
|
1852
|
-
getReq(path) {
|
|
1853
|
-
return new TestRequest(this, "GET", path);
|
|
1854
|
-
}
|
|
1855
|
-
/** Start building a POST request */
|
|
1856
|
-
postReq(path) {
|
|
1857
|
-
return new TestRequest(this, "POST", path);
|
|
1858
|
-
}
|
|
1859
|
-
/** Start building a PUT request */
|
|
1860
|
-
putReq(path) {
|
|
1861
|
-
return new TestRequest(this, "PUT", path);
|
|
1862
|
-
}
|
|
1863
|
-
/** Start building a PATCH request */
|
|
1864
|
-
patchReq(path) {
|
|
1865
|
-
return new TestRequest(this, "PATCH", path);
|
|
1866
|
-
}
|
|
1867
|
-
/** Start building a DELETE request */
|
|
1868
|
-
deleteReq(path) {
|
|
1869
|
-
return new TestRequest(this, "DELETE", path);
|
|
1870
|
-
}
|
|
1871
|
-
/** Get the underlying handler (for advanced usage) */
|
|
1872
|
-
handler() {
|
|
1873
|
-
return this.router.handler();
|
|
1874
|
-
}
|
|
1875
|
-
/** Start building a WebSocket connection to the given path. */
|
|
1876
|
-
wsReq(path) {
|
|
1877
|
-
return new TestWSRequest(this, path);
|
|
1878
|
-
}
|
|
1879
|
-
/**
|
|
1880
|
-
* Internal: ensure HTTP server is running for WebSocket connections.
|
|
1881
|
-
* Starts on a random port.
|
|
1882
|
-
*/
|
|
1883
|
-
/* @internal */
|
|
1884
|
-
async _ensureServer() {
|
|
1885
|
-
if (this.wsServer) {
|
|
1886
|
-
return `http://localhost:${this.wsServer.port}`;
|
|
1887
|
-
}
|
|
1888
|
-
const wsHandler = this.router.websocketHandler();
|
|
1889
|
-
if (!wsHandler) {
|
|
1890
|
-
throw new Error(
|
|
1891
|
-
"No WebSocket routes registered. Use app.ws(path, handler) before calling wsReq()."
|
|
1892
|
-
);
|
|
1893
|
-
}
|
|
1894
|
-
this.wsServer = serve(this.router);
|
|
1895
|
-
await this.wsServer.ready;
|
|
1896
|
-
return `http://localhost:${this.wsServer.port}`;
|
|
1897
|
-
}
|
|
1898
|
-
/**
|
|
1899
|
-
* Internal: register a WS connection for cleanup.
|
|
1900
|
-
*/
|
|
1901
|
-
/* @internal */
|
|
1902
|
-
_trackConnection(conn) {
|
|
1903
|
-
this.wsConnections.push(conn);
|
|
1904
|
-
}
|
|
1905
|
-
/**
|
|
1906
|
-
* Cleanup all WebSocket connections and stop the server.
|
|
1907
|
-
*/
|
|
1908
|
-
async close() {
|
|
1909
|
-
for (const conn of this.wsConnections) {
|
|
1910
|
-
try {
|
|
1911
|
-
conn.close();
|
|
1912
|
-
} catch {
|
|
1913
|
-
}
|
|
1914
|
-
}
|
|
1915
|
-
this.wsConnections = [];
|
|
1916
|
-
if (this.wsServer) {
|
|
1917
|
-
this.wsServer.close();
|
|
1918
|
-
this.wsServer = null;
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
};
|
|
1922
|
-
var TestWSRequest = class {
|
|
1923
|
-
app;
|
|
1924
|
-
path;
|
|
1925
|
-
_timeout = 5e3;
|
|
1926
|
-
constructor(app, path) {
|
|
1927
|
-
this.app = app;
|
|
1928
|
-
this.path = path;
|
|
1929
|
-
}
|
|
1930
|
-
/** Set the timeout for operations (default: 5000ms). */
|
|
1931
|
-
timeout(ms) {
|
|
1932
|
-
this._timeout = ms;
|
|
1933
|
-
return this;
|
|
1934
|
-
}
|
|
1935
|
-
/**
|
|
1936
|
-
* Connect to the WebSocket endpoint.
|
|
1937
|
-
* Starts a real HTTP server (random port) if not already running.
|
|
1938
|
-
*/
|
|
1939
|
-
async connect() {
|
|
1940
|
-
const baseUrl = await this.app._ensureServer();
|
|
1941
|
-
const wsUrl = baseUrl.replace(/^http/, "ws") + this.path;
|
|
1942
|
-
const ws = new WSWebSocket(wsUrl, { handshakeTimeout: this._timeout });
|
|
1943
|
-
return new Promise((resolve4, reject) => {
|
|
1944
|
-
const timer = setTimeout(() => {
|
|
1945
|
-
reject(new Error(`WebSocket connection timed out after ${this._timeout}ms`));
|
|
1946
|
-
ws.close();
|
|
1947
|
-
}, this._timeout);
|
|
1948
|
-
ws.on("open", () => {
|
|
1949
|
-
clearTimeout(timer);
|
|
1950
|
-
const conn = new TestWSConnection(ws, this._timeout);
|
|
1951
|
-
this.app._trackConnection(conn);
|
|
1952
|
-
resolve4(conn);
|
|
1953
|
-
});
|
|
1954
|
-
ws.on("error", (err) => {
|
|
1955
|
-
clearTimeout(timer);
|
|
1956
|
-
reject(new Error(`WebSocket connection error: ${err.message}`));
|
|
1957
|
-
});
|
|
1958
|
-
ws.on("unexpected-response", (_req, res) => {
|
|
1959
|
-
clearTimeout(timer);
|
|
1960
|
-
let body = "";
|
|
1961
|
-
res.on("data", (chunk) => {
|
|
1962
|
-
body += chunk.toString();
|
|
1963
|
-
});
|
|
1964
|
-
res.on("end", () => {
|
|
1965
|
-
reject(new Error(`WebSocket upgrade rejected (${res.statusCode}): ${body.slice(0, 200)}`));
|
|
1966
|
-
});
|
|
1967
|
-
});
|
|
1968
|
-
});
|
|
1969
|
-
}
|
|
1970
|
-
};
|
|
1971
|
-
var TestWSConnection = class {
|
|
1972
|
-
ws;
|
|
1973
|
-
_timeout;
|
|
1974
|
-
messageQueue = [];
|
|
1975
|
-
resolveQueue = [];
|
|
1976
|
-
_closed = false;
|
|
1977
|
-
constructor(ws, timeout = 5e3) {
|
|
1978
|
-
this.ws = ws;
|
|
1979
|
-
this._timeout = timeout;
|
|
1980
|
-
ws.on("message", (data) => {
|
|
1981
|
-
const str = data.toString();
|
|
1982
|
-
if (this.resolveQueue.length > 0) {
|
|
1983
|
-
const resolve4 = this.resolveQueue.shift();
|
|
1984
|
-
resolve4(str);
|
|
1985
|
-
} else {
|
|
1986
|
-
this.messageQueue.push(str);
|
|
1987
|
-
}
|
|
1988
|
-
});
|
|
1989
|
-
ws.on("close", () => {
|
|
1990
|
-
this._closed = true;
|
|
1991
|
-
for (const _r of this.resolveQueue) {
|
|
1992
|
-
}
|
|
1993
|
-
});
|
|
1994
|
-
}
|
|
1995
|
-
/** Send a text message. */
|
|
1996
|
-
send(data) {
|
|
1997
|
-
this.ws.send(data);
|
|
1998
|
-
}
|
|
1999
|
-
/** Send a JSON message. */
|
|
2000
|
-
json(data) {
|
|
2001
|
-
this.ws.send(JSON.stringify(data));
|
|
2002
|
-
}
|
|
2003
|
-
/**
|
|
2004
|
-
* Wait for the next message. Returns the raw text.
|
|
2005
|
-
* Throws on timeout or if the connection is closed.
|
|
2006
|
-
*/
|
|
2007
|
-
async receive(timeout) {
|
|
2008
|
-
if (this.messageQueue.length > 0) {
|
|
2009
|
-
return this.messageQueue.shift();
|
|
2010
|
-
}
|
|
2011
|
-
if (this._closed) {
|
|
2012
|
-
throw new Error("WebSocket connection closed");
|
|
2013
|
-
}
|
|
2014
|
-
return new Promise((resolve4, reject) => {
|
|
2015
|
-
const timer = setTimeout(() => {
|
|
2016
|
-
const idx = this.resolveQueue.indexOf(resolve4);
|
|
2017
|
-
if (idx !== -1) this.resolveQueue.splice(idx, 1);
|
|
2018
|
-
reject(new Error(`WebSocket receive timed out after ${timeout ?? this._timeout}ms`));
|
|
2019
|
-
}, timeout ?? this._timeout);
|
|
2020
|
-
this.resolveQueue.push((msg) => {
|
|
2021
|
-
clearTimeout(timer);
|
|
2022
|
-
resolve4(msg);
|
|
2023
|
-
});
|
|
2024
|
-
});
|
|
2025
|
-
}
|
|
2026
|
-
/** Wait for the next message and parse as JSON. */
|
|
2027
|
-
async receiveJson() {
|
|
2028
|
-
const msg = await this.receive();
|
|
2029
|
-
return JSON.parse(msg);
|
|
2030
|
-
}
|
|
2031
|
-
/**
|
|
2032
|
-
* Assert that no message is received within the given silence period.
|
|
2033
|
-
* Useful for verifying that something did NOT happen.
|
|
2034
|
-
*/
|
|
2035
|
-
async expectSilent(ms) {
|
|
2036
|
-
return new Promise((resolve4, reject) => {
|
|
2037
|
-
if (this.messageQueue.length > 0) {
|
|
2038
|
-
reject(new Error(`Expected silence but got message: ${this.messageQueue[0].slice(0, 100)}`));
|
|
2039
|
-
return;
|
|
2040
|
-
}
|
|
2041
|
-
const timer = setTimeout(() => resolve4(), ms);
|
|
2042
|
-
const origPush = this.resolveQueue.push.bind(this.resolveQueue);
|
|
2043
|
-
this.resolveQueue.push = (_fn) => {
|
|
2044
|
-
clearTimeout(timer);
|
|
2045
|
-
reject(new Error("Expected silence but received a message"));
|
|
2046
|
-
return 0;
|
|
2047
|
-
};
|
|
2048
|
-
setTimeout(() => {
|
|
2049
|
-
this.resolveQueue.push = origPush;
|
|
2050
|
-
}, ms + 10).unref();
|
|
2051
|
-
});
|
|
2052
|
-
}
|
|
2053
|
-
/** Close the connection. */
|
|
2054
|
-
close() {
|
|
2055
|
-
this._closed = true;
|
|
2056
|
-
this.ws.close();
|
|
2057
|
-
}
|
|
2058
|
-
/** Whether the connection is closed. */
|
|
2059
|
-
get closed() {
|
|
2060
|
-
return this._closed;
|
|
2061
|
-
}
|
|
2062
|
-
};
|
|
2063
|
-
function testApp() {
|
|
2064
|
-
return new TestApp();
|
|
2065
|
-
}
|
|
2066
|
-
async function createTestDb(options) {
|
|
2067
|
-
const dbUrl = options?.url || process.env.TEST_DATABASE_URL || process.env.DATABASE_URL;
|
|
2068
|
-
if (!dbUrl) throw new Error("createTestDb: DATABASE_URL or TEST_DATABASE_URL required");
|
|
2069
|
-
const schema = options?.schema || `test_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2070
|
-
const { default: postgres2 } = await import("postgres");
|
|
2071
|
-
const adminSql = postgres2(dbUrl);
|
|
2072
|
-
await adminSql.unsafe('CREATE SCHEMA IF NOT EXISTS "' + schema.replace(/"/g, '""') + '"');
|
|
2073
|
-
const schemaUrl = new URL(dbUrl);
|
|
2074
|
-
schemaUrl.searchParams.set("search_path", schema);
|
|
2075
|
-
const sql = postgres2(schemaUrl.toString());
|
|
2076
|
-
await adminSql.end();
|
|
2077
|
-
return {
|
|
2078
|
-
sql,
|
|
2079
|
-
url: schemaUrl.toString(),
|
|
2080
|
-
schema,
|
|
2081
|
-
destroy: async () => {
|
|
2082
|
-
const destroySql = postgres2(dbUrl);
|
|
2083
|
-
await destroySql.unsafe('DROP SCHEMA IF EXISTS "' + schema.replace(/"/g, '""') + '" CASCADE');
|
|
2084
|
-
await destroySql.end();
|
|
2085
|
-
await sql.end();
|
|
2086
|
-
}
|
|
2087
|
-
};
|
|
2088
|
-
}
|
|
2089
|
-
async function withTestDb(optionsOrFn, fn) {
|
|
2090
|
-
let dbUrl;
|
|
2091
|
-
let callback;
|
|
2092
|
-
if (typeof optionsOrFn === "function") {
|
|
2093
|
-
callback = optionsOrFn;
|
|
2094
|
-
} else if (typeof optionsOrFn === "string") {
|
|
2095
|
-
dbUrl = optionsOrFn;
|
|
2096
|
-
callback = fn;
|
|
2097
|
-
} else {
|
|
2098
|
-
dbUrl = optionsOrFn?.url;
|
|
2099
|
-
callback = fn;
|
|
2100
|
-
}
|
|
2101
|
-
const resolvedUrl = dbUrl || process.env.TEST_DATABASE_URL || process.env.DATABASE_URL;
|
|
2102
|
-
if (!resolvedUrl) throw new Error("withTestDb: DATABASE_URL or TEST_DATABASE_URL required");
|
|
2103
|
-
const { default: postgres2 } = await import("postgres");
|
|
2104
|
-
const sql = postgres2(resolvedUrl);
|
|
2105
|
-
try {
|
|
2106
|
-
await sql.begin(async (txSql) => {
|
|
2107
|
-
await callback(txSql);
|
|
2108
|
-
throw void 0;
|
|
2109
|
-
});
|
|
2110
|
-
} catch {
|
|
2111
|
-
} finally {
|
|
2112
|
-
await sql.end();
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
1306
|
// src/graphql.ts
|
|
2117
1307
|
import {
|
|
2118
1308
|
buildSchema,
|
|
@@ -2145,16 +1335,10 @@ async function parseParamsFromPost(req) {
|
|
|
2145
1335
|
}
|
|
2146
1336
|
}
|
|
2147
1337
|
function buildSchemaFromOptions(options) {
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
return options.resolvers ? makeExecutableSchema({ typeDefs: options.schema, resolvers: options.resolvers }) : buildSchema(options.schema);
|
|
2151
|
-
}
|
|
2152
|
-
return options.schema;
|
|
2153
|
-
} catch (err) {
|
|
2154
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2155
|
-
console.error(`[graphql] schema build failed: ${msg}`);
|
|
2156
|
-
throw err;
|
|
1338
|
+
if (typeof options.schema === "string") {
|
|
1339
|
+
return options.resolvers ? makeExecutableSchema({ typeDefs: options.schema, resolvers: options.resolvers }) : buildSchema(options.schema);
|
|
2157
1340
|
}
|
|
1341
|
+
return options.schema;
|
|
2158
1342
|
}
|
|
2159
1343
|
function queryDepth(doc) {
|
|
2160
1344
|
let max = 0;
|
|
@@ -2222,7 +1406,6 @@ async function executeQuery(schema, params, options, req, ctx) {
|
|
|
2222
1406
|
return Response.json(result, { status: result.errors ? 400 : 200 });
|
|
2223
1407
|
} catch (err) {
|
|
2224
1408
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2225
|
-
console.error(`[${currentTraceId()}] graphql execution failed: ${msg}`);
|
|
2226
1409
|
return Response.json({ errors: [{ message: msg }] }, { status: 500 });
|
|
2227
1410
|
}
|
|
2228
1411
|
}
|
|
@@ -2414,7 +1597,8 @@ function redis(opts) {
|
|
|
2414
1597
|
const options = typeof opts === "string" ? { url: opts } : opts ?? {};
|
|
2415
1598
|
const url = options.url ?? process.env.REDIS_URL ?? "redis://localhost:6379";
|
|
2416
1599
|
const client = new IORedis(url, options);
|
|
2417
|
-
client.on("error", (
|
|
1600
|
+
client.on("error", () => {
|
|
1601
|
+
});
|
|
2418
1602
|
const mw = ((req, ctx, next) => {
|
|
2419
1603
|
ctx.redis = client;
|
|
2420
1604
|
return next(req, ctx);
|
|
@@ -2426,8 +1610,8 @@ function redis(opts) {
|
|
|
2426
1610
|
}
|
|
2427
1611
|
|
|
2428
1612
|
// src/queue/index.ts
|
|
1613
|
+
import crypto2 from "node:crypto";
|
|
2429
1614
|
import { Redis as IORedis2 } from "ioredis";
|
|
2430
|
-
import crypto4 from "node:crypto";
|
|
2431
1615
|
|
|
2432
1616
|
// src/queue/cron.ts
|
|
2433
1617
|
function parseField(field, min, max) {
|
|
@@ -2495,427 +1679,85 @@ function cronNext(expr, from = /* @__PURE__ */ new Date()) {
|
|
|
2495
1679
|
|
|
2496
1680
|
// src/queue/index.ts
|
|
2497
1681
|
function queue(opts) {
|
|
2498
|
-
const
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
}
|
|
2503
|
-
function escapeIdent(s) {
|
|
2504
|
-
return '"' + s.replace(/"/g, '""') + '"';
|
|
2505
|
-
}
|
|
2506
|
-
function attachCron(q, handlers) {
|
|
2507
|
-
;
|
|
2508
|
-
q.cron = function(pattern, handler) {
|
|
2509
|
-
const id = "__cron_" + pattern.replace(/[^a-zA-Z0-9]/g, "_") + "_" + crypto4.randomUUID().slice(0, 8);
|
|
2510
|
-
q.process(id, async () => {
|
|
2511
|
-
await handler();
|
|
2512
|
-
});
|
|
2513
|
-
q.add(id, {}, { schedule: pattern });
|
|
2514
|
-
return { stop: () => handlers.delete(id) };
|
|
2515
|
-
};
|
|
2516
|
-
}
|
|
2517
|
-
function createMemoryQueue(opts) {
|
|
1682
|
+
const redis2 = opts?.redis ?? new IORedis2(opts?.url ?? process.env.REDIS_URL ?? "redis://localhost:6379");
|
|
1683
|
+
const prefix = opts?.prefix ?? "queue";
|
|
1684
|
+
const jobKey = prefix + ":jobs";
|
|
1685
|
+
const failedKey = prefix + ":failed";
|
|
2518
1686
|
const pollInterval = opts?.pollInterval ?? 200;
|
|
2519
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
2520
|
-
const pending = [];
|
|
2521
|
-
const failed = [];
|
|
2522
|
-
const MAX_FAILED = 1e3;
|
|
2523
|
-
let running = false;
|
|
2524
|
-
let pollTimer = null;
|
|
2525
|
-
let _processed = 0;
|
|
2526
|
-
let _failed = 0;
|
|
2527
|
-
let inflight = 0;
|
|
2528
1687
|
const MAX_CONCURRENT = 16;
|
|
2529
|
-
|
|
2530
|
-
let i = 0;
|
|
2531
|
-
while (i < pending.length && pending[i].runAt <= job.runAt) i++;
|
|
2532
|
-
pending.splice(i, 0, job);
|
|
2533
|
-
}
|
|
2534
|
-
async function execute(job, handler) {
|
|
2535
|
-
inflight++;
|
|
2536
|
-
try {
|
|
2537
|
-
await handler(job);
|
|
2538
|
-
_processed++;
|
|
2539
|
-
} catch (e) {
|
|
2540
|
-
_failed++;
|
|
2541
|
-
failed.unshift({ ...job, error: e.message, failedAt: Date.now() });
|
|
2542
|
-
if (failed.length > MAX_FAILED) failed.length = MAX_FAILED;
|
|
2543
|
-
} finally {
|
|
2544
|
-
inflight--;
|
|
2545
|
-
}
|
|
2546
|
-
if (job.schedule) {
|
|
2547
|
-
try {
|
|
2548
|
-
insertJob({
|
|
2549
|
-
...job,
|
|
2550
|
-
id: crypto4.randomUUID(),
|
|
2551
|
-
runAt: cronNext(job.schedule),
|
|
2552
|
-
createdAt: Date.now()
|
|
2553
|
-
});
|
|
2554
|
-
} catch (e) {
|
|
2555
|
-
console.error("[queue] cron re-queue failed:", e.message);
|
|
2556
|
-
}
|
|
2557
|
-
}
|
|
2558
|
-
}
|
|
2559
|
-
async function poll() {
|
|
2560
|
-
if (!running) return;
|
|
2561
|
-
const now = Date.now();
|
|
2562
|
-
while (running && inflight < MAX_CONCURRENT && pending.length > 0 && pending[0].runAt <= now) {
|
|
2563
|
-
const job = pending.shift();
|
|
2564
|
-
const handler = handlers.get(job.type);
|
|
2565
|
-
if (handler) execute(job, handler);
|
|
2566
|
-
}
|
|
2567
|
-
if (running) pollTimer = setTimeout(poll, pollInterval);
|
|
2568
|
-
}
|
|
2569
|
-
const mw = ((req, ctx, next) => {
|
|
2570
|
-
ctx.queue = q;
|
|
2571
|
-
return next(req, ctx);
|
|
2572
|
-
});
|
|
2573
|
-
const q = mw;
|
|
2574
|
-
mw.add = function add(type, payload, opts2) {
|
|
2575
|
-
const id = crypto4.randomUUID();
|
|
2576
|
-
let runAt;
|
|
2577
|
-
if (opts2?.schedule) {
|
|
2578
|
-
try {
|
|
2579
|
-
const f = parsePattern(opts2.schedule);
|
|
2580
|
-
runAt = matches(f, /* @__PURE__ */ new Date()) ? Date.now() : cronNext(opts2.schedule);
|
|
2581
|
-
} catch {
|
|
2582
|
-
runAt = cronNext(opts2.schedule);
|
|
2583
|
-
}
|
|
2584
|
-
} else if (opts2?.delay) {
|
|
2585
|
-
runAt = Date.now() + opts2.delay;
|
|
2586
|
-
} else {
|
|
2587
|
-
runAt = Date.now();
|
|
2588
|
-
}
|
|
2589
|
-
const job = { id, type, payload, createdAt: Date.now(), runAt };
|
|
2590
|
-
if (opts2?.schedule) job.schedule = opts2.schedule;
|
|
2591
|
-
insertJob(job);
|
|
2592
|
-
return Promise.resolve(id);
|
|
2593
|
-
};
|
|
2594
|
-
mw.process = function process2(type, handler) {
|
|
2595
|
-
handlers.set(type, handler);
|
|
2596
|
-
};
|
|
2597
|
-
mw.run = async function run() {
|
|
2598
|
-
if (running) return;
|
|
2599
|
-
running = true;
|
|
2600
|
-
poll();
|
|
2601
|
-
};
|
|
2602
|
-
mw.close = async function close() {
|
|
2603
|
-
running = false;
|
|
2604
|
-
if (pollTimer) {
|
|
2605
|
-
clearTimeout(pollTimer);
|
|
2606
|
-
pollTimer = null;
|
|
2607
|
-
}
|
|
2608
|
-
while (inflight > 0) await new Promise((r) => setTimeout(r, 50));
|
|
2609
|
-
};
|
|
2610
|
-
mw.jobs = async function(limit) {
|
|
2611
|
-
return pending.slice(0, limit ?? 50);
|
|
2612
|
-
};
|
|
2613
|
-
mw.failedJobs = async function failedJobs(limit) {
|
|
2614
|
-
return failed.slice(0, limit ?? 50);
|
|
2615
|
-
};
|
|
2616
|
-
mw.retryFailed = async function retry(jobId) {
|
|
2617
|
-
const idx = failed.findIndex((j) => j.id === jobId);
|
|
2618
|
-
if (idx < 0) return false;
|
|
2619
|
-
const [entry] = failed.splice(idx, 1);
|
|
2620
|
-
_failed--;
|
|
2621
|
-
insertJob({ ...entry, runAt: Date.now() });
|
|
2622
|
-
return true;
|
|
2623
|
-
};
|
|
2624
|
-
mw.retryAllFailed = async function retryAll(type) {
|
|
2625
|
-
let count = 0;
|
|
2626
|
-
for (let i = failed.length - 1; i >= 0; i--) {
|
|
2627
|
-
if (type && failed[i].type !== type) continue;
|
|
2628
|
-
const [entry] = failed.splice(i, 1);
|
|
2629
|
-
_failed--;
|
|
2630
|
-
insertJob({ ...entry, runAt: Date.now() });
|
|
2631
|
-
count++;
|
|
2632
|
-
}
|
|
2633
|
-
return count;
|
|
2634
|
-
};
|
|
2635
|
-
mw.dashboard = function dashboard() {
|
|
2636
|
-
return buildDashboard(q);
|
|
2637
|
-
};
|
|
2638
|
-
mw.stats = () => ({
|
|
2639
|
-
running,
|
|
2640
|
-
inflight,
|
|
2641
|
-
processed: _processed,
|
|
2642
|
-
failed: _failed,
|
|
2643
|
-
handlers: handlers.size,
|
|
2644
|
-
maxConcurrent: MAX_CONCURRENT
|
|
2645
|
-
});
|
|
2646
|
-
attachCron(q, handlers);
|
|
2647
|
-
return q;
|
|
2648
|
-
}
|
|
2649
|
-
function createPgQueue(opts) {
|
|
2650
|
-
const sql = opts.pg.sql;
|
|
2651
|
-
const pollInterval = opts?.pollInterval ?? 200;
|
|
2652
|
-
const table = (opts?.prefix ?? "queue") + "_jobs";
|
|
1688
|
+
const MAX_FAILED = 1e3;
|
|
2653
1689
|
const handlers = /* @__PURE__ */ new Map();
|
|
2654
1690
|
let running = false, pollTimer = null;
|
|
2655
|
-
let _processed = 0, _failed = 0, inflight = 0
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
);
|
|
2663
|
-
await sql.unsafe(
|
|
2664
|
-
`CREATE INDEX IF NOT EXISTS ${escapeIdent(table + "_run_at_idx")} ON ${escapeIdent(table)} (run_at, status)`
|
|
2665
|
-
);
|
|
2666
|
-
ready = true;
|
|
2667
|
-
}
|
|
2668
|
-
async function processJob(job, handler) {
|
|
2669
|
-
inflight++;
|
|
2670
|
-
try {
|
|
2671
|
-
await handler(job);
|
|
2672
|
-
_processed++;
|
|
2673
|
-
await sql.unsafe(`DELETE FROM ${escapeIdent(table)} WHERE id = $1`, [job.id]);
|
|
2674
|
-
} catch (e) {
|
|
2675
|
-
_failed++;
|
|
2676
|
-
const msg = e.message;
|
|
2677
|
-
console.error("[queue] handler error:", msg);
|
|
2678
|
-
await sql.unsafe(
|
|
2679
|
-
`UPDATE ${escapeIdent(table)} SET status = 'failed', error = $2, failed_at = NOW() WHERE id = $1`,
|
|
2680
|
-
[job.id, msg]
|
|
2681
|
-
);
|
|
2682
|
-
} finally {
|
|
2683
|
-
inflight--;
|
|
2684
|
-
}
|
|
2685
|
-
if (job.schedule) {
|
|
2686
|
-
try {
|
|
2687
|
-
const nextRun = cronNext(job.schedule);
|
|
2688
|
-
await sql.unsafe(
|
|
2689
|
-
`INSERT INTO ${escapeIdent(table)} (id, type, payload, run_at, schedule) VALUES ($1, $2, $3::jsonb, $4, $5)`,
|
|
2690
|
-
[
|
|
2691
|
-
crypto4.randomUUID(),
|
|
2692
|
-
job.type,
|
|
2693
|
-
JSON.stringify(job.payload),
|
|
2694
|
-
new Date(nextRun).toISOString(),
|
|
2695
|
-
job.schedule
|
|
2696
|
-
]
|
|
2697
|
-
);
|
|
2698
|
-
} catch (e) {
|
|
2699
|
-
console.error("[queue] cron re-queue failed:", e.message);
|
|
2700
|
-
}
|
|
1691
|
+
let _processed = 0, _failed = 0, inflight = 0;
|
|
1692
|
+
async function insert(job, error) {
|
|
1693
|
+
if (error) {
|
|
1694
|
+
await redis2.lpush(failedKey, JSON.stringify({ ...job, error, failedAt: Date.now() }));
|
|
1695
|
+
await redis2.ltrim(failedKey, 0, MAX_FAILED - 1);
|
|
1696
|
+
} else {
|
|
1697
|
+
await redis2.zadd(jobKey, job.runAt, JSON.stringify(job));
|
|
2701
1698
|
}
|
|
2702
1699
|
}
|
|
2703
1700
|
async function poll() {
|
|
2704
|
-
|
|
1701
|
+
const result = await redis2.zpopmin(jobKey);
|
|
1702
|
+
if (result.length < 2) return null;
|
|
1703
|
+
const score = parseInt(result[1], 10);
|
|
1704
|
+
if (score > Date.now()) {
|
|
1705
|
+
await redis2.zadd(jobKey, score, result[0]);
|
|
1706
|
+
return null;
|
|
1707
|
+
}
|
|
2705
1708
|
try {
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2710
|
-
);
|
|
2711
|
-
if (rows.length === 0) break;
|
|
2712
|
-
const row = rows[0];
|
|
2713
|
-
const job = {
|
|
2714
|
-
id: row.id,
|
|
2715
|
-
type: row.type,
|
|
2716
|
-
payload: typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload,
|
|
2717
|
-
createdAt: new Date(row.created_at).getTime(),
|
|
2718
|
-
runAt: new Date(row.run_at).getTime(),
|
|
2719
|
-
schedule: row.schedule || void 0
|
|
2720
|
-
};
|
|
2721
|
-
const handler = handlers.get(job.type);
|
|
2722
|
-
if (handler) processJob(job, handler);
|
|
2723
|
-
}
|
|
2724
|
-
} catch (e) {
|
|
2725
|
-
const msg = e.message;
|
|
2726
|
-
if (msg.includes("CONNECTION_ENDED") || msg.includes("Connection terminated")) {
|
|
2727
|
-
running = false;
|
|
2728
|
-
return;
|
|
2729
|
-
}
|
|
2730
|
-
console.error("[queue] poll error:", msg);
|
|
1709
|
+
return JSON.parse(result[0]);
|
|
1710
|
+
} catch {
|
|
1711
|
+
return null;
|
|
2731
1712
|
}
|
|
2732
|
-
if (running) pollTimer = setTimeout(poll, pollInterval);
|
|
2733
1713
|
}
|
|
2734
|
-
|
|
2735
|
-
ctx.queue = q;
|
|
2736
|
-
return next(req, ctx);
|
|
2737
|
-
});
|
|
2738
|
-
const q = mw;
|
|
2739
|
-
mw.add = function add(type, payload, opts2) {
|
|
2740
|
-
return (async () => {
|
|
2741
|
-
const id = crypto4.randomUUID();
|
|
2742
|
-
let runAt;
|
|
2743
|
-
if (opts2?.schedule) {
|
|
2744
|
-
try {
|
|
2745
|
-
const f = parsePattern(opts2.schedule);
|
|
2746
|
-
runAt = matches(f, /* @__PURE__ */ new Date()) ? /* @__PURE__ */ new Date() : new Date(cronNext(opts2.schedule));
|
|
2747
|
-
} catch {
|
|
2748
|
-
runAt = new Date(cronNext(opts2.schedule));
|
|
2749
|
-
}
|
|
2750
|
-
} else if (opts2?.delay) {
|
|
2751
|
-
runAt = new Date(Date.now() + opts2.delay);
|
|
2752
|
-
} else {
|
|
2753
|
-
runAt = /* @__PURE__ */ new Date();
|
|
2754
|
-
}
|
|
2755
|
-
await sql.unsafe(
|
|
2756
|
-
`INSERT INTO ${escapeIdent(table)} (id, type, payload, run_at, schedule) VALUES ($1, $2, $3::jsonb, $4, $5)`,
|
|
2757
|
-
[id, type, JSON.stringify(payload), runAt.toISOString(), opts2?.schedule || null]
|
|
2758
|
-
);
|
|
2759
|
-
return id;
|
|
2760
|
-
})();
|
|
2761
|
-
};
|
|
2762
|
-
mw.process = function process2(type, handler) {
|
|
2763
|
-
handlers.set(type, handler);
|
|
2764
|
-
};
|
|
2765
|
-
mw.migrate = ensureTable;
|
|
2766
|
-
mw.run = async function run() {
|
|
2767
|
-
if (running) return;
|
|
2768
|
-
await ensureTable();
|
|
2769
|
-
running = true;
|
|
2770
|
-
poll();
|
|
2771
|
-
};
|
|
2772
|
-
mw.close = async function close() {
|
|
2773
|
-
running = false;
|
|
2774
|
-
if (pollTimer) {
|
|
2775
|
-
clearTimeout(pollTimer);
|
|
2776
|
-
pollTimer = null;
|
|
2777
|
-
}
|
|
2778
|
-
while (inflight > 0) await new Promise((r) => setTimeout(r, 50));
|
|
2779
|
-
};
|
|
2780
|
-
mw.jobs = async function jobs(limit) {
|
|
2781
|
-
const rows = await sql.unsafe(
|
|
2782
|
-
`SELECT * FROM ${escapeIdent(table)} WHERE status = 'pending' ORDER BY run_at LIMIT $1`,
|
|
2783
|
-
[limit ?? 50]
|
|
2784
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2785
|
-
);
|
|
2786
|
-
return rows.map((r) => ({
|
|
2787
|
-
id: r.id,
|
|
2788
|
-
type: r.type,
|
|
2789
|
-
payload: typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
|
|
2790
|
-
createdAt: new Date(r.created_at).getTime(),
|
|
2791
|
-
runAt: new Date(r.run_at).getTime(),
|
|
2792
|
-
schedule: r.schedule || void 0
|
|
2793
|
-
}));
|
|
2794
|
-
};
|
|
2795
|
-
mw.failedJobs = async function failedJobs(limit) {
|
|
2796
|
-
const rows = await sql.unsafe(
|
|
2797
|
-
`SELECT * FROM ${escapeIdent(table)} WHERE status = 'failed' ORDER BY failed_at DESC LIMIT $1`,
|
|
2798
|
-
[limit ?? 50]
|
|
2799
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2800
|
-
);
|
|
2801
|
-
return rows.map((r) => ({
|
|
2802
|
-
id: r.id,
|
|
2803
|
-
type: r.type,
|
|
2804
|
-
payload: typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
|
|
2805
|
-
createdAt: new Date(r.created_at).getTime(),
|
|
2806
|
-
runAt: new Date(r.run_at).getTime(),
|
|
2807
|
-
schedule: r.schedule || void 0,
|
|
2808
|
-
error: r.error || "",
|
|
2809
|
-
failedAt: new Date(r.failed_at).getTime()
|
|
2810
|
-
}));
|
|
2811
|
-
};
|
|
2812
|
-
mw.retryFailed = async function retryFailed(jobId) {
|
|
2813
|
-
const result = await sql.unsafe(
|
|
2814
|
-
`UPDATE ${escapeIdent(table)} SET status = 'pending', error = NULL, failed_at = NULL, run_at = NOW() WHERE id = $1 AND status = 'failed' RETURNING id`,
|
|
2815
|
-
[jobId]
|
|
2816
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2817
|
-
);
|
|
2818
|
-
return result.length > 0;
|
|
2819
|
-
};
|
|
2820
|
-
mw.retryAllFailed = async function retryAllFailed(type) {
|
|
2821
|
-
const result = await sql.unsafe(
|
|
2822
|
-
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`,
|
|
2823
|
-
type ? [type] : []
|
|
2824
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2825
|
-
);
|
|
2826
|
-
return result.length;
|
|
2827
|
-
};
|
|
2828
|
-
mw.dashboard = function dashboard() {
|
|
2829
|
-
return buildDashboard(q);
|
|
2830
|
-
};
|
|
2831
|
-
mw.stats = () => ({
|
|
2832
|
-
running,
|
|
2833
|
-
inflight,
|
|
2834
|
-
processed: _processed,
|
|
2835
|
-
failed: _failed,
|
|
2836
|
-
handlers: handlers.size,
|
|
2837
|
-
maxConcurrent: MAX_CONCURRENT
|
|
2838
|
-
});
|
|
2839
|
-
attachCron(q, handlers);
|
|
2840
|
-
return q;
|
|
2841
|
-
}
|
|
2842
|
-
function createRedisQueue(opts) {
|
|
2843
|
-
const redis2 = opts?.redis ?? new IORedis2(opts?.url ?? process.env.REDIS_URL ?? "redis://localhost:6379");
|
|
2844
|
-
const prefix = opts?.prefix ?? "queue";
|
|
2845
|
-
const pollInterval = opts?.pollInterval ?? 200;
|
|
2846
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
2847
|
-
let running = false, pollTimer = null, epoch = 0;
|
|
2848
|
-
let _processed = 0, _failed = 0, inflight = 0;
|
|
2849
|
-
const jobKey = prefix + ":jobs", failedKey = prefix + ":failed", MAX_FAILED = 1e3, MAX_CONCURRENT = 16;
|
|
2850
|
-
async function processJob(job, handler) {
|
|
1714
|
+
async function execute(job, handler) {
|
|
2851
1715
|
inflight++;
|
|
2852
1716
|
try {
|
|
2853
1717
|
await handler(job);
|
|
2854
1718
|
_processed++;
|
|
2855
1719
|
} catch (e) {
|
|
2856
1720
|
_failed++;
|
|
2857
|
-
|
|
2858
|
-
console.error("[queue] handler error:", msg);
|
|
2859
|
-
await redis2.lpush(failedKey, JSON.stringify({ ...job, error: msg, failedAt: Date.now() }));
|
|
2860
|
-
await redis2.ltrim(failedKey, 0, MAX_FAILED - 1);
|
|
1721
|
+
await insert(job, e.message);
|
|
2861
1722
|
} finally {
|
|
2862
1723
|
inflight--;
|
|
2863
1724
|
}
|
|
2864
1725
|
if (job.schedule) {
|
|
2865
1726
|
try {
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
jobKey,
|
|
2869
|
-
nextRun,
|
|
2870
|
-
JSON.stringify({
|
|
2871
|
-
...job,
|
|
2872
|
-
id: crypto4.randomUUID(),
|
|
2873
|
-
runAt: nextRun,
|
|
2874
|
-
createdAt: Date.now()
|
|
2875
|
-
})
|
|
2876
|
-
);
|
|
2877
|
-
} catch (e) {
|
|
2878
|
-
console.error("[queue] cron re-queue failed:", e.message);
|
|
1727
|
+
await insert({ ...job, id: crypto2.randomUUID(), runAt: cronNext(job.schedule), createdAt: Date.now() });
|
|
1728
|
+
} catch {
|
|
2879
1729
|
}
|
|
2880
1730
|
}
|
|
2881
1731
|
}
|
|
2882
|
-
async function
|
|
2883
|
-
const currentEpoch = epoch;
|
|
1732
|
+
async function pollLoop() {
|
|
2884
1733
|
if (!running) return;
|
|
2885
1734
|
try {
|
|
2886
|
-
const now = Date.now();
|
|
2887
1735
|
while (running && inflight < MAX_CONCURRENT) {
|
|
2888
|
-
const
|
|
2889
|
-
if (
|
|
2890
|
-
const raw2 = result[0], score = parseInt(result[1], 10);
|
|
2891
|
-
if (score > now) {
|
|
2892
|
-
await redis2.zadd(jobKey, score, raw2);
|
|
2893
|
-
break;
|
|
2894
|
-
}
|
|
2895
|
-
let job;
|
|
2896
|
-
try {
|
|
2897
|
-
job = JSON.parse(raw2);
|
|
2898
|
-
} catch {
|
|
2899
|
-
continue;
|
|
2900
|
-
}
|
|
1736
|
+
const job = await poll();
|
|
1737
|
+
if (!job) break;
|
|
2901
1738
|
const handler = handlers.get(job.type);
|
|
2902
|
-
if (handler)
|
|
1739
|
+
if (handler) execute(job, handler);
|
|
2903
1740
|
}
|
|
2904
|
-
} catch
|
|
2905
|
-
console.error("[queue] poll error:", e.message);
|
|
1741
|
+
} catch {
|
|
2906
1742
|
}
|
|
2907
|
-
if (running
|
|
1743
|
+
if (running) pollTimer = setTimeout(pollLoop, pollInterval);
|
|
2908
1744
|
}
|
|
2909
|
-
const
|
|
1745
|
+
const stats = () => ({ running, inflight, processed: _processed, failed: _failed, handlers: handlers.size, maxConcurrent: MAX_CONCURRENT });
|
|
1746
|
+
const mw = ((_req, ctx, next) => {
|
|
1747
|
+
;
|
|
2910
1748
|
ctx.queue = q;
|
|
2911
|
-
return next(
|
|
1749
|
+
return next(_req, ctx);
|
|
2912
1750
|
});
|
|
2913
1751
|
const q = mw;
|
|
2914
1752
|
mw.add = function add(type, payload, opts2) {
|
|
2915
|
-
const id =
|
|
1753
|
+
const id = crypto2.randomUUID();
|
|
2916
1754
|
let runAt;
|
|
2917
1755
|
if (opts2?.schedule) {
|
|
2918
|
-
|
|
1756
|
+
try {
|
|
1757
|
+
runAt = matches(parsePattern(opts2.schedule), /* @__PURE__ */ new Date()) ? Date.now() : cronNext(opts2.schedule);
|
|
1758
|
+
} catch {
|
|
1759
|
+
runAt = cronNext(opts2.schedule);
|
|
1760
|
+
}
|
|
2919
1761
|
} else if (opts2?.delay) {
|
|
2920
1762
|
runAt = Date.now() + opts2.delay;
|
|
2921
1763
|
} else {
|
|
@@ -2931,11 +1773,10 @@ function createRedisQueue(opts) {
|
|
|
2931
1773
|
mw.run = async function run() {
|
|
2932
1774
|
if (running) return;
|
|
2933
1775
|
running = true;
|
|
2934
|
-
|
|
1776
|
+
pollLoop();
|
|
2935
1777
|
};
|
|
2936
1778
|
mw.close = async function close() {
|
|
2937
1779
|
running = false;
|
|
2938
|
-
epoch++;
|
|
2939
1780
|
if (pollTimer) {
|
|
2940
1781
|
clearTimeout(pollTimer);
|
|
2941
1782
|
pollTimer = null;
|
|
@@ -2943,9 +1784,9 @@ function createRedisQueue(opts) {
|
|
|
2943
1784
|
while (inflight > 0) await new Promise((r) => setTimeout(r, 50));
|
|
2944
1785
|
redis2.disconnect();
|
|
2945
1786
|
};
|
|
2946
|
-
mw.jobs = async
|
|
2947
|
-
const
|
|
2948
|
-
return
|
|
1787
|
+
mw.jobs = async (limit = 50) => {
|
|
1788
|
+
const raw = await redis2.zrevrange(jobKey, 0, limit - 1);
|
|
1789
|
+
return raw.map((r) => {
|
|
2949
1790
|
try {
|
|
2950
1791
|
return JSON.parse(r);
|
|
2951
1792
|
} catch {
|
|
@@ -2953,9 +1794,9 @@ function createRedisQueue(opts) {
|
|
|
2953
1794
|
}
|
|
2954
1795
|
}).filter(Boolean);
|
|
2955
1796
|
};
|
|
2956
|
-
mw.failedJobs = async
|
|
2957
|
-
const
|
|
2958
|
-
return
|
|
1797
|
+
mw.failedJobs = async (limit = 50) => {
|
|
1798
|
+
const raw = await redis2.lrange(failedKey, 0, limit - 1);
|
|
1799
|
+
return raw.map((r) => {
|
|
2959
1800
|
try {
|
|
2960
1801
|
return JSON.parse(r);
|
|
2961
1802
|
} catch {
|
|
@@ -2963,29 +1804,28 @@ function createRedisQueue(opts) {
|
|
|
2963
1804
|
}
|
|
2964
1805
|
}).filter(Boolean);
|
|
2965
1806
|
};
|
|
2966
|
-
mw.retryFailed = async
|
|
2967
|
-
const
|
|
2968
|
-
for (const entry of
|
|
1807
|
+
mw.retryFailed = async (jobId) => {
|
|
1808
|
+
const raw = await redis2.lrange(failedKey, 0, -1);
|
|
1809
|
+
for (const entry of raw) {
|
|
2969
1810
|
try {
|
|
2970
1811
|
const job = JSON.parse(entry);
|
|
2971
|
-
if (job.id
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
}
|
|
1812
|
+
if (job.id !== jobId) continue;
|
|
1813
|
+
await redis2.lrem(failedKey, 1, entry);
|
|
1814
|
+
const reJob = { ...job, runAt: Date.now() };
|
|
1815
|
+
delete reJob.error;
|
|
1816
|
+
delete reJob.failedAt;
|
|
1817
|
+
await redis2.zadd(jobKey, reJob.runAt, JSON.stringify(reJob));
|
|
1818
|
+
_failed--;
|
|
1819
|
+
return true;
|
|
2980
1820
|
} catch {
|
|
2981
1821
|
}
|
|
2982
1822
|
}
|
|
2983
1823
|
return false;
|
|
2984
1824
|
};
|
|
2985
|
-
mw.retryAllFailed = async
|
|
1825
|
+
mw.retryAllFailed = async (type) => {
|
|
2986
1826
|
let count = 0;
|
|
2987
|
-
const
|
|
2988
|
-
for (const entry of
|
|
1827
|
+
const raw = await redis2.lrange(failedKey, 0, -1);
|
|
1828
|
+
for (const entry of raw) {
|
|
2989
1829
|
try {
|
|
2990
1830
|
const job = JSON.parse(entry);
|
|
2991
1831
|
if (type && job.type !== type) continue;
|
|
@@ -3001,387 +1841,63 @@ function createRedisQueue(opts) {
|
|
|
3001
1841
|
}
|
|
3002
1842
|
return count;
|
|
3003
1843
|
};
|
|
3004
|
-
mw.
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
}
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
const s = q.stats();
|
|
3022
|
-
const pending = await q.jobs(100);
|
|
3023
|
-
const byType = {};
|
|
3024
|
-
for (const j of pending) {
|
|
3025
|
-
if (!byType[j.type]) byType[j.type] = { pending: 0, failed: 0 };
|
|
3026
|
-
byType[j.type].pending++;
|
|
3027
|
-
}
|
|
3028
|
-
const failed = await q.failedJobs(1e3);
|
|
3029
|
-
for (const j of failed) {
|
|
3030
|
-
if (!byType[j.type]) byType[j.type] = { pending: 0, failed: 0 };
|
|
3031
|
-
byType[j.type].failed++;
|
|
3032
|
-
}
|
|
3033
|
-
return Response.json({ stats: s, types: byType, failedCount: failed.length });
|
|
3034
|
-
});
|
|
3035
|
-
r.get("/:type/failed", async (req, ctx) => {
|
|
3036
|
-
const failed = await q.failedJobs(100);
|
|
3037
|
-
return Response.json({ jobs: failed.filter((j) => j.type === ctx.params.type) });
|
|
3038
|
-
});
|
|
3039
|
-
r.post("/:type/retry", async (req, ctx) => {
|
|
3040
|
-
return Response.json({ retried: await q.retryAllFailed(ctx.params.type) });
|
|
3041
|
-
});
|
|
3042
|
-
r.post("/retry/:id", async (req, ctx) => {
|
|
3043
|
-
const ok = await q.retryFailed(ctx.params.id);
|
|
3044
|
-
if (!ok) return new Response("Not found", { status: 404 });
|
|
3045
|
-
return Response.json({ ok: true });
|
|
3046
|
-
});
|
|
3047
|
-
return r;
|
|
3048
|
-
}
|
|
3049
|
-
|
|
3050
|
-
// src/middleware/health.ts
|
|
3051
|
-
function health(options) {
|
|
3052
|
-
const path = options?.path ?? "/__health";
|
|
3053
|
-
const r = new Router();
|
|
3054
|
-
const handler = async () => {
|
|
3055
|
-
try {
|
|
3056
|
-
await options?.check?.();
|
|
3057
|
-
return new Response("OK", { status: 200 });
|
|
3058
|
-
} catch {
|
|
3059
|
-
return new Response("Service Unavailable", { status: 503 });
|
|
3060
|
-
}
|
|
3061
|
-
};
|
|
3062
|
-
r.get(path, handler);
|
|
3063
|
-
r.head(path, handler);
|
|
3064
|
-
return r;
|
|
3065
|
-
}
|
|
3066
|
-
|
|
3067
|
-
// src/core/html.ts
|
|
3068
|
-
var ESCAPE = {
|
|
3069
|
-
"&": "&",
|
|
3070
|
-
"<": "<",
|
|
3071
|
-
">": ">",
|
|
3072
|
-
'"': """,
|
|
3073
|
-
"'": "'"
|
|
3074
|
-
};
|
|
3075
|
-
function esc(s) {
|
|
3076
|
-
return s.replace(/[&<>"']/g, (c) => ESCAPE[c] ?? c);
|
|
3077
|
-
}
|
|
3078
|
-
function html(strings, ...values) {
|
|
3079
|
-
let result = "";
|
|
3080
|
-
for (let i = 0; i < strings.length; i++) {
|
|
3081
|
-
result += strings[i];
|
|
3082
|
-
if (i < values.length) {
|
|
3083
|
-
const v = values[i];
|
|
3084
|
-
if (v == null || v === false) continue;
|
|
3085
|
-
if (Array.isArray(v)) {
|
|
3086
|
-
result += v.join("");
|
|
3087
|
-
} else if (typeof v === "object" && v !== null && "_raw" in v) {
|
|
3088
|
-
result += v._raw;
|
|
3089
|
-
} else {
|
|
3090
|
-
result += esc(String(v));
|
|
3091
|
-
}
|
|
3092
|
-
}
|
|
3093
|
-
}
|
|
3094
|
-
return result;
|
|
3095
|
-
}
|
|
3096
|
-
function raw(content) {
|
|
3097
|
-
return { _raw: content };
|
|
3098
|
-
}
|
|
3099
|
-
|
|
3100
|
-
// src/middleware/theme.ts
|
|
3101
|
-
function makeSetTheme(cookie, location) {
|
|
3102
|
-
return (value, loc) => {
|
|
3103
|
-
const finalLoc = loc ?? location;
|
|
3104
|
-
const c = `${cookie}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`;
|
|
3105
|
-
return new Response(null, { status: 302, headers: { Location: finalLoc, "Set-Cookie": c } });
|
|
3106
|
-
};
|
|
3107
|
-
}
|
|
3108
|
-
function theme(options) {
|
|
3109
|
-
const opts = { default: "system", cookie: "theme", ...options };
|
|
3110
|
-
const mw = async (req, ctx, next) => {
|
|
3111
|
-
let themeValue = opts.default;
|
|
3112
|
-
if (opts.cookie) {
|
|
3113
|
-
const fromCookie = getCookies(req)[opts.cookie];
|
|
3114
|
-
if (fromCookie) themeValue = fromCookie;
|
|
3115
|
-
}
|
|
3116
|
-
;
|
|
3117
|
-
ctx.theme = {
|
|
3118
|
-
value: themeValue,
|
|
3119
|
-
set: makeSetTheme(opts.cookie, req.headers.get("referer") || "/")
|
|
3120
|
-
};
|
|
3121
|
-
return next(req, ctx);
|
|
3122
|
-
};
|
|
3123
|
-
mw.__meta = { injects: ["theme"], depends: [] };
|
|
3124
|
-
class ThemeRouter extends Router {
|
|
3125
|
-
middleware() {
|
|
3126
|
-
return mw;
|
|
3127
|
-
}
|
|
3128
|
-
}
|
|
3129
|
-
const router = new ThemeRouter();
|
|
3130
|
-
router.get("/__theme/:value", (req) => {
|
|
3131
|
-
const url = new URL(req.url);
|
|
3132
|
-
const value = url.pathname.split("/__theme/")[1] ?? "";
|
|
3133
|
-
const cookie = `${opts.cookie}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`;
|
|
3134
|
-
const accept = req.headers.get("accept") ?? "";
|
|
3135
|
-
if (accept.includes("application/json")) {
|
|
3136
|
-
return Response.json({ ok: true, theme: value }, { headers: { "Set-Cookie": cookie } });
|
|
3137
|
-
}
|
|
3138
|
-
const referer = req.headers.get("referer") || "/";
|
|
3139
|
-
return new Response(null, { status: 302, headers: { Location: referer, "Set-Cookie": cookie } });
|
|
3140
|
-
});
|
|
3141
|
-
return router;
|
|
3142
|
-
}
|
|
3143
|
-
|
|
3144
|
-
// src/middleware/i18n.ts
|
|
3145
|
-
import { readFile, stat } from "node:fs/promises";
|
|
3146
|
-
import { join as join2, resolve as resolve3 } from "node:path";
|
|
3147
|
-
var DEFAULTS2 = {
|
|
3148
|
-
default: "en",
|
|
3149
|
-
cookie: "locale",
|
|
3150
|
-
fromAcceptLanguage: true
|
|
3151
|
-
};
|
|
3152
|
-
function translate(msgs, key, params, fallback) {
|
|
3153
|
-
const msg = key.split(".").reduce((o, k) => o?.[k], msgs);
|
|
3154
|
-
if (msg === void 0 || msg === null) return fallback ?? key;
|
|
3155
|
-
if (!params) return String(msg);
|
|
3156
|
-
let result = String(msg);
|
|
3157
|
-
for (const [k, v] of Object.entries(params)) {
|
|
3158
|
-
result = result.replace(`{${k}}`, v);
|
|
3159
|
-
}
|
|
3160
|
-
return result;
|
|
3161
|
-
}
|
|
3162
|
-
function i18n(options) {
|
|
3163
|
-
const opts = { ...DEFAULTS2, ...options };
|
|
3164
|
-
const dir = opts.dir ? resolve3(opts.dir) : void 0;
|
|
3165
|
-
const cache = /* @__PURE__ */ new Map();
|
|
3166
|
-
function validLocale(locale) {
|
|
3167
|
-
return /^[\w-]+$/.test(locale) && !locale.includes("..");
|
|
3168
|
-
}
|
|
3169
|
-
async function loadMessages(locale) {
|
|
3170
|
-
if (opts.messages?.[locale] && Object.keys(opts.messages[locale]).length > 0) {
|
|
3171
|
-
cache.set(locale, opts.messages[locale]);
|
|
3172
|
-
return opts.messages[locale];
|
|
3173
|
-
}
|
|
3174
|
-
if (!dir || !validLocale(locale)) return {};
|
|
3175
|
-
const cached = cache.get(locale);
|
|
3176
|
-
if (cached) return cached;
|
|
3177
|
-
const filePath = join2(dir, `${locale}.json`);
|
|
3178
|
-
try {
|
|
3179
|
-
await stat(filePath);
|
|
3180
|
-
const content = await readFile(filePath, "utf-8");
|
|
3181
|
-
const data = JSON.parse(content);
|
|
3182
|
-
cache.set(locale, data);
|
|
3183
|
-
return data;
|
|
3184
|
-
} catch {
|
|
3185
|
-
}
|
|
3186
|
-
const short = locale.split("-")[0];
|
|
3187
|
-
if (short !== locale) {
|
|
3188
|
-
const fallback = cache.get(short) || await loadMessages(short);
|
|
3189
|
-
if (fallback && Object.keys(fallback).length > 0) {
|
|
3190
|
-
cache.set(locale, fallback);
|
|
3191
|
-
return fallback;
|
|
3192
|
-
}
|
|
3193
|
-
}
|
|
3194
|
-
return {};
|
|
3195
|
-
}
|
|
3196
|
-
function detectLocale(req) {
|
|
3197
|
-
if (opts.cookie) {
|
|
3198
|
-
const fromCookie = getCookies(req)[opts.cookie];
|
|
3199
|
-
if (fromCookie && validLocale(fromCookie)) return fromCookie;
|
|
3200
|
-
}
|
|
3201
|
-
if (opts.fromAcceptLanguage) {
|
|
3202
|
-
const fromHeader = req.headers.get("Accept-Language")?.split(",")[0]?.trim();
|
|
3203
|
-
if (fromHeader && validLocale(fromHeader)) return fromHeader;
|
|
3204
|
-
}
|
|
3205
|
-
return opts.default;
|
|
3206
|
-
}
|
|
3207
|
-
const mw = async (req, ctx, next) => {
|
|
3208
|
-
const locale = detectLocale(req);
|
|
3209
|
-
const msgs = await loadMessages(locale);
|
|
3210
|
-
ctx.i18n = {
|
|
3211
|
-
locale,
|
|
3212
|
-
messages: msgs,
|
|
3213
|
-
t: (key, params, fallback) => translate(msgs, key, params, fallback),
|
|
3214
|
-
set: (value, loc) => {
|
|
3215
|
-
const cookie = `${opts.cookie}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`;
|
|
3216
|
-
const location = loc ?? (req.headers.get("referer") || "/");
|
|
3217
|
-
return new Response(null, {
|
|
3218
|
-
status: 302,
|
|
3219
|
-
headers: { Location: location, "Set-Cookie": cookie }
|
|
3220
|
-
});
|
|
3221
|
-
}
|
|
3222
|
-
};
|
|
3223
|
-
return next(req, ctx);
|
|
3224
|
-
};
|
|
3225
|
-
mw.__meta = { injects: ["i18n"], depends: [] };
|
|
3226
|
-
class I18nRouter extends Router {
|
|
3227
|
-
middleware() {
|
|
3228
|
-
return mw;
|
|
3229
|
-
}
|
|
3230
|
-
}
|
|
3231
|
-
const router = new I18nRouter();
|
|
3232
|
-
router.get("/__lang/:locale", async (req) => {
|
|
3233
|
-
const url = new URL(req.url);
|
|
3234
|
-
const value = url.pathname.split("/__lang/")[1] ?? "";
|
|
3235
|
-
const cookie = `${opts.cookie}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`;
|
|
3236
|
-
const messages = await loadMessages(value);
|
|
3237
|
-
const accept = req.headers.get("accept") ?? "";
|
|
3238
|
-
if (accept.includes("application/json")) {
|
|
3239
|
-
return Response.json(
|
|
3240
|
-
{
|
|
3241
|
-
ok: true,
|
|
3242
|
-
locale: value,
|
|
3243
|
-
messages: Object.keys(messages).length > 0 ? messages : void 0
|
|
3244
|
-
},
|
|
3245
|
-
{ headers: { "Set-Cookie": cookie } }
|
|
3246
|
-
);
|
|
3247
|
-
}
|
|
3248
|
-
const referer = req.headers.get("referer") || "/";
|
|
3249
|
-
return new Response(null, { status: 302, headers: { Location: referer, "Set-Cookie": cookie } });
|
|
3250
|
-
});
|
|
3251
|
-
return router;
|
|
3252
|
-
}
|
|
3253
|
-
|
|
3254
|
-
// src/middleware/flash.ts
|
|
3255
|
-
function makeSetFlash(name, location) {
|
|
3256
|
-
return (data, loc) => {
|
|
3257
|
-
const finalLoc = loc ?? location;
|
|
3258
|
-
const value = encodeURIComponent(JSON.stringify(data));
|
|
3259
|
-
return new Response(null, {
|
|
3260
|
-
status: 302,
|
|
3261
|
-
headers: {
|
|
3262
|
-
Location: finalLoc,
|
|
3263
|
-
"Set-Cookie": `${name}=${value}; Path=/; SameSite=Lax`
|
|
3264
|
-
}
|
|
1844
|
+
mw.stats = stats;
|
|
1845
|
+
mw.dashboard = () => {
|
|
1846
|
+
const r = new Router();
|
|
1847
|
+
r.get("/", async () => {
|
|
1848
|
+
const s = stats();
|
|
1849
|
+
const pending = await mw.jobs(100);
|
|
1850
|
+
const byType = {};
|
|
1851
|
+
for (const j of pending) {
|
|
1852
|
+
byType[j.type] = byType[j.type] || { pending: 0, failed: 0 };
|
|
1853
|
+
byType[j.type].pending++;
|
|
1854
|
+
}
|
|
1855
|
+
const failed = await mw.failedJobs(1e3);
|
|
1856
|
+
for (const j of failed) {
|
|
1857
|
+
byType[j.type] = byType[j.type] || { pending: 0, failed: 0 };
|
|
1858
|
+
byType[j.type].failed++;
|
|
1859
|
+
}
|
|
1860
|
+
return Response.json({ stats: s, types: byType, failedCount: failed.length });
|
|
3265
1861
|
});
|
|
1862
|
+
r.get("/:type/failed", async (req, ctx) => Response.json({ jobs: (await mw.failedJobs(100)).filter((j) => j.type === ctx.params.type) }));
|
|
1863
|
+
r.post("/:type/retry", async (req, ctx) => Response.json({ retried: await mw.retryAllFailed(ctx.params.type) }));
|
|
1864
|
+
r.post("/retry/:id", async (req, ctx) => {
|
|
1865
|
+
const ok = await mw.retryFailed(ctx.params.id);
|
|
1866
|
+
return ok ? Response.json({ ok: true }) : new Response("Not found", { status: 404 });
|
|
1867
|
+
});
|
|
1868
|
+
return r;
|
|
3266
1869
|
};
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
if (raw2) {
|
|
3275
|
-
try {
|
|
3276
|
-
value = JSON.parse(decodeURIComponent(raw2));
|
|
3277
|
-
} catch {
|
|
3278
|
-
value = raw2;
|
|
3279
|
-
}
|
|
3280
|
-
}
|
|
3281
|
-
ctx.flash = {
|
|
3282
|
-
value,
|
|
3283
|
-
set: makeSetFlash(name, referer)
|
|
3284
|
-
};
|
|
3285
|
-
const res = await next(req, ctx);
|
|
3286
|
-
if (raw2) {
|
|
3287
|
-
const headers = new Headers(res.headers);
|
|
3288
|
-
headers.append("Set-Cookie", `${name}=; Path=/; Max-Age=0`);
|
|
3289
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
3290
|
-
}
|
|
3291
|
-
return res;
|
|
3292
|
-
};
|
|
3293
|
-
mw.__meta = { injects: ["flash"], depends: [] };
|
|
3294
|
-
return mw;
|
|
3295
|
-
}
|
|
3296
|
-
|
|
3297
|
-
// src/middleware/csrf.ts
|
|
3298
|
-
function csrf(options) {
|
|
3299
|
-
const cookieName = options?.cookie ?? "_csrf";
|
|
3300
|
-
const headerName = options?.header ?? "x-csrf-token";
|
|
3301
|
-
const bodyKey = options?.key ?? "_csrf";
|
|
3302
|
-
const excluded = new Set(options?.excludeMethods ?? ["GET", "HEAD", "OPTIONS"]);
|
|
3303
|
-
const mw = async (req, ctx, next) => {
|
|
3304
|
-
const method = req.method.toUpperCase();
|
|
3305
|
-
if (excluded.has(method)) {
|
|
3306
|
-
const token = getCookies(req)[cookieName] || crypto.randomUUID();
|
|
3307
|
-
ctx.csrf = { token };
|
|
3308
|
-
const res = await next(req, ctx);
|
|
3309
|
-
const tokenToSet = ctx.csrf?.token;
|
|
3310
|
-
if (tokenToSet && !getCookies(req)[cookieName]) {
|
|
3311
|
-
return setCookie(res, cookieName, tokenToSet, {
|
|
3312
|
-
httpOnly: true,
|
|
3313
|
-
sameSite: "strict",
|
|
3314
|
-
path: "/"
|
|
3315
|
-
});
|
|
3316
|
-
}
|
|
3317
|
-
return res;
|
|
3318
|
-
}
|
|
3319
|
-
const cookieToken = getCookies(req)[cookieName];
|
|
3320
|
-
let headerToken = req.headers.get(headerName) ?? "";
|
|
3321
|
-
if (!headerToken && (req.method === "POST" || req.method === "PUT" || req.method === "PATCH" || req.method === "DELETE")) {
|
|
3322
|
-
try {
|
|
3323
|
-
const body = await req.clone().json();
|
|
3324
|
-
headerToken = body[bodyKey] ?? "";
|
|
3325
|
-
} catch {
|
|
3326
|
-
return new Response("Invalid request body", { status: 400 });
|
|
3327
|
-
}
|
|
3328
|
-
}
|
|
3329
|
-
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
|
3330
|
-
return new Response("CSRF token mismatch", { status: 403 });
|
|
3331
|
-
}
|
|
3332
|
-
return next(req, ctx);
|
|
1870
|
+
q.cron = function(pattern, handler) {
|
|
1871
|
+
const id = "__cron_" + pattern.replace(/[^a-zA-Z0-9]/g, "_") + "_" + crypto2.randomUUID().slice(0, 8);
|
|
1872
|
+
q.process(id, async () => {
|
|
1873
|
+
await handler();
|
|
1874
|
+
});
|
|
1875
|
+
q.add(id, {}, { schedule: pattern });
|
|
1876
|
+
return { stop: () => handlers.delete(id) };
|
|
3333
1877
|
};
|
|
3334
|
-
|
|
3335
|
-
return mw;
|
|
1878
|
+
return q;
|
|
3336
1879
|
}
|
|
3337
1880
|
export {
|
|
3338
1881
|
DEFAULT_MAX_BODY,
|
|
3339
1882
|
HttpError,
|
|
3340
1883
|
MIGRATIONS_TABLE,
|
|
3341
1884
|
Router,
|
|
3342
|
-
TestApp,
|
|
3343
|
-
TestRequest,
|
|
3344
1885
|
compress,
|
|
3345
1886
|
cors,
|
|
3346
1887
|
createHub,
|
|
3347
|
-
createSSEStream,
|
|
3348
|
-
createTestDb,
|
|
3349
|
-
createTestServer,
|
|
3350
|
-
csrf,
|
|
3351
1888
|
currentTrace,
|
|
3352
1889
|
currentTraceId,
|
|
3353
|
-
deleteCookie,
|
|
3354
|
-
env,
|
|
3355
|
-
flash,
|
|
3356
|
-
formatSSE,
|
|
3357
|
-
formatSSEData,
|
|
3358
|
-
getCookies,
|
|
3359
|
-
getPublicEnv,
|
|
3360
1890
|
graphql,
|
|
3361
|
-
health,
|
|
3362
1891
|
helmet,
|
|
3363
|
-
html,
|
|
3364
|
-
i18n,
|
|
3365
|
-
isBundled,
|
|
3366
|
-
isDev,
|
|
3367
|
-
isProd,
|
|
3368
|
-
loadEnv,
|
|
3369
1892
|
logger,
|
|
3370
1893
|
postgres,
|
|
3371
1894
|
queue,
|
|
3372
1895
|
rateLimit,
|
|
3373
|
-
raw,
|
|
3374
1896
|
redis,
|
|
3375
|
-
requestId,
|
|
3376
1897
|
runWithTrace,
|
|
3377
1898
|
serve,
|
|
3378
1899
|
serveStatic,
|
|
3379
|
-
setCookie,
|
|
3380
|
-
testApp,
|
|
3381
|
-
theme,
|
|
3382
1900
|
trace,
|
|
3383
1901
|
traceElapsed,
|
|
3384
|
-
upload
|
|
3385
|
-
validate,
|
|
3386
|
-
withTestDb
|
|
1902
|
+
upload
|
|
3387
1903
|
};
|