@spfn/core 0.2.0-beta.9 → 0.3.0-beta.10
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/LICENSE +1 -1
- package/README.md +693 -279
- package/dist/app-config/index.d.ts +96 -0
- package/dist/app-config/index.js +58 -0
- package/dist/app-config/index.js.map +1 -0
- package/dist/authz/index.d.ts +34 -0
- package/dist/authz/index.js +430 -0
- package/dist/authz/index.js.map +1 -0
- package/dist/boss-BiDCbbAQ.d.ts +337 -0
- package/dist/cache/index.js +42 -30
- package/dist/cache/index.js.map +1 -1
- package/dist/codegen/index.d.ts +121 -13
- package/dist/codegen/index.js +212 -15
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +576 -30
- package/dist/config/index.js +136 -17
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +221 -0
- package/dist/contract/index.js +558 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/db/index.d.ts +704 -94
- package/dist/db/index.js +1105 -260
- package/dist/db/index.js.map +1 -1
- package/dist/define-middleware-CVKgqo8S.d.ts +167 -0
- package/dist/env/index.d.ts +26 -2
- package/dist/env/index.js +15 -5
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +26 -19
- package/dist/env/loader.js +55 -25
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.d.ts +10 -0
- package/dist/errors/index.js +40 -7
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +33 -3
- package/dist/event/index.js +24 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +55 -3
- package/dist/event/sse/client.js +164 -45
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +12 -5
- package/dist/event/sse/index.js +280 -32
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +59 -0
- package/dist/event/ws/client.js +273 -0
- package/dist/event/ws/client.js.map +1 -0
- package/dist/event/ws/index.d.ts +94 -0
- package/dist/event/ws/index.js +272 -0
- package/dist/event/ws/index.js.map +1 -0
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +265 -74
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.d.ts +5 -0
- package/dist/logger/index.js +14 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +352 -10
- package/dist/middleware/index.js +1079 -13
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +39 -3
- package/dist/nextjs/index.js +82 -29
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +69 -52
- package/dist/nextjs/server.js +140 -60
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +208 -0
- package/dist/ops/index.js +800 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/route/index.d.ts +10 -694
- package/dist/route/index.js +112 -23
- package/dist/route/index.js.map +1 -1
- package/dist/route-builder-2ani2jEI.d.ts +553 -0
- package/dist/router-DJdpwuB6.d.ts +127 -0
- package/dist/security/index.d.ts +83 -0
- package/dist/security/index.js +173 -0
- package/dist/security/index.js.map +1 -0
- package/dist/server/index.d.ts +619 -31
- package/dist/server/index.js +2583 -360
- package/dist/server/index.js.map +1 -1
- package/dist/token-manager-BT5EnUAR.d.ts +278 -0
- package/dist/types-2AbaW4Ie.d.ts +205 -0
- package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
- package/dist/types-ClQVomgV.d.ts +115 -0
- package/dist/types-ZQODsBft.d.ts +282 -0
- package/docs/file-upload.md +195 -333
- package/package.json +250 -208
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +741 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- package/dist/boss-DI1r4kTS.d.ts +0 -244
- package/dist/router-Di7ENoah.d.ts +0 -151
- package/dist/types-B-e_f2dQ.d.ts +0 -121
- package/docs/cache.md +0 -133
- package/docs/codegen.md +0 -74
- package/docs/database.md +0 -346
- package/docs/entity.md +0 -539
- package/docs/env.md +0 -477
- package/docs/errors.md +0 -319
- package/docs/event.md +0 -116
- package/docs/job.md +0 -131
- package/docs/logger.md +0 -108
- package/docs/middleware.md +0 -337
- package/docs/nextjs.md +0 -241
- package/docs/repository.md +0 -496
- package/docs/route.md +0 -497
- package/docs/server.md +0 -307
package/dist/server/index.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { env } from '@spfn/core/config';
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
3
|
import { resolve, join } from 'path';
|
|
4
|
+
import { parse } from 'dotenv';
|
|
5
|
+
import { logger } from '@spfn/core/logger';
|
|
5
6
|
import { Hono } from 'hono';
|
|
6
7
|
import { cors } from 'hono/cors';
|
|
7
|
-
import { registerRoutes } from '@spfn/core/route';
|
|
8
|
-
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
|
|
8
|
+
import { registerRoutes, defineMiddleware } from '@spfn/core/route';
|
|
9
|
+
import { ErrorHandler, RequestLogger, setRateLimitPolicies, setRateLimitFailClosedDefault, rateLimit } from '@spfn/core/middleware';
|
|
10
|
+
import { setDefaultSafeFetchPolicy } from '@spfn/core/security';
|
|
9
11
|
import { streamSSE } from 'hono/streaming';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
+
import { randomBytes, createHash } from 'crypto';
|
|
13
|
+
import { Agent, setGlobalDispatcher } from 'undici';
|
|
14
|
+
import { initDatabase, getDatabase, hasMigrationTargets, collectMigrationStatus, countPendingMigrations, pendingMigrationTargets, formatPendingMigrations, pendingMigrationsSummary, RUN_MIGRATIONS_HINT, closeDatabase, migrationTargets } from '@spfn/core/db';
|
|
12
15
|
import { initCache, getCache, closeCache } from '@spfn/core/cache';
|
|
16
|
+
import { FormatRegistry, Type } from '@sinclair/typebox';
|
|
13
17
|
import { serve } from '@hono/node-server';
|
|
14
18
|
import PgBoss from 'pg-boss';
|
|
15
19
|
import { networkInterfaces } from 'os';
|
|
20
|
+
import { pathToFileURL } from 'url';
|
|
16
21
|
|
|
17
22
|
var __defProp = Object.defineProperty;
|
|
18
23
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -94,6 +99,11 @@ function formatError(error) {
|
|
|
94
99
|
const stackLines = error.stack.split("\n").slice(1);
|
|
95
100
|
lines.push(...stackLines);
|
|
96
101
|
}
|
|
102
|
+
if (error.cause instanceof Error) {
|
|
103
|
+
lines.push(`Caused by: ${formatError(error.cause)}`);
|
|
104
|
+
} else if (error.cause !== void 0) {
|
|
105
|
+
lines.push(`Caused by: ${String(error.cause)}`);
|
|
106
|
+
}
|
|
97
107
|
return lines.join("\n");
|
|
98
108
|
}
|
|
99
109
|
function formatContext(context) {
|
|
@@ -163,6 +173,19 @@ function formatConsole(metadata, colorize = true) {
|
|
|
163
173
|
}
|
|
164
174
|
return output;
|
|
165
175
|
}
|
|
176
|
+
function formatErrorCauseChain(error) {
|
|
177
|
+
const result = {
|
|
178
|
+
name: error.name,
|
|
179
|
+
message: error.message,
|
|
180
|
+
stack: error.stack
|
|
181
|
+
};
|
|
182
|
+
if (error.cause instanceof Error) {
|
|
183
|
+
result.cause = formatErrorCauseChain(error.cause);
|
|
184
|
+
} else if (error.cause !== void 0) {
|
|
185
|
+
result.cause = String(error.cause);
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
166
189
|
function formatJSON(metadata) {
|
|
167
190
|
const obj = {
|
|
168
191
|
timestamp: formatTimestamp(metadata.timestamp),
|
|
@@ -176,11 +199,7 @@ function formatJSON(metadata) {
|
|
|
176
199
|
obj.context = metadata.context;
|
|
177
200
|
}
|
|
178
201
|
if (metadata.error) {
|
|
179
|
-
obj.error =
|
|
180
|
-
name: metadata.error.name,
|
|
181
|
-
message: metadata.error.message,
|
|
182
|
-
stack: metadata.error.stack
|
|
183
|
-
};
|
|
202
|
+
obj.error = formatErrorCauseChain(metadata.error);
|
|
184
203
|
}
|
|
185
204
|
return JSON.stringify(obj);
|
|
186
205
|
}
|
|
@@ -314,36 +333,186 @@ var init_formatters = __esm({
|
|
|
314
333
|
};
|
|
315
334
|
}
|
|
316
335
|
});
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
336
|
+
var envLogger = logger.child("@spfn/core:env-loader");
|
|
337
|
+
function getEnvFiles(nodeEnv, server) {
|
|
338
|
+
const files = [
|
|
339
|
+
".env",
|
|
340
|
+
`.env.${nodeEnv}`
|
|
341
|
+
];
|
|
342
|
+
if (nodeEnv !== "test") {
|
|
343
|
+
files.push(".env.local");
|
|
344
|
+
}
|
|
345
|
+
files.push(`.env.${nodeEnv}.local`);
|
|
346
|
+
if (server) {
|
|
347
|
+
files.push(".env.server");
|
|
348
|
+
}
|
|
349
|
+
return files;
|
|
350
|
+
}
|
|
351
|
+
function parseEnvFile(filePath) {
|
|
352
|
+
if (!existsSync(filePath)) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
return parse(readFileSync(filePath, "utf-8"));
|
|
356
|
+
}
|
|
357
|
+
function loadEnv(options = {}) {
|
|
358
|
+
const nodeEnvAtEntry = process.env.NODE_ENV;
|
|
359
|
+
const {
|
|
360
|
+
cwd = process.cwd(),
|
|
361
|
+
nodeEnv = nodeEnvAtEntry || "local",
|
|
362
|
+
server = true,
|
|
363
|
+
debug = false,
|
|
364
|
+
override = false
|
|
365
|
+
} = options;
|
|
366
|
+
const envFiles = getEnvFiles(nodeEnv, server);
|
|
367
|
+
const loadedFiles = [];
|
|
368
|
+
const existingKeys = new Set(Object.keys(process.env));
|
|
369
|
+
const merged = {};
|
|
370
|
+
for (const fileName of envFiles) {
|
|
371
|
+
const filePath = resolve(cwd, fileName);
|
|
372
|
+
const parsed = parseEnvFile(filePath);
|
|
373
|
+
if (parsed === null) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
loadedFiles.push(fileName);
|
|
377
|
+
Object.assign(merged, parsed);
|
|
378
|
+
}
|
|
379
|
+
const loadedKeys = [];
|
|
380
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
381
|
+
if (!override && existingKeys.has(key)) {
|
|
382
|
+
continue;
|
|
332
383
|
}
|
|
384
|
+
process.env[key] = value;
|
|
385
|
+
loadedKeys.push(key);
|
|
386
|
+
}
|
|
387
|
+
if (debug && loadedFiles.length > 0) {
|
|
388
|
+
envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
|
|
389
|
+
envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
|
|
390
|
+
}
|
|
391
|
+
warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, options.nodeEnv, nodeEnv);
|
|
392
|
+
return { loadedFiles, loadedKeys };
|
|
393
|
+
}
|
|
394
|
+
function warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, explicitNodeEnv, resolvedNodeEnv) {
|
|
395
|
+
if (nodeEnvAtEntry || explicitNodeEnv) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const declared = process.env.NODE_ENV;
|
|
399
|
+
if (!declared) {
|
|
400
|
+
envLogger.warn(
|
|
401
|
+
`NODE_ENV is not set anywhere; env files were resolved for "${resolvedNodeEnv}". Set NODE_ENV in the process environment to choose the file set.`
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
333
404
|
}
|
|
405
|
+
if (declared === resolvedNodeEnv) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (!existsSync(resolve(cwd, `.env.${declared}`))) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
envLogger.warn(
|
|
412
|
+
`.env.${declared} was NOT loaded: NODE_ENV was not set when the process started, so env files were resolved for "${resolvedNodeEnv}" before a file declared NODE_ENV=${declared}. Set NODE_ENV in the process environment, not in a .env file.`
|
|
413
|
+
);
|
|
334
414
|
}
|
|
415
|
+
|
|
416
|
+
// src/server/dotenv-loader.ts
|
|
417
|
+
var warned = false;
|
|
418
|
+
function loadEnvFiles() {
|
|
419
|
+
if (!warned) {
|
|
420
|
+
warned = true;
|
|
421
|
+
console.warn(
|
|
422
|
+
'[SPFN] loadEnvFiles() is deprecated. Use loadEnv() from "@spfn/core/env/loader" instead.'
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
loadEnv();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/event/sse/bounded-writer.ts
|
|
429
|
+
function createBoundedWriter(stream, maxQueue, onClose) {
|
|
430
|
+
const pending = [];
|
|
431
|
+
let flushing = false;
|
|
432
|
+
let closed = false;
|
|
433
|
+
const flush = async () => {
|
|
434
|
+
if (flushing || closed) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
flushing = true;
|
|
438
|
+
while (pending.length > 0 && !closed) {
|
|
439
|
+
const frame = pending.shift();
|
|
440
|
+
try {
|
|
441
|
+
await stream.writeSSE(frame);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
flushing = false;
|
|
444
|
+
onClose(err instanceof Error ? err.message : String(err));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
flushing = false;
|
|
449
|
+
};
|
|
450
|
+
return {
|
|
451
|
+
enqueue(frame) {
|
|
452
|
+
if (closed) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
pending.push(frame);
|
|
456
|
+
if (pending.length > maxQueue) {
|
|
457
|
+
onClose("outbound queue overflow (slow consumer)");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
void flush();
|
|
461
|
+
},
|
|
462
|
+
close() {
|
|
463
|
+
closed = true;
|
|
464
|
+
pending.length = 0;
|
|
465
|
+
},
|
|
466
|
+
get queued() {
|
|
467
|
+
return pending.length;
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/event/sse/handler.ts
|
|
335
473
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
336
|
-
|
|
474
|
+
var frameCache = /* @__PURE__ */ new WeakMap();
|
|
475
|
+
function serializeFrame(eventName, payload) {
|
|
476
|
+
if (payload === null || typeof payload !== "object") {
|
|
477
|
+
return JSON.stringify({ event: eventName, data: payload });
|
|
478
|
+
}
|
|
479
|
+
let byEvent = frameCache.get(payload);
|
|
480
|
+
if (!byEvent) {
|
|
481
|
+
byEvent = /* @__PURE__ */ new Map();
|
|
482
|
+
frameCache.set(payload, byEvent);
|
|
483
|
+
}
|
|
484
|
+
let serialized = byEvent.get(eventName);
|
|
485
|
+
if (serialized === void 0) {
|
|
486
|
+
serialized = JSON.stringify({ event: eventName, data: payload });
|
|
487
|
+
byEvent.set(eventName, serialized);
|
|
488
|
+
}
|
|
489
|
+
return serialized;
|
|
490
|
+
}
|
|
491
|
+
var DEFAULT_MAX_QUEUE = 1e3;
|
|
492
|
+
function createSSEHandler(router, config = {}, tokenManager) {
|
|
337
493
|
const {
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
|
|
494
|
+
// 10s — keep-alive가 중간 프록시(GCP LB/nginx)의 idle 타임아웃(흔히 ~15-30s)보다 자주 와야
|
|
495
|
+
// 첫 ping 전 침묵 구간에 연결이 끊기지 않는다. 30s 기본은 idle 타임아웃보다 길어 SSE가
|
|
496
|
+
// 데이터 없는 동안 끊기던 문제가 있었다.
|
|
497
|
+
pingInterval = 1e4,
|
|
498
|
+
auth: authConfig,
|
|
499
|
+
maxQueue = DEFAULT_MAX_QUEUE
|
|
500
|
+
} = config;
|
|
341
501
|
return async (c) => {
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
502
|
+
const subject = await authenticateToken(c, tokenManager);
|
|
503
|
+
if (subject === false) {
|
|
504
|
+
return c.json({ error: "Missing token parameter" }, 401);
|
|
505
|
+
}
|
|
506
|
+
if (subject === null) {
|
|
507
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
508
|
+
}
|
|
509
|
+
if (subject) {
|
|
510
|
+
c.set("sseSubject", subject);
|
|
511
|
+
}
|
|
512
|
+
const requestedEvents = parseRequestedEvents(c);
|
|
513
|
+
if (!requestedEvents) {
|
|
344
514
|
return c.json({ error: "Missing events parameter" }, 400);
|
|
345
515
|
}
|
|
346
|
-
const requestedEvents = eventsParam.split(",").map((e) => e.trim());
|
|
347
516
|
const validEventNames = router.eventNames;
|
|
348
517
|
const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
|
|
349
518
|
if (invalidEvents.length > 0) {
|
|
@@ -353,62 +522,94 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
353
522
|
validEvents: validEventNames
|
|
354
523
|
}, 400);
|
|
355
524
|
}
|
|
525
|
+
const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
|
|
526
|
+
if (allowedEvents === null) {
|
|
527
|
+
return c.json({ error: "Not authorized for any requested events" }, 403);
|
|
528
|
+
}
|
|
356
529
|
sseLogger.debug("SSE connection requested", {
|
|
357
|
-
events:
|
|
530
|
+
events: allowedEvents,
|
|
531
|
+
subject: subject || void 0,
|
|
358
532
|
clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
359
533
|
});
|
|
534
|
+
c.header("X-Accel-Buffering", "no");
|
|
360
535
|
return streamSSE(c, async (stream) => {
|
|
361
536
|
const unsubscribes = [];
|
|
362
537
|
let messageId = 0;
|
|
363
|
-
|
|
538
|
+
let connectionDead = false;
|
|
539
|
+
let pingTimer;
|
|
540
|
+
let writer;
|
|
541
|
+
let onClosed;
|
|
542
|
+
const cleanup = (reason) => {
|
|
543
|
+
if (connectionDead) return;
|
|
544
|
+
connectionDead = true;
|
|
545
|
+
clearInterval(pingTimer);
|
|
546
|
+
unsubscribes.forEach((fn) => fn());
|
|
547
|
+
writer?.close();
|
|
548
|
+
onClosed?.();
|
|
549
|
+
sseLogger.info("SSE dead connection cleaned up", {
|
|
550
|
+
events: allowedEvents,
|
|
551
|
+
reason
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
writer = createBoundedWriter(stream, maxQueue, (reason) => {
|
|
555
|
+
sseLogger.warn("SSE connection closed", { events: allowedEvents, reason });
|
|
556
|
+
cleanup(reason);
|
|
557
|
+
});
|
|
558
|
+
writer.enqueue({
|
|
559
|
+
event: "connected",
|
|
560
|
+
data: JSON.stringify({
|
|
561
|
+
subscribedEvents: allowedEvents,
|
|
562
|
+
timestamp: Date.now()
|
|
563
|
+
})
|
|
564
|
+
});
|
|
565
|
+
for (const eventName of allowedEvents) {
|
|
364
566
|
const eventDef = router.events[eventName];
|
|
365
567
|
if (!eventDef) {
|
|
366
568
|
continue;
|
|
367
569
|
}
|
|
368
570
|
const unsubscribe = eventDef.subscribe((payload) => {
|
|
571
|
+
if (connectionDead) return;
|
|
572
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
573
|
+
if (!authConfig.filter[eventName](subject, payload)) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
369
577
|
messageId++;
|
|
370
|
-
const message = {
|
|
371
|
-
event: eventName,
|
|
372
|
-
data: payload
|
|
373
|
-
};
|
|
374
578
|
sseLogger.debug("SSE sending event", {
|
|
375
579
|
event: eventName,
|
|
376
580
|
messageId
|
|
377
581
|
});
|
|
378
|
-
|
|
582
|
+
writer.enqueue({
|
|
379
583
|
id: String(messageId),
|
|
380
584
|
event: eventName,
|
|
381
|
-
data:
|
|
585
|
+
data: serializeFrame(eventName, payload)
|
|
382
586
|
});
|
|
383
587
|
});
|
|
384
588
|
unsubscribes.push(unsubscribe);
|
|
385
589
|
}
|
|
386
590
|
sseLogger.info("SSE connection established", {
|
|
387
|
-
events:
|
|
591
|
+
events: allowedEvents,
|
|
388
592
|
subscriptionCount: unsubscribes.length
|
|
389
593
|
});
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
subscribedEvents: requestedEvents,
|
|
394
|
-
timestamp: Date.now()
|
|
395
|
-
})
|
|
396
|
-
});
|
|
397
|
-
const pingTimer = setInterval(() => {
|
|
398
|
-
void stream.writeSSE({
|
|
594
|
+
pingTimer = setInterval(() => {
|
|
595
|
+
if (connectionDead) return;
|
|
596
|
+
writer.enqueue({
|
|
399
597
|
event: "ping",
|
|
400
598
|
data: JSON.stringify({ timestamp: Date.now() })
|
|
401
599
|
});
|
|
402
600
|
}, pingInterval);
|
|
403
601
|
const abortSignal = c.req.raw.signal;
|
|
404
|
-
|
|
405
|
-
await
|
|
602
|
+
if (!abortSignal.aborted && !connectionDead) {
|
|
603
|
+
await new Promise((resolve2) => {
|
|
604
|
+
const onAbort = () => resolve2();
|
|
605
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
606
|
+
onClosed = () => {
|
|
607
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
608
|
+
resolve2();
|
|
609
|
+
};
|
|
610
|
+
});
|
|
406
611
|
}
|
|
407
|
-
|
|
408
|
-
unsubscribes.forEach((fn) => fn());
|
|
409
|
-
sseLogger.info("SSE connection closed", {
|
|
410
|
-
events: requestedEvents
|
|
411
|
-
});
|
|
612
|
+
cleanup();
|
|
412
613
|
}, async (err) => {
|
|
413
614
|
sseLogger.error("SSE stream error", {
|
|
414
615
|
error: err.message
|
|
@@ -416,30 +617,765 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
416
617
|
});
|
|
417
618
|
};
|
|
418
619
|
}
|
|
419
|
-
function
|
|
620
|
+
async function authenticateToken(c, tokenManager) {
|
|
621
|
+
if (!tokenManager) {
|
|
622
|
+
return void 0;
|
|
623
|
+
}
|
|
624
|
+
const token = c.req.query("token");
|
|
625
|
+
if (!token) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
return await tokenManager.verify(token);
|
|
629
|
+
}
|
|
630
|
+
function parseRequestedEvents(c) {
|
|
631
|
+
const eventsParam = c.req.query("events");
|
|
632
|
+
if (!eventsParam) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
return eventsParam.split(",").map((e) => e.trim());
|
|
636
|
+
}
|
|
637
|
+
async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
638
|
+
if (!subject || !authConfig?.authorize) {
|
|
639
|
+
return requestedEvents;
|
|
640
|
+
}
|
|
641
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
642
|
+
if (allowed.length === 0) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
return allowed;
|
|
646
|
+
}
|
|
647
|
+
function hashToken(token) {
|
|
648
|
+
return createHash("sha256").update(token).digest("hex");
|
|
649
|
+
}
|
|
650
|
+
var InMemoryTokenStore = class {
|
|
651
|
+
tokens = /* @__PURE__ */ new Map();
|
|
652
|
+
async set(token, data) {
|
|
653
|
+
this.tokens.set(hashToken(token), data);
|
|
654
|
+
}
|
|
655
|
+
async consume(token) {
|
|
656
|
+
const key = hashToken(token);
|
|
657
|
+
const data = this.tokens.get(key);
|
|
658
|
+
if (!data) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
this.tokens.delete(key);
|
|
662
|
+
return data;
|
|
663
|
+
}
|
|
664
|
+
async cleanup() {
|
|
665
|
+
const now = Date.now();
|
|
666
|
+
for (const [key, data] of this.tokens) {
|
|
667
|
+
if (data.expiresAt <= now) {
|
|
668
|
+
this.tokens.delete(key);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
var CacheTokenStore = class {
|
|
674
|
+
constructor(cache2) {
|
|
675
|
+
this.cache = cache2;
|
|
676
|
+
}
|
|
677
|
+
prefix = "sse:token:";
|
|
678
|
+
async set(token, data) {
|
|
679
|
+
const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
|
|
680
|
+
await this.cache.set(
|
|
681
|
+
this.prefix + hashToken(token),
|
|
682
|
+
JSON.stringify(data),
|
|
683
|
+
"EX",
|
|
684
|
+
ttlSeconds
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
async consume(token) {
|
|
688
|
+
const key = this.prefix + hashToken(token);
|
|
689
|
+
let raw = null;
|
|
690
|
+
if (this.cache.getdel) {
|
|
691
|
+
raw = await this.cache.getdel(key);
|
|
692
|
+
} else {
|
|
693
|
+
raw = await this.cache.get(key);
|
|
694
|
+
if (raw) {
|
|
695
|
+
await this.cache.del(key);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (!raw) {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
return JSON.parse(raw);
|
|
702
|
+
}
|
|
703
|
+
async cleanup() {
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
var SSETokenManager = class {
|
|
707
|
+
store;
|
|
708
|
+
ttl;
|
|
709
|
+
cleanupTimer = null;
|
|
710
|
+
constructor(config) {
|
|
711
|
+
this.ttl = config?.ttl ?? 3e4;
|
|
712
|
+
this.store = config?.store ?? new InMemoryTokenStore();
|
|
713
|
+
const cleanupInterval = config?.cleanupInterval ?? 6e4;
|
|
714
|
+
this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
|
|
715
|
+
this.cleanupTimer.unref();
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* How long an issued token stays valid, in milliseconds.
|
|
719
|
+
*
|
|
720
|
+
* Exposed so a caller that has to report an expiry to its client computes the same
|
|
721
|
+
* number this manager stamps on the token, instead of assuming the default.
|
|
722
|
+
*/
|
|
723
|
+
get ttlMs() {
|
|
724
|
+
return this.ttl;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Issue a new one-time-use token for the given subject
|
|
728
|
+
*/
|
|
729
|
+
async issue(subject) {
|
|
730
|
+
const token = randomBytes(32).toString("hex");
|
|
731
|
+
await this.store.set(token, {
|
|
732
|
+
subject,
|
|
733
|
+
expiresAt: Date.now() + this.ttl
|
|
734
|
+
});
|
|
735
|
+
return token;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Verify and consume a token
|
|
739
|
+
* @returns subject string if valid, null if invalid/expired/already consumed
|
|
740
|
+
*/
|
|
741
|
+
async verify(token) {
|
|
742
|
+
const data = await this.store.consume(token);
|
|
743
|
+
if (!data || data.expiresAt <= Date.now()) {
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
return data.subject;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Cleanup timer and resources
|
|
750
|
+
*/
|
|
751
|
+
destroy() {
|
|
752
|
+
if (this.cleanupTimer) {
|
|
753
|
+
clearInterval(this.cleanupTimer);
|
|
754
|
+
this.cleanupTimer = null;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
var transportLogger = logger.child("@spfn/core:event-transport");
|
|
759
|
+
var DEFAULT_CHANNEL_PREFIX = "spfn:sse:";
|
|
760
|
+
var SUBSCRIBER_QUIT_TIMEOUT_MS = 5e3;
|
|
761
|
+
var PUBLISH_TIMEOUT_MS = 5e3;
|
|
762
|
+
var SUBSCRIBE_TIMEOUT_MS = 5e3;
|
|
763
|
+
var WARN_THROTTLE_MS = 3e4;
|
|
764
|
+
var PUBLISH_BREAKER_COOLDOWN_MS = 1e4;
|
|
765
|
+
function withTimeout(promise, ms, label) {
|
|
766
|
+
return new Promise((resolve2, reject) => {
|
|
767
|
+
const timer = setTimeout(() => {
|
|
768
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
769
|
+
}, ms);
|
|
770
|
+
timer.unref?.();
|
|
771
|
+
promise.then(
|
|
772
|
+
(value) => {
|
|
773
|
+
clearTimeout(timer);
|
|
774
|
+
resolve2(value);
|
|
775
|
+
},
|
|
776
|
+
(err) => {
|
|
777
|
+
clearTimeout(timer);
|
|
778
|
+
reject(err);
|
|
779
|
+
}
|
|
780
|
+
);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
var warnThrottle = /* @__PURE__ */ new Map();
|
|
784
|
+
function throttledWarn(key, message, fields) {
|
|
785
|
+
const now = Date.now();
|
|
786
|
+
const entry = warnThrottle.get(key);
|
|
787
|
+
if (entry && now - entry.last < WARN_THROTTLE_MS) {
|
|
788
|
+
entry.suppressed++;
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
transportLogger.warn(message, entry && entry.suppressed > 0 ? { ...fields, suppressedSinceLast: entry.suppressed } : fields);
|
|
792
|
+
warnThrottle.set(key, { last: now, suppressed: 0 });
|
|
793
|
+
}
|
|
794
|
+
var STATE_KEY = Symbol.for("@spfn/core:event-transport");
|
|
795
|
+
var state = globalThis[STATE_KEY] ??= {
|
|
796
|
+
pubSubCache: void 0,
|
|
797
|
+
subscriber: void 0,
|
|
798
|
+
channelPrefix: void 0,
|
|
799
|
+
wired: /* @__PURE__ */ new Map()
|
|
800
|
+
};
|
|
801
|
+
function resolveChannelPrefix(override) {
|
|
802
|
+
return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;
|
|
803
|
+
}
|
|
804
|
+
function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
|
|
805
|
+
let subscriber;
|
|
806
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
807
|
+
let degradedUntil = 0;
|
|
808
|
+
const ensureSubscriber = () => {
|
|
809
|
+
if (subscriber) {
|
|
810
|
+
return subscriber;
|
|
811
|
+
}
|
|
812
|
+
subscriber = client.duplicate();
|
|
813
|
+
onSubscriber?.(subscriber);
|
|
814
|
+
subscriber.on("message", (channel, raw) => {
|
|
815
|
+
const handler = handlers.get(channel);
|
|
816
|
+
if (!handler) {
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
let payload;
|
|
820
|
+
try {
|
|
821
|
+
payload = JSON.parse(raw);
|
|
822
|
+
} catch {
|
|
823
|
+
transportLogger.warn("Pub/sub message parse failed \u2014 dropped", { channel });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
void handler(payload);
|
|
827
|
+
});
|
|
828
|
+
subscriber.on("error", (err) => {
|
|
829
|
+
throttledWarn("subscriber-error", "Pub/sub subscriber error", { error: err.message });
|
|
830
|
+
});
|
|
831
|
+
return subscriber;
|
|
832
|
+
};
|
|
833
|
+
const subscriberHealthy = () => !!subscriber && (subscriber.status === void 0 || subscriber.status === "ready");
|
|
834
|
+
const deliverLocal = (channel, message) => {
|
|
835
|
+
const handler = handlers.get(channel);
|
|
836
|
+
if (handler) {
|
|
837
|
+
void handler(message);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
return {
|
|
841
|
+
publish: async (name, message) => {
|
|
842
|
+
const channel = channelPrefix + name;
|
|
843
|
+
let serialized;
|
|
844
|
+
try {
|
|
845
|
+
serialized = JSON.stringify(message ?? null);
|
|
846
|
+
} catch (err) {
|
|
847
|
+
transportLogger.warn("Pub/sub payload serialization failed \u2014 local delivery only", {
|
|
848
|
+
channel,
|
|
849
|
+
error: err instanceof Error ? err.message : String(err)
|
|
850
|
+
});
|
|
851
|
+
deliverLocal(channel, message ?? null);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (serialized === void 0) {
|
|
855
|
+
transportLogger.warn("Pub/sub payload not serializable (function/symbol) \u2014 local delivery only", {
|
|
856
|
+
channel
|
|
857
|
+
});
|
|
858
|
+
deliverLocal(channel, message ?? null);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const ready = client.status === void 0 || client.status === "ready";
|
|
862
|
+
const now = Date.now();
|
|
863
|
+
if (!ready || degradedUntil > 0 && now < degradedUntil) {
|
|
864
|
+
deliverLocal(channel, JSON.parse(serialized));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (degradedUntil > 0) {
|
|
868
|
+
degradedUntil = now + PUBLISH_TIMEOUT_MS;
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, "pub/sub publish");
|
|
872
|
+
degradedUntil = 0;
|
|
873
|
+
if (!subscriberHealthy()) {
|
|
874
|
+
throttledWarn("subscriber-down", "Subscriber connection down \u2014 same-pod local fallback", {
|
|
875
|
+
channel
|
|
876
|
+
});
|
|
877
|
+
deliverLocal(channel, JSON.parse(serialized));
|
|
878
|
+
}
|
|
879
|
+
} catch (err) {
|
|
880
|
+
degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;
|
|
881
|
+
throttledWarn("publish-failed", "Pub/sub publish failed \u2014 local delivery only", {
|
|
882
|
+
channel,
|
|
883
|
+
error: err instanceof Error ? err.message : String(err)
|
|
884
|
+
});
|
|
885
|
+
deliverLocal(channel, JSON.parse(serialized));
|
|
886
|
+
}
|
|
887
|
+
},
|
|
888
|
+
subscribe: async (name, handler) => {
|
|
889
|
+
const channel = channelPrefix + name;
|
|
890
|
+
const sub = ensureSubscriber();
|
|
891
|
+
try {
|
|
892
|
+
await withTimeout(sub.subscribe(channel), SUBSCRIBE_TIMEOUT_MS, "pub/sub subscribe");
|
|
893
|
+
} catch (err) {
|
|
894
|
+
if (handlers.size === 0 && subscriber === sub) {
|
|
895
|
+
subscriber = void 0;
|
|
896
|
+
void sub.quit?.().catch(() => void 0);
|
|
897
|
+
}
|
|
898
|
+
throw err;
|
|
899
|
+
}
|
|
900
|
+
handlers.set(channel, handler);
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
async function wireEventRouterCache(router, options = {}) {
|
|
905
|
+
if (options.multiInstance === false) {
|
|
906
|
+
const events2 = router.events;
|
|
907
|
+
const stillRedis = Object.keys(events2).filter((k) => state.wired.has(events2[k].name)).length;
|
|
908
|
+
if (stillRedis > 0) {
|
|
909
|
+
transportLogger.warn("multiInstance:false has no effect on events already wired to redis by another router", {
|
|
910
|
+
redisWired: stillRedis
|
|
911
|
+
});
|
|
912
|
+
} else {
|
|
913
|
+
transportLogger.info("Event transport: in-process (multiInstance disabled)");
|
|
914
|
+
}
|
|
915
|
+
return "in-process";
|
|
916
|
+
}
|
|
917
|
+
const pubSubCache = await resolvePubSubCache(options.channelPrefix);
|
|
918
|
+
if (!pubSubCache) {
|
|
919
|
+
transportLogger.info("Event transport: in-process (no cache configured)");
|
|
920
|
+
return "in-process";
|
|
921
|
+
}
|
|
922
|
+
const events = router.events;
|
|
923
|
+
let wired = 0;
|
|
924
|
+
let degraded = 0;
|
|
925
|
+
let alreadyWired = 0;
|
|
926
|
+
for (const key of Object.keys(events)) {
|
|
927
|
+
const event = events[key];
|
|
928
|
+
if (state.wired.has(event.name)) {
|
|
929
|
+
alreadyWired++;
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
await event.useCache(pubSubCache);
|
|
934
|
+
state.wired.set(event.name, event);
|
|
935
|
+
wired++;
|
|
936
|
+
} catch (err) {
|
|
937
|
+
degraded++;
|
|
938
|
+
transportLogger.warn("Event cache wiring failed \u2014 staying in-process for this event", {
|
|
939
|
+
event: event.name,
|
|
940
|
+
error: err instanceof Error ? err.message : String(err)
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (wired + alreadyWired === 0) {
|
|
945
|
+
transportLogger.warn("Event transport: redis configured but no event wired \u2014 running in-process", {
|
|
946
|
+
total: Object.keys(events).length,
|
|
947
|
+
degraded
|
|
948
|
+
});
|
|
949
|
+
return "in-process";
|
|
950
|
+
}
|
|
951
|
+
transportLogger.info("Event transport: redis (cross-pod fan-out)", {
|
|
952
|
+
total: Object.keys(events).length,
|
|
953
|
+
wired,
|
|
954
|
+
degraded,
|
|
955
|
+
alreadyWired
|
|
956
|
+
});
|
|
957
|
+
return "redis";
|
|
958
|
+
}
|
|
959
|
+
async function resolvePubSubCache(channelPrefix) {
|
|
960
|
+
const resolvedPrefix = resolveChannelPrefix(channelPrefix);
|
|
961
|
+
if (state.pubSubCache) {
|
|
962
|
+
if (state.channelPrefix !== resolvedPrefix) {
|
|
963
|
+
transportLogger.warn("Conflicting channelPrefix ignored \u2014 the transport prefix is process-global (first wiring wins).", {
|
|
964
|
+
active: state.channelPrefix,
|
|
965
|
+
ignored: resolvedPrefix
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
return state.pubSubCache;
|
|
969
|
+
}
|
|
970
|
+
let client;
|
|
971
|
+
try {
|
|
972
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
973
|
+
client = getCache2();
|
|
974
|
+
} catch {
|
|
975
|
+
return void 0;
|
|
976
|
+
}
|
|
977
|
+
if (!client) {
|
|
978
|
+
return void 0;
|
|
979
|
+
}
|
|
980
|
+
if (!channelPrefix && !process.env.SPFN_SSE_CHANNEL_PREFIX) {
|
|
981
|
+
transportLogger.warn(
|
|
982
|
+
"Event fan-out is on the default channel prefix (spfn:sse:). Apps sharing one Redis must set channelPrefix or SPFN_SSE_CHANNEL_PREFIX to avoid cross-app leakage."
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
state.channelPrefix = resolvedPrefix;
|
|
986
|
+
state.pubSubCache = createRedisPubSubCache(
|
|
987
|
+
client,
|
|
988
|
+
resolvedPrefix,
|
|
989
|
+
(connection) => {
|
|
990
|
+
state.subscriber = connection;
|
|
991
|
+
}
|
|
992
|
+
);
|
|
993
|
+
return state.pubSubCache;
|
|
994
|
+
}
|
|
995
|
+
async function closeEventTransport() {
|
|
996
|
+
const subscriber = state.subscriber;
|
|
997
|
+
for (const event of state.wired.values()) {
|
|
998
|
+
event._resetCache?.();
|
|
999
|
+
}
|
|
1000
|
+
state.subscriber = void 0;
|
|
1001
|
+
state.pubSubCache = void 0;
|
|
1002
|
+
state.channelPrefix = void 0;
|
|
1003
|
+
state.wired.clear();
|
|
1004
|
+
if (!subscriber?.quit) {
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
await Promise.race([
|
|
1008
|
+
subscriber.quit().catch(() => void 0),
|
|
1009
|
+
new Promise((resolve2) => {
|
|
1010
|
+
const timer = setTimeout(resolve2, SUBSCRIBER_QUIT_TIMEOUT_MS);
|
|
1011
|
+
timer.unref?.();
|
|
1012
|
+
})
|
|
1013
|
+
]);
|
|
1014
|
+
}
|
|
1015
|
+
var serverLogger = logger.child("@spfn/core:server");
|
|
1016
|
+
|
|
1017
|
+
// src/server/migration-gate.ts
|
|
1018
|
+
var SNAPSHOT_TTL_MS = 3e4;
|
|
1019
|
+
var cache = null;
|
|
1020
|
+
var PendingMigrationsError = class extends Error {
|
|
1021
|
+
targets;
|
|
1022
|
+
constructor(message, targets) {
|
|
1023
|
+
super(message);
|
|
1024
|
+
this.name = "PendingMigrationsError";
|
|
1025
|
+
this.targets = targets;
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
function resetMigrationSnapshot() {
|
|
1029
|
+
cache = null;
|
|
1030
|
+
}
|
|
1031
|
+
async function inspect(cwd, db) {
|
|
1032
|
+
const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1033
|
+
if (!hasMigrationTargets(cwd)) {
|
|
1034
|
+
return { state: "skipped", checkedAt, reason: "no function package or project migrations found" };
|
|
1035
|
+
}
|
|
1036
|
+
let database = db;
|
|
1037
|
+
if (!database) {
|
|
1038
|
+
try {
|
|
1039
|
+
database = getDatabase();
|
|
1040
|
+
} catch {
|
|
1041
|
+
return { state: "skipped", checkedAt, reason: "no database initialized" };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
try {
|
|
1045
|
+
const status = await collectMigrationStatus(database, cwd);
|
|
1046
|
+
return { state: "ok", checkedAt, status, pending: countPendingMigrations(status) };
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
return {
|
|
1049
|
+
state: "unavailable",
|
|
1050
|
+
checkedAt,
|
|
1051
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
async function getMigrationSnapshot(options = {}) {
|
|
1056
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1057
|
+
const ttl = options.ttlMs ?? SNAPSHOT_TTL_MS;
|
|
1058
|
+
const now = Date.now();
|
|
1059
|
+
if (!options.force && cache && cache.cwd === cwd && now - cache.at < ttl) {
|
|
1060
|
+
return cache.value;
|
|
1061
|
+
}
|
|
1062
|
+
const value = await inspect(cwd, options.db);
|
|
1063
|
+
cache = { at: now, cwd, value };
|
|
1064
|
+
return value;
|
|
1065
|
+
}
|
|
1066
|
+
function pendingIsAllowed(config) {
|
|
1067
|
+
return config?.migrations?.allowPending ?? env.SPFN_ALLOW_PENDING_MIGRATIONS === true;
|
|
1068
|
+
}
|
|
1069
|
+
async function runMigrationBootGate(config, cwd = process.cwd()) {
|
|
1070
|
+
const snapshot = await getMigrationSnapshot({ cwd, force: true });
|
|
1071
|
+
if (snapshot.state === "skipped") {
|
|
1072
|
+
serverLogger.debug(`Migration gate skipped: ${snapshot.reason}`);
|
|
1073
|
+
return snapshot;
|
|
1074
|
+
}
|
|
1075
|
+
if (snapshot.state === "unavailable") {
|
|
1076
|
+
serverLogger.warn(
|
|
1077
|
+
`Could not verify migration status: ${snapshot.reason}. Starting anyway \u2014 run \`pnpm spfn db status\` to check.`
|
|
1078
|
+
);
|
|
1079
|
+
return snapshot;
|
|
1080
|
+
}
|
|
1081
|
+
const targets = pendingMigrationTargets(snapshot.status);
|
|
1082
|
+
if (targets.length === 0) {
|
|
1083
|
+
serverLogger.debug("Migration gate passed: database is up to date");
|
|
1084
|
+
return snapshot;
|
|
1085
|
+
}
|
|
1086
|
+
const lines = formatPendingMigrations(targets);
|
|
1087
|
+
const summary = pendingMigrationsSummary(targets);
|
|
1088
|
+
if (pendingIsAllowed(config)) {
|
|
1089
|
+
serverLogger.warn(`Starting with pending migrations \u2014 ${summary}`);
|
|
1090
|
+
lines.forEach((line) => serverLogger.warn(` ${line}`));
|
|
1091
|
+
serverLogger.warn(` Requests hitting the missing columns will fail. ${RUN_MIGRATIONS_HINT}`);
|
|
1092
|
+
return snapshot;
|
|
1093
|
+
}
|
|
1094
|
+
serverLogger.error(`Refusing to start: ${summary}`);
|
|
1095
|
+
lines.forEach((line) => serverLogger.error(` ${line}`));
|
|
1096
|
+
serverLogger.error(` ${RUN_MIGRATIONS_HINT}`);
|
|
1097
|
+
serverLogger.error(" To start anyway: SPFN_ALLOW_PENDING_MIGRATIONS=true (or `spfn dev --allow-pending-migrations`)");
|
|
1098
|
+
throw new PendingMigrationsError(
|
|
1099
|
+
`${summary}. ${RUN_MIGRATIONS_HINT}, or set SPFN_ALLOW_PENDING_MIGRATIONS=true to start anyway.`,
|
|
1100
|
+
targets.map((target) => target.name)
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/server/namespace.ts
|
|
1105
|
+
var CORE_NAMESPACE = "/_core";
|
|
1106
|
+
var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
|
|
1107
|
+
var CORE_TIME_PATH = `${CORE_NAMESPACE}/time`;
|
|
1108
|
+
var LEGACY_HEALTH_PATH = "/health";
|
|
1109
|
+
|
|
1110
|
+
// src/server/shutdown-manager.ts
|
|
1111
|
+
var DEFAULT_HOOK_TIMEOUT = 1e4;
|
|
1112
|
+
var DEFAULT_HOOK_ORDER = 100;
|
|
1113
|
+
var DRAIN_POLL_INTERVAL = 500;
|
|
1114
|
+
var ShutdownManager = class {
|
|
1115
|
+
state = "running";
|
|
1116
|
+
hooks = [];
|
|
1117
|
+
operations = /* @__PURE__ */ new Map();
|
|
1118
|
+
operationCounter = 0;
|
|
1119
|
+
/**
|
|
1120
|
+
* Register a shutdown hook
|
|
1121
|
+
*
|
|
1122
|
+
* Hooks run in order during shutdown, after all tracked operations drain.
|
|
1123
|
+
* Each hook has its own timeout — failure does not block subsequent hooks.
|
|
1124
|
+
*
|
|
1125
|
+
* @example
|
|
1126
|
+
* shutdown.onShutdown('ai-service', async () => {
|
|
1127
|
+
* await aiService.cancelPending();
|
|
1128
|
+
* }, { timeout: 30000, order: 10 });
|
|
1129
|
+
*/
|
|
1130
|
+
onShutdown(name, handler, options) {
|
|
1131
|
+
this.hooks.push({
|
|
1132
|
+
name,
|
|
1133
|
+
handler,
|
|
1134
|
+
timeout: options?.timeout ?? DEFAULT_HOOK_TIMEOUT,
|
|
1135
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER
|
|
1136
|
+
});
|
|
1137
|
+
this.hooks.sort((a, b) => a.order - b.order);
|
|
1138
|
+
serverLogger.debug(`Shutdown hook registered: ${name}`, {
|
|
1139
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER,
|
|
1140
|
+
timeout: `${options?.timeout ?? DEFAULT_HOOK_TIMEOUT}ms`
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Track a long-running operation
|
|
1145
|
+
*
|
|
1146
|
+
* During shutdown (drain phase), the process waits for ALL tracked
|
|
1147
|
+
* operations to complete before proceeding with cleanup.
|
|
1148
|
+
*
|
|
1149
|
+
* If shutdown has already started, the operation is rejected immediately.
|
|
1150
|
+
*
|
|
1151
|
+
* @returns The operation result (pass-through)
|
|
1152
|
+
*
|
|
1153
|
+
* @example
|
|
1154
|
+
* const result = await shutdown.trackOperation(
|
|
1155
|
+
* 'ai-generate',
|
|
1156
|
+
* aiService.generate(prompt)
|
|
1157
|
+
* );
|
|
1158
|
+
*/
|
|
1159
|
+
async trackOperation(name, operation) {
|
|
1160
|
+
if (this.state !== "running") {
|
|
1161
|
+
throw new Error(`Cannot start operation '${name}': server is shutting down`);
|
|
1162
|
+
}
|
|
1163
|
+
const id = `${name}-${++this.operationCounter}`;
|
|
1164
|
+
this.operations.set(id, {
|
|
1165
|
+
name,
|
|
1166
|
+
startedAt: Date.now()
|
|
1167
|
+
});
|
|
1168
|
+
serverLogger.debug(`Operation tracked: ${id}`, {
|
|
1169
|
+
activeOperations: this.operations.size
|
|
1170
|
+
});
|
|
1171
|
+
try {
|
|
1172
|
+
return await operation;
|
|
1173
|
+
} finally {
|
|
1174
|
+
this.operations.delete(id);
|
|
1175
|
+
serverLogger.debug(`Operation completed: ${id}`, {
|
|
1176
|
+
activeOperations: this.operations.size
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Whether the server is shutting down
|
|
1182
|
+
*
|
|
1183
|
+
* Use this to reject new work early (e.g., return 503 in route handlers).
|
|
1184
|
+
*/
|
|
1185
|
+
isShuttingDown() {
|
|
1186
|
+
return this.state !== "running";
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Number of currently active tracked operations
|
|
1190
|
+
*/
|
|
1191
|
+
getActiveOperationCount() {
|
|
1192
|
+
return this.operations.size;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Mark shutdown as started immediately
|
|
1196
|
+
*
|
|
1197
|
+
* Call this at the very beginning of the shutdown sequence so that:
|
|
1198
|
+
* - Health check returns 503 right away
|
|
1199
|
+
* - trackOperation() rejects new work
|
|
1200
|
+
* - isShuttingDown() returns true
|
|
1201
|
+
*/
|
|
1202
|
+
beginShutdown() {
|
|
1203
|
+
if (this.state !== "running") {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
this.state = "draining";
|
|
1207
|
+
serverLogger.info("Shutdown manager: state set to draining");
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Execute the full shutdown sequence
|
|
1211
|
+
*
|
|
1212
|
+
* 1. State → draining (reject new operations)
|
|
1213
|
+
* 2. Wait for all tracked operations to complete (drain)
|
|
1214
|
+
* 3. Run shutdown hooks in order
|
|
1215
|
+
* 4. State → closed
|
|
1216
|
+
*
|
|
1217
|
+
* @param drainTimeout - Max time to wait for operations to drain (ms)
|
|
1218
|
+
*/
|
|
1219
|
+
async execute(drainTimeout) {
|
|
1220
|
+
if (this.state === "closed") {
|
|
1221
|
+
serverLogger.warn("ShutdownManager.execute() called but already closed");
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
this.state = "draining";
|
|
1225
|
+
serverLogger.info("Shutdown manager: draining started", {
|
|
1226
|
+
activeOperations: this.operations.size,
|
|
1227
|
+
registeredHooks: this.hooks.length,
|
|
1228
|
+
drainTimeout: `${drainTimeout}ms`
|
|
1229
|
+
});
|
|
1230
|
+
await this.drain(drainTimeout);
|
|
1231
|
+
await this.executeHooks();
|
|
1232
|
+
this.state = "closed";
|
|
1233
|
+
serverLogger.info("Shutdown manager: all hooks executed");
|
|
1234
|
+
}
|
|
1235
|
+
// ========================================================================
|
|
1236
|
+
// Private
|
|
1237
|
+
// ========================================================================
|
|
1238
|
+
/**
|
|
1239
|
+
* Wait for all tracked operations to complete, up to drainTimeout
|
|
1240
|
+
*/
|
|
1241
|
+
async drain(drainTimeout) {
|
|
1242
|
+
if (this.operations.size === 0) {
|
|
1243
|
+
serverLogger.info("Shutdown manager: no active operations, drain skipped");
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
serverLogger.info(`Shutdown manager: waiting for ${this.operations.size} operations to drain...`);
|
|
1247
|
+
const deadline = Date.now() + drainTimeout;
|
|
1248
|
+
while (this.operations.size > 0 && Date.now() < deadline) {
|
|
1249
|
+
const remaining = deadline - Date.now();
|
|
1250
|
+
const ops = Array.from(this.operations.values()).map((op) => ({
|
|
1251
|
+
name: op.name,
|
|
1252
|
+
elapsed: `${Math.round((Date.now() - op.startedAt) / 1e3)}s`
|
|
1253
|
+
}));
|
|
1254
|
+
serverLogger.info("Shutdown manager: drain in progress", {
|
|
1255
|
+
activeOperations: this.operations.size,
|
|
1256
|
+
remainingTimeout: `${Math.round(remaining / 1e3)}s`,
|
|
1257
|
+
operations: ops
|
|
1258
|
+
});
|
|
1259
|
+
await sleep(Math.min(DRAIN_POLL_INTERVAL, remaining));
|
|
1260
|
+
}
|
|
1261
|
+
if (this.operations.size > 0) {
|
|
1262
|
+
const abandoned = Array.from(this.operations.values()).map((op) => op.name);
|
|
1263
|
+
serverLogger.warn("Shutdown manager: drain timeout \u2014 abandoning operations", {
|
|
1264
|
+
abandoned
|
|
1265
|
+
});
|
|
1266
|
+
} else {
|
|
1267
|
+
serverLogger.info("Shutdown manager: all operations drained successfully");
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Execute registered shutdown hooks in order
|
|
1272
|
+
*/
|
|
1273
|
+
async executeHooks() {
|
|
1274
|
+
if (this.hooks.length === 0) {
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
serverLogger.info(`Shutdown manager: executing ${this.hooks.length} hooks...`);
|
|
1278
|
+
for (const hook of this.hooks) {
|
|
1279
|
+
serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
|
|
1280
|
+
try {
|
|
1281
|
+
await withTimeout2(
|
|
1282
|
+
hook.handler(),
|
|
1283
|
+
hook.timeout,
|
|
1284
|
+
`Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
|
|
1285
|
+
);
|
|
1286
|
+
serverLogger.info(`Shutdown hook [${hook.name}] completed`);
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
serverLogger.error(
|
|
1289
|
+
`Shutdown hook [${hook.name}] failed`,
|
|
1290
|
+
error
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
var instance = null;
|
|
1297
|
+
function getShutdownManager() {
|
|
1298
|
+
if (!instance) {
|
|
1299
|
+
instance = new ShutdownManager();
|
|
1300
|
+
}
|
|
1301
|
+
return instance;
|
|
1302
|
+
}
|
|
1303
|
+
function resetShutdownManager() {
|
|
1304
|
+
instance = null;
|
|
1305
|
+
}
|
|
1306
|
+
function sleep(ms) {
|
|
1307
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1308
|
+
}
|
|
1309
|
+
async function withTimeout2(promise, timeout, message) {
|
|
1310
|
+
let timeoutId;
|
|
1311
|
+
return Promise.race([
|
|
1312
|
+
promise.finally(() => {
|
|
1313
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1314
|
+
}),
|
|
1315
|
+
new Promise((_, reject) => {
|
|
1316
|
+
timeoutId = setTimeout(() => {
|
|
1317
|
+
reject(new Error(message));
|
|
1318
|
+
}, timeout);
|
|
1319
|
+
})
|
|
1320
|
+
]);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// src/server/helpers.ts
|
|
1324
|
+
async function readMigrationHealth() {
|
|
1325
|
+
const snapshot = await getMigrationSnapshot();
|
|
1326
|
+
if (snapshot.state !== "ok") {
|
|
1327
|
+
return {
|
|
1328
|
+
status: "unknown",
|
|
1329
|
+
pending: 0,
|
|
1330
|
+
checkedAt: snapshot.checkedAt,
|
|
1331
|
+
targets: [],
|
|
1332
|
+
reason: snapshot.reason
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
return {
|
|
1336
|
+
status: snapshot.pending > 0 ? "pending" : "up_to_date",
|
|
1337
|
+
pending: snapshot.pending,
|
|
1338
|
+
checkedAt: snapshot.checkedAt,
|
|
1339
|
+
targets: migrationTargets(snapshot.status)
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
function createHealthCheckHandler(detailed, infrastructure) {
|
|
420
1343
|
return async (c) => {
|
|
1344
|
+
const shutdownManager = getShutdownManager();
|
|
1345
|
+
if (shutdownManager.isShuttingDown()) {
|
|
1346
|
+
return c.json({
|
|
1347
|
+
status: "shutting_down",
|
|
1348
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1349
|
+
}, 503);
|
|
1350
|
+
}
|
|
421
1351
|
const response = {
|
|
422
1352
|
status: "ok",
|
|
423
1353
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
424
1354
|
};
|
|
425
1355
|
if (detailed) {
|
|
1356
|
+
const databaseDisabled = infrastructure?.database === false;
|
|
1357
|
+
const redisDisabled = infrastructure?.redis === false;
|
|
426
1358
|
let dbStatus = "unknown";
|
|
427
1359
|
let dbError;
|
|
428
|
-
|
|
429
|
-
|
|
1360
|
+
if (databaseDisabled) {
|
|
1361
|
+
dbStatus = "disabled";
|
|
1362
|
+
} else {
|
|
430
1363
|
try {
|
|
431
|
-
|
|
432
|
-
|
|
1364
|
+
const db = getDatabase();
|
|
1365
|
+
try {
|
|
1366
|
+
await db.execute("SELECT 1");
|
|
1367
|
+
dbStatus = "connected";
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
dbStatus = "error";
|
|
1370
|
+
dbError = error instanceof Error ? error.message : String(error);
|
|
1371
|
+
}
|
|
433
1372
|
} catch (error) {
|
|
434
|
-
dbStatus = "
|
|
435
|
-
dbError =
|
|
1373
|
+
dbStatus = "not_initialized";
|
|
1374
|
+
dbError = "Database not available";
|
|
436
1375
|
}
|
|
437
|
-
} catch (error) {
|
|
438
|
-
dbStatus = "not_initialized";
|
|
439
|
-
dbError = "Database not available";
|
|
440
1376
|
}
|
|
441
|
-
const redis = getCache();
|
|
442
|
-
let redisStatus = redis ? "unknown" : "not_initialized";
|
|
1377
|
+
const redis = redisDisabled ? null : getCache();
|
|
1378
|
+
let redisStatus = redisDisabled ? "disabled" : redis ? "unknown" : "not_initialized";
|
|
443
1379
|
let redisError;
|
|
444
1380
|
if (redis) {
|
|
445
1381
|
try {
|
|
@@ -462,11 +1398,32 @@ function createHealthCheckHandler(detailed) {
|
|
|
462
1398
|
};
|
|
463
1399
|
const hasErrors = dbStatus === "error" || dbStatus === "not_initialized" || redisStatus === "error";
|
|
464
1400
|
response.status = hasErrors ? "degraded" : "ok";
|
|
1401
|
+
response.migrations = await readMigrationHealth();
|
|
465
1402
|
}
|
|
466
1403
|
const statusCode = response.status === "ok" ? 200 : 503;
|
|
467
1404
|
return c.json(response, statusCode);
|
|
468
1405
|
};
|
|
469
1406
|
}
|
|
1407
|
+
function resolveEndpointMiddlewares(config) {
|
|
1408
|
+
const candidates = [
|
|
1409
|
+
...config.middlewares ?? [],
|
|
1410
|
+
...config.routes?._globalMiddlewares ?? []
|
|
1411
|
+
];
|
|
1412
|
+
for (const pkgRouter of config.routes?._packageRouters ?? []) {
|
|
1413
|
+
candidates.push(...pkgRouter._globalMiddlewares ?? []);
|
|
1414
|
+
}
|
|
1415
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1416
|
+
return candidates.filter((mw) => {
|
|
1417
|
+
if (!mw.name) {
|
|
1418
|
+
return true;
|
|
1419
|
+
}
|
|
1420
|
+
if (seen.has(mw.name)) {
|
|
1421
|
+
return false;
|
|
1422
|
+
}
|
|
1423
|
+
seen.add(mw.name);
|
|
1424
|
+
return true;
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
470
1427
|
function applyServerTimeouts(server, timeouts) {
|
|
471
1428
|
if ("timeout" in server) {
|
|
472
1429
|
server.timeout = timeouts.request;
|
|
@@ -474,52 +1431,68 @@ function applyServerTimeouts(server, timeouts) {
|
|
|
474
1431
|
server.headersTimeout = timeouts.headers;
|
|
475
1432
|
}
|
|
476
1433
|
}
|
|
477
|
-
function getTimeoutConfig(
|
|
1434
|
+
function getTimeoutConfig(config) {
|
|
478
1435
|
return {
|
|
479
|
-
request:
|
|
480
|
-
keepAlive:
|
|
481
|
-
headers:
|
|
1436
|
+
request: config?.request ?? env.SERVER_TIMEOUT,
|
|
1437
|
+
keepAlive: config?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
|
|
1438
|
+
headers: config?.headers ?? env.SERVER_HEADERS_TIMEOUT
|
|
482
1439
|
};
|
|
483
1440
|
}
|
|
484
|
-
function getShutdownTimeout(
|
|
485
|
-
return
|
|
1441
|
+
function getShutdownTimeout(config) {
|
|
1442
|
+
return config?.timeout ?? env.SHUTDOWN_TIMEOUT;
|
|
486
1443
|
}
|
|
487
|
-
function
|
|
1444
|
+
function getFetchTimeoutConfig(config) {
|
|
1445
|
+
return {
|
|
1446
|
+
connect: config?.connect ?? env.FETCH_CONNECT_TIMEOUT,
|
|
1447
|
+
headers: config?.headers ?? env.FETCH_HEADERS_TIMEOUT,
|
|
1448
|
+
body: config?.body ?? env.FETCH_BODY_TIMEOUT
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
function applyGlobalFetchTimeouts(timeouts) {
|
|
1452
|
+
const agent = new Agent({
|
|
1453
|
+
connect: { timeout: timeouts.connect },
|
|
1454
|
+
headersTimeout: timeouts.headers,
|
|
1455
|
+
bodyTimeout: timeouts.body
|
|
1456
|
+
});
|
|
1457
|
+
setGlobalDispatcher(agent);
|
|
1458
|
+
}
|
|
1459
|
+
function buildMiddlewareOrder(config) {
|
|
488
1460
|
const order = [];
|
|
489
|
-
const middlewareConfig =
|
|
1461
|
+
const middlewareConfig = config.middleware ?? {};
|
|
490
1462
|
const enableLogger = middlewareConfig.logger !== false;
|
|
491
1463
|
const enableCors = middlewareConfig.cors !== false;
|
|
492
1464
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
493
1465
|
if (enableLogger) order.push("RequestLogger");
|
|
494
1466
|
if (enableCors) order.push("CORS");
|
|
495
|
-
|
|
496
|
-
if (
|
|
1467
|
+
config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
|
|
1468
|
+
if (config.beforeRoutes) order.push("beforeRoutes hook");
|
|
497
1469
|
order.push("Routes");
|
|
498
|
-
if (
|
|
1470
|
+
if (config.afterRoutes) order.push("afterRoutes hook");
|
|
499
1471
|
if (enableErrorHandler) order.push("ErrorHandler");
|
|
500
1472
|
return order;
|
|
501
1473
|
}
|
|
502
|
-
function buildStartupConfig(
|
|
503
|
-
const middlewareConfig =
|
|
504
|
-
const healthCheckConfig =
|
|
1474
|
+
function buildStartupConfig(config, timeouts) {
|
|
1475
|
+
const middlewareConfig = config.middleware ?? {};
|
|
1476
|
+
const healthCheckConfig = config.healthCheck ?? {};
|
|
505
1477
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
506
|
-
const healthCheckPath = healthCheckConfig.path
|
|
1478
|
+
const healthCheckPath = healthCheckConfig.path;
|
|
507
1479
|
const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
|
|
508
1480
|
return {
|
|
509
1481
|
middleware: {
|
|
510
1482
|
logger: middlewareConfig.logger !== false,
|
|
511
1483
|
cors: middlewareConfig.cors !== false,
|
|
512
1484
|
errorHandler: middlewareConfig.errorHandler !== false,
|
|
513
|
-
custom:
|
|
1485
|
+
custom: config.use?.length ?? 0
|
|
514
1486
|
},
|
|
515
1487
|
healthCheck: healthCheckEnabled ? {
|
|
516
1488
|
enabled: true,
|
|
1489
|
+
corePath: CORE_HEALTH_PATH,
|
|
517
1490
|
path: healthCheckPath,
|
|
518
1491
|
detailed: healthCheckDetailed
|
|
519
1492
|
} : { enabled: false },
|
|
520
1493
|
hooks: {
|
|
521
|
-
beforeRoutes: !!
|
|
522
|
-
afterRoutes: !!
|
|
1494
|
+
beforeRoutes: !!config.beforeRoutes,
|
|
1495
|
+
afterRoutes: !!config.afterRoutes
|
|
523
1496
|
},
|
|
524
1497
|
timeout: {
|
|
525
1498
|
request: `${timeouts.request}ms`,
|
|
@@ -527,23 +1500,396 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
527
1500
|
headers: `${timeouts.headers}ms`
|
|
528
1501
|
},
|
|
529
1502
|
shutdown: {
|
|
530
|
-
timeout: `${
|
|
1503
|
+
timeout: `${config.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
|
|
531
1504
|
}
|
|
532
1505
|
};
|
|
533
1506
|
}
|
|
534
|
-
|
|
1507
|
+
|
|
1508
|
+
// src/route/route-builder.ts
|
|
1509
|
+
var RouteBuilder = class _RouteBuilder {
|
|
1510
|
+
_method;
|
|
1511
|
+
_path;
|
|
1512
|
+
_input;
|
|
1513
|
+
_interceptor;
|
|
1514
|
+
_middlewares;
|
|
1515
|
+
_skipMiddlewares;
|
|
1516
|
+
_contract;
|
|
1517
|
+
/**
|
|
1518
|
+
* Create a new RouteBuilder with copied properties and optional overrides
|
|
1519
|
+
*/
|
|
1520
|
+
clone(overrides) {
|
|
1521
|
+
const builder = new _RouteBuilder();
|
|
1522
|
+
builder._method = this._method;
|
|
1523
|
+
builder._path = this._path;
|
|
1524
|
+
builder._input = overrides?.input ?? this._input;
|
|
1525
|
+
builder._interceptor = overrides?.interceptor ?? this._interceptor;
|
|
1526
|
+
builder._middlewares = overrides?.middlewares ?? this._middlewares;
|
|
1527
|
+
builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
|
|
1528
|
+
builder._contract = overrides?.contract ?? this._contract;
|
|
1529
|
+
return builder;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Define input schemas
|
|
1533
|
+
*
|
|
1534
|
+
* @example
|
|
1535
|
+
* ```ts
|
|
1536
|
+
* route.get('/users/:id')
|
|
1537
|
+
* .input({
|
|
1538
|
+
* params: Type.Object({ id: Type.String() }),
|
|
1539
|
+
* query: Type.Object({ page: Type.Number() }),
|
|
1540
|
+
* headers: Type.Object({ authorization: Type.String() })
|
|
1541
|
+
* })
|
|
1542
|
+
* .handler(async (c) => {
|
|
1543
|
+
* const { params, query, headers } = await c.data();
|
|
1544
|
+
* // params = { id: string }
|
|
1545
|
+
* // query = { page: number }
|
|
1546
|
+
* // headers = { authorization: string }
|
|
1547
|
+
* })
|
|
1548
|
+
* ```
|
|
1549
|
+
*/
|
|
1550
|
+
input(input) {
|
|
1551
|
+
return this.clone({ input });
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Define fields injected by interceptors
|
|
1555
|
+
*
|
|
1556
|
+
* These fields are:
|
|
1557
|
+
* - Available in the handler (merged with input)
|
|
1558
|
+
* - Excluded from client types (codegen uses only input)
|
|
1559
|
+
* - Not validated by route input schema (injected by middleware)
|
|
1560
|
+
*
|
|
1561
|
+
* Use this when middleware/interceptors add fields to the request
|
|
1562
|
+
* before it reaches the handler.
|
|
1563
|
+
*
|
|
1564
|
+
* @example
|
|
1565
|
+
* ```ts
|
|
1566
|
+
* // Auth interceptor injects crypto key fields
|
|
1567
|
+
* route.post('/_auth/login')
|
|
1568
|
+
* .input({
|
|
1569
|
+
* body: Type.Object({
|
|
1570
|
+
* email: Type.String(),
|
|
1571
|
+
* password: Type.String()
|
|
1572
|
+
* })
|
|
1573
|
+
* })
|
|
1574
|
+
* .interceptor({
|
|
1575
|
+
* body: Type.Object({
|
|
1576
|
+
* publicKey: Type.String(),
|
|
1577
|
+
* keyId: Type.String(),
|
|
1578
|
+
* fingerprint: Type.String()
|
|
1579
|
+
* })
|
|
1580
|
+
* })
|
|
1581
|
+
* .handler(async (c) => {
|
|
1582
|
+
* const { body } = await c.data();
|
|
1583
|
+
* // body type: { email, password, publicKey, keyId, fingerprint }
|
|
1584
|
+
* // Client only sees: { email, password }
|
|
1585
|
+
* return loginService(body);
|
|
1586
|
+
* });
|
|
1587
|
+
* ```
|
|
1588
|
+
*/
|
|
1589
|
+
interceptor(interceptor) {
|
|
1590
|
+
return this.clone({ interceptor });
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Add middlewares to the route
|
|
1594
|
+
*
|
|
1595
|
+
* Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
|
|
1596
|
+
* Named middlewares that are already registered globally will be automatically
|
|
1597
|
+
* deduplicated to prevent double execution.
|
|
1598
|
+
*
|
|
1599
|
+
* @example
|
|
1600
|
+
* ```ts
|
|
1601
|
+
* import { authenticate } from '@spfn/auth/server/middleware';
|
|
1602
|
+
*
|
|
1603
|
+
* // With NamedMiddleware (auto-deduped if registered globally)
|
|
1604
|
+
* route.get('/users')
|
|
1605
|
+
* .use([authenticate, RateLimitMiddleware()])
|
|
1606
|
+
*
|
|
1607
|
+
* // With regular middleware handlers
|
|
1608
|
+
* route.get('/users')
|
|
1609
|
+
* .use([AuthMiddleware(), RateLimitMiddleware()])
|
|
1610
|
+
* ```
|
|
1611
|
+
*/
|
|
1612
|
+
middleware(middlewares) {
|
|
1613
|
+
return this.clone({ middlewares });
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Add middlewares to the route (alias for `.middleware()`)
|
|
1617
|
+
*
|
|
1618
|
+
* Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
|
|
1619
|
+
* Named middlewares that are already registered globally will be automatically
|
|
1620
|
+
* deduplicated to prevent double execution.
|
|
1621
|
+
*
|
|
1622
|
+
* @example
|
|
1623
|
+
* ```ts
|
|
1624
|
+
* import { authenticate } from '@spfn/auth/server/middleware';
|
|
1625
|
+
*
|
|
1626
|
+
* // With NamedMiddleware (auto-deduped if registered globally)
|
|
1627
|
+
* route.get('/users')
|
|
1628
|
+
* .use([authenticate, RateLimitMiddleware()])
|
|
1629
|
+
*
|
|
1630
|
+
* // With regular middleware handlers
|
|
1631
|
+
* route.get('/users')
|
|
1632
|
+
* .use([AuthMiddleware(), RateLimitMiddleware()])
|
|
1633
|
+
* ```
|
|
1634
|
+
*/
|
|
1635
|
+
use(middlewares) {
|
|
1636
|
+
return this.middleware(middlewares);
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Skip server-level named middlewares
|
|
1640
|
+
*
|
|
1641
|
+
* Useful for public endpoints that should bypass auth or rate limiting
|
|
1642
|
+
*
|
|
1643
|
+
* @param middlewareNames - Array of middleware names to skip, or '*' to skip all
|
|
1644
|
+
*
|
|
1645
|
+
* @example
|
|
1646
|
+
* ```ts
|
|
1647
|
+
* // Skip specific middlewares
|
|
1648
|
+
* route.get('/status')
|
|
1649
|
+
* .skip(['auth', 'rateLimit'])
|
|
1650
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
1651
|
+
*
|
|
1652
|
+
* // Skip only auth (still apply rate limiting)
|
|
1653
|
+
* route.get('/public-data')
|
|
1654
|
+
* .skip(['auth'])
|
|
1655
|
+
* .handler(async (c) => { ... });
|
|
1656
|
+
*
|
|
1657
|
+
* // Skip all middlewares
|
|
1658
|
+
* route.get('/public-health')
|
|
1659
|
+
* .skip('*')
|
|
1660
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
1661
|
+
* ```
|
|
1662
|
+
*/
|
|
1663
|
+
skip(middlewareNames) {
|
|
1664
|
+
return this.clone({ skipMiddlewares: middlewareNames });
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Publish this route as a versioned contract operation
|
|
1668
|
+
*
|
|
1669
|
+
* Marks the route as a promise to clients that are compiled and deployed
|
|
1670
|
+
* separately from the server — a mobile app, an external API consumer.
|
|
1671
|
+
* The `@spfn/core:contract` generator writes every contracted route into
|
|
1672
|
+
* `contracts/current.json`, and the build refuses a change that would break
|
|
1673
|
+
* an already-released client.
|
|
1674
|
+
*
|
|
1675
|
+
* Routes without `.contract()` are unaffected: they simply do not appear in
|
|
1676
|
+
* the contract. A web client needs nothing here — it derives its types from
|
|
1677
|
+
* the router in the same build.
|
|
1678
|
+
*
|
|
1679
|
+
* @example
|
|
1680
|
+
* ```ts
|
|
1681
|
+
* export const getUser = route.get('/users/:id')
|
|
1682
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
1683
|
+
* .contract({
|
|
1684
|
+
* since: '1.2.0',
|
|
1685
|
+
* auth: 'clientProofV1',
|
|
1686
|
+
* requiresSession: true,
|
|
1687
|
+
* response: Type.Object({
|
|
1688
|
+
* id: Type.String(),
|
|
1689
|
+
* name: Type.String(),
|
|
1690
|
+
* email: Type.Optional(Type.String()),
|
|
1691
|
+
* }),
|
|
1692
|
+
* })
|
|
1693
|
+
* .handler(async (c) => { ... });
|
|
1694
|
+
* ```
|
|
1695
|
+
*/
|
|
1696
|
+
contract(contract) {
|
|
1697
|
+
return this.clone({ contract });
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Define handler function
|
|
1701
|
+
*
|
|
1702
|
+
* Response type is automatically inferred from the return value.
|
|
1703
|
+
* Use helper methods like `c.created()`, `c.paginated()` for proper type inference.
|
|
1704
|
+
*
|
|
1705
|
+
* @example
|
|
1706
|
+
* ```ts
|
|
1707
|
+
* // Direct return - type inferred from data
|
|
1708
|
+
* route.get('/users/:id')
|
|
1709
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
1710
|
+
* .handler(async (c) => {
|
|
1711
|
+
* const { params } = await c.data();
|
|
1712
|
+
* return await getUser(params.id); // Type: User
|
|
1713
|
+
* })
|
|
1714
|
+
*
|
|
1715
|
+
* // Using c.created() - returns data with 201 status, type preserved
|
|
1716
|
+
* route.post('/users')
|
|
1717
|
+
* .input({ body: Type.Object({ name: Type.String() }) })
|
|
1718
|
+
* .handler(async (c) => {
|
|
1719
|
+
* const { body } = await c.data();
|
|
1720
|
+
* return c.created(await createUser(body)); // Type: User
|
|
1721
|
+
* })
|
|
1722
|
+
*
|
|
1723
|
+
* // Using c.paginated() - returns PaginatedResult<T>
|
|
1724
|
+
* route.get('/users')
|
|
1725
|
+
* .handler(async (c) => {
|
|
1726
|
+
* const users = await getUsers();
|
|
1727
|
+
* return c.paginated(users, 1, 20, 100); // Type: PaginatedResult<User>
|
|
1728
|
+
* })
|
|
1729
|
+
*
|
|
1730
|
+
* // Using c.noContent() - returns void
|
|
1731
|
+
* route.delete('/users/:id')
|
|
1732
|
+
* .handler(async (c) => {
|
|
1733
|
+
* await deleteUser(params.id);
|
|
1734
|
+
* return c.noContent(); // Type: void
|
|
1735
|
+
* })
|
|
1736
|
+
*
|
|
1737
|
+
* // Using c.json() - returns Response (type inference lost)
|
|
1738
|
+
* // Use only when you need custom status codes not covered by helpers
|
|
1739
|
+
* route.get('/custom')
|
|
1740
|
+
* .handler(async (c) => {
|
|
1741
|
+
* return c.json({ data }, 418); // Type: Response
|
|
1742
|
+
* })
|
|
1743
|
+
* ```
|
|
1744
|
+
*/
|
|
1745
|
+
handler(fn) {
|
|
1746
|
+
return {
|
|
1747
|
+
method: this._method,
|
|
1748
|
+
path: this._path,
|
|
1749
|
+
input: this._input,
|
|
1750
|
+
interceptor: this._interceptor,
|
|
1751
|
+
middlewares: this._middlewares,
|
|
1752
|
+
skipMiddlewares: this._skipMiddlewares,
|
|
1753
|
+
contract: this._contract,
|
|
1754
|
+
handler: fn,
|
|
1755
|
+
_input: {},
|
|
1756
|
+
_interceptor: {},
|
|
1757
|
+
_response: {}
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
function createMethodRoute(method) {
|
|
1762
|
+
return (path) => {
|
|
1763
|
+
const builder = new RouteBuilder();
|
|
1764
|
+
builder._method = method;
|
|
1765
|
+
builder._path = path;
|
|
1766
|
+
return builder;
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
var route = {
|
|
1770
|
+
get: createMethodRoute("GET"),
|
|
1771
|
+
post: createMethodRoute("POST"),
|
|
1772
|
+
put: createMethodRoute("PUT"),
|
|
1773
|
+
patch: createMethodRoute("PATCH"),
|
|
1774
|
+
delete: createMethodRoute("DELETE")
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
// src/route/router.ts
|
|
1778
|
+
function createRouterInstance(routes, packageRouters = [], globalMiddlewares = [], contractVersion = null) {
|
|
1779
|
+
return {
|
|
1780
|
+
routes,
|
|
1781
|
+
_routes: routes,
|
|
1782
|
+
_packageRouters: packageRouters,
|
|
1783
|
+
_globalMiddlewares: globalMiddlewares,
|
|
1784
|
+
_contractVersion: contractVersion,
|
|
1785
|
+
packages(routers) {
|
|
1786
|
+
const newPackageRouters = [...this._packageRouters, ...routers];
|
|
1787
|
+
for (const pkgRouter of routers) {
|
|
1788
|
+
if (pkgRouter._packageRouters?.length > 0) {
|
|
1789
|
+
newPackageRouters.push(...pkgRouter._packageRouters);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return createRouterInstance(
|
|
1793
|
+
this.routes,
|
|
1794
|
+
newPackageRouters,
|
|
1795
|
+
this._globalMiddlewares,
|
|
1796
|
+
this._contractVersion
|
|
1797
|
+
);
|
|
1798
|
+
},
|
|
1799
|
+
use(middlewares) {
|
|
1800
|
+
return createRouterInstance(
|
|
1801
|
+
this.routes,
|
|
1802
|
+
this._packageRouters,
|
|
1803
|
+
[...this._globalMiddlewares, ...middlewares],
|
|
1804
|
+
this._contractVersion
|
|
1805
|
+
);
|
|
1806
|
+
},
|
|
1807
|
+
contractVersion(version) {
|
|
1808
|
+
assertContractVersion(version);
|
|
1809
|
+
return createRouterInstance(
|
|
1810
|
+
this.routes,
|
|
1811
|
+
this._packageRouters,
|
|
1812
|
+
this._globalMiddlewares,
|
|
1813
|
+
version
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
function assertContractVersion(version) {
|
|
1819
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
|
|
1820
|
+
throw new Error(
|
|
1821
|
+
`contractVersion("${version}") is not a version of the form major.minor.patch. The released snapshot is named from this value and releases are compared by it.`
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
function defineRouter(routes) {
|
|
1826
|
+
return createRouterInstance(routes);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// src/route/validation.ts
|
|
1830
|
+
FormatRegistry.Set(
|
|
1831
|
+
"email",
|
|
1832
|
+
(value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
1833
|
+
);
|
|
1834
|
+
FormatRegistry.Set(
|
|
1835
|
+
"uri",
|
|
1836
|
+
(value) => /^https?:\/\/.+/.test(value)
|
|
1837
|
+
);
|
|
1838
|
+
FormatRegistry.Set(
|
|
1839
|
+
"uuid",
|
|
1840
|
+
(value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
1841
|
+
);
|
|
1842
|
+
FormatRegistry.Set(
|
|
1843
|
+
"date",
|
|
1844
|
+
(value) => /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
1845
|
+
);
|
|
1846
|
+
FormatRegistry.Set(
|
|
1847
|
+
"date-time",
|
|
1848
|
+
(value) => !isNaN(Date.parse(value))
|
|
1849
|
+
);
|
|
1850
|
+
|
|
1851
|
+
// src/server/server-time.ts
|
|
1852
|
+
var CORE_TIME_OPERATION_ID = "core.time";
|
|
1853
|
+
var ServerTimeResponseSchema = Type.Object({
|
|
1854
|
+
serverTimeMillis: Type.Integer()
|
|
1855
|
+
}, { additionalProperties: false });
|
|
1856
|
+
var systemServerClock = {
|
|
1857
|
+
now: () => Date.now()
|
|
1858
|
+
};
|
|
1859
|
+
function createCoreTimeRoute(clock = systemServerClock) {
|
|
1860
|
+
return route.get(CORE_TIME_PATH).skip("*").contract({
|
|
1861
|
+
since: "0.3.0",
|
|
1862
|
+
auth: "none",
|
|
1863
|
+
requiresSession: false,
|
|
1864
|
+
response: ServerTimeResponseSchema
|
|
1865
|
+
}).handler(async (c) => {
|
|
1866
|
+
const response = {
|
|
1867
|
+
serverTimeMillis: clock.now()
|
|
1868
|
+
};
|
|
1869
|
+
c.raw.header("Cache-Control", "no-store");
|
|
1870
|
+
return response;
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
var CORE_TIME_ROUTE = createCoreTimeRoute();
|
|
1874
|
+
function createCoreTimeRouter(clock) {
|
|
1875
|
+
return defineRouter({
|
|
1876
|
+
[CORE_TIME_OPERATION_ID]: createCoreTimeRoute(clock)
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
535
1879
|
|
|
536
1880
|
// src/server/create-server.ts
|
|
537
|
-
|
|
1881
|
+
var rateLimitApplied = /* @__PURE__ */ new WeakSet();
|
|
1882
|
+
async function createServer(config) {
|
|
1883
|
+
applyOutboundFetch(config);
|
|
538
1884
|
const cwd = process.cwd();
|
|
539
1885
|
const appPath = join(cwd, "src", "server", "app.ts");
|
|
540
1886
|
const appJsPath = join(cwd, "src", "server", "app");
|
|
541
1887
|
if (existsSync(appPath) || existsSync(appJsPath)) {
|
|
542
|
-
return await loadCustomApp(appPath, appJsPath,
|
|
1888
|
+
return await loadCustomApp(appPath, appJsPath, config);
|
|
543
1889
|
}
|
|
544
|
-
return await createAutoConfiguredApp(
|
|
1890
|
+
return await createAutoConfiguredApp(config);
|
|
545
1891
|
}
|
|
546
|
-
async function loadCustomApp(appPath, appJsPath,
|
|
1892
|
+
async function loadCustomApp(appPath, appJsPath, config) {
|
|
547
1893
|
const actualPath = existsSync(appPath) ? appPath : appJsPath;
|
|
548
1894
|
const appModule = await import(actualPath);
|
|
549
1895
|
const appFactory = appModule.default;
|
|
@@ -551,15 +1897,16 @@ async function loadCustomApp(appPath, appJsPath, config2) {
|
|
|
551
1897
|
throw new Error("app.ts must export a default function that returns a Hono app");
|
|
552
1898
|
}
|
|
553
1899
|
const app = await appFactory();
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
1900
|
+
applyRateLimit(config);
|
|
1901
|
+
if (config?.routes) {
|
|
1902
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
1903
|
+
logRegisteredRoutes(routes, config?.debug ?? false);
|
|
557
1904
|
}
|
|
558
1905
|
return app;
|
|
559
1906
|
}
|
|
560
|
-
async function createAutoConfiguredApp(
|
|
1907
|
+
async function createAutoConfiguredApp(config) {
|
|
561
1908
|
const app = new Hono();
|
|
562
|
-
const middlewareConfig =
|
|
1909
|
+
const middlewareConfig = config?.middleware ?? {};
|
|
563
1910
|
const enableLogger = middlewareConfig.logger !== false;
|
|
564
1911
|
const enableCors = middlewareConfig.cors !== false;
|
|
565
1912
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
@@ -569,52 +1916,195 @@ async function createAutoConfiguredApp(config2) {
|
|
|
569
1916
|
await next();
|
|
570
1917
|
});
|
|
571
1918
|
}
|
|
572
|
-
applyDefaultMiddleware(app,
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
await
|
|
1919
|
+
applyDefaultMiddleware(app, config, enableLogger, enableCors);
|
|
1920
|
+
await applyProxyGuard(app, config);
|
|
1921
|
+
applyRateLimit(config);
|
|
1922
|
+
registerCoreTimeEndpoint(app, config);
|
|
1923
|
+
if (Array.isArray(config?.use)) {
|
|
1924
|
+
config.use.forEach((mw) => app.use("*", mw));
|
|
1925
|
+
}
|
|
1926
|
+
registerCoreHealthEndpoint(app, config);
|
|
1927
|
+
await executeBeforeRoutesHook(app, config);
|
|
1928
|
+
const appRoutes = await loadAppRoutes(app, config);
|
|
1929
|
+
registerMovedHealthSignpost(app, config, appRoutes);
|
|
1930
|
+
warnOnShadowedOptInPath(config, appRoutes);
|
|
1931
|
+
warnOnCoreNamespaceRoutes(appRoutes);
|
|
1932
|
+
await registerSSEEndpoint(app, config);
|
|
1933
|
+
await executeAfterRoutesHook(app, config);
|
|
581
1934
|
if (enableErrorHandler) {
|
|
582
|
-
app.onError(ErrorHandler());
|
|
1935
|
+
app.onError(ErrorHandler({ onError: config?.middleware?.onError }));
|
|
583
1936
|
}
|
|
584
1937
|
return app;
|
|
585
1938
|
}
|
|
586
|
-
function applyDefaultMiddleware(app,
|
|
1939
|
+
function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
|
|
587
1940
|
if (enableLogger) {
|
|
588
1941
|
app.use("*", RequestLogger());
|
|
589
1942
|
}
|
|
590
1943
|
if (enableCors) {
|
|
591
|
-
const corsOptions =
|
|
1944
|
+
const corsOptions = config?.cors !== false ? config?.cors : void 0;
|
|
592
1945
|
app.use("*", cors(corsOptions));
|
|
593
1946
|
}
|
|
594
1947
|
}
|
|
595
|
-
function
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
1948
|
+
async function applyProxyGuard(app, config) {
|
|
1949
|
+
const proxyGuardConfig = config?.proxyGuard;
|
|
1950
|
+
const mode = proxyGuardConfig?.mode ?? "off";
|
|
1951
|
+
if (mode === "off") {
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
const { createProxyGuard, createCacheNonceStore, createInMemoryNonceStore } = await import('@spfn/core/middleware');
|
|
1955
|
+
let nonceStore;
|
|
1956
|
+
if (proxyGuardConfig?.nonce) {
|
|
1957
|
+
try {
|
|
1958
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
1959
|
+
const cache2 = getCache2();
|
|
1960
|
+
if (cache2) {
|
|
1961
|
+
nonceStore = createCacheNonceStore(cache2);
|
|
1962
|
+
serverLogger.info("Proxy-guard nonce replay rejection: cache (Redis/Valkey)");
|
|
1963
|
+
} else {
|
|
1964
|
+
nonceStore = createInMemoryNonceStore();
|
|
1965
|
+
serverLogger.info("Proxy-guard nonce replay rejection: in-memory (single instance only \u2014 use a cache for multi-instance)");
|
|
1966
|
+
}
|
|
1967
|
+
} catch {
|
|
1968
|
+
nonceStore = createInMemoryNonceStore();
|
|
1969
|
+
serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
const autoSkip = [CORE_HEALTH_PATH, CORE_TIME_PATH, LEGACY_HEALTH_PATH];
|
|
1973
|
+
if (config?.healthCheck?.path) {
|
|
1974
|
+
autoSkip.push(config.healthCheck.path);
|
|
1975
|
+
}
|
|
1976
|
+
if (config?.events) {
|
|
1977
|
+
autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
|
|
1978
|
+
}
|
|
1979
|
+
if (config?.websockets) {
|
|
1980
|
+
autoSkip.push(config.websocketsConfig?.path ?? "/ws");
|
|
1981
|
+
}
|
|
1982
|
+
const skipPaths = [...proxyGuardConfig?.skipPaths ?? [], ...autoSkip];
|
|
1983
|
+
app.use("*", createProxyGuard({
|
|
1984
|
+
mode,
|
|
1985
|
+
secret: proxyGuardConfig?.secret,
|
|
1986
|
+
previousSecrets: proxyGuardConfig?.previousSecrets,
|
|
1987
|
+
windowMs: proxyGuardConfig?.windowMs,
|
|
1988
|
+
allowedOrigins: proxyGuardConfig?.allowedOrigins,
|
|
1989
|
+
maxBodyBytes: proxyGuardConfig?.maxBodyBytes,
|
|
1990
|
+
nonceStore,
|
|
1991
|
+
nonceFailClosed: proxyGuardConfig?.nonceFailClosed,
|
|
1992
|
+
skipPaths
|
|
1993
|
+
}));
|
|
1994
|
+
serverLogger.info(`\u2713 Proxy-guard enabled (mode: ${mode})`);
|
|
1995
|
+
}
|
|
1996
|
+
function applyRateLimit(config) {
|
|
1997
|
+
const rl = config?.rateLimit;
|
|
1998
|
+
setRateLimitPolicies(rl?.policies);
|
|
1999
|
+
setRateLimitFailClosedDefault(env.RATE_LIMIT_FAIL_CLOSED);
|
|
2000
|
+
const enabled = (rl?.mode ?? env.RATE_LIMIT_MODE) === "on";
|
|
2001
|
+
if (!enabled || !config || rateLimitApplied.has(config)) {
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
rateLimitApplied.add(config);
|
|
2005
|
+
const defaultPolicy = {
|
|
2006
|
+
limit: rl?.default?.limit ?? env.RATE_LIMIT_DEFAULT_LIMIT,
|
|
2007
|
+
windowMs: rl?.default?.windowMs ?? env.RATE_LIMIT_DEFAULT_WINDOW_MS,
|
|
2008
|
+
failClosed: rl?.default?.failClosed ?? env.RATE_LIMIT_FAIL_CLOSED
|
|
2009
|
+
};
|
|
2010
|
+
const globalRateLimit = defineMiddleware("rateLimit", rateLimit(defaultPolicy));
|
|
2011
|
+
config.middlewares = [globalRateLimit, ...config.middlewares ?? []];
|
|
2012
|
+
serverLogger.info(`\u2713 Rate limit default enabled (${defaultPolicy.limit} per ${defaultPolicy.windowMs}ms)`);
|
|
2013
|
+
}
|
|
2014
|
+
function applyOutboundFetch(config) {
|
|
2015
|
+
const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
|
|
2016
|
+
setDefaultSafeFetchPolicy(policy);
|
|
2017
|
+
}
|
|
2018
|
+
function resolveHealthCheck(config) {
|
|
2019
|
+
const healthCheckConfig = config?.healthCheck ?? {};
|
|
2020
|
+
return {
|
|
2021
|
+
enabled: healthCheckConfig.enabled !== false,
|
|
2022
|
+
// No default. An unset `path` means the endpoint answers at
|
|
2023
|
+
// CORE_HEALTH_PATH and nowhere else — a default here is what used to put
|
|
2024
|
+
// it on /health without anyone asking.
|
|
2025
|
+
path: healthCheckConfig.path,
|
|
2026
|
+
detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
function registerCoreTimeEndpoint(app, config) {
|
|
2030
|
+
registerRoutes(app, createCoreTimeRouter(config?.serverTime?.clock));
|
|
2031
|
+
serverLogger.debug(`Server time endpoint enabled at ${CORE_TIME_PATH}`);
|
|
2032
|
+
}
|
|
2033
|
+
function registerCoreHealthEndpoint(app, config) {
|
|
2034
|
+
const { enabled, path, detailed } = resolveHealthCheck(config);
|
|
2035
|
+
if (!enabled) {
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
const handler = createHealthCheckHandler(detailed, config?.infrastructure);
|
|
2039
|
+
app.get(CORE_HEALTH_PATH, handler);
|
|
2040
|
+
serverLogger.debug(`Health check endpoint enabled at ${CORE_HEALTH_PATH}`);
|
|
2041
|
+
if (path && path !== CORE_HEALTH_PATH) {
|
|
2042
|
+
app.get(path, handler);
|
|
2043
|
+
serverLogger.debug(`Health check endpoint also answering at ${path}, as configured`);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
function registerMovedHealthSignpost(app, config, appRoutes) {
|
|
2047
|
+
const { enabled, path } = resolveHealthCheck(config);
|
|
2048
|
+
if (!enabled || path === LEGACY_HEALTH_PATH) {
|
|
2049
|
+
return;
|
|
603
2050
|
}
|
|
2051
|
+
if (appRoutes.some((r) => r.method === "GET" && r.path === LEGACY_HEALTH_PATH)) {
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
let warned2 = false;
|
|
2055
|
+
app.get(LEGACY_HEALTH_PATH, (c) => {
|
|
2056
|
+
if (!warned2) {
|
|
2057
|
+
warned2 = true;
|
|
2058
|
+
serverLogger.warn(
|
|
2059
|
+
`\u26A0\uFE0F GET ${LEGACY_HEALTH_PATH} is answering 410: @spfn/core no longer serves it. Point your readiness probe, Dockerfile HEALTHCHECK and load balancer at ${CORE_HEALTH_PATH}, or restore this path with healthCheck({ path: '${LEGACY_HEALTH_PATH}' }). This notice is removed in the next release.`
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
return c.json({
|
|
2063
|
+
error: "health endpoint moved",
|
|
2064
|
+
movedTo: CORE_HEALTH_PATH,
|
|
2065
|
+
detail: `@spfn/core no longer serves ${LEGACY_HEALTH_PATH}. Point your readiness probe, Dockerfile HEALTHCHECK and load balancer at ${CORE_HEALTH_PATH}, or restore this path with healthCheck({ path: '${LEGACY_HEALTH_PATH}' }).`
|
|
2066
|
+
}, 410);
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
function warnOnShadowedOptInPath(config, appRoutes) {
|
|
2070
|
+
const { enabled, path } = resolveHealthCheck(config);
|
|
2071
|
+
if (!enabled || !path || path === CORE_HEALTH_PATH) {
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
const shadowed = appRoutes.filter((r) => r.method === "GET" && r.path === path);
|
|
2075
|
+
if (shadowed.length === 0) {
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
serverLogger.warn(
|
|
2079
|
+
`\u26A0\uFE0F ${shadowed.map((r) => r.name).join(", ")} never runs: GET ${path} is served by the built-in health endpoint, which healthCheck({ path }) asked for and which is registered before app routes. Drop that option, or move the route to a path your app owns.`
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
function warnOnCoreNamespaceRoutes(appRoutes) {
|
|
2083
|
+
const inside = appRoutes.filter((r) => r.path === CORE_NAMESPACE || r.path.startsWith(`${CORE_NAMESPACE}/`));
|
|
2084
|
+
if (inside.length === 0) {
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
const names = inside.map((r) => `${r.name} (${r.method} ${r.path})`).join(", ");
|
|
2088
|
+
serverLogger.warn(
|
|
2089
|
+
`\u26A0\uFE0F ${names} never runs: ${CORE_NAMESPACE}/ belongs to @spfn/core and its endpoints are registered before app routes. Move the route to a path your app owns.`
|
|
2090
|
+
);
|
|
604
2091
|
}
|
|
605
|
-
async function executeBeforeRoutesHook(app,
|
|
606
|
-
if (
|
|
607
|
-
await
|
|
2092
|
+
async function executeBeforeRoutesHook(app, config) {
|
|
2093
|
+
if (config?.lifecycle?.beforeRoutes) {
|
|
2094
|
+
await config.lifecycle.beforeRoutes(app);
|
|
608
2095
|
}
|
|
609
2096
|
}
|
|
610
|
-
async function loadAppRoutes(app,
|
|
611
|
-
const debug = isDebugMode(
|
|
612
|
-
if (
|
|
613
|
-
const routes = registerRoutes(app,
|
|
2097
|
+
async function loadAppRoutes(app, config) {
|
|
2098
|
+
const debug = isDebugMode(config);
|
|
2099
|
+
if (config?.routes) {
|
|
2100
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
614
2101
|
logRegisteredRoutes(routes, debug);
|
|
615
|
-
|
|
2102
|
+
return routes;
|
|
2103
|
+
}
|
|
2104
|
+
if (debug) {
|
|
616
2105
|
serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
|
|
617
2106
|
}
|
|
2107
|
+
return [];
|
|
618
2108
|
}
|
|
619
2109
|
function logRegisteredRoutes(routes, debug) {
|
|
620
2110
|
if (routes.length === 0) {
|
|
@@ -631,78 +2121,174 @@ function logRegisteredRoutes(routes, debug) {
|
|
|
631
2121
|
serverLogger.info(`\u2713 Routes registered (${routes.length}):
|
|
632
2122
|
${routeLines}`);
|
|
633
2123
|
}
|
|
634
|
-
async function executeAfterRoutesHook(app,
|
|
635
|
-
if (
|
|
636
|
-
await
|
|
2124
|
+
async function executeAfterRoutesHook(app, config) {
|
|
2125
|
+
if (config?.lifecycle?.afterRoutes) {
|
|
2126
|
+
await config.lifecycle.afterRoutes(app);
|
|
637
2127
|
}
|
|
638
2128
|
}
|
|
639
|
-
function registerSSEEndpoint(app,
|
|
640
|
-
if (!
|
|
2129
|
+
async function registerSSEEndpoint(app, config) {
|
|
2130
|
+
if (!config?.events) {
|
|
641
2131
|
return;
|
|
642
2132
|
}
|
|
643
|
-
const eventsConfig =
|
|
644
|
-
const
|
|
645
|
-
const
|
|
646
|
-
|
|
2133
|
+
const eventsConfig = config.eventsConfig ?? {};
|
|
2134
|
+
const streamPath = eventsConfig.path ?? "/events/stream";
|
|
2135
|
+
const authConfig = eventsConfig.auth;
|
|
2136
|
+
const debug = isDebugMode(config);
|
|
2137
|
+
let tokenManager;
|
|
2138
|
+
if (authConfig?.enabled) {
|
|
2139
|
+
let store = authConfig.store;
|
|
2140
|
+
if (!store) {
|
|
2141
|
+
try {
|
|
2142
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
2143
|
+
const cache2 = getCache2();
|
|
2144
|
+
if (cache2) {
|
|
2145
|
+
store = new CacheTokenStore(cache2);
|
|
2146
|
+
if (debug) {
|
|
2147
|
+
serverLogger.info("SSE token store: cache (Redis/Valkey)");
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
} catch {
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
2154
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
2155
|
+
ttl: authConfig.tokenTtl,
|
|
2156
|
+
store
|
|
2157
|
+
});
|
|
2158
|
+
const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
|
|
2159
|
+
const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
|
|
2160
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
2161
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
2162
|
+
const subject = getSubject(c);
|
|
2163
|
+
if (!subject) {
|
|
2164
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
2165
|
+
}
|
|
2166
|
+
const token = await tokenManager.issue(subject);
|
|
2167
|
+
return c.json({ token });
|
|
2168
|
+
});
|
|
2169
|
+
if (debug) {
|
|
2170
|
+
serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
const transport = await wireEventRouterCache(config.events, {
|
|
2174
|
+
multiInstance: eventsConfig.multiInstance,
|
|
2175
|
+
channelPrefix: eventsConfig.channelPrefix
|
|
2176
|
+
});
|
|
2177
|
+
app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
|
|
647
2178
|
if (debug) {
|
|
648
|
-
const eventNames =
|
|
649
|
-
serverLogger.info(`\u2713 SSE endpoint registered at ${
|
|
650
|
-
events: eventNames
|
|
2179
|
+
const eventNames = config.events.eventNames;
|
|
2180
|
+
serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
|
|
2181
|
+
events: eventNames,
|
|
2182
|
+
auth: !!authConfig?.enabled,
|
|
2183
|
+
transport
|
|
651
2184
|
});
|
|
652
2185
|
}
|
|
653
2186
|
}
|
|
654
|
-
function isDebugMode(
|
|
655
|
-
return
|
|
2187
|
+
function isDebugMode(config) {
|
|
2188
|
+
return config?.debug ?? process.env.NODE_ENV === "development";
|
|
656
2189
|
}
|
|
657
2190
|
var jobLogger = logger.child("@spfn/core:job");
|
|
658
|
-
|
|
659
|
-
|
|
2191
|
+
function requiresSSLWithoutVerification(connectionString) {
|
|
2192
|
+
try {
|
|
2193
|
+
const url = new URL(connectionString);
|
|
2194
|
+
const sslmode = url.searchParams.get("sslmode");
|
|
2195
|
+
return sslmode === "require" || sslmode === "prefer";
|
|
2196
|
+
} catch {
|
|
2197
|
+
return false;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
function stripSslModeFromUrl(connectionString) {
|
|
2201
|
+
const url = new URL(connectionString);
|
|
2202
|
+
url.searchParams.delete("sslmode");
|
|
2203
|
+
return url.toString();
|
|
2204
|
+
}
|
|
2205
|
+
var BOSS_KEY = Symbol.for("spfn:boss-instance");
|
|
2206
|
+
var CONFIG_KEY = Symbol.for("spfn:boss-config");
|
|
2207
|
+
var g = globalThis;
|
|
2208
|
+
function getBossInstance() {
|
|
2209
|
+
return g[BOSS_KEY] ?? null;
|
|
2210
|
+
}
|
|
2211
|
+
function setBossInstance(instance2) {
|
|
2212
|
+
g[BOSS_KEY] = instance2;
|
|
2213
|
+
}
|
|
2214
|
+
function getBossConfig() {
|
|
2215
|
+
return g[CONFIG_KEY] ?? null;
|
|
2216
|
+
}
|
|
2217
|
+
function setBossConfig(config) {
|
|
2218
|
+
g[CONFIG_KEY] = config;
|
|
2219
|
+
}
|
|
660
2220
|
async function initBoss(options) {
|
|
661
|
-
|
|
2221
|
+
const existing = getBossInstance();
|
|
2222
|
+
if (existing) {
|
|
662
2223
|
jobLogger.warn("pg-boss already initialized, returning existing instance");
|
|
663
|
-
return
|
|
2224
|
+
return existing;
|
|
664
2225
|
}
|
|
665
2226
|
jobLogger.info("Initializing pg-boss...");
|
|
666
|
-
|
|
2227
|
+
setBossConfig(options);
|
|
2228
|
+
const needsSSL = requiresSSLWithoutVerification(options.connectionString);
|
|
667
2229
|
const pgBossOptions = {
|
|
668
|
-
|
|
2230
|
+
// pg 드라이버가 URL의 sslmode=require를 verify-full로 해석해서
|
|
2231
|
+
// ssl 옵션을 무시하므로, URL에서 sslmode를 빼고 ssl 객체만 전달
|
|
2232
|
+
connectionString: needsSSL ? stripSslModeFromUrl(options.connectionString) : options.connectionString,
|
|
669
2233
|
schema: options.schema ?? "spfn_queue",
|
|
670
2234
|
maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
|
|
671
2235
|
};
|
|
2236
|
+
if (needsSSL) {
|
|
2237
|
+
pgBossOptions.ssl = { rejectUnauthorized: false };
|
|
2238
|
+
}
|
|
672
2239
|
if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
|
|
673
2240
|
pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
|
|
674
2241
|
}
|
|
675
|
-
|
|
676
|
-
|
|
2242
|
+
const boss = new PgBoss(pgBossOptions);
|
|
2243
|
+
boss.on("error", (error) => {
|
|
677
2244
|
jobLogger.error("pg-boss error:", error);
|
|
678
2245
|
});
|
|
679
|
-
await
|
|
2246
|
+
await boss.start();
|
|
2247
|
+
setBossInstance(boss);
|
|
680
2248
|
jobLogger.info("pg-boss started successfully");
|
|
681
|
-
return
|
|
2249
|
+
return boss;
|
|
682
2250
|
}
|
|
683
2251
|
function getBoss() {
|
|
684
|
-
return
|
|
2252
|
+
return getBossInstance();
|
|
685
2253
|
}
|
|
686
2254
|
async function stopBoss() {
|
|
687
|
-
|
|
2255
|
+
const boss = getBossInstance();
|
|
2256
|
+
if (!boss) {
|
|
688
2257
|
return;
|
|
689
2258
|
}
|
|
690
2259
|
jobLogger.info("Stopping pg-boss...");
|
|
691
2260
|
try {
|
|
692
|
-
await
|
|
2261
|
+
await boss.stop({ graceful: true, timeout: 3e4 });
|
|
693
2262
|
jobLogger.info("pg-boss stopped gracefully");
|
|
694
2263
|
} catch (error) {
|
|
695
2264
|
jobLogger.error("Error stopping pg-boss:", error);
|
|
696
2265
|
throw error;
|
|
697
2266
|
} finally {
|
|
698
|
-
|
|
699
|
-
|
|
2267
|
+
setBossInstance(null);
|
|
2268
|
+
setBossConfig(null);
|
|
700
2269
|
}
|
|
701
2270
|
}
|
|
702
2271
|
function shouldClearOnStart() {
|
|
703
|
-
return
|
|
2272
|
+
return getBossConfig()?.clearOnStart ?? false;
|
|
2273
|
+
}
|
|
2274
|
+
function shouldSweepOrphanSchedules() {
|
|
2275
|
+
return getBossConfig()?.sweepOrphanSchedules ?? false;
|
|
704
2276
|
}
|
|
705
2277
|
|
|
2278
|
+
// src/job/queue-policy.ts
|
|
2279
|
+
function resolveQueuePolicy(job2) {
|
|
2280
|
+
if (job2.options?.policy) {
|
|
2281
|
+
return job2.options.policy;
|
|
2282
|
+
}
|
|
2283
|
+
if (job2.runOnce || job2.options?.singletonKey) {
|
|
2284
|
+
return "exclusive";
|
|
2285
|
+
}
|
|
2286
|
+
return "standard";
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// src/job/job-builder.ts
|
|
2290
|
+
logger.child("@spfn/core:job");
|
|
2291
|
+
|
|
706
2292
|
// src/job/job-router.ts
|
|
707
2293
|
function isJobDef(value) {
|
|
708
2294
|
return value !== null && typeof value === "object" && "name" in value && "handler" in value && "send" in value && "run" in value;
|
|
@@ -710,19 +2296,38 @@ function isJobDef(value) {
|
|
|
710
2296
|
function isJobRouter(value) {
|
|
711
2297
|
return value !== null && typeof value === "object" && "jobs" in value && "_jobs" in value;
|
|
712
2298
|
}
|
|
713
|
-
function
|
|
714
|
-
|
|
2299
|
+
function defineJobRouter(jobs) {
|
|
2300
|
+
return {
|
|
2301
|
+
jobs,
|
|
2302
|
+
_jobs: jobs
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
function mergeJobRouters(base, extra) {
|
|
2306
|
+
for (const key of Object.keys(extra.jobs)) {
|
|
2307
|
+
if (key in base.jobs) {
|
|
2308
|
+
throw new Error(
|
|
2309
|
+
`jobs(): key "${key}" is already registered by an earlier .jobs() call; rename it or nest it under a domain router`
|
|
2310
|
+
);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
return defineJobRouter({ ...base.jobs, ...extra.jobs });
|
|
2314
|
+
}
|
|
2315
|
+
function collectJobEntries(router, prefix = "") {
|
|
2316
|
+
const entries = [];
|
|
715
2317
|
for (const [key, value] of Object.entries(router.jobs)) {
|
|
716
|
-
const
|
|
2318
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
717
2319
|
if (isJobRouter(value)) {
|
|
718
|
-
|
|
2320
|
+
entries.push(...collectJobEntries(value, path));
|
|
719
2321
|
} else if (isJobDef(value)) {
|
|
720
|
-
|
|
2322
|
+
entries.push({ key: path, job: value });
|
|
721
2323
|
}
|
|
722
2324
|
}
|
|
723
|
-
return
|
|
2325
|
+
return entries;
|
|
724
2326
|
}
|
|
725
|
-
|
|
2327
|
+
function collectJobs(router, prefix = "") {
|
|
2328
|
+
return collectJobEntries(router, prefix).map((entry) => entry.job);
|
|
2329
|
+
}
|
|
2330
|
+
var jobLogger3 = logger.child("@spfn/core:job");
|
|
726
2331
|
function getEventQueueName(eventName) {
|
|
727
2332
|
return `event:${eventName}`;
|
|
728
2333
|
}
|
|
@@ -730,6 +2335,9 @@ function getDefaultJobOptions(options) {
|
|
|
730
2335
|
return {
|
|
731
2336
|
retryLimit: options?.retryLimit ?? 3,
|
|
732
2337
|
retryDelay: options?.retryDelay ?? 1e3,
|
|
2338
|
+
// Exponential backoff by default — a failed cohort (e.g. a provider 429)
|
|
2339
|
+
// would otherwise all retry at the same fixed offset (thundering herd).
|
|
2340
|
+
retryBackoff: options?.retryBackoff ?? true,
|
|
733
2341
|
expireInSeconds: options?.expireInSeconds ?? 300
|
|
734
2342
|
};
|
|
735
2343
|
}
|
|
@@ -740,58 +2348,147 @@ async function registerJobs(router) {
|
|
|
740
2348
|
"pg-boss not initialized. Call initBoss() before registerJobs()"
|
|
741
2349
|
);
|
|
742
2350
|
}
|
|
2351
|
+
assertUniqueJobNames(router);
|
|
743
2352
|
const jobs = collectJobs(router);
|
|
744
2353
|
const clearOnStart = shouldClearOnStart();
|
|
745
|
-
|
|
2354
|
+
jobLogger3.info(`Registering ${jobs.length} job(s)...`);
|
|
746
2355
|
if (clearOnStart) {
|
|
747
|
-
|
|
2356
|
+
jobLogger3.info("Clearing existing jobs before registration...");
|
|
748
2357
|
for (const job2 of jobs) {
|
|
749
2358
|
await boss.deleteAllJobs(job2.name);
|
|
750
2359
|
if (job2.subscribedEvent) {
|
|
751
2360
|
const eventQueue = getEventQueueName(job2.subscribedEvent);
|
|
752
2361
|
await boss.deleteAllJobs(eventQueue);
|
|
753
2362
|
}
|
|
754
|
-
}
|
|
755
|
-
|
|
2363
|
+
}
|
|
2364
|
+
jobLogger3.info("Existing jobs cleared");
|
|
2365
|
+
}
|
|
2366
|
+
await Promise.all(jobs.map((job2) => registerJob(job2)));
|
|
2367
|
+
for (const job2 of jobs) {
|
|
2368
|
+
if (job2.cronExpression) {
|
|
2369
|
+
registeredCronNames.add(job2.name);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (shouldSweepOrphanSchedules()) {
|
|
2373
|
+
await sweepOrphanSchedules(boss);
|
|
2374
|
+
}
|
|
2375
|
+
jobLogger3.info("All jobs registered successfully");
|
|
2376
|
+
}
|
|
2377
|
+
function assertUniqueJobNames(router) {
|
|
2378
|
+
const keysByName = /* @__PURE__ */ new Map();
|
|
2379
|
+
for (const { key, job: job2 } of collectJobEntries(router)) {
|
|
2380
|
+
const existingKey = keysByName.get(job2.name);
|
|
2381
|
+
if (existingKey) {
|
|
2382
|
+
throw new Error(
|
|
2383
|
+
`registerJobs(): job name "${job2.name}" is defined twice (keys "${existingKey}" and "${key}"); job names are pg-boss queue names and must be unique`
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
keysByName.set(job2.name, key);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
var registeredCronNames = /* @__PURE__ */ new Set();
|
|
2390
|
+
async function sweepOrphanSchedules(boss) {
|
|
2391
|
+
if (registeredCronNames.size === 0) {
|
|
2392
|
+
jobLogger3.debug("No cron jobs declared; skipping orphan schedule sweep");
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
try {
|
|
2396
|
+
const schedules = await boss.getSchedules();
|
|
2397
|
+
const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
|
|
2398
|
+
if (orphans.length === 0) {
|
|
2399
|
+
jobLogger3.debug("No orphan schedules found");
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
await Promise.all(orphans.map(async (orphan) => {
|
|
2403
|
+
try {
|
|
2404
|
+
await boss.unschedule(orphan.name, orphan.key);
|
|
2405
|
+
jobLogger3.info(`Removed orphan schedule: ${orphan.name}`);
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
jobLogger3.error(`Failed to remove orphan schedule: ${orphan.name}`, {
|
|
2408
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
}));
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
jobLogger3.error("Orphan schedule sweep failed", {
|
|
2414
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2415
|
+
});
|
|
756
2416
|
}
|
|
757
|
-
|
|
758
|
-
|
|
2417
|
+
}
|
|
2418
|
+
async function ensureQueue(boss, queueName, policy) {
|
|
2419
|
+
await boss.createQueue(queueName, { policy });
|
|
2420
|
+
const existing = await boss.getQueue(queueName);
|
|
2421
|
+
if (!existing || existing.policy === policy) {
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
if (shouldClearOnStart()) {
|
|
2425
|
+
await boss.deleteQueue(queueName);
|
|
2426
|
+
await boss.createQueue(queueName, { policy });
|
|
2427
|
+
jobLogger3.info(`[Queue:${queueName}] policy ${existing.policy} \u2192 ${policy} (recreated: clearOnStart)`);
|
|
2428
|
+
return;
|
|
759
2429
|
}
|
|
760
|
-
|
|
2430
|
+
jobLogger3.error(
|
|
2431
|
+
`[Queue:${queueName}] policy mismatch: the queue exists as "${existing.policy}" but its jobs require "${policy}". Delete the queue in a quiet window (getBoss().deleteQueue('${queueName}') drops its job rows) or set clearOnStart in development; until then singletonKey/runOnce on this queue do not deduplicate`
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
function createQueueEnsurer(boss, policy) {
|
|
2435
|
+
const ensured = /* @__PURE__ */ new Set();
|
|
2436
|
+
return async (queueName) => {
|
|
2437
|
+
if (ensured.has(queueName)) {
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
ensured.add(queueName);
|
|
2441
|
+
await ensureQueue(boss, queueName, policy);
|
|
2442
|
+
};
|
|
761
2443
|
}
|
|
762
|
-
async function
|
|
763
|
-
|
|
2444
|
+
async function executeJobHandler(job2, pgBossJob) {
|
|
2445
|
+
jobLogger3.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
|
|
2446
|
+
const startTime = Date.now();
|
|
2447
|
+
try {
|
|
2448
|
+
if (job2.inputSchema) {
|
|
2449
|
+
await job2.handler(pgBossJob.data);
|
|
2450
|
+
} else {
|
|
2451
|
+
await job2.handler();
|
|
2452
|
+
}
|
|
2453
|
+
const duration = Date.now() - startTime;
|
|
2454
|
+
jobLogger3.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
|
|
2455
|
+
jobId: pgBossJob.id,
|
|
2456
|
+
duration
|
|
2457
|
+
});
|
|
2458
|
+
} catch (error) {
|
|
2459
|
+
const duration = Date.now() - startTime;
|
|
2460
|
+
jobLogger3.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
|
|
2461
|
+
jobId: pgBossJob.id,
|
|
2462
|
+
duration,
|
|
2463
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2464
|
+
});
|
|
2465
|
+
throw error;
|
|
2466
|
+
}
|
|
764
2467
|
}
|
|
765
|
-
async function registerWorker(boss, job2, queueName) {
|
|
766
|
-
await
|
|
2468
|
+
async function registerWorker(boss, job2, queueName, ensureQueueOnce) {
|
|
2469
|
+
await ensureQueueOnce(queueName);
|
|
2470
|
+
const batchSize = job2.options?.batchSize ?? 1;
|
|
2471
|
+
const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
|
|
767
2472
|
await boss.work(
|
|
768
2473
|
queueName,
|
|
769
|
-
{ batchSize
|
|
770
|
-
async (
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
jobId: pgBossJob.id,
|
|
783
|
-
duration
|
|
784
|
-
});
|
|
785
|
-
} catch (error) {
|
|
786
|
-
const duration = Date.now() - startTime;
|
|
787
|
-
jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
|
|
788
|
-
jobId: pgBossJob.id,
|
|
789
|
-
duration,
|
|
790
|
-
error: error instanceof Error ? error.message : String(error)
|
|
791
|
-
});
|
|
792
|
-
throw error;
|
|
2474
|
+
{ batchSize, pollingIntervalSeconds },
|
|
2475
|
+
async (pgBossJobs) => {
|
|
2476
|
+
if (batchSize <= 1) {
|
|
2477
|
+
await executeJobHandler(job2, pgBossJobs[0]);
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
const results = await Promise.allSettled(
|
|
2481
|
+
pgBossJobs.map((pgBossJob) => executeJobHandler(job2, pgBossJob))
|
|
2482
|
+
);
|
|
2483
|
+
const failedIds = [];
|
|
2484
|
+
for (let i = 0; i < results.length; i++) {
|
|
2485
|
+
if (results[i].status === "rejected") {
|
|
2486
|
+
failedIds.push(pgBossJobs[i].id);
|
|
793
2487
|
}
|
|
794
2488
|
}
|
|
2489
|
+
if (failedIds.length > 0) {
|
|
2490
|
+
await boss.fail(queueName, failedIds);
|
|
2491
|
+
}
|
|
795
2492
|
}
|
|
796
2493
|
);
|
|
797
2494
|
}
|
|
@@ -803,28 +2500,28 @@ function connectEventToQueue(boss, job2, queueName) {
|
|
|
803
2500
|
eventDef._registerJobQueue(queueName, async (queue, payload) => {
|
|
804
2501
|
await boss.send(queue, payload, getDefaultJobOptions(job2.options));
|
|
805
2502
|
});
|
|
806
|
-
|
|
2503
|
+
jobLogger3.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
|
|
807
2504
|
}
|
|
808
|
-
async function registerCronSchedule(boss, job2) {
|
|
2505
|
+
async function registerCronSchedule(boss, job2, ensureQueueOnce) {
|
|
809
2506
|
if (!job2.cronExpression) {
|
|
810
2507
|
return;
|
|
811
2508
|
}
|
|
812
|
-
|
|
813
|
-
await
|
|
2509
|
+
jobLogger3.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
|
|
2510
|
+
await ensureQueueOnce(job2.name);
|
|
814
2511
|
await boss.schedule(
|
|
815
2512
|
job2.name,
|
|
816
2513
|
job2.cronExpression,
|
|
817
2514
|
{},
|
|
818
2515
|
getDefaultJobOptions(job2.options)
|
|
819
2516
|
);
|
|
820
|
-
|
|
2517
|
+
jobLogger3.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
|
|
821
2518
|
}
|
|
822
|
-
async function queueRunOnceJob(boss, job2) {
|
|
2519
|
+
async function queueRunOnceJob(boss, job2, ensureQueueOnce) {
|
|
823
2520
|
if (!job2.runOnce) {
|
|
824
2521
|
return;
|
|
825
2522
|
}
|
|
826
|
-
|
|
827
|
-
await
|
|
2523
|
+
jobLogger3.debug(`[Job:${job2.name}] Queuing runOnce job`);
|
|
2524
|
+
await ensureQueueOnce(job2.name);
|
|
828
2525
|
await boss.send(
|
|
829
2526
|
job2.name,
|
|
830
2527
|
{},
|
|
@@ -833,7 +2530,7 @@ async function queueRunOnceJob(boss, job2) {
|
|
|
833
2530
|
singletonKey: `runOnce:${job2.name}`
|
|
834
2531
|
}
|
|
835
2532
|
);
|
|
836
|
-
|
|
2533
|
+
jobLogger3.info(`[Job:${job2.name}] runOnce job queued`);
|
|
837
2534
|
}
|
|
838
2535
|
async function registerJob(job2) {
|
|
839
2536
|
const boss = getBoss();
|
|
@@ -841,15 +2538,271 @@ async function registerJob(job2) {
|
|
|
841
2538
|
throw new Error("pg-boss not initialized");
|
|
842
2539
|
}
|
|
843
2540
|
const queueName = job2.subscribedEvent ? getEventQueueName(job2.subscribedEvent) : job2.name;
|
|
844
|
-
|
|
2541
|
+
jobLogger3.debug(`Registering job: ${job2.name}`, {
|
|
845
2542
|
queueName,
|
|
846
2543
|
subscribedEvent: job2.subscribedEvent
|
|
847
2544
|
});
|
|
848
|
-
|
|
2545
|
+
const ensureQueueOnce = createQueueEnsurer(boss, resolveQueuePolicy(job2));
|
|
2546
|
+
await registerWorker(boss, job2, queueName, ensureQueueOnce);
|
|
849
2547
|
connectEventToQueue(boss, job2, queueName);
|
|
850
|
-
await registerCronSchedule(boss, job2);
|
|
851
|
-
await queueRunOnceJob(boss, job2);
|
|
852
|
-
|
|
2548
|
+
await registerCronSchedule(boss, job2, ensureQueueOnce);
|
|
2549
|
+
await queueRunOnceJob(boss, job2, ensureQueueOnce);
|
|
2550
|
+
jobLogger3.debug(`Job registered: ${job2.name}`);
|
|
2551
|
+
}
|
|
2552
|
+
var wsLogger = logger.child("@spfn/core:ws");
|
|
2553
|
+
function isOriginAllowed(origin, allowList) {
|
|
2554
|
+
if (!allowList) {
|
|
2555
|
+
return true;
|
|
2556
|
+
}
|
|
2557
|
+
if (!origin) {
|
|
2558
|
+
return true;
|
|
2559
|
+
}
|
|
2560
|
+
return allowList.has(origin);
|
|
2561
|
+
}
|
|
2562
|
+
async function attachWSHandler(server, router, config = {}, tokenManager) {
|
|
2563
|
+
const WebSocketServer = await loadWSServer();
|
|
2564
|
+
const {
|
|
2565
|
+
pingInterval = 3e4,
|
|
2566
|
+
path = "/ws",
|
|
2567
|
+
maxPayload = 1048576,
|
|
2568
|
+
maxBufferedBytes = 1048576,
|
|
2569
|
+
maxConnections = 1e4,
|
|
2570
|
+
maxConnectionsPerSubject = 0,
|
|
2571
|
+
allowedOrigins,
|
|
2572
|
+
auth: authConfig
|
|
2573
|
+
} = config;
|
|
2574
|
+
const originAllowList = allowedOrigins && allowedOrigins.length > 0 ? new Set(allowedOrigins) : null;
|
|
2575
|
+
if (authConfig?.enabled && !tokenManager) {
|
|
2576
|
+
throw new Error(
|
|
2577
|
+
"WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
const wss = new WebSocketServer({ server, path, maxPayload });
|
|
2581
|
+
const clients = /* @__PURE__ */ new Set();
|
|
2582
|
+
const subjectCounts = /* @__PURE__ */ new Map();
|
|
2583
|
+
wss.on("connection", (ws, req) => {
|
|
2584
|
+
if (!isOriginAllowed(req.headers?.origin, originAllowList)) {
|
|
2585
|
+
wsLogger.warn("WebSocket rejected: disallowed origin", { origin: req.headers?.origin });
|
|
2586
|
+
ws.close(1008, "Origin not allowed");
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
if (clients.size >= maxConnections) {
|
|
2590
|
+
ws.close(1013, "Server at capacity");
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
clients.add(ws);
|
|
2594
|
+
ws.on("close", () => clients.delete(ws));
|
|
2595
|
+
handleConnection(ws, req, router, authConfig, tokenManager, {
|
|
2596
|
+
pingInterval,
|
|
2597
|
+
maxBufferedBytes,
|
|
2598
|
+
maxConnectionsPerSubject,
|
|
2599
|
+
subjectCounts
|
|
2600
|
+
}).catch((err) => {
|
|
2601
|
+
wsLogger.error("WebSocket connection handler error", err);
|
|
2602
|
+
if (ws.readyState === 1) ws.close(1011, "Internal server error");
|
|
2603
|
+
});
|
|
2604
|
+
});
|
|
2605
|
+
wss.on("error", (err) => {
|
|
2606
|
+
wsLogger.error("WebSocket server error", err);
|
|
2607
|
+
});
|
|
2608
|
+
wsLogger.info(`\u2713 WebSocket endpoint registered at ${path}`, {
|
|
2609
|
+
events: router.eventNames,
|
|
2610
|
+
auth: !!authConfig?.enabled
|
|
2611
|
+
});
|
|
2612
|
+
return () => new Promise((resolve2, reject) => {
|
|
2613
|
+
for (const client of clients) {
|
|
2614
|
+
client.close(1001, "Server shutting down");
|
|
2615
|
+
}
|
|
2616
|
+
clients.clear();
|
|
2617
|
+
wss.close((err) => {
|
|
2618
|
+
if (err) reject(err);
|
|
2619
|
+
else resolve2();
|
|
2620
|
+
});
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
async function handleConnection(ws, req, router, authConfig, tokenManager, opts) {
|
|
2624
|
+
const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;
|
|
2625
|
+
let pingTimer;
|
|
2626
|
+
let connectionUnsubscribes = [];
|
|
2627
|
+
let subscribedEvents = [];
|
|
2628
|
+
ws.on("close", () => {
|
|
2629
|
+
clearInterval(pingTimer);
|
|
2630
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
2631
|
+
if (subscribedEvents.length > 0)
|
|
2632
|
+
wsLogger.info("WebSocket connection closed", { events: subscribedEvents });
|
|
2633
|
+
});
|
|
2634
|
+
const url = parseURL(req);
|
|
2635
|
+
if (!url) {
|
|
2636
|
+
ws.close(1002, "Invalid request URL");
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : void 0);
|
|
2640
|
+
if (subject === false) {
|
|
2641
|
+
ws.close(4001, "Missing token");
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
if (subject === null) {
|
|
2645
|
+
ws.close(4001, "Invalid or expired token");
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
const requestedEvents = parseRequestedEvents2(url, router.eventNames);
|
|
2649
|
+
if (requestedEvents.length === 0) {
|
|
2650
|
+
ws.close(4e3, "No valid event names specified");
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);
|
|
2654
|
+
if (allowedEvents === null) {
|
|
2655
|
+
ws.close(4003, "Not authorized for any requested events");
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
if (maxConnectionsPerSubject > 0 && typeof subject === "string") {
|
|
2659
|
+
const current = subjectCounts.get(subject) ?? 0;
|
|
2660
|
+
if (current >= maxConnectionsPerSubject) {
|
|
2661
|
+
ws.close(1013, "Too many connections for this subject");
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
subjectCounts.set(subject, current + 1);
|
|
2665
|
+
ws.on("close", () => {
|
|
2666
|
+
const remaining = (subjectCounts.get(subject) ?? 1) - 1;
|
|
2667
|
+
if (remaining <= 0) subjectCounts.delete(subject);
|
|
2668
|
+
else subjectCounts.set(subject, remaining);
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
subscribedEvents = allowedEvents;
|
|
2672
|
+
wsLogger.info("WebSocket connection established", {
|
|
2673
|
+
events: allowedEvents,
|
|
2674
|
+
subject: subject ?? void 0
|
|
2675
|
+
});
|
|
2676
|
+
const connection = createConnection(ws, maxBufferedBytes);
|
|
2677
|
+
connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);
|
|
2678
|
+
if (ws.readyState !== 1) {
|
|
2679
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
2680
|
+
connectionUnsubscribes = [];
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
ws.on("message", (data) => {
|
|
2684
|
+
onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
|
|
2685
|
+
});
|
|
2686
|
+
if (pingInterval > 0) {
|
|
2687
|
+
ws.isAlive = true;
|
|
2688
|
+
ws.on("pong", () => {
|
|
2689
|
+
ws.isAlive = true;
|
|
2690
|
+
});
|
|
2691
|
+
pingTimer = setInterval(() => {
|
|
2692
|
+
if (ws.readyState !== 1) return;
|
|
2693
|
+
if (ws.isAlive === false) {
|
|
2694
|
+
ws.terminate();
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
ws.isAlive = false;
|
|
2698
|
+
ws.ping();
|
|
2699
|
+
}, pingInterval);
|
|
2700
|
+
}
|
|
2701
|
+
connection.send("__connected", {
|
|
2702
|
+
subscribedEvents: allowedEvents,
|
|
2703
|
+
timestamp: Date.now()
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2706
|
+
function parseURL(req) {
|
|
2707
|
+
try {
|
|
2708
|
+
return new URL(req.url ?? "/", "ws://localhost");
|
|
2709
|
+
} catch {
|
|
2710
|
+
return null;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
async function resolveSubject(url, tokenManager) {
|
|
2714
|
+
if (!tokenManager) {
|
|
2715
|
+
return void 0;
|
|
2716
|
+
}
|
|
2717
|
+
const token = url.searchParams.get("token");
|
|
2718
|
+
if (!token) {
|
|
2719
|
+
return false;
|
|
2720
|
+
}
|
|
2721
|
+
return await tokenManager.verify(token);
|
|
2722
|
+
}
|
|
2723
|
+
function parseRequestedEvents2(url, validEventNames) {
|
|
2724
|
+
const eventsParam = url.searchParams.get("events");
|
|
2725
|
+
if (!eventsParam) {
|
|
2726
|
+
return [];
|
|
2727
|
+
}
|
|
2728
|
+
return eventsParam.split(",").map((e) => e.trim()).filter((e) => validEventNames.includes(e));
|
|
2729
|
+
}
|
|
2730
|
+
async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
|
|
2731
|
+
if (!subject || !authConfig?.authorize) {
|
|
2732
|
+
return requestedEvents;
|
|
2733
|
+
}
|
|
2734
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
2735
|
+
return allowed.length === 0 ? null : allowed;
|
|
2736
|
+
}
|
|
2737
|
+
function safeSend(ws, frame, maxBufferedBytes) {
|
|
2738
|
+
if (ws.readyState !== 1) return;
|
|
2739
|
+
if (ws.bufferedAmount > maxBufferedBytes) {
|
|
2740
|
+
ws.close(1013, "Send buffer overflow");
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2743
|
+
try {
|
|
2744
|
+
ws.send(JSON.stringify(frame));
|
|
2745
|
+
} catch {
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
function createConnection(ws, maxBufferedBytes) {
|
|
2749
|
+
return {
|
|
2750
|
+
send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),
|
|
2751
|
+
close: (code, reason) => ws.close(code, reason)
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
function subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes) {
|
|
2755
|
+
const unsubscribes = [];
|
|
2756
|
+
for (const eventName of allowedEvents) {
|
|
2757
|
+
const eventDef = router.events[eventName];
|
|
2758
|
+
if (!eventDef) continue;
|
|
2759
|
+
const unsubscribe = eventDef.subscribe((payload) => {
|
|
2760
|
+
if (ws.readyState !== 1) return;
|
|
2761
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
2762
|
+
if (!authConfig.filter[eventName](subject, payload)) return;
|
|
2763
|
+
}
|
|
2764
|
+
safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);
|
|
2765
|
+
});
|
|
2766
|
+
unsubscribes.push(unsubscribe);
|
|
2767
|
+
}
|
|
2768
|
+
return unsubscribes;
|
|
2769
|
+
}
|
|
2770
|
+
async function onClientMessage(data, router, connection, subject) {
|
|
2771
|
+
let message;
|
|
2772
|
+
try {
|
|
2773
|
+
message = JSON.parse(data.toString());
|
|
2774
|
+
} catch {
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
const { type, data: payload } = message;
|
|
2778
|
+
if (!type) return;
|
|
2779
|
+
const handler = router.messages[type];
|
|
2780
|
+
if (!handler) return;
|
|
2781
|
+
try {
|
|
2782
|
+
await handler({ payload, subject, ws: connection });
|
|
2783
|
+
} catch (err) {
|
|
2784
|
+
wsLogger.error(`WebSocket message handler error: ${type}`, err);
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
function resolveWSServerCtor(mod) {
|
|
2788
|
+
return mod.WebSocketServer ?? mod.Server ?? mod.default?.WebSocketServer ?? mod.default?.Server;
|
|
2789
|
+
}
|
|
2790
|
+
async function loadWSServer() {
|
|
2791
|
+
let mod;
|
|
2792
|
+
try {
|
|
2793
|
+
mod = await import('ws');
|
|
2794
|
+
} catch {
|
|
2795
|
+
throw new Error(
|
|
2796
|
+
'@spfn/core WebSocket support requires the "ws" package.\nInstall it with: pnpm add ws'
|
|
2797
|
+
);
|
|
2798
|
+
}
|
|
2799
|
+
const WSS = resolveWSServerCtor(mod);
|
|
2800
|
+
if (typeof WSS !== "function") {
|
|
2801
|
+
throw new Error(
|
|
2802
|
+
"WebSocketServer not found in ws module. Ensure ws@^8 is installed: pnpm add ws"
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
return WSS;
|
|
853
2806
|
}
|
|
854
2807
|
function getNetworkAddress() {
|
|
855
2808
|
const nets = networkInterfaces();
|
|
@@ -886,18 +2839,57 @@ function printBanner(options) {
|
|
|
886
2839
|
}
|
|
887
2840
|
console.log("");
|
|
888
2841
|
}
|
|
2842
|
+
var PORT_DEFAULTS = {
|
|
2843
|
+
server: 8790
|
|
2844
|
+
};
|
|
2845
|
+
var HOST_DEFAULT = "localhost";
|
|
2846
|
+
var CONFIG_FILE_NAMES = [
|
|
2847
|
+
"spfn.config.js",
|
|
2848
|
+
"spfn.config.mjs"
|
|
2849
|
+
];
|
|
2850
|
+
async function loadAppConfig(cwd = process.cwd()) {
|
|
2851
|
+
for (const fileName of CONFIG_FILE_NAMES) {
|
|
2852
|
+
const fullPath = join(cwd, fileName);
|
|
2853
|
+
if (!existsSync(fullPath)) {
|
|
2854
|
+
continue;
|
|
2855
|
+
}
|
|
2856
|
+
try {
|
|
2857
|
+
const module = await import(pathToFileURL(fullPath).href);
|
|
2858
|
+
return module.default ?? {};
|
|
2859
|
+
} catch (error) {
|
|
2860
|
+
console.warn(
|
|
2861
|
+
`\u26A0\uFE0F ${fileName} could not be imported \u2014 falling back to defaults for ports and host. ${error instanceof Error ? error.message : String(error)}`
|
|
2862
|
+
);
|
|
2863
|
+
return {};
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
return {};
|
|
2867
|
+
}
|
|
2868
|
+
function resolveServerAddress(config, deprecated = {}, env6 = process.env) {
|
|
2869
|
+
return {
|
|
2870
|
+
port: readPort(env6.SPFN_PORT) ?? config.ports?.server ?? deprecated.port ?? PORT_DEFAULTS.server,
|
|
2871
|
+
host: env6.SPFN_HOST || config.host || deprecated.host || HOST_DEFAULT
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
function readPort(value) {
|
|
2875
|
+
if (!value) {
|
|
2876
|
+
return void 0;
|
|
2877
|
+
}
|
|
2878
|
+
const port = Number(value);
|
|
2879
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
|
|
2880
|
+
}
|
|
889
2881
|
|
|
890
2882
|
// src/server/validation.ts
|
|
891
|
-
function validateServerConfig(
|
|
892
|
-
if (
|
|
893
|
-
if (!Number.isInteger(
|
|
2883
|
+
function validateServerConfig(config) {
|
|
2884
|
+
if (config.port !== void 0) {
|
|
2885
|
+
if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
|
|
894
2886
|
throw new Error(
|
|
895
|
-
`Invalid port: ${
|
|
2887
|
+
`Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
|
|
896
2888
|
);
|
|
897
2889
|
}
|
|
898
2890
|
}
|
|
899
|
-
if (
|
|
900
|
-
const { request, keepAlive, headers } =
|
|
2891
|
+
if (config.timeout) {
|
|
2892
|
+
const { request, keepAlive, headers } = config.timeout;
|
|
901
2893
|
if (request !== void 0 && (request < 0 || !Number.isFinite(request))) {
|
|
902
2894
|
throw new Error(`Invalid timeout.request: ${request}. Must be a positive number.`);
|
|
903
2895
|
}
|
|
@@ -913,16 +2905,16 @@ function validateServerConfig(config2) {
|
|
|
913
2905
|
);
|
|
914
2906
|
}
|
|
915
2907
|
}
|
|
916
|
-
if (
|
|
917
|
-
const timeout =
|
|
2908
|
+
if (config.shutdown?.timeout !== void 0) {
|
|
2909
|
+
const timeout = config.shutdown.timeout;
|
|
918
2910
|
if (timeout < 0 || !Number.isFinite(timeout)) {
|
|
919
2911
|
throw new Error(`Invalid shutdown.timeout: ${timeout}. Must be a positive number.`);
|
|
920
2912
|
}
|
|
921
2913
|
}
|
|
922
|
-
if (
|
|
923
|
-
if (!
|
|
2914
|
+
if (config.healthCheck?.path) {
|
|
2915
|
+
if (!config.healthCheck.path.startsWith("/")) {
|
|
924
2916
|
throw new Error(
|
|
925
|
-
`Invalid healthCheck.path: "${
|
|
2917
|
+
`Invalid healthCheck.path: "${config.healthCheck.path}". Must start with "/".`
|
|
926
2918
|
);
|
|
927
2919
|
}
|
|
928
2920
|
}
|
|
@@ -931,19 +2923,19 @@ var DEFAULT_MAX_LISTENERS = 15;
|
|
|
931
2923
|
var TIMEOUTS = {
|
|
932
2924
|
SERVER_CLOSE: 5e3,
|
|
933
2925
|
DATABASE_CLOSE: 5e3,
|
|
934
|
-
REDIS_CLOSE: 5e3
|
|
935
|
-
PRODUCTION_ERROR_SHUTDOWN: 1e4
|
|
936
|
-
};
|
|
2926
|
+
REDIS_CLOSE: 5e3};
|
|
937
2927
|
var CONFIG_FILE_PATHS = [
|
|
938
2928
|
".spfn/server/server.config.mjs",
|
|
2929
|
+
".spfn/server/server.config.js",
|
|
939
2930
|
".spfn/server/server.config",
|
|
940
2931
|
"src/server/server.config",
|
|
941
2932
|
"src/server/server.config.ts"
|
|
942
2933
|
];
|
|
2934
|
+
var AUTHORED_CONFIG_PATH = "src/server/server.config.ts";
|
|
943
2935
|
var processHandlersRegistered = false;
|
|
944
|
-
async function startServer(
|
|
945
|
-
|
|
946
|
-
const finalConfig = await loadAndMergeConfig(
|
|
2936
|
+
async function startServer(config) {
|
|
2937
|
+
loadEnv();
|
|
2938
|
+
const finalConfig = await loadAndMergeConfig(config);
|
|
947
2939
|
const { host, port, debug } = finalConfig;
|
|
948
2940
|
validateServerConfig(finalConfig);
|
|
949
2941
|
if (!host || !port) {
|
|
@@ -957,10 +2949,17 @@ async function startServer(config2) {
|
|
|
957
2949
|
};
|
|
958
2950
|
try {
|
|
959
2951
|
await initializeInfrastructure(finalConfig);
|
|
2952
|
+
await runMigrationBootGate(finalConfig);
|
|
960
2953
|
const app = await createServer(finalConfig);
|
|
961
2954
|
const server = startHttpServer(app, host, port);
|
|
2955
|
+
let wsCleanup;
|
|
2956
|
+
if (finalConfig.websockets) {
|
|
2957
|
+
wsCleanup = await initializeWebSocket(server, app, finalConfig);
|
|
2958
|
+
}
|
|
962
2959
|
const timeouts = getTimeoutConfig(finalConfig.timeout);
|
|
963
2960
|
applyServerTimeouts(server, timeouts);
|
|
2961
|
+
const fetchTimeouts = getFetchTimeoutConfig(finalConfig.fetchTimeout);
|
|
2962
|
+
applyGlobalFetchTimeouts(fetchTimeouts);
|
|
964
2963
|
logServerTimeouts(timeouts);
|
|
965
2964
|
printBanner({
|
|
966
2965
|
mode: debug ? "Development" : "Production",
|
|
@@ -968,7 +2967,7 @@ async function startServer(config2) {
|
|
|
968
2967
|
port
|
|
969
2968
|
});
|
|
970
2969
|
logServerStarted(debug, host, port, finalConfig, timeouts);
|
|
971
|
-
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState);
|
|
2970
|
+
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState, wsCleanup);
|
|
972
2971
|
const shutdown = createGracefulShutdown(shutdownServer, finalConfig, shutdownState);
|
|
973
2972
|
registerProcessHandlers(shutdown);
|
|
974
2973
|
const serverInstance = {
|
|
@@ -977,11 +2976,6 @@ async function startServer(config2) {
|
|
|
977
2976
|
config: finalConfig,
|
|
978
2977
|
close: async () => {
|
|
979
2978
|
serverLogger.info("Manual server shutdown requested");
|
|
980
|
-
if (shutdownState.isShuttingDown) {
|
|
981
|
-
serverLogger.warn("Shutdown already in progress, ignoring manual close request");
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
shutdownState.isShuttingDown = true;
|
|
985
2979
|
await shutdownServer();
|
|
986
2980
|
}
|
|
987
2981
|
};
|
|
@@ -1001,8 +2995,7 @@ async function startServer(config2) {
|
|
|
1001
2995
|
throw error;
|
|
1002
2996
|
}
|
|
1003
2997
|
}
|
|
1004
|
-
async function loadAndMergeConfig(
|
|
1005
|
-
const cwd = process.cwd();
|
|
2998
|
+
async function loadAndMergeConfig(config, cwd = process.cwd()) {
|
|
1006
2999
|
let fileConfig = {};
|
|
1007
3000
|
let loadedConfigPath = null;
|
|
1008
3001
|
for (const configPath of CONFIG_FILE_PATHS) {
|
|
@@ -1020,31 +3013,52 @@ async function loadAndMergeConfig(config2) {
|
|
|
1020
3013
|
}
|
|
1021
3014
|
if (loadedConfigPath) {
|
|
1022
3015
|
serverLogger.debug(`Loaded configuration from ${loadedConfigPath}`);
|
|
3016
|
+
} else if (existsSync(join(cwd, AUTHORED_CONFIG_PATH))) {
|
|
3017
|
+
serverLogger.warn(
|
|
3018
|
+
`\u26A0\uFE0F ${AUTHORED_CONFIG_PATH} exists but no configuration was loaded, so this server is running on defaults: no middlewares, no routes and no infrastructure settings from it. Run "spfn build" so the compiled config lands in .spfn/server/.`
|
|
3019
|
+
);
|
|
1023
3020
|
} else {
|
|
1024
3021
|
serverLogger.debug("No configuration file found, using defaults");
|
|
1025
3022
|
}
|
|
3023
|
+
const appConfig = await loadAppConfig(cwd);
|
|
3024
|
+
warnOnDeprecatedAddress(config, fileConfig);
|
|
3025
|
+
const address = resolveServerAddress(appConfig, {
|
|
3026
|
+
port: config?.port ?? fileConfig?.port,
|
|
3027
|
+
host: config?.host ?? fileConfig?.host
|
|
3028
|
+
});
|
|
1026
3029
|
return {
|
|
1027
3030
|
...fileConfig,
|
|
1028
|
-
...
|
|
1029
|
-
port:
|
|
1030
|
-
host:
|
|
3031
|
+
...config,
|
|
3032
|
+
port: address.port,
|
|
3033
|
+
host: address.host
|
|
1031
3034
|
};
|
|
1032
3035
|
}
|
|
1033
|
-
function
|
|
3036
|
+
function warnOnDeprecatedAddress(config, fileConfig) {
|
|
3037
|
+
const used = [
|
|
3038
|
+
(config?.port ?? fileConfig?.port) !== void 0 ? ".port()" : null,
|
|
3039
|
+
(config?.host ?? fileConfig?.host) !== void 0 ? ".host()" : null
|
|
3040
|
+
].filter(Boolean);
|
|
3041
|
+
if (used.length > 0) {
|
|
3042
|
+
serverLogger.warn(
|
|
3043
|
+
`\u26A0\uFE0F ${used.join(" and ")} in server.config is deprecated and will be removed. Move the address to spfn.config.js \u2014 \`ports: { next, server }\` and \`host\` \u2014 so the Dockerfile, the compose file and next.config can read the same value.`
|
|
3044
|
+
);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
function getInfrastructureConfig(config) {
|
|
1034
3048
|
return {
|
|
1035
|
-
database:
|
|
1036
|
-
redis:
|
|
3049
|
+
database: config.infrastructure?.database !== false,
|
|
3050
|
+
redis: config.infrastructure?.redis !== false
|
|
1037
3051
|
};
|
|
1038
3052
|
}
|
|
1039
|
-
async function initializeInfrastructure(
|
|
1040
|
-
if (
|
|
3053
|
+
async function initializeInfrastructure(config) {
|
|
3054
|
+
if (config.lifecycle?.beforeInfrastructure) {
|
|
1041
3055
|
serverLogger.debug("Executing beforeInfrastructure hook...");
|
|
1042
|
-
await
|
|
3056
|
+
await config.lifecycle.beforeInfrastructure(config);
|
|
1043
3057
|
}
|
|
1044
|
-
const infraConfig = getInfrastructureConfig(
|
|
3058
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1045
3059
|
if (infraConfig.database) {
|
|
1046
3060
|
serverLogger.debug("Initializing database...");
|
|
1047
|
-
await initDatabase(
|
|
3061
|
+
await initDatabase(config.database);
|
|
1048
3062
|
} else {
|
|
1049
3063
|
serverLogger.debug("Database initialization disabled");
|
|
1050
3064
|
}
|
|
@@ -1054,11 +3068,11 @@ async function initializeInfrastructure(config2) {
|
|
|
1054
3068
|
} else {
|
|
1055
3069
|
serverLogger.debug("Redis initialization disabled");
|
|
1056
3070
|
}
|
|
1057
|
-
if (
|
|
3071
|
+
if (config.lifecycle?.afterInfrastructure) {
|
|
1058
3072
|
serverLogger.debug("Executing afterInfrastructure hook...");
|
|
1059
|
-
await
|
|
3073
|
+
await config.lifecycle.afterInfrastructure();
|
|
1060
3074
|
}
|
|
1061
|
-
if (
|
|
3075
|
+
if (config.jobs) {
|
|
1062
3076
|
const dbUrl = env.DATABASE_URL;
|
|
1063
3077
|
if (!dbUrl) {
|
|
1064
3078
|
throw new Error(
|
|
@@ -1068,22 +3082,22 @@ async function initializeInfrastructure(config2) {
|
|
|
1068
3082
|
serverLogger.debug("Initializing pg-boss...");
|
|
1069
3083
|
await initBoss({
|
|
1070
3084
|
connectionString: dbUrl,
|
|
1071
|
-
...
|
|
3085
|
+
...config.jobsConfig
|
|
1072
3086
|
});
|
|
1073
3087
|
serverLogger.debug("Registering jobs...");
|
|
1074
|
-
await registerJobs(
|
|
3088
|
+
await registerJobs(config.jobs);
|
|
1075
3089
|
}
|
|
1076
|
-
if (
|
|
1077
|
-
const infraConfig2 = getInfrastructureConfig(
|
|
3090
|
+
if (config.workflows) {
|
|
3091
|
+
const infraConfig2 = getInfrastructureConfig(config);
|
|
1078
3092
|
if (!infraConfig2.database) {
|
|
1079
3093
|
throw new Error(
|
|
1080
3094
|
"Workflows require database connection. Ensure database is enabled in infrastructure config."
|
|
1081
3095
|
);
|
|
1082
3096
|
}
|
|
1083
3097
|
serverLogger.debug("Initializing workflow engine...");
|
|
1084
|
-
|
|
3098
|
+
config.workflows._init(
|
|
1085
3099
|
getDatabase(),
|
|
1086
|
-
|
|
3100
|
+
config.workflowsConfig
|
|
1087
3101
|
);
|
|
1088
3102
|
serverLogger.info("Workflow engine initialized");
|
|
1089
3103
|
}
|
|
@@ -1096,8 +3110,52 @@ function startHttpServer(app, host, port) {
|
|
|
1096
3110
|
hostname: host
|
|
1097
3111
|
});
|
|
1098
3112
|
}
|
|
1099
|
-
function
|
|
1100
|
-
const
|
|
3113
|
+
async function initializeWebSocket(server, app, config) {
|
|
3114
|
+
const wsRouter = config.websockets;
|
|
3115
|
+
const wsConfig = config.websocketsConfig ?? {};
|
|
3116
|
+
const authConfig = wsConfig.auth;
|
|
3117
|
+
const wsPath = wsConfig.path ?? "/ws";
|
|
3118
|
+
const debug = config.debug ?? process.env.NODE_ENV === "development";
|
|
3119
|
+
let tokenManager;
|
|
3120
|
+
if (authConfig?.enabled) {
|
|
3121
|
+
let store = authConfig.store;
|
|
3122
|
+
if (!store) {
|
|
3123
|
+
try {
|
|
3124
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
3125
|
+
const cache2 = getCache2();
|
|
3126
|
+
if (cache2) {
|
|
3127
|
+
store = new CacheTokenStore(cache2);
|
|
3128
|
+
if (debug) serverLogger.info("WS token store: cache (Redis/Valkey)");
|
|
3129
|
+
}
|
|
3130
|
+
} catch {
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
3134
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
3135
|
+
ttl: authConfig.tokenTtl,
|
|
3136
|
+
store
|
|
3137
|
+
});
|
|
3138
|
+
const tokenPath = wsPath.replace(/\/[^/]+$/, "/token");
|
|
3139
|
+
const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
|
|
3140
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
3141
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
3142
|
+
const subject = getSubject(c);
|
|
3143
|
+
if (!subject) {
|
|
3144
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
3145
|
+
}
|
|
3146
|
+
const token = await tokenManager.issue(subject);
|
|
3147
|
+
return c.json({ token });
|
|
3148
|
+
});
|
|
3149
|
+
if (debug) serverLogger.info(`\u2713 WS token endpoint registered at POST ${tokenPath}`);
|
|
3150
|
+
}
|
|
3151
|
+
await wireEventRouterCache(wsRouter, {
|
|
3152
|
+
multiInstance: wsConfig.multiInstance,
|
|
3153
|
+
channelPrefix: wsConfig.channelPrefix
|
|
3154
|
+
});
|
|
3155
|
+
return await attachWSHandler(server, wsRouter, wsConfig, tokenManager);
|
|
3156
|
+
}
|
|
3157
|
+
function logMiddlewareOrder(config) {
|
|
3158
|
+
const middlewareOrder = buildMiddlewareOrder(config);
|
|
1101
3159
|
serverLogger.debug("Middleware execution order", {
|
|
1102
3160
|
order: middlewareOrder
|
|
1103
3161
|
});
|
|
@@ -1109,8 +3167,8 @@ function logServerTimeouts(timeouts) {
|
|
|
1109
3167
|
headers: `${timeouts.headers}ms`
|
|
1110
3168
|
});
|
|
1111
3169
|
}
|
|
1112
|
-
function logServerStarted(debug, host, port,
|
|
1113
|
-
const startupConfig = buildStartupConfig(
|
|
3170
|
+
function logServerStarted(debug, host, port, config, timeouts) {
|
|
3171
|
+
const startupConfig = buildStartupConfig(config, timeouts);
|
|
1114
3172
|
serverLogger.info("Server started successfully", {
|
|
1115
3173
|
mode: debug ? "development" : "production",
|
|
1116
3174
|
host,
|
|
@@ -1118,65 +3176,84 @@ function logServerStarted(debug, host, port, config2, timeouts) {
|
|
|
1118
3176
|
config: startupConfig
|
|
1119
3177
|
});
|
|
1120
3178
|
}
|
|
1121
|
-
function createShutdownHandler(server,
|
|
3179
|
+
function createShutdownHandler(server, config, shutdownState, wsCleanup) {
|
|
1122
3180
|
return async () => {
|
|
1123
3181
|
if (shutdownState.isShuttingDown) {
|
|
1124
3182
|
serverLogger.debug("Shutdown already in progress for this instance, skipping");
|
|
1125
3183
|
return;
|
|
1126
3184
|
}
|
|
1127
3185
|
shutdownState.isShuttingDown = true;
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
timeoutId = setTimeout(() => {
|
|
1145
|
-
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
1146
|
-
}, TIMEOUTS.SERVER_CLOSE);
|
|
1147
|
-
})
|
|
1148
|
-
]).catch((error) => {
|
|
1149
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
1150
|
-
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
1151
|
-
});
|
|
1152
|
-
if (config2.jobs) {
|
|
1153
|
-
serverLogger.debug("Stopping pg-boss...");
|
|
3186
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
3187
|
+
const shutdownManager = getShutdownManager();
|
|
3188
|
+
shutdownManager.beginShutdown();
|
|
3189
|
+
serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
|
|
3190
|
+
await closeHttpServer(server);
|
|
3191
|
+
if (wsCleanup) {
|
|
3192
|
+
serverLogger.info("Phase 1.5: Closing WebSocket server...");
|
|
3193
|
+
try {
|
|
3194
|
+
await wsCleanup();
|
|
3195
|
+
serverLogger.info("WebSocket server closed");
|
|
3196
|
+
} catch (error) {
|
|
3197
|
+
serverLogger.error("WebSocket server close failed", error);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
if (config.jobs) {
|
|
3201
|
+
serverLogger.info("Phase 2: Stopping pg-boss...");
|
|
1154
3202
|
try {
|
|
1155
3203
|
await stopBoss();
|
|
3204
|
+
serverLogger.info("pg-boss stopped");
|
|
1156
3205
|
} catch (error) {
|
|
1157
3206
|
serverLogger.error("pg-boss stop failed", error);
|
|
1158
3207
|
}
|
|
1159
3208
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
3209
|
+
const drainTimeout = Math.floor(shutdownTimeout * 0.8);
|
|
3210
|
+
serverLogger.info(`Phase 3: Draining tracked operations (timeout: ${drainTimeout}ms)...`);
|
|
3211
|
+
await shutdownManager.execute(drainTimeout);
|
|
3212
|
+
if (config.lifecycle?.beforeShutdown) {
|
|
3213
|
+
serverLogger.info("Phase 4: Executing beforeShutdown lifecycle hook...");
|
|
1162
3214
|
try {
|
|
1163
|
-
await
|
|
3215
|
+
await config.lifecycle.beforeShutdown();
|
|
1164
3216
|
} catch (error) {
|
|
1165
|
-
serverLogger.error("beforeShutdown hook failed", error);
|
|
3217
|
+
serverLogger.error("beforeShutdown lifecycle hook failed", error);
|
|
1166
3218
|
}
|
|
1167
3219
|
}
|
|
1168
|
-
|
|
3220
|
+
serverLogger.info("Phase 5: Closing infrastructure...");
|
|
3221
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1169
3222
|
if (infraConfig.database) {
|
|
1170
|
-
serverLogger.debug("Closing database connections...");
|
|
1171
3223
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1172
3224
|
}
|
|
1173
3225
|
if (infraConfig.redis) {
|
|
1174
|
-
|
|
3226
|
+
await closeEventTransport();
|
|
1175
3227
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1176
3228
|
}
|
|
1177
3229
|
serverLogger.info("Server shutdown completed");
|
|
1178
3230
|
};
|
|
1179
3231
|
}
|
|
3232
|
+
async function closeHttpServer(server) {
|
|
3233
|
+
let timeoutId;
|
|
3234
|
+
await Promise.race([
|
|
3235
|
+
new Promise((resolve2, reject) => {
|
|
3236
|
+
server.close((err) => {
|
|
3237
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
3238
|
+
if (err) {
|
|
3239
|
+
serverLogger.error("HTTP server close error", err);
|
|
3240
|
+
reject(err);
|
|
3241
|
+
} else {
|
|
3242
|
+
serverLogger.info("HTTP server closed");
|
|
3243
|
+
resolve2();
|
|
3244
|
+
}
|
|
3245
|
+
});
|
|
3246
|
+
}),
|
|
3247
|
+
new Promise((_, reject) => {
|
|
3248
|
+
timeoutId = setTimeout(() => {
|
|
3249
|
+
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
3250
|
+
}, TIMEOUTS.SERVER_CLOSE);
|
|
3251
|
+
})
|
|
3252
|
+
]).catch((error) => {
|
|
3253
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
3254
|
+
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
1180
3257
|
async function closeInfrastructure(closeFn, name, timeout) {
|
|
1181
3258
|
let timeoutId;
|
|
1182
3259
|
try {
|
|
@@ -1196,14 +3273,14 @@ async function closeInfrastructure(closeFn, name, timeout) {
|
|
|
1196
3273
|
serverLogger.error(`${name} close failed or timed out`, error);
|
|
1197
3274
|
}
|
|
1198
3275
|
}
|
|
1199
|
-
function createGracefulShutdown(shutdownServer,
|
|
3276
|
+
function createGracefulShutdown(shutdownServer, config, shutdownState) {
|
|
1200
3277
|
return async (signal) => {
|
|
1201
3278
|
if (shutdownState.isShuttingDown) {
|
|
1202
3279
|
serverLogger.warn(`${signal} received but shutdown already in progress, ignoring`);
|
|
1203
3280
|
return;
|
|
1204
3281
|
}
|
|
1205
3282
|
serverLogger.info(`${signal} received, starting graceful shutdown...`);
|
|
1206
|
-
const shutdownTimeout = getShutdownTimeout(
|
|
3283
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
1207
3284
|
let timeoutId;
|
|
1208
3285
|
try {
|
|
1209
3286
|
await Promise.race([
|
|
@@ -1231,31 +3308,8 @@ function createGracefulShutdown(shutdownServer, config2, shutdownState) {
|
|
|
1231
3308
|
}
|
|
1232
3309
|
};
|
|
1233
3310
|
}
|
|
1234
|
-
function handleProcessError(errorType
|
|
1235
|
-
|
|
1236
|
-
const isDevelopment = env.NODE_ENV === "development";
|
|
1237
|
-
if (isDevelopment || process.env.WATCH_MODE === "true") {
|
|
1238
|
-
serverLogger.info("Exiting immediately for clean restart");
|
|
1239
|
-
process.exit(1);
|
|
1240
|
-
} else if (isProduction) {
|
|
1241
|
-
serverLogger.info(`Attempting graceful shutdown after ${errorType}`);
|
|
1242
|
-
const forceExitTimer = setTimeout(() => {
|
|
1243
|
-
serverLogger.error(`Forced exit after ${TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN}ms - graceful shutdown did not complete`);
|
|
1244
|
-
process.exit(1);
|
|
1245
|
-
}, TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN);
|
|
1246
|
-
shutdown(errorType).then(() => {
|
|
1247
|
-
clearTimeout(forceExitTimer);
|
|
1248
|
-
serverLogger.info("Graceful shutdown completed, exiting");
|
|
1249
|
-
process.exit(0);
|
|
1250
|
-
}).catch((shutdownError) => {
|
|
1251
|
-
clearTimeout(forceExitTimer);
|
|
1252
|
-
serverLogger.error("Graceful shutdown failed", shutdownError);
|
|
1253
|
-
process.exit(1);
|
|
1254
|
-
});
|
|
1255
|
-
} else {
|
|
1256
|
-
serverLogger.info("Exiting immediately");
|
|
1257
|
-
process.exit(1);
|
|
1258
|
-
}
|
|
3311
|
+
function handleProcessError(errorType) {
|
|
3312
|
+
serverLogger.warn(`${errorType} occurred - server continues running. Check logs above for details.`);
|
|
1259
3313
|
}
|
|
1260
3314
|
function registerProcessHandlers(shutdown) {
|
|
1261
3315
|
if (processHandlersRegistered) {
|
|
@@ -1290,7 +3344,7 @@ function registerProcessHandlers(shutdown) {
|
|
|
1290
3344
|
} else {
|
|
1291
3345
|
serverLogger.error("Uncaught exception", error);
|
|
1292
3346
|
}
|
|
1293
|
-
handleProcessError("UNCAUGHT_EXCEPTION"
|
|
3347
|
+
handleProcessError("UNCAUGHT_EXCEPTION");
|
|
1294
3348
|
});
|
|
1295
3349
|
process.on("unhandledRejection", (reason, promise) => {
|
|
1296
3350
|
if (reason instanceof Error) {
|
|
@@ -1308,25 +3362,89 @@ function registerProcessHandlers(shutdown) {
|
|
|
1308
3362
|
promise
|
|
1309
3363
|
});
|
|
1310
3364
|
}
|
|
1311
|
-
handleProcessError("UNHANDLED_REJECTION"
|
|
3365
|
+
handleProcessError("UNHANDLED_REJECTION");
|
|
1312
3366
|
});
|
|
1313
3367
|
serverLogger.debug("Process-level shutdown handlers registered successfully");
|
|
1314
3368
|
}
|
|
1315
|
-
async function cleanupOnFailure(
|
|
3369
|
+
async function cleanupOnFailure(config) {
|
|
1316
3370
|
try {
|
|
1317
3371
|
serverLogger.debug("Cleaning up after initialization failure...");
|
|
1318
|
-
const infraConfig = getInfrastructureConfig(
|
|
3372
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1319
3373
|
if (infraConfig.database) {
|
|
1320
3374
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1321
3375
|
}
|
|
1322
3376
|
if (infraConfig.redis) {
|
|
3377
|
+
await closeEventTransport();
|
|
1323
3378
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1324
3379
|
}
|
|
3380
|
+
resetShutdownManager();
|
|
1325
3381
|
serverLogger.debug("Cleanup completed");
|
|
1326
3382
|
} catch (cleanupError) {
|
|
1327
3383
|
serverLogger.error("Cleanup failed", cleanupError);
|
|
1328
3384
|
}
|
|
1329
3385
|
}
|
|
3386
|
+
function getInfrastructure(config) {
|
|
3387
|
+
return {
|
|
3388
|
+
database: config?.infrastructure?.database !== false,
|
|
3389
|
+
redis: config?.infrastructure?.redis !== false
|
|
3390
|
+
};
|
|
3391
|
+
}
|
|
3392
|
+
function withServerlessDefaults(config) {
|
|
3393
|
+
if (!config) {
|
|
3394
|
+
return config;
|
|
3395
|
+
}
|
|
3396
|
+
return {
|
|
3397
|
+
...config,
|
|
3398
|
+
database: {
|
|
3399
|
+
...config.database,
|
|
3400
|
+
healthCheck: { enabled: false, ...config.database?.healthCheck }
|
|
3401
|
+
}
|
|
3402
|
+
};
|
|
3403
|
+
}
|
|
3404
|
+
function warnIfJobsConfigured(config) {
|
|
3405
|
+
if (config?.jobs) {
|
|
3406
|
+
serverLogger.warn(
|
|
3407
|
+
"Jobs are configured but this is a serverless target: the in-process pg-boss worker is NOT started here, so enqueued jobs will not be processed on this deployment. Drain the queue from a scheduled endpoint (e.g. Vercel Cron \u2192 a route that processes a batch) or run jobs on an always-on target."
|
|
3408
|
+
);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
async function initInfrastructure(config) {
|
|
3412
|
+
const infra = getInfrastructure(config);
|
|
3413
|
+
if (infra.database) {
|
|
3414
|
+
await initDatabase(config?.database);
|
|
3415
|
+
}
|
|
3416
|
+
if (infra.redis) {
|
|
3417
|
+
await initCache();
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
var appPromise;
|
|
3421
|
+
function createServerlessApp(config) {
|
|
3422
|
+
if (!appPromise) {
|
|
3423
|
+
appPromise = buildServerlessApp(config);
|
|
3424
|
+
}
|
|
3425
|
+
return appPromise;
|
|
3426
|
+
}
|
|
3427
|
+
async function buildServerlessApp(rawConfig) {
|
|
3428
|
+
loadEnv();
|
|
3429
|
+
const config = withServerlessDefaults(rawConfig);
|
|
3430
|
+
warnIfJobsConfigured(config);
|
|
3431
|
+
await initInfrastructure(config);
|
|
3432
|
+
return createServer(config);
|
|
3433
|
+
}
|
|
3434
|
+
function resetServerlessApp() {
|
|
3435
|
+
appPromise = void 0;
|
|
3436
|
+
}
|
|
3437
|
+
async function provisionInfrastructure(config) {
|
|
3438
|
+
loadEnv();
|
|
3439
|
+
if (config?.lifecycle?.beforeInfrastructure) {
|
|
3440
|
+
await config.lifecycle.beforeInfrastructure(config);
|
|
3441
|
+
}
|
|
3442
|
+
await initInfrastructure(config);
|
|
3443
|
+
if (config?.lifecycle?.afterInfrastructure) {
|
|
3444
|
+
await config.lifecycle.afterInfrastructure();
|
|
3445
|
+
}
|
|
3446
|
+
serverLogger.info("Provisioning complete");
|
|
3447
|
+
}
|
|
1330
3448
|
|
|
1331
3449
|
// src/server/config-builder.ts
|
|
1332
3450
|
function collectHooks(lifecycles, key) {
|
|
@@ -1387,12 +3505,56 @@ var ServerConfigBuilder = class {
|
|
|
1387
3505
|
this.config.middlewares = middlewares;
|
|
1388
3506
|
return this;
|
|
1389
3507
|
}
|
|
3508
|
+
/**
|
|
3509
|
+
* Configure proxy-guard (verify trusted-proxy signature + origin → clientType)
|
|
3510
|
+
*/
|
|
3511
|
+
proxyGuard(proxyGuard) {
|
|
3512
|
+
this.config.proxyGuard = proxyGuard;
|
|
3513
|
+
return this;
|
|
3514
|
+
}
|
|
3515
|
+
/**
|
|
3516
|
+
* Configure rate limiting: an optional global default limiter plus the named
|
|
3517
|
+
* policies that `rateLimitPolicy(name, fallback)` tags resolve against.
|
|
3518
|
+
*
|
|
3519
|
+
* @example
|
|
3520
|
+
* ```typescript
|
|
3521
|
+
* .rateLimit({
|
|
3522
|
+
* mode: 'on',
|
|
3523
|
+
* default: { limit: 100, windowMs: 60_000 },
|
|
3524
|
+
* policies: { 'auth-login': { limit: 5, windowMs: 60_000 } },
|
|
3525
|
+
* })
|
|
3526
|
+
* ```
|
|
3527
|
+
*/
|
|
3528
|
+
rateLimit(rateLimit2) {
|
|
3529
|
+
this.config.rateLimit = rateLimit2;
|
|
3530
|
+
return this;
|
|
3531
|
+
}
|
|
3532
|
+
/**
|
|
3533
|
+
* Configure the SSRF policy for outbound `safeFetch` calls (webhooks,
|
|
3534
|
+
* callbacks). Private/reserved IPs are blocked by default.
|
|
3535
|
+
*
|
|
3536
|
+
* @example
|
|
3537
|
+
* ```typescript
|
|
3538
|
+
* .outboundFetch({ allowHosts: ['hooks.slack.com'] })
|
|
3539
|
+
* ```
|
|
3540
|
+
*/
|
|
3541
|
+
outboundFetch(outboundFetch) {
|
|
3542
|
+
this.config.outboundFetch = outboundFetch;
|
|
3543
|
+
return this;
|
|
3544
|
+
}
|
|
1390
3545
|
/**
|
|
1391
3546
|
* Register define-route based router
|
|
1392
3547
|
*
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
3548
|
+
* Router-level middleware (`.use()`) and package routers (`.packages()`) travel
|
|
3549
|
+
* with the router itself and are applied by `registerRoutes` when the routes are
|
|
3550
|
+
* mounted — this method only records which router to mount.
|
|
3551
|
+
*
|
|
3552
|
+
* It deliberately does **not** copy `router._globalMiddlewares` into
|
|
3553
|
+
* `config.middlewares`: that copy used to make `registerRoutes` see the same
|
|
3554
|
+
* middleware twice (once from the config list, once from the router it was
|
|
3555
|
+
* handed) and attach both to every route. Middleware that verifies a JWT survives
|
|
3556
|
+
* running twice; middleware that consumes one-shot state — a nonce replay ledger —
|
|
3557
|
+
* rejects its own request the second time round.
|
|
1396
3558
|
*
|
|
1397
3559
|
* @example
|
|
1398
3560
|
* ```typescript
|
|
@@ -1403,34 +3565,31 @@ var ServerConfigBuilder = class {
|
|
|
1403
3565
|
* .use([authMiddleware]);
|
|
1404
3566
|
*
|
|
1405
3567
|
* export default defineServerConfig()
|
|
1406
|
-
* .routes(appRouter) //
|
|
3568
|
+
* .routes(appRouter) // .use() middleware applied once, at registration
|
|
1407
3569
|
* .build();
|
|
1408
3570
|
* ```
|
|
1409
3571
|
*/
|
|
1410
3572
|
routes(router) {
|
|
1411
3573
|
this.config.routes = router;
|
|
1412
|
-
const allGlobalMiddlewares = [];
|
|
1413
|
-
if (router._globalMiddlewares?.length > 0) {
|
|
1414
|
-
allGlobalMiddlewares.push(...router._globalMiddlewares);
|
|
1415
|
-
}
|
|
1416
|
-
if (router._packageRouters?.length > 0) {
|
|
1417
|
-
for (const pkgRouter of router._packageRouters) {
|
|
1418
|
-
if (pkgRouter._globalMiddlewares?.length > 0) {
|
|
1419
|
-
allGlobalMiddlewares.push(...pkgRouter._globalMiddlewares);
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
if (allGlobalMiddlewares.length > 0) {
|
|
1424
|
-
this.config.middlewares = [
|
|
1425
|
-
...this.config.middlewares || [],
|
|
1426
|
-
...allGlobalMiddlewares
|
|
1427
|
-
];
|
|
1428
|
-
}
|
|
1429
3574
|
return this;
|
|
1430
3575
|
}
|
|
1431
3576
|
/**
|
|
1432
3577
|
* Register background jobs router
|
|
1433
3578
|
*
|
|
3579
|
+
* May be called once per domain router: repeated calls **merge** into one
|
|
3580
|
+
* router, the way `.routes()` composes. Calling it several times used to
|
|
3581
|
+
* keep only the last router and register nothing else, without warning.
|
|
3582
|
+
*
|
|
3583
|
+
* The pg-boss `config` is per server, so pass it on one call only.
|
|
3584
|
+
*
|
|
3585
|
+
* Two names can collide, and both fail loudly:
|
|
3586
|
+
* - a router **key** present in two calls throws here, at config time;
|
|
3587
|
+
* - two jobs sharing a **`job.name`** (the pg-boss queue name) throw in
|
|
3588
|
+
* `registerJobs()` at boot, before any queue or worker is created.
|
|
3589
|
+
*
|
|
3590
|
+
* @throws if a router key was already registered by an earlier call
|
|
3591
|
+
* @throws if `config` is given when an earlier call already gave one
|
|
3592
|
+
*
|
|
1434
3593
|
* @example
|
|
1435
3594
|
* ```typescript
|
|
1436
3595
|
* import { job, defineJobRouter } from '@spfn/core/job';
|
|
@@ -1443,14 +3602,20 @@ var ServerConfigBuilder = class {
|
|
|
1443
3602
|
*
|
|
1444
3603
|
* export default defineServerConfig()
|
|
1445
3604
|
* .routes(appRouter)
|
|
1446
|
-
* .jobs(jobRouter)
|
|
3605
|
+
* .jobs(jobRouter, { clearOnStart: isDev }) // config on one call only
|
|
3606
|
+
* .jobs(billingJobRouter) // merged, not replaced
|
|
1447
3607
|
* .build();
|
|
1448
3608
|
* ```
|
|
1449
3609
|
*/
|
|
1450
|
-
jobs(router,
|
|
1451
|
-
this.config.
|
|
1452
|
-
|
|
1453
|
-
|
|
3610
|
+
jobs(router, config) {
|
|
3611
|
+
if (config && this.config.jobsConfig) {
|
|
3612
|
+
throw new Error(
|
|
3613
|
+
"jobs(): pg-boss config was already given by an earlier .jobs() call; pass it once"
|
|
3614
|
+
);
|
|
3615
|
+
}
|
|
3616
|
+
this.config.jobs = this.config.jobs ? mergeJobRouters(this.config.jobs, router) : router;
|
|
3617
|
+
if (config) {
|
|
3618
|
+
this.config.jobsConfig = config;
|
|
1454
3619
|
}
|
|
1455
3620
|
return this;
|
|
1456
3621
|
}
|
|
@@ -1482,10 +3647,44 @@ var ServerConfigBuilder = class {
|
|
|
1482
3647
|
* .events(eventRouter, { path: '/sse' })
|
|
1483
3648
|
* ```
|
|
1484
3649
|
*/
|
|
1485
|
-
events(router,
|
|
3650
|
+
events(router, config) {
|
|
1486
3651
|
this.config.events = router;
|
|
1487
|
-
if (
|
|
1488
|
-
this.config.eventsConfig =
|
|
3652
|
+
if (config) {
|
|
3653
|
+
this.config.eventsConfig = config;
|
|
3654
|
+
}
|
|
3655
|
+
return this;
|
|
3656
|
+
}
|
|
3657
|
+
/**
|
|
3658
|
+
* Register WebSocket router for bidirectional real-time communication
|
|
3659
|
+
*
|
|
3660
|
+
* Enables type-safe WebSocket connections with:
|
|
3661
|
+
* - Server→client event push (via defineEvent + emit)
|
|
3662
|
+
* - Client→server message handling (via messages in defineWSRouter)
|
|
3663
|
+
*
|
|
3664
|
+
* @example
|
|
3665
|
+
* ```typescript
|
|
3666
|
+
* // src/server/ws.ts
|
|
3667
|
+
* export const wsRouter = defineWSRouter({
|
|
3668
|
+
* events: { userUpdated, notification },
|
|
3669
|
+
* messages: {
|
|
3670
|
+
* ping: ({ ws }) => ws.send('pong', {}),
|
|
3671
|
+
* },
|
|
3672
|
+
* });
|
|
3673
|
+
*
|
|
3674
|
+
* // server.config.ts
|
|
3675
|
+
* export default defineServerConfig()
|
|
3676
|
+
* .websockets(wsRouter) // → WS /ws
|
|
3677
|
+
* .websockets(wsRouter, {
|
|
3678
|
+
* path: '/realtime', // custom path
|
|
3679
|
+
* auth: { enabled: true }, // token authentication
|
|
3680
|
+
* })
|
|
3681
|
+
* .build();
|
|
3682
|
+
* ```
|
|
3683
|
+
*/
|
|
3684
|
+
websockets(router, config) {
|
|
3685
|
+
this.config.websockets = router;
|
|
3686
|
+
if (config) {
|
|
3687
|
+
this.config.websocketsConfig = config;
|
|
1489
3688
|
}
|
|
1490
3689
|
return this;
|
|
1491
3690
|
}
|
|
@@ -1524,6 +3723,15 @@ var ServerConfigBuilder = class {
|
|
|
1524
3723
|
this.config.healthCheck = healthCheck;
|
|
1525
3724
|
return this;
|
|
1526
3725
|
}
|
|
3726
|
+
/**
|
|
3727
|
+
* Supply the clock used by the built-in `GET /_core/time` capability.
|
|
3728
|
+
* Production servers normally keep the default `Date.now()` clock; this is
|
|
3729
|
+
* exposed so tests can assert an exact wire value without replacing globals.
|
|
3730
|
+
*/
|
|
3731
|
+
serverTime(serverTime) {
|
|
3732
|
+
this.config.serverTime = serverTime;
|
|
3733
|
+
return this;
|
|
3734
|
+
}
|
|
1527
3735
|
/**
|
|
1528
3736
|
* Configure infrastructure initialization
|
|
1529
3737
|
*/
|
|
@@ -1531,6 +3739,21 @@ var ServerConfigBuilder = class {
|
|
|
1531
3739
|
this.config.infrastructure = infrastructure;
|
|
1532
3740
|
return this;
|
|
1533
3741
|
}
|
|
3742
|
+
/**
|
|
3743
|
+
* Configure the migration boot gate
|
|
3744
|
+
*
|
|
3745
|
+
* @example
|
|
3746
|
+
* ```typescript
|
|
3747
|
+
* // A harness that applies migrations itself, after the server is up
|
|
3748
|
+
* export default defineServerConfig()
|
|
3749
|
+
* .migrations({ allowPending: true })
|
|
3750
|
+
* .build();
|
|
3751
|
+
* ```
|
|
3752
|
+
*/
|
|
3753
|
+
migrations(migrations) {
|
|
3754
|
+
this.config.migrations = migrations;
|
|
3755
|
+
return this;
|
|
3756
|
+
}
|
|
1534
3757
|
/**
|
|
1535
3758
|
* Register workflow router for workflow orchestration
|
|
1536
3759
|
*
|
|
@@ -1551,10 +3774,10 @@ var ServerConfigBuilder = class {
|
|
|
1551
3774
|
* .build();
|
|
1552
3775
|
* ```
|
|
1553
3776
|
*/
|
|
1554
|
-
workflows(router,
|
|
3777
|
+
workflows(router, config) {
|
|
1555
3778
|
this.config.workflows = router;
|
|
1556
|
-
if (
|
|
1557
|
-
this.config.workflowsConfig =
|
|
3779
|
+
if (config) {
|
|
3780
|
+
this.config.workflowsConfig = config;
|
|
1558
3781
|
}
|
|
1559
3782
|
return this;
|
|
1560
3783
|
}
|
|
@@ -1605,6 +3828,6 @@ function defineServerConfig() {
|
|
|
1605
3828
|
return new ServerConfigBuilder();
|
|
1606
3829
|
}
|
|
1607
3830
|
|
|
1608
|
-
export { createServer, defineServerConfig, loadEnvFiles, startServer };
|
|
3831
|
+
export { CORE_HEALTH_PATH, CORE_NAMESPACE, CORE_TIME_OPERATION_ID, CORE_TIME_PATH, CORE_TIME_ROUTE, PendingMigrationsError, ServerTimeResponseSchema, createCoreTimeRoute, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
|
|
1609
3832
|
//# sourceMappingURL=index.js.map
|
|
1610
3833
|
//# sourceMappingURL=index.js.map
|