@spfn/core 0.2.0-beta.5 → 0.2.0-beta.50
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 +181 -1281
- package/dist/{boss-BO8ty33K.d.ts → boss-Cxqc-Oiw.d.ts} +37 -7
- package/dist/cache/index.js +32 -29
- package/dist/cache/index.js.map +1 -1
- package/dist/codegen/index.d.ts +55 -8
- package/dist/codegen/index.js +179 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +168 -6
- package/dist/config/index.js +29 -5
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +218 -4
- package/dist/db/index.js +351 -57
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +2 -1
- package/dist/env/index.js +2 -1
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +26 -19
- package/dist/env/loader.js +32 -25
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +33 -3
- package/dist/event/index.js +17 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +42 -3
- package/dist/event/sse/client.js +128 -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 +188 -20
- 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 +213 -0
- package/dist/event/ws/index.js.map +1 -0
- package/dist/job/index.d.ts +23 -8
- package/dist/job/index.js +154 -44
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.d.ts +5 -0
- package/dist/logger/index.js +14 -0
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +23 -1
- package/dist/middleware/index.js +58 -5
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +77 -31
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +44 -23
- package/dist/nextjs/server.js +83 -65
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +158 -4
- package/dist/route/index.js +238 -22
- package/dist/route/index.js.map +1 -1
- package/dist/server/index.d.ts +308 -17
- package/dist/server/index.js +1128 -261
- package/dist/server/index.js.map +1 -1
- package/dist/{router-Di7ENoah.d.ts → token-manager-CyG7la3p.d.ts} +116 -1
- package/dist/{types-D_N_U-Py.d.ts → types-7Mhoxnnt.d.ts} +21 -1
- package/dist/types-C1jMLGwK.d.ts +257 -0
- package/dist/types-Cfj--lfr.d.ts +151 -0
- package/docs/file-upload.md +717 -0
- package/package.json +18 -5
- package/dist/types-B-e_f2dQ.d.ts +0 -121
package/dist/server/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
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
8
|
import { registerRoutes } from '@spfn/core/route';
|
|
8
9
|
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
|
|
9
10
|
import { streamSSE } from 'hono/streaming';
|
|
10
|
-
import {
|
|
11
|
+
import { randomBytes } from 'crypto';
|
|
12
|
+
import { Agent, setGlobalDispatcher } from 'undici';
|
|
11
13
|
import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
|
|
12
14
|
import { initCache, getCache, closeCache } from '@spfn/core/cache';
|
|
13
15
|
import { serve } from '@hono/node-server';
|
|
@@ -94,6 +96,11 @@ function formatError(error) {
|
|
|
94
96
|
const stackLines = error.stack.split("\n").slice(1);
|
|
95
97
|
lines.push(...stackLines);
|
|
96
98
|
}
|
|
99
|
+
if (error.cause instanceof Error) {
|
|
100
|
+
lines.push(`Caused by: ${formatError(error.cause)}`);
|
|
101
|
+
} else if (error.cause !== void 0) {
|
|
102
|
+
lines.push(`Caused by: ${String(error.cause)}`);
|
|
103
|
+
}
|
|
97
104
|
return lines.join("\n");
|
|
98
105
|
}
|
|
99
106
|
function formatContext(context) {
|
|
@@ -163,6 +170,19 @@ function formatConsole(metadata, colorize = true) {
|
|
|
163
170
|
}
|
|
164
171
|
return output;
|
|
165
172
|
}
|
|
173
|
+
function formatErrorCauseChain(error) {
|
|
174
|
+
const result = {
|
|
175
|
+
name: error.name,
|
|
176
|
+
message: error.message,
|
|
177
|
+
stack: error.stack
|
|
178
|
+
};
|
|
179
|
+
if (error.cause instanceof Error) {
|
|
180
|
+
result.cause = formatErrorCauseChain(error.cause);
|
|
181
|
+
} else if (error.cause !== void 0) {
|
|
182
|
+
result.cause = String(error.cause);
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
166
186
|
function formatJSON(metadata) {
|
|
167
187
|
const obj = {
|
|
168
188
|
timestamp: formatTimestamp(metadata.timestamp),
|
|
@@ -176,11 +196,7 @@ function formatJSON(metadata) {
|
|
|
176
196
|
obj.context = metadata.context;
|
|
177
197
|
}
|
|
178
198
|
if (metadata.error) {
|
|
179
|
-
obj.error =
|
|
180
|
-
name: metadata.error.name,
|
|
181
|
-
message: metadata.error.message,
|
|
182
|
-
stack: metadata.error.stack
|
|
183
|
-
};
|
|
199
|
+
obj.error = formatErrorCauseChain(metadata.error);
|
|
184
200
|
}
|
|
185
201
|
return JSON.stringify(obj);
|
|
186
202
|
}
|
|
@@ -314,36 +330,95 @@ var init_formatters = __esm({
|
|
|
314
330
|
};
|
|
315
331
|
}
|
|
316
332
|
});
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
333
|
+
var envLogger = logger.child("@spfn/core:env-loader");
|
|
334
|
+
function getEnvFiles(nodeEnv, server) {
|
|
335
|
+
const files = [
|
|
336
|
+
".env",
|
|
337
|
+
`.env.${nodeEnv}`
|
|
338
|
+
];
|
|
339
|
+
if (nodeEnv !== "test") {
|
|
340
|
+
files.push(".env.local");
|
|
341
|
+
}
|
|
342
|
+
files.push(`.env.${nodeEnv}.local`);
|
|
343
|
+
if (server) {
|
|
344
|
+
files.push(".env.server");
|
|
345
|
+
}
|
|
346
|
+
return files;
|
|
347
|
+
}
|
|
348
|
+
function parseEnvFile(filePath) {
|
|
349
|
+
if (!existsSync(filePath)) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
return parse(readFileSync(filePath, "utf-8"));
|
|
353
|
+
}
|
|
354
|
+
function loadEnv(options = {}) {
|
|
355
|
+
const {
|
|
356
|
+
cwd = process.cwd(),
|
|
357
|
+
nodeEnv = process.env.NODE_ENV || "local",
|
|
358
|
+
server = true,
|
|
359
|
+
debug = false,
|
|
360
|
+
override = false
|
|
361
|
+
} = options;
|
|
362
|
+
const envFiles = getEnvFiles(nodeEnv, server);
|
|
363
|
+
const loadedFiles = [];
|
|
364
|
+
const existingKeys = new Set(Object.keys(process.env));
|
|
365
|
+
const merged = {};
|
|
366
|
+
for (const fileName of envFiles) {
|
|
367
|
+
const filePath = resolve(cwd, fileName);
|
|
368
|
+
const parsed = parseEnvFile(filePath);
|
|
369
|
+
if (parsed === null) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
loadedFiles.push(fileName);
|
|
373
|
+
Object.assign(merged, parsed);
|
|
374
|
+
}
|
|
375
|
+
const loadedKeys = [];
|
|
376
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
377
|
+
if (!override && existingKeys.has(key)) {
|
|
378
|
+
continue;
|
|
332
379
|
}
|
|
380
|
+
process.env[key] = value;
|
|
381
|
+
loadedKeys.push(key);
|
|
382
|
+
}
|
|
383
|
+
if (debug && loadedFiles.length > 0) {
|
|
384
|
+
envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
|
|
385
|
+
envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
|
|
333
386
|
}
|
|
387
|
+
return { loadedFiles, loadedKeys };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/server/dotenv-loader.ts
|
|
391
|
+
var warned = false;
|
|
392
|
+
function loadEnvFiles() {
|
|
393
|
+
if (!warned) {
|
|
394
|
+
warned = true;
|
|
395
|
+
console.warn(
|
|
396
|
+
'[SPFN] loadEnvFiles() is deprecated. Use loadEnv() from "@spfn/core/env/loader" instead.'
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
loadEnv();
|
|
334
400
|
}
|
|
335
401
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
336
|
-
function createSSEHandler(router,
|
|
402
|
+
function createSSEHandler(router, config = {}, tokenManager) {
|
|
337
403
|
const {
|
|
338
|
-
pingInterval = 3e4
|
|
339
|
-
|
|
340
|
-
} =
|
|
404
|
+
pingInterval = 3e4,
|
|
405
|
+
auth: authConfig
|
|
406
|
+
} = config;
|
|
341
407
|
return async (c) => {
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
408
|
+
const subject = await authenticateToken(c, tokenManager);
|
|
409
|
+
if (subject === false) {
|
|
410
|
+
return c.json({ error: "Missing token parameter" }, 401);
|
|
411
|
+
}
|
|
412
|
+
if (subject === null) {
|
|
413
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
414
|
+
}
|
|
415
|
+
if (subject) {
|
|
416
|
+
c.set("sseSubject", subject);
|
|
417
|
+
}
|
|
418
|
+
const requestedEvents = parseRequestedEvents(c);
|
|
419
|
+
if (!requestedEvents) {
|
|
344
420
|
return c.json({ error: "Missing events parameter" }, 400);
|
|
345
421
|
}
|
|
346
|
-
const requestedEvents = eventsParam.split(",").map((e) => e.trim());
|
|
347
422
|
const validEventNames = router.eventNames;
|
|
348
423
|
const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
|
|
349
424
|
if (invalidEvents.length > 0) {
|
|
@@ -353,19 +428,42 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
353
428
|
validEvents: validEventNames
|
|
354
429
|
}, 400);
|
|
355
430
|
}
|
|
431
|
+
const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
|
|
432
|
+
if (allowedEvents === null) {
|
|
433
|
+
return c.json({ error: "Not authorized for any requested events" }, 403);
|
|
434
|
+
}
|
|
356
435
|
sseLogger.debug("SSE connection requested", {
|
|
357
|
-
events:
|
|
436
|
+
events: allowedEvents,
|
|
437
|
+
subject: subject || void 0,
|
|
358
438
|
clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
359
439
|
});
|
|
440
|
+
c.header("X-Accel-Buffering", "no");
|
|
360
441
|
return streamSSE(c, async (stream) => {
|
|
361
442
|
const unsubscribes = [];
|
|
362
443
|
let messageId = 0;
|
|
363
|
-
|
|
444
|
+
let connectionDead = false;
|
|
445
|
+
let pingTimer;
|
|
446
|
+
const cleanup = () => {
|
|
447
|
+
if (connectionDead) return;
|
|
448
|
+
connectionDead = true;
|
|
449
|
+
clearInterval(pingTimer);
|
|
450
|
+
unsubscribes.forEach((fn) => fn());
|
|
451
|
+
sseLogger.info("SSE dead connection cleaned up", {
|
|
452
|
+
events: allowedEvents
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
for (const eventName of allowedEvents) {
|
|
364
456
|
const eventDef = router.events[eventName];
|
|
365
457
|
if (!eventDef) {
|
|
366
458
|
continue;
|
|
367
459
|
}
|
|
368
460
|
const unsubscribe = eventDef.subscribe((payload) => {
|
|
461
|
+
if (connectionDead) return;
|
|
462
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
463
|
+
if (!authConfig.filter[eventName](subject, payload)) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
369
467
|
messageId++;
|
|
370
468
|
const message = {
|
|
371
469
|
event: eventName,
|
|
@@ -375,40 +473,49 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
375
473
|
event: eventName,
|
|
376
474
|
messageId
|
|
377
475
|
});
|
|
378
|
-
|
|
476
|
+
stream.writeSSE({
|
|
379
477
|
id: String(messageId),
|
|
380
478
|
event: eventName,
|
|
381
479
|
data: JSON.stringify(message)
|
|
480
|
+
}).catch((err) => {
|
|
481
|
+
sseLogger.warn("SSE write failed", {
|
|
482
|
+
event: eventName,
|
|
483
|
+
messageId,
|
|
484
|
+
error: err.message
|
|
485
|
+
});
|
|
486
|
+
cleanup();
|
|
382
487
|
});
|
|
383
488
|
});
|
|
384
489
|
unsubscribes.push(unsubscribe);
|
|
385
490
|
}
|
|
386
491
|
sseLogger.info("SSE connection established", {
|
|
387
|
-
events:
|
|
492
|
+
events: allowedEvents,
|
|
388
493
|
subscriptionCount: unsubscribes.length
|
|
389
494
|
});
|
|
390
495
|
await stream.writeSSE({
|
|
391
496
|
event: "connected",
|
|
392
497
|
data: JSON.stringify({
|
|
393
|
-
subscribedEvents:
|
|
498
|
+
subscribedEvents: allowedEvents,
|
|
394
499
|
timestamp: Date.now()
|
|
395
500
|
})
|
|
396
501
|
});
|
|
397
|
-
|
|
398
|
-
|
|
502
|
+
pingTimer = setInterval(() => {
|
|
503
|
+
if (connectionDead) return;
|
|
504
|
+
stream.writeSSE({
|
|
399
505
|
event: "ping",
|
|
400
506
|
data: JSON.stringify({ timestamp: Date.now() })
|
|
507
|
+
}).catch((err) => {
|
|
508
|
+
sseLogger.warn("SSE ping failed", {
|
|
509
|
+
error: err.message
|
|
510
|
+
});
|
|
511
|
+
cleanup();
|
|
401
512
|
});
|
|
402
513
|
}, pingInterval);
|
|
403
514
|
const abortSignal = c.req.raw.signal;
|
|
404
|
-
while (!abortSignal.aborted) {
|
|
515
|
+
while (!abortSignal.aborted && !connectionDead) {
|
|
405
516
|
await stream.sleep(pingInterval);
|
|
406
517
|
}
|
|
407
|
-
|
|
408
|
-
unsubscribes.forEach((fn) => fn());
|
|
409
|
-
sseLogger.info("SSE connection closed", {
|
|
410
|
-
events: requestedEvents
|
|
411
|
-
});
|
|
518
|
+
cleanup();
|
|
412
519
|
}, async (err) => {
|
|
413
520
|
sseLogger.error("SSE stream error", {
|
|
414
521
|
error: err.message
|
|
@@ -416,8 +523,357 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
416
523
|
});
|
|
417
524
|
};
|
|
418
525
|
}
|
|
526
|
+
async function authenticateToken(c, tokenManager) {
|
|
527
|
+
if (!tokenManager) {
|
|
528
|
+
return void 0;
|
|
529
|
+
}
|
|
530
|
+
const token = c.req.query("token");
|
|
531
|
+
if (!token) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
return await tokenManager.verify(token);
|
|
535
|
+
}
|
|
536
|
+
function parseRequestedEvents(c) {
|
|
537
|
+
const eventsParam = c.req.query("events");
|
|
538
|
+
if (!eventsParam) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
return eventsParam.split(",").map((e) => e.trim());
|
|
542
|
+
}
|
|
543
|
+
async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
544
|
+
if (!subject || !authConfig?.authorize) {
|
|
545
|
+
return requestedEvents;
|
|
546
|
+
}
|
|
547
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
548
|
+
if (allowed.length === 0) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
return allowed;
|
|
552
|
+
}
|
|
553
|
+
var InMemoryTokenStore = class {
|
|
554
|
+
tokens = /* @__PURE__ */ new Map();
|
|
555
|
+
async set(token, data) {
|
|
556
|
+
this.tokens.set(token, data);
|
|
557
|
+
}
|
|
558
|
+
async consume(token) {
|
|
559
|
+
const data = this.tokens.get(token);
|
|
560
|
+
if (!data) {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
this.tokens.delete(token);
|
|
564
|
+
return data;
|
|
565
|
+
}
|
|
566
|
+
async cleanup() {
|
|
567
|
+
const now = Date.now();
|
|
568
|
+
for (const [token, data] of this.tokens) {
|
|
569
|
+
if (data.expiresAt <= now) {
|
|
570
|
+
this.tokens.delete(token);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
var CacheTokenStore = class {
|
|
576
|
+
constructor(cache) {
|
|
577
|
+
this.cache = cache;
|
|
578
|
+
}
|
|
579
|
+
prefix = "sse:token:";
|
|
580
|
+
async set(token, data) {
|
|
581
|
+
const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
|
|
582
|
+
await this.cache.set(
|
|
583
|
+
this.prefix + token,
|
|
584
|
+
JSON.stringify(data),
|
|
585
|
+
"EX",
|
|
586
|
+
ttlSeconds
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
async consume(token) {
|
|
590
|
+
const key = this.prefix + token;
|
|
591
|
+
let raw = null;
|
|
592
|
+
if (this.cache.getdel) {
|
|
593
|
+
raw = await this.cache.getdel(key);
|
|
594
|
+
} else {
|
|
595
|
+
raw = await this.cache.get(key);
|
|
596
|
+
if (raw) {
|
|
597
|
+
await this.cache.del(key);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (!raw) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
return JSON.parse(raw);
|
|
604
|
+
}
|
|
605
|
+
async cleanup() {
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
var SSETokenManager = class {
|
|
609
|
+
store;
|
|
610
|
+
ttl;
|
|
611
|
+
cleanupTimer = null;
|
|
612
|
+
constructor(config) {
|
|
613
|
+
this.ttl = config?.ttl ?? 3e4;
|
|
614
|
+
this.store = config?.store ?? new InMemoryTokenStore();
|
|
615
|
+
const cleanupInterval = config?.cleanupInterval ?? 6e4;
|
|
616
|
+
this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
|
|
617
|
+
this.cleanupTimer.unref();
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Issue a new one-time-use token for the given subject
|
|
621
|
+
*/
|
|
622
|
+
async issue(subject) {
|
|
623
|
+
const token = randomBytes(32).toString("hex");
|
|
624
|
+
await this.store.set(token, {
|
|
625
|
+
token,
|
|
626
|
+
subject,
|
|
627
|
+
expiresAt: Date.now() + this.ttl
|
|
628
|
+
});
|
|
629
|
+
return token;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Verify and consume a token
|
|
633
|
+
* @returns subject string if valid, null if invalid/expired/already consumed
|
|
634
|
+
*/
|
|
635
|
+
async verify(token) {
|
|
636
|
+
const data = await this.store.consume(token);
|
|
637
|
+
if (!data || data.expiresAt <= Date.now()) {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
return data.subject;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Cleanup timer and resources
|
|
644
|
+
*/
|
|
645
|
+
destroy() {
|
|
646
|
+
if (this.cleanupTimer) {
|
|
647
|
+
clearInterval(this.cleanupTimer);
|
|
648
|
+
this.cleanupTimer = null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
var serverLogger = logger.child("@spfn/core:server");
|
|
653
|
+
|
|
654
|
+
// src/server/shutdown-manager.ts
|
|
655
|
+
var DEFAULT_HOOK_TIMEOUT = 1e4;
|
|
656
|
+
var DEFAULT_HOOK_ORDER = 100;
|
|
657
|
+
var DRAIN_POLL_INTERVAL = 500;
|
|
658
|
+
var ShutdownManager = class {
|
|
659
|
+
state = "running";
|
|
660
|
+
hooks = [];
|
|
661
|
+
operations = /* @__PURE__ */ new Map();
|
|
662
|
+
operationCounter = 0;
|
|
663
|
+
/**
|
|
664
|
+
* Register a shutdown hook
|
|
665
|
+
*
|
|
666
|
+
* Hooks run in order during shutdown, after all tracked operations drain.
|
|
667
|
+
* Each hook has its own timeout — failure does not block subsequent hooks.
|
|
668
|
+
*
|
|
669
|
+
* @example
|
|
670
|
+
* shutdown.onShutdown('ai-service', async () => {
|
|
671
|
+
* await aiService.cancelPending();
|
|
672
|
+
* }, { timeout: 30000, order: 10 });
|
|
673
|
+
*/
|
|
674
|
+
onShutdown(name, handler, options) {
|
|
675
|
+
this.hooks.push({
|
|
676
|
+
name,
|
|
677
|
+
handler,
|
|
678
|
+
timeout: options?.timeout ?? DEFAULT_HOOK_TIMEOUT,
|
|
679
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER
|
|
680
|
+
});
|
|
681
|
+
this.hooks.sort((a, b) => a.order - b.order);
|
|
682
|
+
serverLogger.debug(`Shutdown hook registered: ${name}`, {
|
|
683
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER,
|
|
684
|
+
timeout: `${options?.timeout ?? DEFAULT_HOOK_TIMEOUT}ms`
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Track a long-running operation
|
|
689
|
+
*
|
|
690
|
+
* During shutdown (drain phase), the process waits for ALL tracked
|
|
691
|
+
* operations to complete before proceeding with cleanup.
|
|
692
|
+
*
|
|
693
|
+
* If shutdown has already started, the operation is rejected immediately.
|
|
694
|
+
*
|
|
695
|
+
* @returns The operation result (pass-through)
|
|
696
|
+
*
|
|
697
|
+
* @example
|
|
698
|
+
* const result = await shutdown.trackOperation(
|
|
699
|
+
* 'ai-generate',
|
|
700
|
+
* aiService.generate(prompt)
|
|
701
|
+
* );
|
|
702
|
+
*/
|
|
703
|
+
async trackOperation(name, operation) {
|
|
704
|
+
if (this.state !== "running") {
|
|
705
|
+
throw new Error(`Cannot start operation '${name}': server is shutting down`);
|
|
706
|
+
}
|
|
707
|
+
const id = `${name}-${++this.operationCounter}`;
|
|
708
|
+
this.operations.set(id, {
|
|
709
|
+
name,
|
|
710
|
+
startedAt: Date.now()
|
|
711
|
+
});
|
|
712
|
+
serverLogger.debug(`Operation tracked: ${id}`, {
|
|
713
|
+
activeOperations: this.operations.size
|
|
714
|
+
});
|
|
715
|
+
try {
|
|
716
|
+
return await operation;
|
|
717
|
+
} finally {
|
|
718
|
+
this.operations.delete(id);
|
|
719
|
+
serverLogger.debug(`Operation completed: ${id}`, {
|
|
720
|
+
activeOperations: this.operations.size
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Whether the server is shutting down
|
|
726
|
+
*
|
|
727
|
+
* Use this to reject new work early (e.g., return 503 in route handlers).
|
|
728
|
+
*/
|
|
729
|
+
isShuttingDown() {
|
|
730
|
+
return this.state !== "running";
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Number of currently active tracked operations
|
|
734
|
+
*/
|
|
735
|
+
getActiveOperationCount() {
|
|
736
|
+
return this.operations.size;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Mark shutdown as started immediately
|
|
740
|
+
*
|
|
741
|
+
* Call this at the very beginning of the shutdown sequence so that:
|
|
742
|
+
* - Health check returns 503 right away
|
|
743
|
+
* - trackOperation() rejects new work
|
|
744
|
+
* - isShuttingDown() returns true
|
|
745
|
+
*/
|
|
746
|
+
beginShutdown() {
|
|
747
|
+
if (this.state !== "running") {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
this.state = "draining";
|
|
751
|
+
serverLogger.info("Shutdown manager: state set to draining");
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Execute the full shutdown sequence
|
|
755
|
+
*
|
|
756
|
+
* 1. State → draining (reject new operations)
|
|
757
|
+
* 2. Wait for all tracked operations to complete (drain)
|
|
758
|
+
* 3. Run shutdown hooks in order
|
|
759
|
+
* 4. State → closed
|
|
760
|
+
*
|
|
761
|
+
* @param drainTimeout - Max time to wait for operations to drain (ms)
|
|
762
|
+
*/
|
|
763
|
+
async execute(drainTimeout) {
|
|
764
|
+
if (this.state === "closed") {
|
|
765
|
+
serverLogger.warn("ShutdownManager.execute() called but already closed");
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
this.state = "draining";
|
|
769
|
+
serverLogger.info("Shutdown manager: draining started", {
|
|
770
|
+
activeOperations: this.operations.size,
|
|
771
|
+
registeredHooks: this.hooks.length,
|
|
772
|
+
drainTimeout: `${drainTimeout}ms`
|
|
773
|
+
});
|
|
774
|
+
await this.drain(drainTimeout);
|
|
775
|
+
await this.executeHooks();
|
|
776
|
+
this.state = "closed";
|
|
777
|
+
serverLogger.info("Shutdown manager: all hooks executed");
|
|
778
|
+
}
|
|
779
|
+
// ========================================================================
|
|
780
|
+
// Private
|
|
781
|
+
// ========================================================================
|
|
782
|
+
/**
|
|
783
|
+
* Wait for all tracked operations to complete, up to drainTimeout
|
|
784
|
+
*/
|
|
785
|
+
async drain(drainTimeout) {
|
|
786
|
+
if (this.operations.size === 0) {
|
|
787
|
+
serverLogger.info("Shutdown manager: no active operations, drain skipped");
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
serverLogger.info(`Shutdown manager: waiting for ${this.operations.size} operations to drain...`);
|
|
791
|
+
const deadline = Date.now() + drainTimeout;
|
|
792
|
+
while (this.operations.size > 0 && Date.now() < deadline) {
|
|
793
|
+
const remaining = deadline - Date.now();
|
|
794
|
+
const ops = Array.from(this.operations.values()).map((op) => ({
|
|
795
|
+
name: op.name,
|
|
796
|
+
elapsed: `${Math.round((Date.now() - op.startedAt) / 1e3)}s`
|
|
797
|
+
}));
|
|
798
|
+
serverLogger.info("Shutdown manager: drain in progress", {
|
|
799
|
+
activeOperations: this.operations.size,
|
|
800
|
+
remainingTimeout: `${Math.round(remaining / 1e3)}s`,
|
|
801
|
+
operations: ops
|
|
802
|
+
});
|
|
803
|
+
await sleep(Math.min(DRAIN_POLL_INTERVAL, remaining));
|
|
804
|
+
}
|
|
805
|
+
if (this.operations.size > 0) {
|
|
806
|
+
const abandoned = Array.from(this.operations.values()).map((op) => op.name);
|
|
807
|
+
serverLogger.warn("Shutdown manager: drain timeout \u2014 abandoning operations", {
|
|
808
|
+
abandoned
|
|
809
|
+
});
|
|
810
|
+
} else {
|
|
811
|
+
serverLogger.info("Shutdown manager: all operations drained successfully");
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Execute registered shutdown hooks in order
|
|
816
|
+
*/
|
|
817
|
+
async executeHooks() {
|
|
818
|
+
if (this.hooks.length === 0) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
serverLogger.info(`Shutdown manager: executing ${this.hooks.length} hooks...`);
|
|
822
|
+
for (const hook of this.hooks) {
|
|
823
|
+
serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
|
|
824
|
+
try {
|
|
825
|
+
await withTimeout(
|
|
826
|
+
hook.handler(),
|
|
827
|
+
hook.timeout,
|
|
828
|
+
`Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
|
|
829
|
+
);
|
|
830
|
+
serverLogger.info(`Shutdown hook [${hook.name}] completed`);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
serverLogger.error(
|
|
833
|
+
`Shutdown hook [${hook.name}] failed`,
|
|
834
|
+
error
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
var instance = null;
|
|
841
|
+
function getShutdownManager() {
|
|
842
|
+
if (!instance) {
|
|
843
|
+
instance = new ShutdownManager();
|
|
844
|
+
}
|
|
845
|
+
return instance;
|
|
846
|
+
}
|
|
847
|
+
function resetShutdownManager() {
|
|
848
|
+
instance = null;
|
|
849
|
+
}
|
|
850
|
+
function sleep(ms) {
|
|
851
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
852
|
+
}
|
|
853
|
+
async function withTimeout(promise, timeout, message) {
|
|
854
|
+
let timeoutId;
|
|
855
|
+
return Promise.race([
|
|
856
|
+
promise.finally(() => {
|
|
857
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
858
|
+
}),
|
|
859
|
+
new Promise((_, reject) => {
|
|
860
|
+
timeoutId = setTimeout(() => {
|
|
861
|
+
reject(new Error(message));
|
|
862
|
+
}, timeout);
|
|
863
|
+
})
|
|
864
|
+
]);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/server/helpers.ts
|
|
419
868
|
function createHealthCheckHandler(detailed) {
|
|
420
869
|
return async (c) => {
|
|
870
|
+
const shutdownManager = getShutdownManager();
|
|
871
|
+
if (shutdownManager.isShuttingDown()) {
|
|
872
|
+
return c.json({
|
|
873
|
+
status: "shutting_down",
|
|
874
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
875
|
+
}, 503);
|
|
876
|
+
}
|
|
421
877
|
const response = {
|
|
422
878
|
status: "ok",
|
|
423
879
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -474,34 +930,49 @@ function applyServerTimeouts(server, timeouts) {
|
|
|
474
930
|
server.headersTimeout = timeouts.headers;
|
|
475
931
|
}
|
|
476
932
|
}
|
|
477
|
-
function getTimeoutConfig(
|
|
933
|
+
function getTimeoutConfig(config) {
|
|
934
|
+
return {
|
|
935
|
+
request: config?.request ?? env.SERVER_TIMEOUT,
|
|
936
|
+
keepAlive: config?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
|
|
937
|
+
headers: config?.headers ?? env.SERVER_HEADERS_TIMEOUT
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
function getShutdownTimeout(config) {
|
|
941
|
+
return config?.timeout ?? env.SHUTDOWN_TIMEOUT;
|
|
942
|
+
}
|
|
943
|
+
function getFetchTimeoutConfig(config) {
|
|
478
944
|
return {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
945
|
+
connect: config?.connect ?? env.FETCH_CONNECT_TIMEOUT,
|
|
946
|
+
headers: config?.headers ?? env.FETCH_HEADERS_TIMEOUT,
|
|
947
|
+
body: config?.body ?? env.FETCH_BODY_TIMEOUT
|
|
482
948
|
};
|
|
483
949
|
}
|
|
484
|
-
function
|
|
485
|
-
|
|
950
|
+
function applyGlobalFetchTimeouts(timeouts) {
|
|
951
|
+
const agent = new Agent({
|
|
952
|
+
connect: { timeout: timeouts.connect },
|
|
953
|
+
headersTimeout: timeouts.headers,
|
|
954
|
+
bodyTimeout: timeouts.body
|
|
955
|
+
});
|
|
956
|
+
setGlobalDispatcher(agent);
|
|
486
957
|
}
|
|
487
|
-
function buildMiddlewareOrder(
|
|
958
|
+
function buildMiddlewareOrder(config) {
|
|
488
959
|
const order = [];
|
|
489
|
-
const middlewareConfig =
|
|
960
|
+
const middlewareConfig = config.middleware ?? {};
|
|
490
961
|
const enableLogger = middlewareConfig.logger !== false;
|
|
491
962
|
const enableCors = middlewareConfig.cors !== false;
|
|
492
963
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
493
964
|
if (enableLogger) order.push("RequestLogger");
|
|
494
965
|
if (enableCors) order.push("CORS");
|
|
495
|
-
|
|
496
|
-
if (
|
|
966
|
+
config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
|
|
967
|
+
if (config.beforeRoutes) order.push("beforeRoutes hook");
|
|
497
968
|
order.push("Routes");
|
|
498
|
-
if (
|
|
969
|
+
if (config.afterRoutes) order.push("afterRoutes hook");
|
|
499
970
|
if (enableErrorHandler) order.push("ErrorHandler");
|
|
500
971
|
return order;
|
|
501
972
|
}
|
|
502
|
-
function buildStartupConfig(
|
|
503
|
-
const middlewareConfig =
|
|
504
|
-
const healthCheckConfig =
|
|
973
|
+
function buildStartupConfig(config, timeouts) {
|
|
974
|
+
const middlewareConfig = config.middleware ?? {};
|
|
975
|
+
const healthCheckConfig = config.healthCheck ?? {};
|
|
505
976
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
506
977
|
const healthCheckPath = healthCheckConfig.path ?? "/health";
|
|
507
978
|
const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
|
|
@@ -510,7 +981,7 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
510
981
|
logger: middlewareConfig.logger !== false,
|
|
511
982
|
cors: middlewareConfig.cors !== false,
|
|
512
983
|
errorHandler: middlewareConfig.errorHandler !== false,
|
|
513
|
-
custom:
|
|
984
|
+
custom: config.use?.length ?? 0
|
|
514
985
|
},
|
|
515
986
|
healthCheck: healthCheckEnabled ? {
|
|
516
987
|
enabled: true,
|
|
@@ -518,8 +989,8 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
518
989
|
detailed: healthCheckDetailed
|
|
519
990
|
} : { enabled: false },
|
|
520
991
|
hooks: {
|
|
521
|
-
beforeRoutes: !!
|
|
522
|
-
afterRoutes: !!
|
|
992
|
+
beforeRoutes: !!config.beforeRoutes,
|
|
993
|
+
afterRoutes: !!config.afterRoutes
|
|
523
994
|
},
|
|
524
995
|
timeout: {
|
|
525
996
|
request: `${timeouts.request}ms`,
|
|
@@ -527,23 +998,22 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
527
998
|
headers: `${timeouts.headers}ms`
|
|
528
999
|
},
|
|
529
1000
|
shutdown: {
|
|
530
|
-
timeout: `${
|
|
1001
|
+
timeout: `${config.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
|
|
531
1002
|
}
|
|
532
1003
|
};
|
|
533
1004
|
}
|
|
534
|
-
var serverLogger = logger.child("@spfn/core:server");
|
|
535
1005
|
|
|
536
1006
|
// src/server/create-server.ts
|
|
537
|
-
async function createServer(
|
|
1007
|
+
async function createServer(config) {
|
|
538
1008
|
const cwd = process.cwd();
|
|
539
1009
|
const appPath = join(cwd, "src", "server", "app.ts");
|
|
540
1010
|
const appJsPath = join(cwd, "src", "server", "app");
|
|
541
1011
|
if (existsSync(appPath) || existsSync(appJsPath)) {
|
|
542
|
-
return await loadCustomApp(appPath, appJsPath,
|
|
1012
|
+
return await loadCustomApp(appPath, appJsPath, config);
|
|
543
1013
|
}
|
|
544
|
-
return await createAutoConfiguredApp(
|
|
1014
|
+
return await createAutoConfiguredApp(config);
|
|
545
1015
|
}
|
|
546
|
-
async function loadCustomApp(appPath, appJsPath,
|
|
1016
|
+
async function loadCustomApp(appPath, appJsPath, config) {
|
|
547
1017
|
const actualPath = existsSync(appPath) ? appPath : appJsPath;
|
|
548
1018
|
const appModule = await import(actualPath);
|
|
549
1019
|
const appFactory = appModule.default;
|
|
@@ -551,15 +1021,15 @@ async function loadCustomApp(appPath, appJsPath, config2) {
|
|
|
551
1021
|
throw new Error("app.ts must export a default function that returns a Hono app");
|
|
552
1022
|
}
|
|
553
1023
|
const app = await appFactory();
|
|
554
|
-
if (
|
|
555
|
-
const routes = registerRoutes(app,
|
|
556
|
-
logRegisteredRoutes(routes,
|
|
1024
|
+
if (config?.routes) {
|
|
1025
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
1026
|
+
logRegisteredRoutes(routes, config?.debug ?? false);
|
|
557
1027
|
}
|
|
558
1028
|
return app;
|
|
559
1029
|
}
|
|
560
|
-
async function createAutoConfiguredApp(
|
|
1030
|
+
async function createAutoConfiguredApp(config) {
|
|
561
1031
|
const app = new Hono();
|
|
562
|
-
const middlewareConfig =
|
|
1032
|
+
const middlewareConfig = config?.middleware ?? {};
|
|
563
1033
|
const enableLogger = middlewareConfig.logger !== false;
|
|
564
1034
|
const enableCors = middlewareConfig.cors !== false;
|
|
565
1035
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
@@ -569,31 +1039,31 @@ async function createAutoConfiguredApp(config2) {
|
|
|
569
1039
|
await next();
|
|
570
1040
|
});
|
|
571
1041
|
}
|
|
572
|
-
applyDefaultMiddleware(app,
|
|
573
|
-
if (Array.isArray(
|
|
574
|
-
|
|
1042
|
+
applyDefaultMiddleware(app, config, enableLogger, enableCors);
|
|
1043
|
+
if (Array.isArray(config?.use)) {
|
|
1044
|
+
config.use.forEach((mw) => app.use("*", mw));
|
|
575
1045
|
}
|
|
576
|
-
registerHealthCheckEndpoint(app,
|
|
577
|
-
await executeBeforeRoutesHook(app,
|
|
578
|
-
await loadAppRoutes(app,
|
|
579
|
-
registerSSEEndpoint(app,
|
|
580
|
-
await executeAfterRoutesHook(app,
|
|
1046
|
+
registerHealthCheckEndpoint(app, config);
|
|
1047
|
+
await executeBeforeRoutesHook(app, config);
|
|
1048
|
+
await loadAppRoutes(app, config);
|
|
1049
|
+
await registerSSEEndpoint(app, config);
|
|
1050
|
+
await executeAfterRoutesHook(app, config);
|
|
581
1051
|
if (enableErrorHandler) {
|
|
582
|
-
app.onError(ErrorHandler());
|
|
1052
|
+
app.onError(ErrorHandler({ onError: config?.middleware?.onError }));
|
|
583
1053
|
}
|
|
584
1054
|
return app;
|
|
585
1055
|
}
|
|
586
|
-
function applyDefaultMiddleware(app,
|
|
1056
|
+
function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
|
|
587
1057
|
if (enableLogger) {
|
|
588
1058
|
app.use("*", RequestLogger());
|
|
589
1059
|
}
|
|
590
1060
|
if (enableCors) {
|
|
591
|
-
const corsOptions =
|
|
1061
|
+
const corsOptions = config?.cors !== false ? config?.cors : void 0;
|
|
592
1062
|
app.use("*", cors(corsOptions));
|
|
593
1063
|
}
|
|
594
1064
|
}
|
|
595
|
-
function registerHealthCheckEndpoint(app,
|
|
596
|
-
const healthCheckConfig =
|
|
1065
|
+
function registerHealthCheckEndpoint(app, config) {
|
|
1066
|
+
const healthCheckConfig = config?.healthCheck ?? {};
|
|
597
1067
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
598
1068
|
const healthCheckPath = healthCheckConfig.path ?? "/health";
|
|
599
1069
|
const healthCheckDetailed = healthCheckConfig.detailed ?? process.env.NODE_ENV === "development";
|
|
@@ -602,15 +1072,15 @@ function registerHealthCheckEndpoint(app, config2) {
|
|
|
602
1072
|
serverLogger.debug(`Health check endpoint enabled at ${healthCheckPath}`);
|
|
603
1073
|
}
|
|
604
1074
|
}
|
|
605
|
-
async function executeBeforeRoutesHook(app,
|
|
606
|
-
if (
|
|
607
|
-
await
|
|
1075
|
+
async function executeBeforeRoutesHook(app, config) {
|
|
1076
|
+
if (config?.lifecycle?.beforeRoutes) {
|
|
1077
|
+
await config.lifecycle.beforeRoutes(app);
|
|
608
1078
|
}
|
|
609
1079
|
}
|
|
610
|
-
async function loadAppRoutes(app,
|
|
611
|
-
const debug = isDebugMode(
|
|
612
|
-
if (
|
|
613
|
-
const routes = registerRoutes(app,
|
|
1080
|
+
async function loadAppRoutes(app, config) {
|
|
1081
|
+
const debug = isDebugMode(config);
|
|
1082
|
+
if (config?.routes) {
|
|
1083
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
614
1084
|
logRegisteredRoutes(routes, debug);
|
|
615
1085
|
} else if (debug) {
|
|
616
1086
|
serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
|
|
@@ -631,76 +1101,150 @@ function logRegisteredRoutes(routes, debug) {
|
|
|
631
1101
|
serverLogger.info(`\u2713 Routes registered (${routes.length}):
|
|
632
1102
|
${routeLines}`);
|
|
633
1103
|
}
|
|
634
|
-
async function executeAfterRoutesHook(app,
|
|
635
|
-
if (
|
|
636
|
-
await
|
|
1104
|
+
async function executeAfterRoutesHook(app, config) {
|
|
1105
|
+
if (config?.lifecycle?.afterRoutes) {
|
|
1106
|
+
await config.lifecycle.afterRoutes(app);
|
|
637
1107
|
}
|
|
638
1108
|
}
|
|
639
|
-
function registerSSEEndpoint(app,
|
|
640
|
-
if (!
|
|
1109
|
+
async function registerSSEEndpoint(app, config) {
|
|
1110
|
+
if (!config?.events) {
|
|
641
1111
|
return;
|
|
642
1112
|
}
|
|
643
|
-
const eventsConfig =
|
|
644
|
-
const
|
|
645
|
-
const
|
|
646
|
-
|
|
1113
|
+
const eventsConfig = config.eventsConfig ?? {};
|
|
1114
|
+
const streamPath = eventsConfig.path ?? "/events/stream";
|
|
1115
|
+
const authConfig = eventsConfig.auth;
|
|
1116
|
+
const debug = isDebugMode(config);
|
|
1117
|
+
let tokenManager;
|
|
1118
|
+
if (authConfig?.enabled) {
|
|
1119
|
+
let store = authConfig.store;
|
|
1120
|
+
if (!store) {
|
|
1121
|
+
try {
|
|
1122
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
1123
|
+
const cache = getCache2();
|
|
1124
|
+
if (cache) {
|
|
1125
|
+
store = new CacheTokenStore(cache);
|
|
1126
|
+
if (debug) {
|
|
1127
|
+
serverLogger.info("SSE token store: cache (Redis/Valkey)");
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
} catch {
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
1134
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
1135
|
+
ttl: authConfig.tokenTtl,
|
|
1136
|
+
store
|
|
1137
|
+
});
|
|
1138
|
+
const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
|
|
1139
|
+
const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
|
|
1140
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
1141
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
1142
|
+
const subject = getSubject(c);
|
|
1143
|
+
if (!subject) {
|
|
1144
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
1145
|
+
}
|
|
1146
|
+
const token = await tokenManager.issue(subject);
|
|
1147
|
+
return c.json({ token });
|
|
1148
|
+
});
|
|
1149
|
+
if (debug) {
|
|
1150
|
+
serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
|
|
647
1154
|
if (debug) {
|
|
648
|
-
const eventNames =
|
|
649
|
-
serverLogger.info(`\u2713 SSE endpoint registered at ${
|
|
650
|
-
events: eventNames
|
|
1155
|
+
const eventNames = config.events.eventNames;
|
|
1156
|
+
serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
|
|
1157
|
+
events: eventNames,
|
|
1158
|
+
auth: !!authConfig?.enabled
|
|
651
1159
|
});
|
|
652
1160
|
}
|
|
653
1161
|
}
|
|
654
|
-
function isDebugMode(
|
|
655
|
-
return
|
|
1162
|
+
function isDebugMode(config) {
|
|
1163
|
+
return config?.debug ?? process.env.NODE_ENV === "development";
|
|
656
1164
|
}
|
|
657
1165
|
var jobLogger = logger.child("@spfn/core:job");
|
|
658
|
-
|
|
659
|
-
|
|
1166
|
+
function requiresSSLWithoutVerification(connectionString) {
|
|
1167
|
+
try {
|
|
1168
|
+
const url = new URL(connectionString);
|
|
1169
|
+
const sslmode = url.searchParams.get("sslmode");
|
|
1170
|
+
return sslmode === "require" || sslmode === "prefer";
|
|
1171
|
+
} catch {
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
function stripSslModeFromUrl(connectionString) {
|
|
1176
|
+
const url = new URL(connectionString);
|
|
1177
|
+
url.searchParams.delete("sslmode");
|
|
1178
|
+
return url.toString();
|
|
1179
|
+
}
|
|
1180
|
+
var BOSS_KEY = Symbol.for("spfn:boss-instance");
|
|
1181
|
+
var CONFIG_KEY = Symbol.for("spfn:boss-config");
|
|
1182
|
+
var g = globalThis;
|
|
1183
|
+
function getBossInstance() {
|
|
1184
|
+
return g[BOSS_KEY] ?? null;
|
|
1185
|
+
}
|
|
1186
|
+
function setBossInstance(instance2) {
|
|
1187
|
+
g[BOSS_KEY] = instance2;
|
|
1188
|
+
}
|
|
1189
|
+
function getBossConfig() {
|
|
1190
|
+
return g[CONFIG_KEY] ?? null;
|
|
1191
|
+
}
|
|
1192
|
+
function setBossConfig(config) {
|
|
1193
|
+
g[CONFIG_KEY] = config;
|
|
1194
|
+
}
|
|
660
1195
|
async function initBoss(options) {
|
|
661
|
-
|
|
1196
|
+
const existing = getBossInstance();
|
|
1197
|
+
if (existing) {
|
|
662
1198
|
jobLogger.warn("pg-boss already initialized, returning existing instance");
|
|
663
|
-
return
|
|
1199
|
+
return existing;
|
|
664
1200
|
}
|
|
665
1201
|
jobLogger.info("Initializing pg-boss...");
|
|
666
|
-
|
|
1202
|
+
setBossConfig(options);
|
|
1203
|
+
const needsSSL = requiresSSLWithoutVerification(options.connectionString);
|
|
667
1204
|
const pgBossOptions = {
|
|
668
|
-
|
|
1205
|
+
// pg 드라이버가 URL의 sslmode=require를 verify-full로 해석해서
|
|
1206
|
+
// ssl 옵션을 무시하므로, URL에서 sslmode를 빼고 ssl 객체만 전달
|
|
1207
|
+
connectionString: needsSSL ? stripSslModeFromUrl(options.connectionString) : options.connectionString,
|
|
669
1208
|
schema: options.schema ?? "spfn_queue",
|
|
670
1209
|
maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
|
|
671
1210
|
};
|
|
1211
|
+
if (needsSSL) {
|
|
1212
|
+
pgBossOptions.ssl = { rejectUnauthorized: false };
|
|
1213
|
+
}
|
|
672
1214
|
if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
|
|
673
1215
|
pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
|
|
674
1216
|
}
|
|
675
|
-
|
|
676
|
-
|
|
1217
|
+
const boss = new PgBoss(pgBossOptions);
|
|
1218
|
+
boss.on("error", (error) => {
|
|
677
1219
|
jobLogger.error("pg-boss error:", error);
|
|
678
1220
|
});
|
|
679
|
-
await
|
|
1221
|
+
await boss.start();
|
|
1222
|
+
setBossInstance(boss);
|
|
680
1223
|
jobLogger.info("pg-boss started successfully");
|
|
681
|
-
return
|
|
1224
|
+
return boss;
|
|
682
1225
|
}
|
|
683
1226
|
function getBoss() {
|
|
684
|
-
return
|
|
1227
|
+
return getBossInstance();
|
|
685
1228
|
}
|
|
686
1229
|
async function stopBoss() {
|
|
687
|
-
|
|
1230
|
+
const boss = getBossInstance();
|
|
1231
|
+
if (!boss) {
|
|
688
1232
|
return;
|
|
689
1233
|
}
|
|
690
1234
|
jobLogger.info("Stopping pg-boss...");
|
|
691
1235
|
try {
|
|
692
|
-
await
|
|
1236
|
+
await boss.stop({ graceful: true, timeout: 3e4 });
|
|
693
1237
|
jobLogger.info("pg-boss stopped gracefully");
|
|
694
1238
|
} catch (error) {
|
|
695
1239
|
jobLogger.error("Error stopping pg-boss:", error);
|
|
696
1240
|
throw error;
|
|
697
1241
|
} finally {
|
|
698
|
-
|
|
699
|
-
|
|
1242
|
+
setBossInstance(null);
|
|
1243
|
+
setBossConfig(null);
|
|
700
1244
|
}
|
|
701
1245
|
}
|
|
702
1246
|
function shouldClearOnStart() {
|
|
703
|
-
return
|
|
1247
|
+
return getBossConfig()?.clearOnStart ?? false;
|
|
704
1248
|
}
|
|
705
1249
|
|
|
706
1250
|
// src/job/job-router.ts
|
|
@@ -762,36 +1306,53 @@ async function registerJobs(router) {
|
|
|
762
1306
|
async function ensureQueue(boss, queueName) {
|
|
763
1307
|
await boss.createQueue(queueName);
|
|
764
1308
|
}
|
|
1309
|
+
async function executeJobHandler(job2, pgBossJob) {
|
|
1310
|
+
jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
|
|
1311
|
+
const startTime = Date.now();
|
|
1312
|
+
try {
|
|
1313
|
+
if (job2.inputSchema) {
|
|
1314
|
+
await job2.handler(pgBossJob.data);
|
|
1315
|
+
} else {
|
|
1316
|
+
await job2.handler();
|
|
1317
|
+
}
|
|
1318
|
+
const duration = Date.now() - startTime;
|
|
1319
|
+
jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
|
|
1320
|
+
jobId: pgBossJob.id,
|
|
1321
|
+
duration
|
|
1322
|
+
});
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
const duration = Date.now() - startTime;
|
|
1325
|
+
jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
|
|
1326
|
+
jobId: pgBossJob.id,
|
|
1327
|
+
duration,
|
|
1328
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1329
|
+
});
|
|
1330
|
+
throw error;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
765
1333
|
async function registerWorker(boss, job2, queueName) {
|
|
766
1334
|
await ensureQueue(boss, queueName);
|
|
1335
|
+
const batchSize = job2.options?.batchSize ?? 1;
|
|
767
1336
|
await boss.work(
|
|
768
1337
|
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;
|
|
1338
|
+
{ batchSize },
|
|
1339
|
+
async (pgBossJobs) => {
|
|
1340
|
+
if (batchSize <= 1) {
|
|
1341
|
+
await executeJobHandler(job2, pgBossJobs[0]);
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
const results = await Promise.allSettled(
|
|
1345
|
+
pgBossJobs.map((pgBossJob) => executeJobHandler(job2, pgBossJob))
|
|
1346
|
+
);
|
|
1347
|
+
const failedIds = [];
|
|
1348
|
+
for (let i = 0; i < results.length; i++) {
|
|
1349
|
+
if (results[i].status === "rejected") {
|
|
1350
|
+
failedIds.push(pgBossJobs[i].id);
|
|
793
1351
|
}
|
|
794
1352
|
}
|
|
1353
|
+
if (failedIds.length > 0) {
|
|
1354
|
+
await boss.fail(queueName, failedIds);
|
|
1355
|
+
}
|
|
795
1356
|
}
|
|
796
1357
|
);
|
|
797
1358
|
}
|
|
@@ -851,6 +1412,202 @@ async function registerJob(job2) {
|
|
|
851
1412
|
await queueRunOnceJob(boss, job2);
|
|
852
1413
|
jobLogger2.debug(`Job registered: ${job2.name}`);
|
|
853
1414
|
}
|
|
1415
|
+
var wsLogger = logger.child("@spfn/core:ws");
|
|
1416
|
+
async function attachWSHandler(server, router, config = {}, tokenManager) {
|
|
1417
|
+
const WebSocketServer = await loadWSServer();
|
|
1418
|
+
const {
|
|
1419
|
+
pingInterval = 3e4,
|
|
1420
|
+
path = "/ws",
|
|
1421
|
+
auth: authConfig
|
|
1422
|
+
} = config;
|
|
1423
|
+
if (authConfig?.enabled && !tokenManager) {
|
|
1424
|
+
throw new Error(
|
|
1425
|
+
"WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
const wss = new WebSocketServer({ server, path });
|
|
1429
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1430
|
+
wss.on("connection", (ws, req) => {
|
|
1431
|
+
clients.add(ws);
|
|
1432
|
+
ws.on("close", () => clients.delete(ws));
|
|
1433
|
+
handleConnection(ws, req, router, authConfig, tokenManager, pingInterval).catch((err) => {
|
|
1434
|
+
wsLogger.error("WebSocket connection handler error", err);
|
|
1435
|
+
if (ws.readyState === 1) ws.close(1011, "Internal server error");
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
1438
|
+
wss.on("error", (err) => {
|
|
1439
|
+
wsLogger.error("WebSocket server error", err);
|
|
1440
|
+
});
|
|
1441
|
+
wsLogger.info(`\u2713 WebSocket endpoint registered at ${path}`, {
|
|
1442
|
+
events: router.eventNames,
|
|
1443
|
+
auth: !!authConfig?.enabled
|
|
1444
|
+
});
|
|
1445
|
+
return () => new Promise((resolve2, reject) => {
|
|
1446
|
+
for (const client of clients) {
|
|
1447
|
+
client.close(1001, "Server shutting down");
|
|
1448
|
+
}
|
|
1449
|
+
clients.clear();
|
|
1450
|
+
wss.close((err) => {
|
|
1451
|
+
if (err) reject(err);
|
|
1452
|
+
else resolve2();
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
async function handleConnection(ws, req, router, authConfig, tokenManager, pingInterval) {
|
|
1457
|
+
let pingTimer;
|
|
1458
|
+
let connectionUnsubscribes = [];
|
|
1459
|
+
let subscribedEvents = [];
|
|
1460
|
+
ws.on("close", () => {
|
|
1461
|
+
clearInterval(pingTimer);
|
|
1462
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
1463
|
+
if (subscribedEvents.length > 0)
|
|
1464
|
+
wsLogger.info("WebSocket connection closed", { events: subscribedEvents });
|
|
1465
|
+
});
|
|
1466
|
+
const url = parseURL(req);
|
|
1467
|
+
if (!url) {
|
|
1468
|
+
ws.close(1002, "Invalid request URL");
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : void 0);
|
|
1472
|
+
if (subject === false) {
|
|
1473
|
+
ws.close(4001, "Missing token");
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
if (subject === null) {
|
|
1477
|
+
ws.close(4001, "Invalid or expired token");
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
const requestedEvents = parseRequestedEvents2(url, router.eventNames);
|
|
1481
|
+
if (requestedEvents.length === 0) {
|
|
1482
|
+
ws.close(4e3, "No valid event names specified");
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);
|
|
1486
|
+
if (allowedEvents === null) {
|
|
1487
|
+
ws.close(4003, "Not authorized for any requested events");
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
subscribedEvents = allowedEvents;
|
|
1491
|
+
wsLogger.info("WebSocket connection established", {
|
|
1492
|
+
events: allowedEvents,
|
|
1493
|
+
subject: subject ?? void 0
|
|
1494
|
+
});
|
|
1495
|
+
const connection = createConnection(ws);
|
|
1496
|
+
connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig);
|
|
1497
|
+
if (ws.readyState !== 1) {
|
|
1498
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
1499
|
+
connectionUnsubscribes = [];
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
ws.on("message", (data) => {
|
|
1503
|
+
onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
|
|
1504
|
+
});
|
|
1505
|
+
if (pingInterval > 0) {
|
|
1506
|
+
pingTimer = setInterval(() => {
|
|
1507
|
+
if (ws.readyState === 1) ws.ping();
|
|
1508
|
+
}, pingInterval);
|
|
1509
|
+
}
|
|
1510
|
+
connection.send("__connected", {
|
|
1511
|
+
subscribedEvents: allowedEvents,
|
|
1512
|
+
timestamp: Date.now()
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
function parseURL(req) {
|
|
1516
|
+
try {
|
|
1517
|
+
return new URL(req.url ?? "/", "ws://localhost");
|
|
1518
|
+
} catch {
|
|
1519
|
+
return null;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
async function resolveSubject(url, tokenManager) {
|
|
1523
|
+
if (!tokenManager) {
|
|
1524
|
+
return void 0;
|
|
1525
|
+
}
|
|
1526
|
+
const token = url.searchParams.get("token");
|
|
1527
|
+
if (!token) {
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1530
|
+
return await tokenManager.verify(token);
|
|
1531
|
+
}
|
|
1532
|
+
function parseRequestedEvents2(url, validEventNames) {
|
|
1533
|
+
const eventsParam = url.searchParams.get("events");
|
|
1534
|
+
if (!eventsParam) {
|
|
1535
|
+
return [];
|
|
1536
|
+
}
|
|
1537
|
+
return eventsParam.split(",").map((e) => e.trim()).filter((e) => validEventNames.includes(e));
|
|
1538
|
+
}
|
|
1539
|
+
async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
|
|
1540
|
+
if (!subject || !authConfig?.authorize) {
|
|
1541
|
+
return requestedEvents;
|
|
1542
|
+
}
|
|
1543
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
1544
|
+
return allowed.length === 0 ? null : allowed;
|
|
1545
|
+
}
|
|
1546
|
+
function createConnection(ws) {
|
|
1547
|
+
return {
|
|
1548
|
+
send: (type, payload) => {
|
|
1549
|
+
if (ws.readyState !== 1) return;
|
|
1550
|
+
ws.send(JSON.stringify({ type, data: payload }));
|
|
1551
|
+
},
|
|
1552
|
+
close: (code, reason) => ws.close(code, reason)
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
function subscribeEvents(ws, router, allowedEvents, subject, authConfig) {
|
|
1556
|
+
const unsubscribes = [];
|
|
1557
|
+
for (const eventName of allowedEvents) {
|
|
1558
|
+
const eventDef = router.events[eventName];
|
|
1559
|
+
if (!eventDef) continue;
|
|
1560
|
+
const unsubscribe = eventDef.subscribe((payload) => {
|
|
1561
|
+
if (ws.readyState !== 1) return;
|
|
1562
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
1563
|
+
if (!authConfig.filter[eventName](subject, payload)) return;
|
|
1564
|
+
}
|
|
1565
|
+
try {
|
|
1566
|
+
ws.send(JSON.stringify({ type: eventName, data: payload }));
|
|
1567
|
+
} catch {
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
unsubscribes.push(unsubscribe);
|
|
1571
|
+
}
|
|
1572
|
+
return unsubscribes;
|
|
1573
|
+
}
|
|
1574
|
+
async function onClientMessage(data, router, connection, subject) {
|
|
1575
|
+
let message;
|
|
1576
|
+
try {
|
|
1577
|
+
message = JSON.parse(data.toString());
|
|
1578
|
+
} catch {
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
const { type, data: payload } = message;
|
|
1582
|
+
if (!type) return;
|
|
1583
|
+
const handler = router.messages[type];
|
|
1584
|
+
if (!handler) return;
|
|
1585
|
+
try {
|
|
1586
|
+
await handler({ payload, subject, ws: connection });
|
|
1587
|
+
} catch (err) {
|
|
1588
|
+
wsLogger.error(`WebSocket message handler error: ${type}`, err);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
async function loadWSServer() {
|
|
1592
|
+
try {
|
|
1593
|
+
const mod = await import('ws');
|
|
1594
|
+
const WS = mod.default ?? mod;
|
|
1595
|
+
const WSS = WS.WebSocketServer ?? WS.Server;
|
|
1596
|
+
if (typeof WSS !== "function") {
|
|
1597
|
+
throw new Error(
|
|
1598
|
+
"WebSocketServer not found in ws module. Ensure ws@^8 is installed: pnpm add ws"
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
return WSS;
|
|
1602
|
+
} catch (err) {
|
|
1603
|
+
if (err instanceof Error && err.message.includes("WebSocketServer not found")) {
|
|
1604
|
+
throw err;
|
|
1605
|
+
}
|
|
1606
|
+
throw new Error(
|
|
1607
|
+
'@spfn/core WebSocket support requires the "ws" package.\nInstall it with: pnpm add ws'
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
854
1611
|
function getNetworkAddress() {
|
|
855
1612
|
const nets = networkInterfaces();
|
|
856
1613
|
for (const name of Object.keys(nets)) {
|
|
@@ -888,16 +1645,16 @@ function printBanner(options) {
|
|
|
888
1645
|
}
|
|
889
1646
|
|
|
890
1647
|
// src/server/validation.ts
|
|
891
|
-
function validateServerConfig(
|
|
892
|
-
if (
|
|
893
|
-
if (!Number.isInteger(
|
|
1648
|
+
function validateServerConfig(config) {
|
|
1649
|
+
if (config.port !== void 0) {
|
|
1650
|
+
if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
|
|
894
1651
|
throw new Error(
|
|
895
|
-
`Invalid port: ${
|
|
1652
|
+
`Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
|
|
896
1653
|
);
|
|
897
1654
|
}
|
|
898
1655
|
}
|
|
899
|
-
if (
|
|
900
|
-
const { request, keepAlive, headers } =
|
|
1656
|
+
if (config.timeout) {
|
|
1657
|
+
const { request, keepAlive, headers } = config.timeout;
|
|
901
1658
|
if (request !== void 0 && (request < 0 || !Number.isFinite(request))) {
|
|
902
1659
|
throw new Error(`Invalid timeout.request: ${request}. Must be a positive number.`);
|
|
903
1660
|
}
|
|
@@ -913,16 +1670,16 @@ function validateServerConfig(config2) {
|
|
|
913
1670
|
);
|
|
914
1671
|
}
|
|
915
1672
|
}
|
|
916
|
-
if (
|
|
917
|
-
const timeout =
|
|
1673
|
+
if (config.shutdown?.timeout !== void 0) {
|
|
1674
|
+
const timeout = config.shutdown.timeout;
|
|
918
1675
|
if (timeout < 0 || !Number.isFinite(timeout)) {
|
|
919
1676
|
throw new Error(`Invalid shutdown.timeout: ${timeout}. Must be a positive number.`);
|
|
920
1677
|
}
|
|
921
1678
|
}
|
|
922
|
-
if (
|
|
923
|
-
if (!
|
|
1679
|
+
if (config.healthCheck?.path) {
|
|
1680
|
+
if (!config.healthCheck.path.startsWith("/")) {
|
|
924
1681
|
throw new Error(
|
|
925
|
-
`Invalid healthCheck.path: "${
|
|
1682
|
+
`Invalid healthCheck.path: "${config.healthCheck.path}". Must start with "/".`
|
|
926
1683
|
);
|
|
927
1684
|
}
|
|
928
1685
|
}
|
|
@@ -931,9 +1688,7 @@ var DEFAULT_MAX_LISTENERS = 15;
|
|
|
931
1688
|
var TIMEOUTS = {
|
|
932
1689
|
SERVER_CLOSE: 5e3,
|
|
933
1690
|
DATABASE_CLOSE: 5e3,
|
|
934
|
-
REDIS_CLOSE: 5e3
|
|
935
|
-
PRODUCTION_ERROR_SHUTDOWN: 1e4
|
|
936
|
-
};
|
|
1691
|
+
REDIS_CLOSE: 5e3};
|
|
937
1692
|
var CONFIG_FILE_PATHS = [
|
|
938
1693
|
".spfn/server/server.config.mjs",
|
|
939
1694
|
".spfn/server/server.config",
|
|
@@ -941,9 +1696,9 @@ var CONFIG_FILE_PATHS = [
|
|
|
941
1696
|
"src/server/server.config.ts"
|
|
942
1697
|
];
|
|
943
1698
|
var processHandlersRegistered = false;
|
|
944
|
-
async function startServer(
|
|
945
|
-
|
|
946
|
-
const finalConfig = await loadAndMergeConfig(
|
|
1699
|
+
async function startServer(config) {
|
|
1700
|
+
loadEnv();
|
|
1701
|
+
const finalConfig = await loadAndMergeConfig(config);
|
|
947
1702
|
const { host, port, debug } = finalConfig;
|
|
948
1703
|
validateServerConfig(finalConfig);
|
|
949
1704
|
if (!host || !port) {
|
|
@@ -959,8 +1714,14 @@ async function startServer(config2) {
|
|
|
959
1714
|
await initializeInfrastructure(finalConfig);
|
|
960
1715
|
const app = await createServer(finalConfig);
|
|
961
1716
|
const server = startHttpServer(app, host, port);
|
|
1717
|
+
let wsCleanup;
|
|
1718
|
+
if (finalConfig.websockets) {
|
|
1719
|
+
wsCleanup = await initializeWebSocket(server, app, finalConfig);
|
|
1720
|
+
}
|
|
962
1721
|
const timeouts = getTimeoutConfig(finalConfig.timeout);
|
|
963
1722
|
applyServerTimeouts(server, timeouts);
|
|
1723
|
+
const fetchTimeouts = getFetchTimeoutConfig(finalConfig.fetchTimeout);
|
|
1724
|
+
applyGlobalFetchTimeouts(fetchTimeouts);
|
|
964
1725
|
logServerTimeouts(timeouts);
|
|
965
1726
|
printBanner({
|
|
966
1727
|
mode: debug ? "Development" : "Production",
|
|
@@ -968,7 +1729,7 @@ async function startServer(config2) {
|
|
|
968
1729
|
port
|
|
969
1730
|
});
|
|
970
1731
|
logServerStarted(debug, host, port, finalConfig, timeouts);
|
|
971
|
-
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState);
|
|
1732
|
+
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState, wsCleanup);
|
|
972
1733
|
const shutdown = createGracefulShutdown(shutdownServer, finalConfig, shutdownState);
|
|
973
1734
|
registerProcessHandlers(shutdown);
|
|
974
1735
|
const serverInstance = {
|
|
@@ -977,11 +1738,6 @@ async function startServer(config2) {
|
|
|
977
1738
|
config: finalConfig,
|
|
978
1739
|
close: async () => {
|
|
979
1740
|
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
1741
|
await shutdownServer();
|
|
986
1742
|
}
|
|
987
1743
|
};
|
|
@@ -1001,7 +1757,7 @@ async function startServer(config2) {
|
|
|
1001
1757
|
throw error;
|
|
1002
1758
|
}
|
|
1003
1759
|
}
|
|
1004
|
-
async function loadAndMergeConfig(
|
|
1760
|
+
async function loadAndMergeConfig(config) {
|
|
1005
1761
|
const cwd = process.cwd();
|
|
1006
1762
|
let fileConfig = {};
|
|
1007
1763
|
let loadedConfigPath = null;
|
|
@@ -1025,26 +1781,26 @@ async function loadAndMergeConfig(config2) {
|
|
|
1025
1781
|
}
|
|
1026
1782
|
return {
|
|
1027
1783
|
...fileConfig,
|
|
1028
|
-
...
|
|
1029
|
-
port:
|
|
1030
|
-
host:
|
|
1784
|
+
...config,
|
|
1785
|
+
port: config?.port ?? fileConfig?.port ?? env.PORT,
|
|
1786
|
+
host: config?.host ?? fileConfig?.host ?? env.HOST
|
|
1031
1787
|
};
|
|
1032
1788
|
}
|
|
1033
|
-
function getInfrastructureConfig(
|
|
1789
|
+
function getInfrastructureConfig(config) {
|
|
1034
1790
|
return {
|
|
1035
|
-
database:
|
|
1036
|
-
redis:
|
|
1791
|
+
database: config.infrastructure?.database !== false,
|
|
1792
|
+
redis: config.infrastructure?.redis !== false
|
|
1037
1793
|
};
|
|
1038
1794
|
}
|
|
1039
|
-
async function initializeInfrastructure(
|
|
1040
|
-
if (
|
|
1795
|
+
async function initializeInfrastructure(config) {
|
|
1796
|
+
if (config.lifecycle?.beforeInfrastructure) {
|
|
1041
1797
|
serverLogger.debug("Executing beforeInfrastructure hook...");
|
|
1042
|
-
await
|
|
1798
|
+
await config.lifecycle.beforeInfrastructure(config);
|
|
1043
1799
|
}
|
|
1044
|
-
const infraConfig = getInfrastructureConfig(
|
|
1800
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1045
1801
|
if (infraConfig.database) {
|
|
1046
1802
|
serverLogger.debug("Initializing database...");
|
|
1047
|
-
await initDatabase(
|
|
1803
|
+
await initDatabase(config.database);
|
|
1048
1804
|
} else {
|
|
1049
1805
|
serverLogger.debug("Database initialization disabled");
|
|
1050
1806
|
}
|
|
@@ -1054,11 +1810,11 @@ async function initializeInfrastructure(config2) {
|
|
|
1054
1810
|
} else {
|
|
1055
1811
|
serverLogger.debug("Redis initialization disabled");
|
|
1056
1812
|
}
|
|
1057
|
-
if (
|
|
1813
|
+
if (config.lifecycle?.afterInfrastructure) {
|
|
1058
1814
|
serverLogger.debug("Executing afterInfrastructure hook...");
|
|
1059
|
-
await
|
|
1815
|
+
await config.lifecycle.afterInfrastructure();
|
|
1060
1816
|
}
|
|
1061
|
-
if (
|
|
1817
|
+
if (config.jobs) {
|
|
1062
1818
|
const dbUrl = env.DATABASE_URL;
|
|
1063
1819
|
if (!dbUrl) {
|
|
1064
1820
|
throw new Error(
|
|
@@ -1068,10 +1824,24 @@ async function initializeInfrastructure(config2) {
|
|
|
1068
1824
|
serverLogger.debug("Initializing pg-boss...");
|
|
1069
1825
|
await initBoss({
|
|
1070
1826
|
connectionString: dbUrl,
|
|
1071
|
-
...
|
|
1827
|
+
...config.jobsConfig
|
|
1072
1828
|
});
|
|
1073
1829
|
serverLogger.debug("Registering jobs...");
|
|
1074
|
-
await registerJobs(
|
|
1830
|
+
await registerJobs(config.jobs);
|
|
1831
|
+
}
|
|
1832
|
+
if (config.workflows) {
|
|
1833
|
+
const infraConfig2 = getInfrastructureConfig(config);
|
|
1834
|
+
if (!infraConfig2.database) {
|
|
1835
|
+
throw new Error(
|
|
1836
|
+
"Workflows require database connection. Ensure database is enabled in infrastructure config."
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
serverLogger.debug("Initializing workflow engine...");
|
|
1840
|
+
config.workflows._init(
|
|
1841
|
+
getDatabase(),
|
|
1842
|
+
config.workflowsConfig
|
|
1843
|
+
);
|
|
1844
|
+
serverLogger.info("Workflow engine initialized");
|
|
1075
1845
|
}
|
|
1076
1846
|
}
|
|
1077
1847
|
function startHttpServer(app, host, port) {
|
|
@@ -1082,8 +1852,48 @@ function startHttpServer(app, host, port) {
|
|
|
1082
1852
|
hostname: host
|
|
1083
1853
|
});
|
|
1084
1854
|
}
|
|
1085
|
-
function
|
|
1086
|
-
const
|
|
1855
|
+
async function initializeWebSocket(server, app, config) {
|
|
1856
|
+
const wsRouter = config.websockets;
|
|
1857
|
+
const wsConfig = config.websocketsConfig ?? {};
|
|
1858
|
+
const authConfig = wsConfig.auth;
|
|
1859
|
+
const wsPath = wsConfig.path ?? "/ws";
|
|
1860
|
+
const debug = config.debug ?? process.env.NODE_ENV === "development";
|
|
1861
|
+
let tokenManager;
|
|
1862
|
+
if (authConfig?.enabled) {
|
|
1863
|
+
let store = authConfig.store;
|
|
1864
|
+
if (!store) {
|
|
1865
|
+
try {
|
|
1866
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
1867
|
+
const cache = getCache2();
|
|
1868
|
+
if (cache) {
|
|
1869
|
+
store = new CacheTokenStore(cache);
|
|
1870
|
+
if (debug) serverLogger.info("WS token store: cache (Redis/Valkey)");
|
|
1871
|
+
}
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
1876
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
1877
|
+
ttl: authConfig.tokenTtl,
|
|
1878
|
+
store
|
|
1879
|
+
});
|
|
1880
|
+
const tokenPath = wsPath.replace(/\/[^/]+$/, "/token");
|
|
1881
|
+
const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
|
|
1882
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
1883
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
1884
|
+
const subject = getSubject(c);
|
|
1885
|
+
if (!subject) {
|
|
1886
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
1887
|
+
}
|
|
1888
|
+
const token = await tokenManager.issue(subject);
|
|
1889
|
+
return c.json({ token });
|
|
1890
|
+
});
|
|
1891
|
+
if (debug) serverLogger.info(`\u2713 WS token endpoint registered at POST ${tokenPath}`);
|
|
1892
|
+
}
|
|
1893
|
+
return await attachWSHandler(server, wsRouter, wsConfig, tokenManager);
|
|
1894
|
+
}
|
|
1895
|
+
function logMiddlewareOrder(config) {
|
|
1896
|
+
const middlewareOrder = buildMiddlewareOrder(config);
|
|
1087
1897
|
serverLogger.debug("Middleware execution order", {
|
|
1088
1898
|
order: middlewareOrder
|
|
1089
1899
|
});
|
|
@@ -1095,8 +1905,8 @@ function logServerTimeouts(timeouts) {
|
|
|
1095
1905
|
headers: `${timeouts.headers}ms`
|
|
1096
1906
|
});
|
|
1097
1907
|
}
|
|
1098
|
-
function logServerStarted(debug, host, port,
|
|
1099
|
-
const startupConfig = buildStartupConfig(
|
|
1908
|
+
function logServerStarted(debug, host, port, config, timeouts) {
|
|
1909
|
+
const startupConfig = buildStartupConfig(config, timeouts);
|
|
1100
1910
|
serverLogger.info("Server started successfully", {
|
|
1101
1911
|
mode: debug ? "development" : "production",
|
|
1102
1912
|
host,
|
|
@@ -1104,65 +1914,83 @@ function logServerStarted(debug, host, port, config2, timeouts) {
|
|
|
1104
1914
|
config: startupConfig
|
|
1105
1915
|
});
|
|
1106
1916
|
}
|
|
1107
|
-
function createShutdownHandler(server,
|
|
1917
|
+
function createShutdownHandler(server, config, shutdownState, wsCleanup) {
|
|
1108
1918
|
return async () => {
|
|
1109
1919
|
if (shutdownState.isShuttingDown) {
|
|
1110
1920
|
serverLogger.debug("Shutdown already in progress for this instance, skipping");
|
|
1111
1921
|
return;
|
|
1112
1922
|
}
|
|
1113
1923
|
shutdownState.isShuttingDown = true;
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
timeoutId = setTimeout(() => {
|
|
1131
|
-
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
1132
|
-
}, TIMEOUTS.SERVER_CLOSE);
|
|
1133
|
-
})
|
|
1134
|
-
]).catch((error) => {
|
|
1135
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
1136
|
-
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
1137
|
-
});
|
|
1138
|
-
if (config2.jobs) {
|
|
1139
|
-
serverLogger.debug("Stopping pg-boss...");
|
|
1924
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
1925
|
+
const shutdownManager = getShutdownManager();
|
|
1926
|
+
shutdownManager.beginShutdown();
|
|
1927
|
+
serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
|
|
1928
|
+
await closeHttpServer(server);
|
|
1929
|
+
if (wsCleanup) {
|
|
1930
|
+
serverLogger.info("Phase 1.5: Closing WebSocket server...");
|
|
1931
|
+
try {
|
|
1932
|
+
await wsCleanup();
|
|
1933
|
+
serverLogger.info("WebSocket server closed");
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
serverLogger.error("WebSocket server close failed", error);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
if (config.jobs) {
|
|
1939
|
+
serverLogger.info("Phase 2: Stopping pg-boss...");
|
|
1140
1940
|
try {
|
|
1141
1941
|
await stopBoss();
|
|
1942
|
+
serverLogger.info("pg-boss stopped");
|
|
1142
1943
|
} catch (error) {
|
|
1143
1944
|
serverLogger.error("pg-boss stop failed", error);
|
|
1144
1945
|
}
|
|
1145
1946
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1947
|
+
const drainTimeout = Math.floor(shutdownTimeout * 0.8);
|
|
1948
|
+
serverLogger.info(`Phase 3: Draining tracked operations (timeout: ${drainTimeout}ms)...`);
|
|
1949
|
+
await shutdownManager.execute(drainTimeout);
|
|
1950
|
+
if (config.lifecycle?.beforeShutdown) {
|
|
1951
|
+
serverLogger.info("Phase 4: Executing beforeShutdown lifecycle hook...");
|
|
1148
1952
|
try {
|
|
1149
|
-
await
|
|
1953
|
+
await config.lifecycle.beforeShutdown();
|
|
1150
1954
|
} catch (error) {
|
|
1151
|
-
serverLogger.error("beforeShutdown hook failed", error);
|
|
1955
|
+
serverLogger.error("beforeShutdown lifecycle hook failed", error);
|
|
1152
1956
|
}
|
|
1153
1957
|
}
|
|
1154
|
-
|
|
1958
|
+
serverLogger.info("Phase 5: Closing infrastructure...");
|
|
1959
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1155
1960
|
if (infraConfig.database) {
|
|
1156
|
-
serverLogger.debug("Closing database connections...");
|
|
1157
1961
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1158
1962
|
}
|
|
1159
1963
|
if (infraConfig.redis) {
|
|
1160
|
-
serverLogger.debug("Closing Redis connections...");
|
|
1161
1964
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1162
1965
|
}
|
|
1163
1966
|
serverLogger.info("Server shutdown completed");
|
|
1164
1967
|
};
|
|
1165
1968
|
}
|
|
1969
|
+
async function closeHttpServer(server) {
|
|
1970
|
+
let timeoutId;
|
|
1971
|
+
await Promise.race([
|
|
1972
|
+
new Promise((resolve2, reject) => {
|
|
1973
|
+
server.close((err) => {
|
|
1974
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1975
|
+
if (err) {
|
|
1976
|
+
serverLogger.error("HTTP server close error", err);
|
|
1977
|
+
reject(err);
|
|
1978
|
+
} else {
|
|
1979
|
+
serverLogger.info("HTTP server closed");
|
|
1980
|
+
resolve2();
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
}),
|
|
1984
|
+
new Promise((_, reject) => {
|
|
1985
|
+
timeoutId = setTimeout(() => {
|
|
1986
|
+
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
1987
|
+
}, TIMEOUTS.SERVER_CLOSE);
|
|
1988
|
+
})
|
|
1989
|
+
]).catch((error) => {
|
|
1990
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1991
|
+
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1166
1994
|
async function closeInfrastructure(closeFn, name, timeout) {
|
|
1167
1995
|
let timeoutId;
|
|
1168
1996
|
try {
|
|
@@ -1182,14 +2010,14 @@ async function closeInfrastructure(closeFn, name, timeout) {
|
|
|
1182
2010
|
serverLogger.error(`${name} close failed or timed out`, error);
|
|
1183
2011
|
}
|
|
1184
2012
|
}
|
|
1185
|
-
function createGracefulShutdown(shutdownServer,
|
|
2013
|
+
function createGracefulShutdown(shutdownServer, config, shutdownState) {
|
|
1186
2014
|
return async (signal) => {
|
|
1187
2015
|
if (shutdownState.isShuttingDown) {
|
|
1188
2016
|
serverLogger.warn(`${signal} received but shutdown already in progress, ignoring`);
|
|
1189
2017
|
return;
|
|
1190
2018
|
}
|
|
1191
2019
|
serverLogger.info(`${signal} received, starting graceful shutdown...`);
|
|
1192
|
-
const shutdownTimeout = getShutdownTimeout(
|
|
2020
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
1193
2021
|
let timeoutId;
|
|
1194
2022
|
try {
|
|
1195
2023
|
await Promise.race([
|
|
@@ -1217,31 +2045,8 @@ function createGracefulShutdown(shutdownServer, config2, shutdownState) {
|
|
|
1217
2045
|
}
|
|
1218
2046
|
};
|
|
1219
2047
|
}
|
|
1220
|
-
function handleProcessError(errorType
|
|
1221
|
-
|
|
1222
|
-
const isDevelopment = env.NODE_ENV === "development";
|
|
1223
|
-
if (isDevelopment || process.env.WATCH_MODE === "true") {
|
|
1224
|
-
serverLogger.info("Exiting immediately for clean restart");
|
|
1225
|
-
process.exit(1);
|
|
1226
|
-
} else if (isProduction) {
|
|
1227
|
-
serverLogger.info(`Attempting graceful shutdown after ${errorType}`);
|
|
1228
|
-
const forceExitTimer = setTimeout(() => {
|
|
1229
|
-
serverLogger.error(`Forced exit after ${TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN}ms - graceful shutdown did not complete`);
|
|
1230
|
-
process.exit(1);
|
|
1231
|
-
}, TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN);
|
|
1232
|
-
shutdown(errorType).then(() => {
|
|
1233
|
-
clearTimeout(forceExitTimer);
|
|
1234
|
-
serverLogger.info("Graceful shutdown completed, exiting");
|
|
1235
|
-
process.exit(0);
|
|
1236
|
-
}).catch((shutdownError) => {
|
|
1237
|
-
clearTimeout(forceExitTimer);
|
|
1238
|
-
serverLogger.error("Graceful shutdown failed", shutdownError);
|
|
1239
|
-
process.exit(1);
|
|
1240
|
-
});
|
|
1241
|
-
} else {
|
|
1242
|
-
serverLogger.info("Exiting immediately");
|
|
1243
|
-
process.exit(1);
|
|
1244
|
-
}
|
|
2048
|
+
function handleProcessError(errorType) {
|
|
2049
|
+
serverLogger.warn(`${errorType} occurred - server continues running. Check logs above for details.`);
|
|
1245
2050
|
}
|
|
1246
2051
|
function registerProcessHandlers(shutdown) {
|
|
1247
2052
|
if (processHandlersRegistered) {
|
|
@@ -1276,7 +2081,7 @@ function registerProcessHandlers(shutdown) {
|
|
|
1276
2081
|
} else {
|
|
1277
2082
|
serverLogger.error("Uncaught exception", error);
|
|
1278
2083
|
}
|
|
1279
|
-
handleProcessError("UNCAUGHT_EXCEPTION"
|
|
2084
|
+
handleProcessError("UNCAUGHT_EXCEPTION");
|
|
1280
2085
|
});
|
|
1281
2086
|
process.on("unhandledRejection", (reason, promise) => {
|
|
1282
2087
|
if (reason instanceof Error) {
|
|
@@ -1294,20 +2099,21 @@ function registerProcessHandlers(shutdown) {
|
|
|
1294
2099
|
promise
|
|
1295
2100
|
});
|
|
1296
2101
|
}
|
|
1297
|
-
handleProcessError("UNHANDLED_REJECTION"
|
|
2102
|
+
handleProcessError("UNHANDLED_REJECTION");
|
|
1298
2103
|
});
|
|
1299
2104
|
serverLogger.debug("Process-level shutdown handlers registered successfully");
|
|
1300
2105
|
}
|
|
1301
|
-
async function cleanupOnFailure(
|
|
2106
|
+
async function cleanupOnFailure(config) {
|
|
1302
2107
|
try {
|
|
1303
2108
|
serverLogger.debug("Cleaning up after initialization failure...");
|
|
1304
|
-
const infraConfig = getInfrastructureConfig(
|
|
2109
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1305
2110
|
if (infraConfig.database) {
|
|
1306
2111
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1307
2112
|
}
|
|
1308
2113
|
if (infraConfig.redis) {
|
|
1309
2114
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1310
2115
|
}
|
|
2116
|
+
resetShutdownManager();
|
|
1311
2117
|
serverLogger.debug("Cleanup completed");
|
|
1312
2118
|
} catch (cleanupError) {
|
|
1313
2119
|
serverLogger.error("Cleanup failed", cleanupError);
|
|
@@ -1433,10 +2239,10 @@ var ServerConfigBuilder = class {
|
|
|
1433
2239
|
* .build();
|
|
1434
2240
|
* ```
|
|
1435
2241
|
*/
|
|
1436
|
-
jobs(router,
|
|
2242
|
+
jobs(router, config) {
|
|
1437
2243
|
this.config.jobs = router;
|
|
1438
|
-
if (
|
|
1439
|
-
this.config.jobsConfig =
|
|
2244
|
+
if (config) {
|
|
2245
|
+
this.config.jobsConfig = config;
|
|
1440
2246
|
}
|
|
1441
2247
|
return this;
|
|
1442
2248
|
}
|
|
@@ -1468,10 +2274,44 @@ var ServerConfigBuilder = class {
|
|
|
1468
2274
|
* .events(eventRouter, { path: '/sse' })
|
|
1469
2275
|
* ```
|
|
1470
2276
|
*/
|
|
1471
|
-
events(router,
|
|
2277
|
+
events(router, config) {
|
|
1472
2278
|
this.config.events = router;
|
|
1473
|
-
if (
|
|
1474
|
-
this.config.eventsConfig =
|
|
2279
|
+
if (config) {
|
|
2280
|
+
this.config.eventsConfig = config;
|
|
2281
|
+
}
|
|
2282
|
+
return this;
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Register WebSocket router for bidirectional real-time communication
|
|
2286
|
+
*
|
|
2287
|
+
* Enables type-safe WebSocket connections with:
|
|
2288
|
+
* - Server→client event push (via defineEvent + emit)
|
|
2289
|
+
* - Client→server message handling (via messages in defineWSRouter)
|
|
2290
|
+
*
|
|
2291
|
+
* @example
|
|
2292
|
+
* ```typescript
|
|
2293
|
+
* // src/server/ws.ts
|
|
2294
|
+
* export const wsRouter = defineWSRouter({
|
|
2295
|
+
* events: { userUpdated, notification },
|
|
2296
|
+
* messages: {
|
|
2297
|
+
* ping: ({ ws }) => ws.send('pong', {}),
|
|
2298
|
+
* },
|
|
2299
|
+
* });
|
|
2300
|
+
*
|
|
2301
|
+
* // server.config.ts
|
|
2302
|
+
* export default defineServerConfig()
|
|
2303
|
+
* .websockets(wsRouter) // → WS /ws
|
|
2304
|
+
* .websockets(wsRouter, {
|
|
2305
|
+
* path: '/realtime', // custom path
|
|
2306
|
+
* auth: { enabled: true }, // token authentication
|
|
2307
|
+
* })
|
|
2308
|
+
* .build();
|
|
2309
|
+
* ```
|
|
2310
|
+
*/
|
|
2311
|
+
websockets(router, config) {
|
|
2312
|
+
this.config.websockets = router;
|
|
2313
|
+
if (config) {
|
|
2314
|
+
this.config.websocketsConfig = config;
|
|
1475
2315
|
}
|
|
1476
2316
|
return this;
|
|
1477
2317
|
}
|
|
@@ -1517,6 +2357,33 @@ var ServerConfigBuilder = class {
|
|
|
1517
2357
|
this.config.infrastructure = infrastructure;
|
|
1518
2358
|
return this;
|
|
1519
2359
|
}
|
|
2360
|
+
/**
|
|
2361
|
+
* Register workflow router for workflow orchestration
|
|
2362
|
+
*
|
|
2363
|
+
* Automatically initializes the workflow engine after database is ready.
|
|
2364
|
+
*
|
|
2365
|
+
* @example
|
|
2366
|
+
* ```typescript
|
|
2367
|
+
* import { defineWorkflowRouter } from '@spfn/workflow';
|
|
2368
|
+
*
|
|
2369
|
+
* const workflowRouter = defineWorkflowRouter([
|
|
2370
|
+
* provisionTenant,
|
|
2371
|
+
* deprovisionTenant,
|
|
2372
|
+
* ]);
|
|
2373
|
+
*
|
|
2374
|
+
* export default defineServerConfig()
|
|
2375
|
+
* .routes(appRouter)
|
|
2376
|
+
* .workflows(workflowRouter)
|
|
2377
|
+
* .build();
|
|
2378
|
+
* ```
|
|
2379
|
+
*/
|
|
2380
|
+
workflows(router, config) {
|
|
2381
|
+
this.config.workflows = router;
|
|
2382
|
+
if (config) {
|
|
2383
|
+
this.config.workflowsConfig = config;
|
|
2384
|
+
}
|
|
2385
|
+
return this;
|
|
2386
|
+
}
|
|
1520
2387
|
/**
|
|
1521
2388
|
* Configure lifecycle hooks
|
|
1522
2389
|
* Can be called multiple times - hooks will be executed in registration order
|
|
@@ -1564,6 +2431,6 @@ function defineServerConfig() {
|
|
|
1564
2431
|
return new ServerConfigBuilder();
|
|
1565
2432
|
}
|
|
1566
2433
|
|
|
1567
|
-
export { createServer, defineServerConfig, loadEnvFiles, startServer };
|
|
2434
|
+
export { createServer, defineServerConfig, getShutdownManager, loadEnv, loadEnvFiles, startServer };
|
|
1568
2435
|
//# sourceMappingURL=index.js.map
|
|
1569
2436
|
//# sourceMappingURL=index.js.map
|