@spfn/core 0.2.0-beta.4 → 0.2.0-beta.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -1175
- package/dist/{boss-BO8ty33K.d.ts → boss-DI1r4kTS.d.ts} +24 -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 +128 -4
- package/dist/db/index.js +177 -50
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +55 -1
- package/dist/env/index.js +71 -3
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +27 -19
- package/dist/env/loader.js +33 -25
- package/dist/env/loader.js.map +1 -1
- package/dist/event/index.d.ts +27 -1
- package/dist/event/index.js +6 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +77 -2
- package/dist/event/sse/client.js +87 -24
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +10 -4
- package/dist/event/sse/index.js +158 -12
- package/dist/event/sse/index.js.map +1 -1
- package/dist/job/index.d.ts +23 -8
- package/dist/job/index.js +96 -20
- 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 +253 -17
- package/dist/route/index.js.map +1 -1
- package/dist/server/index.d.ts +251 -16
- package/dist/server/index.js +774 -228
- package/dist/server/index.js.map +1 -1
- package/dist/{types-D_N_U-Py.d.ts → types-7Mhoxnnt.d.ts} +21 -1
- package/dist/types-DKQ90YL7.d.ts +372 -0
- package/docs/cache.md +133 -0
- package/docs/codegen.md +74 -0
- package/docs/database.md +370 -0
- package/docs/entity.md +539 -0
- package/docs/env.md +499 -0
- package/docs/errors.md +319 -0
- package/docs/event.md +443 -0
- package/docs/file-upload.md +717 -0
- package/docs/job.md +131 -0
- package/docs/logger.md +108 -0
- package/docs/middleware.md +337 -0
- package/docs/nextjs.md +247 -0
- package/docs/repository.md +496 -0
- package/docs/route.md +497 -0
- package/docs/server.md +429 -0
- package/package.json +3 -2
- 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,96 @@ 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
|
+
files.push(".env.server.local");
|
|
346
|
+
}
|
|
347
|
+
return files;
|
|
348
|
+
}
|
|
349
|
+
function parseEnvFile(filePath) {
|
|
350
|
+
if (!existsSync(filePath)) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
return parse(readFileSync(filePath, "utf-8"));
|
|
354
|
+
}
|
|
355
|
+
function loadEnv(options = {}) {
|
|
356
|
+
const {
|
|
357
|
+
cwd = process.cwd(),
|
|
358
|
+
nodeEnv = process.env.NODE_ENV || "local",
|
|
359
|
+
server = true,
|
|
360
|
+
debug = false,
|
|
361
|
+
override = false
|
|
362
|
+
} = options;
|
|
363
|
+
const envFiles = getEnvFiles(nodeEnv, server);
|
|
364
|
+
const loadedFiles = [];
|
|
365
|
+
const existingKeys = new Set(Object.keys(process.env));
|
|
366
|
+
const merged = {};
|
|
367
|
+
for (const fileName of envFiles) {
|
|
368
|
+
const filePath = resolve(cwd, fileName);
|
|
369
|
+
const parsed = parseEnvFile(filePath);
|
|
370
|
+
if (parsed === null) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
loadedFiles.push(fileName);
|
|
374
|
+
Object.assign(merged, parsed);
|
|
375
|
+
}
|
|
376
|
+
const loadedKeys = [];
|
|
377
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
378
|
+
if (!override && existingKeys.has(key)) {
|
|
379
|
+
continue;
|
|
332
380
|
}
|
|
381
|
+
process.env[key] = value;
|
|
382
|
+
loadedKeys.push(key);
|
|
383
|
+
}
|
|
384
|
+
if (debug && loadedFiles.length > 0) {
|
|
385
|
+
envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
|
|
386
|
+
envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
|
|
333
387
|
}
|
|
388
|
+
return { loadedFiles, loadedKeys };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/server/dotenv-loader.ts
|
|
392
|
+
var warned = false;
|
|
393
|
+
function loadEnvFiles() {
|
|
394
|
+
if (!warned) {
|
|
395
|
+
warned = true;
|
|
396
|
+
console.warn(
|
|
397
|
+
'[SPFN] loadEnvFiles() is deprecated. Use loadEnv() from "@spfn/core/env/loader" instead.'
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
loadEnv();
|
|
334
401
|
}
|
|
335
402
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
336
|
-
function createSSEHandler(router,
|
|
403
|
+
function createSSEHandler(router, config = {}, tokenManager) {
|
|
337
404
|
const {
|
|
338
|
-
pingInterval = 3e4
|
|
339
|
-
|
|
340
|
-
} =
|
|
405
|
+
pingInterval = 3e4,
|
|
406
|
+
auth: authConfig
|
|
407
|
+
} = config;
|
|
341
408
|
return async (c) => {
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
409
|
+
const subject = await authenticateToken(c, tokenManager);
|
|
410
|
+
if (subject === false) {
|
|
411
|
+
return c.json({ error: "Missing token parameter" }, 401);
|
|
412
|
+
}
|
|
413
|
+
if (subject === null) {
|
|
414
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
415
|
+
}
|
|
416
|
+
if (subject) {
|
|
417
|
+
c.set("sseSubject", subject);
|
|
418
|
+
}
|
|
419
|
+
const requestedEvents = parseRequestedEvents(c);
|
|
420
|
+
if (!requestedEvents) {
|
|
344
421
|
return c.json({ error: "Missing events parameter" }, 400);
|
|
345
422
|
}
|
|
346
|
-
const requestedEvents = eventsParam.split(",").map((e) => e.trim());
|
|
347
423
|
const validEventNames = router.eventNames;
|
|
348
424
|
const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
|
|
349
425
|
if (invalidEvents.length > 0) {
|
|
@@ -353,19 +429,29 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
353
429
|
validEvents: validEventNames
|
|
354
430
|
}, 400);
|
|
355
431
|
}
|
|
432
|
+
const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
|
|
433
|
+
if (allowedEvents === null) {
|
|
434
|
+
return c.json({ error: "Not authorized for any requested events" }, 403);
|
|
435
|
+
}
|
|
356
436
|
sseLogger.debug("SSE connection requested", {
|
|
357
|
-
events:
|
|
437
|
+
events: allowedEvents,
|
|
438
|
+
subject: subject || void 0,
|
|
358
439
|
clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
359
440
|
});
|
|
360
441
|
return streamSSE(c, async (stream) => {
|
|
361
442
|
const unsubscribes = [];
|
|
362
443
|
let messageId = 0;
|
|
363
|
-
for (const eventName of
|
|
444
|
+
for (const eventName of allowedEvents) {
|
|
364
445
|
const eventDef = router.events[eventName];
|
|
365
446
|
if (!eventDef) {
|
|
366
447
|
continue;
|
|
367
448
|
}
|
|
368
449
|
const unsubscribe = eventDef.subscribe((payload) => {
|
|
450
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
451
|
+
if (!authConfig.filter[eventName](subject, payload)) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
369
455
|
messageId++;
|
|
370
456
|
const message = {
|
|
371
457
|
event: eventName,
|
|
@@ -384,13 +470,13 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
384
470
|
unsubscribes.push(unsubscribe);
|
|
385
471
|
}
|
|
386
472
|
sseLogger.info("SSE connection established", {
|
|
387
|
-
events:
|
|
473
|
+
events: allowedEvents,
|
|
388
474
|
subscriptionCount: unsubscribes.length
|
|
389
475
|
});
|
|
390
476
|
await stream.writeSSE({
|
|
391
477
|
event: "connected",
|
|
392
478
|
data: JSON.stringify({
|
|
393
|
-
subscribedEvents:
|
|
479
|
+
subscribedEvents: allowedEvents,
|
|
394
480
|
timestamp: Date.now()
|
|
395
481
|
})
|
|
396
482
|
});
|
|
@@ -407,7 +493,7 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
407
493
|
clearInterval(pingTimer);
|
|
408
494
|
unsubscribes.forEach((fn) => fn());
|
|
409
495
|
sseLogger.info("SSE connection closed", {
|
|
410
|
-
events:
|
|
496
|
+
events: allowedEvents
|
|
411
497
|
});
|
|
412
498
|
}, async (err) => {
|
|
413
499
|
sseLogger.error("SSE stream error", {
|
|
@@ -416,8 +502,357 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
416
502
|
});
|
|
417
503
|
};
|
|
418
504
|
}
|
|
505
|
+
async function authenticateToken(c, tokenManager) {
|
|
506
|
+
if (!tokenManager) {
|
|
507
|
+
return void 0;
|
|
508
|
+
}
|
|
509
|
+
const token = c.req.query("token");
|
|
510
|
+
if (!token) {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
return await tokenManager.verify(token);
|
|
514
|
+
}
|
|
515
|
+
function parseRequestedEvents(c) {
|
|
516
|
+
const eventsParam = c.req.query("events");
|
|
517
|
+
if (!eventsParam) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
return eventsParam.split(",").map((e) => e.trim());
|
|
521
|
+
}
|
|
522
|
+
async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
523
|
+
if (!subject || !authConfig?.authorize) {
|
|
524
|
+
return requestedEvents;
|
|
525
|
+
}
|
|
526
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
527
|
+
if (allowed.length === 0) {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
return allowed;
|
|
531
|
+
}
|
|
532
|
+
var InMemoryTokenStore = class {
|
|
533
|
+
tokens = /* @__PURE__ */ new Map();
|
|
534
|
+
async set(token, data) {
|
|
535
|
+
this.tokens.set(token, data);
|
|
536
|
+
}
|
|
537
|
+
async consume(token) {
|
|
538
|
+
const data = this.tokens.get(token);
|
|
539
|
+
if (!data) {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
this.tokens.delete(token);
|
|
543
|
+
return data;
|
|
544
|
+
}
|
|
545
|
+
async cleanup() {
|
|
546
|
+
const now = Date.now();
|
|
547
|
+
for (const [token, data] of this.tokens) {
|
|
548
|
+
if (data.expiresAt <= now) {
|
|
549
|
+
this.tokens.delete(token);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
var CacheTokenStore = class {
|
|
555
|
+
constructor(cache) {
|
|
556
|
+
this.cache = cache;
|
|
557
|
+
}
|
|
558
|
+
prefix = "sse:token:";
|
|
559
|
+
async set(token, data) {
|
|
560
|
+
const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
|
|
561
|
+
await this.cache.set(
|
|
562
|
+
this.prefix + token,
|
|
563
|
+
JSON.stringify(data),
|
|
564
|
+
"EX",
|
|
565
|
+
ttlSeconds
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
async consume(token) {
|
|
569
|
+
const key = this.prefix + token;
|
|
570
|
+
let raw = null;
|
|
571
|
+
if (this.cache.getdel) {
|
|
572
|
+
raw = await this.cache.getdel(key);
|
|
573
|
+
} else {
|
|
574
|
+
raw = await this.cache.get(key);
|
|
575
|
+
if (raw) {
|
|
576
|
+
await this.cache.del(key);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (!raw) {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
return JSON.parse(raw);
|
|
583
|
+
}
|
|
584
|
+
async cleanup() {
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
var SSETokenManager = class {
|
|
588
|
+
store;
|
|
589
|
+
ttl;
|
|
590
|
+
cleanupTimer = null;
|
|
591
|
+
constructor(config) {
|
|
592
|
+
this.ttl = config?.ttl ?? 3e4;
|
|
593
|
+
this.store = config?.store ?? new InMemoryTokenStore();
|
|
594
|
+
const cleanupInterval = config?.cleanupInterval ?? 6e4;
|
|
595
|
+
this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
|
|
596
|
+
this.cleanupTimer.unref();
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Issue a new one-time-use token for the given subject
|
|
600
|
+
*/
|
|
601
|
+
async issue(subject) {
|
|
602
|
+
const token = randomBytes(32).toString("hex");
|
|
603
|
+
await this.store.set(token, {
|
|
604
|
+
token,
|
|
605
|
+
subject,
|
|
606
|
+
expiresAt: Date.now() + this.ttl
|
|
607
|
+
});
|
|
608
|
+
return token;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Verify and consume a token
|
|
612
|
+
* @returns subject string if valid, null if invalid/expired/already consumed
|
|
613
|
+
*/
|
|
614
|
+
async verify(token) {
|
|
615
|
+
const data = await this.store.consume(token);
|
|
616
|
+
if (!data || data.expiresAt <= Date.now()) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
return data.subject;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Cleanup timer and resources
|
|
623
|
+
*/
|
|
624
|
+
destroy() {
|
|
625
|
+
if (this.cleanupTimer) {
|
|
626
|
+
clearInterval(this.cleanupTimer);
|
|
627
|
+
this.cleanupTimer = null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
var serverLogger = logger.child("@spfn/core:server");
|
|
632
|
+
|
|
633
|
+
// src/server/shutdown-manager.ts
|
|
634
|
+
var DEFAULT_HOOK_TIMEOUT = 1e4;
|
|
635
|
+
var DEFAULT_HOOK_ORDER = 100;
|
|
636
|
+
var DRAIN_POLL_INTERVAL = 500;
|
|
637
|
+
var ShutdownManager = class {
|
|
638
|
+
state = "running";
|
|
639
|
+
hooks = [];
|
|
640
|
+
operations = /* @__PURE__ */ new Map();
|
|
641
|
+
operationCounter = 0;
|
|
642
|
+
/**
|
|
643
|
+
* Register a shutdown hook
|
|
644
|
+
*
|
|
645
|
+
* Hooks run in order during shutdown, after all tracked operations drain.
|
|
646
|
+
* Each hook has its own timeout — failure does not block subsequent hooks.
|
|
647
|
+
*
|
|
648
|
+
* @example
|
|
649
|
+
* shutdown.onShutdown('ai-service', async () => {
|
|
650
|
+
* await aiService.cancelPending();
|
|
651
|
+
* }, { timeout: 30000, order: 10 });
|
|
652
|
+
*/
|
|
653
|
+
onShutdown(name, handler, options) {
|
|
654
|
+
this.hooks.push({
|
|
655
|
+
name,
|
|
656
|
+
handler,
|
|
657
|
+
timeout: options?.timeout ?? DEFAULT_HOOK_TIMEOUT,
|
|
658
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER
|
|
659
|
+
});
|
|
660
|
+
this.hooks.sort((a, b) => a.order - b.order);
|
|
661
|
+
serverLogger.debug(`Shutdown hook registered: ${name}`, {
|
|
662
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER,
|
|
663
|
+
timeout: `${options?.timeout ?? DEFAULT_HOOK_TIMEOUT}ms`
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Track a long-running operation
|
|
668
|
+
*
|
|
669
|
+
* During shutdown (drain phase), the process waits for ALL tracked
|
|
670
|
+
* operations to complete before proceeding with cleanup.
|
|
671
|
+
*
|
|
672
|
+
* If shutdown has already started, the operation is rejected immediately.
|
|
673
|
+
*
|
|
674
|
+
* @returns The operation result (pass-through)
|
|
675
|
+
*
|
|
676
|
+
* @example
|
|
677
|
+
* const result = await shutdown.trackOperation(
|
|
678
|
+
* 'ai-generate',
|
|
679
|
+
* aiService.generate(prompt)
|
|
680
|
+
* );
|
|
681
|
+
*/
|
|
682
|
+
async trackOperation(name, operation) {
|
|
683
|
+
if (this.state !== "running") {
|
|
684
|
+
throw new Error(`Cannot start operation '${name}': server is shutting down`);
|
|
685
|
+
}
|
|
686
|
+
const id = `${name}-${++this.operationCounter}`;
|
|
687
|
+
this.operations.set(id, {
|
|
688
|
+
name,
|
|
689
|
+
startedAt: Date.now()
|
|
690
|
+
});
|
|
691
|
+
serverLogger.debug(`Operation tracked: ${id}`, {
|
|
692
|
+
activeOperations: this.operations.size
|
|
693
|
+
});
|
|
694
|
+
try {
|
|
695
|
+
return await operation;
|
|
696
|
+
} finally {
|
|
697
|
+
this.operations.delete(id);
|
|
698
|
+
serverLogger.debug(`Operation completed: ${id}`, {
|
|
699
|
+
activeOperations: this.operations.size
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Whether the server is shutting down
|
|
705
|
+
*
|
|
706
|
+
* Use this to reject new work early (e.g., return 503 in route handlers).
|
|
707
|
+
*/
|
|
708
|
+
isShuttingDown() {
|
|
709
|
+
return this.state !== "running";
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Number of currently active tracked operations
|
|
713
|
+
*/
|
|
714
|
+
getActiveOperationCount() {
|
|
715
|
+
return this.operations.size;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Mark shutdown as started immediately
|
|
719
|
+
*
|
|
720
|
+
* Call this at the very beginning of the shutdown sequence so that:
|
|
721
|
+
* - Health check returns 503 right away
|
|
722
|
+
* - trackOperation() rejects new work
|
|
723
|
+
* - isShuttingDown() returns true
|
|
724
|
+
*/
|
|
725
|
+
beginShutdown() {
|
|
726
|
+
if (this.state !== "running") {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
this.state = "draining";
|
|
730
|
+
serverLogger.info("Shutdown manager: state set to draining");
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Execute the full shutdown sequence
|
|
734
|
+
*
|
|
735
|
+
* 1. State → draining (reject new operations)
|
|
736
|
+
* 2. Wait for all tracked operations to complete (drain)
|
|
737
|
+
* 3. Run shutdown hooks in order
|
|
738
|
+
* 4. State → closed
|
|
739
|
+
*
|
|
740
|
+
* @param drainTimeout - Max time to wait for operations to drain (ms)
|
|
741
|
+
*/
|
|
742
|
+
async execute(drainTimeout) {
|
|
743
|
+
if (this.state === "closed") {
|
|
744
|
+
serverLogger.warn("ShutdownManager.execute() called but already closed");
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
this.state = "draining";
|
|
748
|
+
serverLogger.info("Shutdown manager: draining started", {
|
|
749
|
+
activeOperations: this.operations.size,
|
|
750
|
+
registeredHooks: this.hooks.length,
|
|
751
|
+
drainTimeout: `${drainTimeout}ms`
|
|
752
|
+
});
|
|
753
|
+
await this.drain(drainTimeout);
|
|
754
|
+
await this.executeHooks();
|
|
755
|
+
this.state = "closed";
|
|
756
|
+
serverLogger.info("Shutdown manager: all hooks executed");
|
|
757
|
+
}
|
|
758
|
+
// ========================================================================
|
|
759
|
+
// Private
|
|
760
|
+
// ========================================================================
|
|
761
|
+
/**
|
|
762
|
+
* Wait for all tracked operations to complete, up to drainTimeout
|
|
763
|
+
*/
|
|
764
|
+
async drain(drainTimeout) {
|
|
765
|
+
if (this.operations.size === 0) {
|
|
766
|
+
serverLogger.info("Shutdown manager: no active operations, drain skipped");
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
serverLogger.info(`Shutdown manager: waiting for ${this.operations.size} operations to drain...`);
|
|
770
|
+
const deadline = Date.now() + drainTimeout;
|
|
771
|
+
while (this.operations.size > 0 && Date.now() < deadline) {
|
|
772
|
+
const remaining = deadline - Date.now();
|
|
773
|
+
const ops = Array.from(this.operations.values()).map((op) => ({
|
|
774
|
+
name: op.name,
|
|
775
|
+
elapsed: `${Math.round((Date.now() - op.startedAt) / 1e3)}s`
|
|
776
|
+
}));
|
|
777
|
+
serverLogger.info("Shutdown manager: drain in progress", {
|
|
778
|
+
activeOperations: this.operations.size,
|
|
779
|
+
remainingTimeout: `${Math.round(remaining / 1e3)}s`,
|
|
780
|
+
operations: ops
|
|
781
|
+
});
|
|
782
|
+
await sleep(Math.min(DRAIN_POLL_INTERVAL, remaining));
|
|
783
|
+
}
|
|
784
|
+
if (this.operations.size > 0) {
|
|
785
|
+
const abandoned = Array.from(this.operations.values()).map((op) => op.name);
|
|
786
|
+
serverLogger.warn("Shutdown manager: drain timeout \u2014 abandoning operations", {
|
|
787
|
+
abandoned
|
|
788
|
+
});
|
|
789
|
+
} else {
|
|
790
|
+
serverLogger.info("Shutdown manager: all operations drained successfully");
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Execute registered shutdown hooks in order
|
|
795
|
+
*/
|
|
796
|
+
async executeHooks() {
|
|
797
|
+
if (this.hooks.length === 0) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
serverLogger.info(`Shutdown manager: executing ${this.hooks.length} hooks...`);
|
|
801
|
+
for (const hook of this.hooks) {
|
|
802
|
+
serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
|
|
803
|
+
try {
|
|
804
|
+
await withTimeout(
|
|
805
|
+
hook.handler(),
|
|
806
|
+
hook.timeout,
|
|
807
|
+
`Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
|
|
808
|
+
);
|
|
809
|
+
serverLogger.info(`Shutdown hook [${hook.name}] completed`);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
serverLogger.error(
|
|
812
|
+
`Shutdown hook [${hook.name}] failed`,
|
|
813
|
+
error
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
var instance = null;
|
|
820
|
+
function getShutdownManager() {
|
|
821
|
+
if (!instance) {
|
|
822
|
+
instance = new ShutdownManager();
|
|
823
|
+
}
|
|
824
|
+
return instance;
|
|
825
|
+
}
|
|
826
|
+
function resetShutdownManager() {
|
|
827
|
+
instance = null;
|
|
828
|
+
}
|
|
829
|
+
function sleep(ms) {
|
|
830
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
831
|
+
}
|
|
832
|
+
async function withTimeout(promise, timeout, message) {
|
|
833
|
+
let timeoutId;
|
|
834
|
+
return Promise.race([
|
|
835
|
+
promise.finally(() => {
|
|
836
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
837
|
+
}),
|
|
838
|
+
new Promise((_, reject) => {
|
|
839
|
+
timeoutId = setTimeout(() => {
|
|
840
|
+
reject(new Error(message));
|
|
841
|
+
}, timeout);
|
|
842
|
+
})
|
|
843
|
+
]);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// src/server/helpers.ts
|
|
419
847
|
function createHealthCheckHandler(detailed) {
|
|
420
848
|
return async (c) => {
|
|
849
|
+
const shutdownManager = getShutdownManager();
|
|
850
|
+
if (shutdownManager.isShuttingDown()) {
|
|
851
|
+
return c.json({
|
|
852
|
+
status: "shutting_down",
|
|
853
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
854
|
+
}, 503);
|
|
855
|
+
}
|
|
421
856
|
const response = {
|
|
422
857
|
status: "ok",
|
|
423
858
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -474,34 +909,49 @@ function applyServerTimeouts(server, timeouts) {
|
|
|
474
909
|
server.headersTimeout = timeouts.headers;
|
|
475
910
|
}
|
|
476
911
|
}
|
|
477
|
-
function getTimeoutConfig(
|
|
912
|
+
function getTimeoutConfig(config) {
|
|
478
913
|
return {
|
|
479
|
-
request:
|
|
480
|
-
keepAlive:
|
|
481
|
-
headers:
|
|
914
|
+
request: config?.request ?? env.SERVER_TIMEOUT,
|
|
915
|
+
keepAlive: config?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
|
|
916
|
+
headers: config?.headers ?? env.SERVER_HEADERS_TIMEOUT
|
|
482
917
|
};
|
|
483
918
|
}
|
|
484
|
-
function getShutdownTimeout(
|
|
485
|
-
return
|
|
919
|
+
function getShutdownTimeout(config) {
|
|
920
|
+
return config?.timeout ?? env.SHUTDOWN_TIMEOUT;
|
|
486
921
|
}
|
|
487
|
-
function
|
|
922
|
+
function getFetchTimeoutConfig(config) {
|
|
923
|
+
return {
|
|
924
|
+
connect: config?.connect ?? env.FETCH_CONNECT_TIMEOUT,
|
|
925
|
+
headers: config?.headers ?? env.FETCH_HEADERS_TIMEOUT,
|
|
926
|
+
body: config?.body ?? env.FETCH_BODY_TIMEOUT
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
function applyGlobalFetchTimeouts(timeouts) {
|
|
930
|
+
const agent = new Agent({
|
|
931
|
+
connect: { timeout: timeouts.connect },
|
|
932
|
+
headersTimeout: timeouts.headers,
|
|
933
|
+
bodyTimeout: timeouts.body
|
|
934
|
+
});
|
|
935
|
+
setGlobalDispatcher(agent);
|
|
936
|
+
}
|
|
937
|
+
function buildMiddlewareOrder(config) {
|
|
488
938
|
const order = [];
|
|
489
|
-
const middlewareConfig =
|
|
939
|
+
const middlewareConfig = config.middleware ?? {};
|
|
490
940
|
const enableLogger = middlewareConfig.logger !== false;
|
|
491
941
|
const enableCors = middlewareConfig.cors !== false;
|
|
492
942
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
493
943
|
if (enableLogger) order.push("RequestLogger");
|
|
494
944
|
if (enableCors) order.push("CORS");
|
|
495
|
-
|
|
496
|
-
if (
|
|
945
|
+
config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
|
|
946
|
+
if (config.beforeRoutes) order.push("beforeRoutes hook");
|
|
497
947
|
order.push("Routes");
|
|
498
|
-
if (
|
|
948
|
+
if (config.afterRoutes) order.push("afterRoutes hook");
|
|
499
949
|
if (enableErrorHandler) order.push("ErrorHandler");
|
|
500
950
|
return order;
|
|
501
951
|
}
|
|
502
|
-
function buildStartupConfig(
|
|
503
|
-
const middlewareConfig =
|
|
504
|
-
const healthCheckConfig =
|
|
952
|
+
function buildStartupConfig(config, timeouts) {
|
|
953
|
+
const middlewareConfig = config.middleware ?? {};
|
|
954
|
+
const healthCheckConfig = config.healthCheck ?? {};
|
|
505
955
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
506
956
|
const healthCheckPath = healthCheckConfig.path ?? "/health";
|
|
507
957
|
const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
|
|
@@ -510,7 +960,7 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
510
960
|
logger: middlewareConfig.logger !== false,
|
|
511
961
|
cors: middlewareConfig.cors !== false,
|
|
512
962
|
errorHandler: middlewareConfig.errorHandler !== false,
|
|
513
|
-
custom:
|
|
963
|
+
custom: config.use?.length ?? 0
|
|
514
964
|
},
|
|
515
965
|
healthCheck: healthCheckEnabled ? {
|
|
516
966
|
enabled: true,
|
|
@@ -518,8 +968,8 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
518
968
|
detailed: healthCheckDetailed
|
|
519
969
|
} : { enabled: false },
|
|
520
970
|
hooks: {
|
|
521
|
-
beforeRoutes: !!
|
|
522
|
-
afterRoutes: !!
|
|
971
|
+
beforeRoutes: !!config.beforeRoutes,
|
|
972
|
+
afterRoutes: !!config.afterRoutes
|
|
523
973
|
},
|
|
524
974
|
timeout: {
|
|
525
975
|
request: `${timeouts.request}ms`,
|
|
@@ -527,23 +977,22 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
527
977
|
headers: `${timeouts.headers}ms`
|
|
528
978
|
},
|
|
529
979
|
shutdown: {
|
|
530
|
-
timeout: `${
|
|
980
|
+
timeout: `${config.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
|
|
531
981
|
}
|
|
532
982
|
};
|
|
533
983
|
}
|
|
534
|
-
var serverLogger = logger.child("@spfn/core:server");
|
|
535
984
|
|
|
536
985
|
// src/server/create-server.ts
|
|
537
|
-
async function createServer(
|
|
986
|
+
async function createServer(config) {
|
|
538
987
|
const cwd = process.cwd();
|
|
539
988
|
const appPath = join(cwd, "src", "server", "app.ts");
|
|
540
989
|
const appJsPath = join(cwd, "src", "server", "app");
|
|
541
990
|
if (existsSync(appPath) || existsSync(appJsPath)) {
|
|
542
|
-
return await loadCustomApp(appPath, appJsPath,
|
|
991
|
+
return await loadCustomApp(appPath, appJsPath, config);
|
|
543
992
|
}
|
|
544
|
-
return await createAutoConfiguredApp(
|
|
993
|
+
return await createAutoConfiguredApp(config);
|
|
545
994
|
}
|
|
546
|
-
async function loadCustomApp(appPath, appJsPath,
|
|
995
|
+
async function loadCustomApp(appPath, appJsPath, config) {
|
|
547
996
|
const actualPath = existsSync(appPath) ? appPath : appJsPath;
|
|
548
997
|
const appModule = await import(actualPath);
|
|
549
998
|
const appFactory = appModule.default;
|
|
@@ -551,15 +1000,15 @@ async function loadCustomApp(appPath, appJsPath, config2) {
|
|
|
551
1000
|
throw new Error("app.ts must export a default function that returns a Hono app");
|
|
552
1001
|
}
|
|
553
1002
|
const app = await appFactory();
|
|
554
|
-
if (
|
|
555
|
-
const routes = registerRoutes(app,
|
|
556
|
-
logRegisteredRoutes(routes,
|
|
1003
|
+
if (config?.routes) {
|
|
1004
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
1005
|
+
logRegisteredRoutes(routes, config?.debug ?? false);
|
|
557
1006
|
}
|
|
558
1007
|
return app;
|
|
559
1008
|
}
|
|
560
|
-
async function createAutoConfiguredApp(
|
|
1009
|
+
async function createAutoConfiguredApp(config) {
|
|
561
1010
|
const app = new Hono();
|
|
562
|
-
const middlewareConfig =
|
|
1011
|
+
const middlewareConfig = config?.middleware ?? {};
|
|
563
1012
|
const enableLogger = middlewareConfig.logger !== false;
|
|
564
1013
|
const enableCors = middlewareConfig.cors !== false;
|
|
565
1014
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
@@ -569,31 +1018,31 @@ async function createAutoConfiguredApp(config2) {
|
|
|
569
1018
|
await next();
|
|
570
1019
|
});
|
|
571
1020
|
}
|
|
572
|
-
applyDefaultMiddleware(app,
|
|
573
|
-
if (Array.isArray(
|
|
574
|
-
|
|
1021
|
+
applyDefaultMiddleware(app, config, enableLogger, enableCors);
|
|
1022
|
+
if (Array.isArray(config?.use)) {
|
|
1023
|
+
config.use.forEach((mw) => app.use("*", mw));
|
|
575
1024
|
}
|
|
576
|
-
registerHealthCheckEndpoint(app,
|
|
577
|
-
await executeBeforeRoutesHook(app,
|
|
578
|
-
await loadAppRoutes(app,
|
|
579
|
-
registerSSEEndpoint(app,
|
|
580
|
-
await executeAfterRoutesHook(app,
|
|
1025
|
+
registerHealthCheckEndpoint(app, config);
|
|
1026
|
+
await executeBeforeRoutesHook(app, config);
|
|
1027
|
+
await loadAppRoutes(app, config);
|
|
1028
|
+
await registerSSEEndpoint(app, config);
|
|
1029
|
+
await executeAfterRoutesHook(app, config);
|
|
581
1030
|
if (enableErrorHandler) {
|
|
582
|
-
app.onError(ErrorHandler());
|
|
1031
|
+
app.onError(ErrorHandler({ onError: config?.middleware?.onError }));
|
|
583
1032
|
}
|
|
584
1033
|
return app;
|
|
585
1034
|
}
|
|
586
|
-
function applyDefaultMiddleware(app,
|
|
1035
|
+
function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
|
|
587
1036
|
if (enableLogger) {
|
|
588
1037
|
app.use("*", RequestLogger());
|
|
589
1038
|
}
|
|
590
1039
|
if (enableCors) {
|
|
591
|
-
const corsOptions =
|
|
1040
|
+
const corsOptions = config?.cors !== false ? config?.cors : void 0;
|
|
592
1041
|
app.use("*", cors(corsOptions));
|
|
593
1042
|
}
|
|
594
1043
|
}
|
|
595
|
-
function registerHealthCheckEndpoint(app,
|
|
596
|
-
const healthCheckConfig =
|
|
1044
|
+
function registerHealthCheckEndpoint(app, config) {
|
|
1045
|
+
const healthCheckConfig = config?.healthCheck ?? {};
|
|
597
1046
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
598
1047
|
const healthCheckPath = healthCheckConfig.path ?? "/health";
|
|
599
1048
|
const healthCheckDetailed = healthCheckConfig.detailed ?? process.env.NODE_ENV === "development";
|
|
@@ -602,15 +1051,15 @@ function registerHealthCheckEndpoint(app, config2) {
|
|
|
602
1051
|
serverLogger.debug(`Health check endpoint enabled at ${healthCheckPath}`);
|
|
603
1052
|
}
|
|
604
1053
|
}
|
|
605
|
-
async function executeBeforeRoutesHook(app,
|
|
606
|
-
if (
|
|
607
|
-
await
|
|
1054
|
+
async function executeBeforeRoutesHook(app, config) {
|
|
1055
|
+
if (config?.lifecycle?.beforeRoutes) {
|
|
1056
|
+
await config.lifecycle.beforeRoutes(app);
|
|
608
1057
|
}
|
|
609
1058
|
}
|
|
610
|
-
async function loadAppRoutes(app,
|
|
611
|
-
const debug = isDebugMode(
|
|
612
|
-
if (
|
|
613
|
-
const routes = registerRoutes(app,
|
|
1059
|
+
async function loadAppRoutes(app, config) {
|
|
1060
|
+
const debug = isDebugMode(config);
|
|
1061
|
+
if (config?.routes) {
|
|
1062
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
614
1063
|
logRegisteredRoutes(routes, debug);
|
|
615
1064
|
} else if (debug) {
|
|
616
1065
|
serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
|
|
@@ -631,76 +1080,150 @@ function logRegisteredRoutes(routes, debug) {
|
|
|
631
1080
|
serverLogger.info(`\u2713 Routes registered (${routes.length}):
|
|
632
1081
|
${routeLines}`);
|
|
633
1082
|
}
|
|
634
|
-
async function executeAfterRoutesHook(app,
|
|
635
|
-
if (
|
|
636
|
-
await
|
|
1083
|
+
async function executeAfterRoutesHook(app, config) {
|
|
1084
|
+
if (config?.lifecycle?.afterRoutes) {
|
|
1085
|
+
await config.lifecycle.afterRoutes(app);
|
|
637
1086
|
}
|
|
638
1087
|
}
|
|
639
|
-
function registerSSEEndpoint(app,
|
|
640
|
-
if (!
|
|
1088
|
+
async function registerSSEEndpoint(app, config) {
|
|
1089
|
+
if (!config?.events) {
|
|
641
1090
|
return;
|
|
642
1091
|
}
|
|
643
|
-
const eventsConfig =
|
|
644
|
-
const
|
|
645
|
-
const
|
|
646
|
-
|
|
1092
|
+
const eventsConfig = config.eventsConfig ?? {};
|
|
1093
|
+
const streamPath = eventsConfig.path ?? "/events/stream";
|
|
1094
|
+
const authConfig = eventsConfig.auth;
|
|
1095
|
+
const debug = isDebugMode(config);
|
|
1096
|
+
let tokenManager;
|
|
1097
|
+
if (authConfig?.enabled) {
|
|
1098
|
+
let store = authConfig.store;
|
|
1099
|
+
if (!store) {
|
|
1100
|
+
try {
|
|
1101
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
1102
|
+
const cache = getCache2();
|
|
1103
|
+
if (cache) {
|
|
1104
|
+
store = new CacheTokenStore(cache);
|
|
1105
|
+
if (debug) {
|
|
1106
|
+
serverLogger.info("SSE token store: cache (Redis/Valkey)");
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
} catch {
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
1113
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
1114
|
+
ttl: authConfig.tokenTtl,
|
|
1115
|
+
store
|
|
1116
|
+
});
|
|
1117
|
+
const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
|
|
1118
|
+
const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
|
|
1119
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
1120
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
1121
|
+
const subject = getSubject(c);
|
|
1122
|
+
if (!subject) {
|
|
1123
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
1124
|
+
}
|
|
1125
|
+
const token = await tokenManager.issue(subject);
|
|
1126
|
+
return c.json({ token });
|
|
1127
|
+
});
|
|
1128
|
+
if (debug) {
|
|
1129
|
+
serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
|
|
647
1133
|
if (debug) {
|
|
648
|
-
const eventNames =
|
|
649
|
-
serverLogger.info(`\u2713 SSE endpoint registered at ${
|
|
650
|
-
events: eventNames
|
|
1134
|
+
const eventNames = config.events.eventNames;
|
|
1135
|
+
serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
|
|
1136
|
+
events: eventNames,
|
|
1137
|
+
auth: !!authConfig?.enabled
|
|
651
1138
|
});
|
|
652
1139
|
}
|
|
653
1140
|
}
|
|
654
|
-
function isDebugMode(
|
|
655
|
-
return
|
|
1141
|
+
function isDebugMode(config) {
|
|
1142
|
+
return config?.debug ?? process.env.NODE_ENV === "development";
|
|
656
1143
|
}
|
|
657
1144
|
var jobLogger = logger.child("@spfn/core:job");
|
|
658
|
-
|
|
659
|
-
|
|
1145
|
+
function requiresSSLWithoutVerification(connectionString) {
|
|
1146
|
+
try {
|
|
1147
|
+
const url = new URL(connectionString);
|
|
1148
|
+
const sslmode = url.searchParams.get("sslmode");
|
|
1149
|
+
return sslmode === "require" || sslmode === "prefer";
|
|
1150
|
+
} catch {
|
|
1151
|
+
return false;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function stripSslModeFromUrl(connectionString) {
|
|
1155
|
+
const url = new URL(connectionString);
|
|
1156
|
+
url.searchParams.delete("sslmode");
|
|
1157
|
+
return url.toString();
|
|
1158
|
+
}
|
|
1159
|
+
var BOSS_KEY = Symbol.for("spfn:boss-instance");
|
|
1160
|
+
var CONFIG_KEY = Symbol.for("spfn:boss-config");
|
|
1161
|
+
var g = globalThis;
|
|
1162
|
+
function getBossInstance() {
|
|
1163
|
+
return g[BOSS_KEY] ?? null;
|
|
1164
|
+
}
|
|
1165
|
+
function setBossInstance(instance2) {
|
|
1166
|
+
g[BOSS_KEY] = instance2;
|
|
1167
|
+
}
|
|
1168
|
+
function getBossConfig() {
|
|
1169
|
+
return g[CONFIG_KEY] ?? null;
|
|
1170
|
+
}
|
|
1171
|
+
function setBossConfig(config) {
|
|
1172
|
+
g[CONFIG_KEY] = config;
|
|
1173
|
+
}
|
|
660
1174
|
async function initBoss(options) {
|
|
661
|
-
|
|
1175
|
+
const existing = getBossInstance();
|
|
1176
|
+
if (existing) {
|
|
662
1177
|
jobLogger.warn("pg-boss already initialized, returning existing instance");
|
|
663
|
-
return
|
|
1178
|
+
return existing;
|
|
664
1179
|
}
|
|
665
1180
|
jobLogger.info("Initializing pg-boss...");
|
|
666
|
-
|
|
1181
|
+
setBossConfig(options);
|
|
1182
|
+
const needsSSL = requiresSSLWithoutVerification(options.connectionString);
|
|
667
1183
|
const pgBossOptions = {
|
|
668
|
-
|
|
1184
|
+
// pg 드라이버가 URL의 sslmode=require를 verify-full로 해석해서
|
|
1185
|
+
// ssl 옵션을 무시하므로, URL에서 sslmode를 빼고 ssl 객체만 전달
|
|
1186
|
+
connectionString: needsSSL ? stripSslModeFromUrl(options.connectionString) : options.connectionString,
|
|
669
1187
|
schema: options.schema ?? "spfn_queue",
|
|
670
1188
|
maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
|
|
671
1189
|
};
|
|
1190
|
+
if (needsSSL) {
|
|
1191
|
+
pgBossOptions.ssl = { rejectUnauthorized: false };
|
|
1192
|
+
}
|
|
672
1193
|
if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
|
|
673
1194
|
pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
|
|
674
1195
|
}
|
|
675
|
-
|
|
676
|
-
|
|
1196
|
+
const boss = new PgBoss(pgBossOptions);
|
|
1197
|
+
boss.on("error", (error) => {
|
|
677
1198
|
jobLogger.error("pg-boss error:", error);
|
|
678
1199
|
});
|
|
679
|
-
await
|
|
1200
|
+
await boss.start();
|
|
1201
|
+
setBossInstance(boss);
|
|
680
1202
|
jobLogger.info("pg-boss started successfully");
|
|
681
|
-
return
|
|
1203
|
+
return boss;
|
|
682
1204
|
}
|
|
683
1205
|
function getBoss() {
|
|
684
|
-
return
|
|
1206
|
+
return getBossInstance();
|
|
685
1207
|
}
|
|
686
1208
|
async function stopBoss() {
|
|
687
|
-
|
|
1209
|
+
const boss = getBossInstance();
|
|
1210
|
+
if (!boss) {
|
|
688
1211
|
return;
|
|
689
1212
|
}
|
|
690
1213
|
jobLogger.info("Stopping pg-boss...");
|
|
691
1214
|
try {
|
|
692
|
-
await
|
|
1215
|
+
await boss.stop({ graceful: true, timeout: 3e4 });
|
|
693
1216
|
jobLogger.info("pg-boss stopped gracefully");
|
|
694
1217
|
} catch (error) {
|
|
695
1218
|
jobLogger.error("Error stopping pg-boss:", error);
|
|
696
1219
|
throw error;
|
|
697
1220
|
} finally {
|
|
698
|
-
|
|
699
|
-
|
|
1221
|
+
setBossInstance(null);
|
|
1222
|
+
setBossConfig(null);
|
|
700
1223
|
}
|
|
701
1224
|
}
|
|
702
1225
|
function shouldClearOnStart() {
|
|
703
|
-
return
|
|
1226
|
+
return getBossConfig()?.clearOnStart ?? false;
|
|
704
1227
|
}
|
|
705
1228
|
|
|
706
1229
|
// src/job/job-router.ts
|
|
@@ -888,16 +1411,16 @@ function printBanner(options) {
|
|
|
888
1411
|
}
|
|
889
1412
|
|
|
890
1413
|
// src/server/validation.ts
|
|
891
|
-
function validateServerConfig(
|
|
892
|
-
if (
|
|
893
|
-
if (!Number.isInteger(
|
|
1414
|
+
function validateServerConfig(config) {
|
|
1415
|
+
if (config.port !== void 0) {
|
|
1416
|
+
if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
|
|
894
1417
|
throw new Error(
|
|
895
|
-
`Invalid port: ${
|
|
1418
|
+
`Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
|
|
896
1419
|
);
|
|
897
1420
|
}
|
|
898
1421
|
}
|
|
899
|
-
if (
|
|
900
|
-
const { request, keepAlive, headers } =
|
|
1422
|
+
if (config.timeout) {
|
|
1423
|
+
const { request, keepAlive, headers } = config.timeout;
|
|
901
1424
|
if (request !== void 0 && (request < 0 || !Number.isFinite(request))) {
|
|
902
1425
|
throw new Error(`Invalid timeout.request: ${request}. Must be a positive number.`);
|
|
903
1426
|
}
|
|
@@ -913,16 +1436,16 @@ function validateServerConfig(config2) {
|
|
|
913
1436
|
);
|
|
914
1437
|
}
|
|
915
1438
|
}
|
|
916
|
-
if (
|
|
917
|
-
const timeout =
|
|
1439
|
+
if (config.shutdown?.timeout !== void 0) {
|
|
1440
|
+
const timeout = config.shutdown.timeout;
|
|
918
1441
|
if (timeout < 0 || !Number.isFinite(timeout)) {
|
|
919
1442
|
throw new Error(`Invalid shutdown.timeout: ${timeout}. Must be a positive number.`);
|
|
920
1443
|
}
|
|
921
1444
|
}
|
|
922
|
-
if (
|
|
923
|
-
if (!
|
|
1445
|
+
if (config.healthCheck?.path) {
|
|
1446
|
+
if (!config.healthCheck.path.startsWith("/")) {
|
|
924
1447
|
throw new Error(
|
|
925
|
-
`Invalid healthCheck.path: "${
|
|
1448
|
+
`Invalid healthCheck.path: "${config.healthCheck.path}". Must start with "/".`
|
|
926
1449
|
);
|
|
927
1450
|
}
|
|
928
1451
|
}
|
|
@@ -931,9 +1454,7 @@ var DEFAULT_MAX_LISTENERS = 15;
|
|
|
931
1454
|
var TIMEOUTS = {
|
|
932
1455
|
SERVER_CLOSE: 5e3,
|
|
933
1456
|
DATABASE_CLOSE: 5e3,
|
|
934
|
-
REDIS_CLOSE: 5e3
|
|
935
|
-
PRODUCTION_ERROR_SHUTDOWN: 1e4
|
|
936
|
-
};
|
|
1457
|
+
REDIS_CLOSE: 5e3};
|
|
937
1458
|
var CONFIG_FILE_PATHS = [
|
|
938
1459
|
".spfn/server/server.config.mjs",
|
|
939
1460
|
".spfn/server/server.config",
|
|
@@ -941,9 +1462,9 @@ var CONFIG_FILE_PATHS = [
|
|
|
941
1462
|
"src/server/server.config.ts"
|
|
942
1463
|
];
|
|
943
1464
|
var processHandlersRegistered = false;
|
|
944
|
-
async function startServer(
|
|
945
|
-
|
|
946
|
-
const finalConfig = await loadAndMergeConfig(
|
|
1465
|
+
async function startServer(config) {
|
|
1466
|
+
loadEnv();
|
|
1467
|
+
const finalConfig = await loadAndMergeConfig(config);
|
|
947
1468
|
const { host, port, debug } = finalConfig;
|
|
948
1469
|
validateServerConfig(finalConfig);
|
|
949
1470
|
if (!host || !port) {
|
|
@@ -961,6 +1482,8 @@ async function startServer(config2) {
|
|
|
961
1482
|
const server = startHttpServer(app, host, port);
|
|
962
1483
|
const timeouts = getTimeoutConfig(finalConfig.timeout);
|
|
963
1484
|
applyServerTimeouts(server, timeouts);
|
|
1485
|
+
const fetchTimeouts = getFetchTimeoutConfig(finalConfig.fetchTimeout);
|
|
1486
|
+
applyGlobalFetchTimeouts(fetchTimeouts);
|
|
964
1487
|
logServerTimeouts(timeouts);
|
|
965
1488
|
printBanner({
|
|
966
1489
|
mode: debug ? "Development" : "Production",
|
|
@@ -977,11 +1500,6 @@ async function startServer(config2) {
|
|
|
977
1500
|
config: finalConfig,
|
|
978
1501
|
close: async () => {
|
|
979
1502
|
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
1503
|
await shutdownServer();
|
|
986
1504
|
}
|
|
987
1505
|
};
|
|
@@ -1001,7 +1519,7 @@ async function startServer(config2) {
|
|
|
1001
1519
|
throw error;
|
|
1002
1520
|
}
|
|
1003
1521
|
}
|
|
1004
|
-
async function loadAndMergeConfig(
|
|
1522
|
+
async function loadAndMergeConfig(config) {
|
|
1005
1523
|
const cwd = process.cwd();
|
|
1006
1524
|
let fileConfig = {};
|
|
1007
1525
|
let loadedConfigPath = null;
|
|
@@ -1025,26 +1543,26 @@ async function loadAndMergeConfig(config2) {
|
|
|
1025
1543
|
}
|
|
1026
1544
|
return {
|
|
1027
1545
|
...fileConfig,
|
|
1028
|
-
...
|
|
1029
|
-
port:
|
|
1030
|
-
host:
|
|
1546
|
+
...config,
|
|
1547
|
+
port: config?.port ?? fileConfig?.port ?? env.PORT,
|
|
1548
|
+
host: config?.host ?? fileConfig?.host ?? env.HOST
|
|
1031
1549
|
};
|
|
1032
1550
|
}
|
|
1033
|
-
function getInfrastructureConfig(
|
|
1551
|
+
function getInfrastructureConfig(config) {
|
|
1034
1552
|
return {
|
|
1035
|
-
database:
|
|
1036
|
-
redis:
|
|
1553
|
+
database: config.infrastructure?.database !== false,
|
|
1554
|
+
redis: config.infrastructure?.redis !== false
|
|
1037
1555
|
};
|
|
1038
1556
|
}
|
|
1039
|
-
async function initializeInfrastructure(
|
|
1040
|
-
if (
|
|
1557
|
+
async function initializeInfrastructure(config) {
|
|
1558
|
+
if (config.lifecycle?.beforeInfrastructure) {
|
|
1041
1559
|
serverLogger.debug("Executing beforeInfrastructure hook...");
|
|
1042
|
-
await
|
|
1560
|
+
await config.lifecycle.beforeInfrastructure(config);
|
|
1043
1561
|
}
|
|
1044
|
-
const infraConfig = getInfrastructureConfig(
|
|
1562
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1045
1563
|
if (infraConfig.database) {
|
|
1046
1564
|
serverLogger.debug("Initializing database...");
|
|
1047
|
-
await initDatabase(
|
|
1565
|
+
await initDatabase(config.database);
|
|
1048
1566
|
} else {
|
|
1049
1567
|
serverLogger.debug("Database initialization disabled");
|
|
1050
1568
|
}
|
|
@@ -1054,11 +1572,11 @@ async function initializeInfrastructure(config2) {
|
|
|
1054
1572
|
} else {
|
|
1055
1573
|
serverLogger.debug("Redis initialization disabled");
|
|
1056
1574
|
}
|
|
1057
|
-
if (
|
|
1575
|
+
if (config.lifecycle?.afterInfrastructure) {
|
|
1058
1576
|
serverLogger.debug("Executing afterInfrastructure hook...");
|
|
1059
|
-
await
|
|
1577
|
+
await config.lifecycle.afterInfrastructure();
|
|
1060
1578
|
}
|
|
1061
|
-
if (
|
|
1579
|
+
if (config.jobs) {
|
|
1062
1580
|
const dbUrl = env.DATABASE_URL;
|
|
1063
1581
|
if (!dbUrl) {
|
|
1064
1582
|
throw new Error(
|
|
@@ -1068,10 +1586,24 @@ async function initializeInfrastructure(config2) {
|
|
|
1068
1586
|
serverLogger.debug("Initializing pg-boss...");
|
|
1069
1587
|
await initBoss({
|
|
1070
1588
|
connectionString: dbUrl,
|
|
1071
|
-
...
|
|
1589
|
+
...config.jobsConfig
|
|
1072
1590
|
});
|
|
1073
1591
|
serverLogger.debug("Registering jobs...");
|
|
1074
|
-
await registerJobs(
|
|
1592
|
+
await registerJobs(config.jobs);
|
|
1593
|
+
}
|
|
1594
|
+
if (config.workflows) {
|
|
1595
|
+
const infraConfig2 = getInfrastructureConfig(config);
|
|
1596
|
+
if (!infraConfig2.database) {
|
|
1597
|
+
throw new Error(
|
|
1598
|
+
"Workflows require database connection. Ensure database is enabled in infrastructure config."
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
serverLogger.debug("Initializing workflow engine...");
|
|
1602
|
+
config.workflows._init(
|
|
1603
|
+
getDatabase(),
|
|
1604
|
+
config.workflowsConfig
|
|
1605
|
+
);
|
|
1606
|
+
serverLogger.info("Workflow engine initialized");
|
|
1075
1607
|
}
|
|
1076
1608
|
}
|
|
1077
1609
|
function startHttpServer(app, host, port) {
|
|
@@ -1082,8 +1614,8 @@ function startHttpServer(app, host, port) {
|
|
|
1082
1614
|
hostname: host
|
|
1083
1615
|
});
|
|
1084
1616
|
}
|
|
1085
|
-
function logMiddlewareOrder(
|
|
1086
|
-
const middlewareOrder = buildMiddlewareOrder(
|
|
1617
|
+
function logMiddlewareOrder(config) {
|
|
1618
|
+
const middlewareOrder = buildMiddlewareOrder(config);
|
|
1087
1619
|
serverLogger.debug("Middleware execution order", {
|
|
1088
1620
|
order: middlewareOrder
|
|
1089
1621
|
});
|
|
@@ -1095,8 +1627,8 @@ function logServerTimeouts(timeouts) {
|
|
|
1095
1627
|
headers: `${timeouts.headers}ms`
|
|
1096
1628
|
});
|
|
1097
1629
|
}
|
|
1098
|
-
function logServerStarted(debug, host, port,
|
|
1099
|
-
const startupConfig = buildStartupConfig(
|
|
1630
|
+
function logServerStarted(debug, host, port, config, timeouts) {
|
|
1631
|
+
const startupConfig = buildStartupConfig(config, timeouts);
|
|
1100
1632
|
serverLogger.info("Server started successfully", {
|
|
1101
1633
|
mode: debug ? "development" : "production",
|
|
1102
1634
|
host,
|
|
@@ -1104,65 +1636,74 @@ function logServerStarted(debug, host, port, config2, timeouts) {
|
|
|
1104
1636
|
config: startupConfig
|
|
1105
1637
|
});
|
|
1106
1638
|
}
|
|
1107
|
-
function createShutdownHandler(server,
|
|
1639
|
+
function createShutdownHandler(server, config, shutdownState) {
|
|
1108
1640
|
return async () => {
|
|
1109
1641
|
if (shutdownState.isShuttingDown) {
|
|
1110
1642
|
serverLogger.debug("Shutdown already in progress for this instance, skipping");
|
|
1111
1643
|
return;
|
|
1112
1644
|
}
|
|
1113
1645
|
shutdownState.isShuttingDown = true;
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
serverLogger.error("HTTP server close error", err);
|
|
1122
|
-
reject(err);
|
|
1123
|
-
} else {
|
|
1124
|
-
serverLogger.info("HTTP server closed");
|
|
1125
|
-
resolve2();
|
|
1126
|
-
}
|
|
1127
|
-
});
|
|
1128
|
-
}),
|
|
1129
|
-
new Promise((_, reject) => {
|
|
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...");
|
|
1646
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
1647
|
+
const shutdownManager = getShutdownManager();
|
|
1648
|
+
shutdownManager.beginShutdown();
|
|
1649
|
+
serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
|
|
1650
|
+
await closeHttpServer(server);
|
|
1651
|
+
if (config.jobs) {
|
|
1652
|
+
serverLogger.info("Phase 2: Stopping pg-boss...");
|
|
1140
1653
|
try {
|
|
1141
1654
|
await stopBoss();
|
|
1655
|
+
serverLogger.info("pg-boss stopped");
|
|
1142
1656
|
} catch (error) {
|
|
1143
1657
|
serverLogger.error("pg-boss stop failed", error);
|
|
1144
1658
|
}
|
|
1145
1659
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1660
|
+
const drainTimeout = Math.floor(shutdownTimeout * 0.8);
|
|
1661
|
+
serverLogger.info(`Phase 3: Draining tracked operations (timeout: ${drainTimeout}ms)...`);
|
|
1662
|
+
await shutdownManager.execute(drainTimeout);
|
|
1663
|
+
if (config.lifecycle?.beforeShutdown) {
|
|
1664
|
+
serverLogger.info("Phase 4: Executing beforeShutdown lifecycle hook...");
|
|
1148
1665
|
try {
|
|
1149
|
-
await
|
|
1666
|
+
await config.lifecycle.beforeShutdown();
|
|
1150
1667
|
} catch (error) {
|
|
1151
|
-
serverLogger.error("beforeShutdown hook failed", error);
|
|
1668
|
+
serverLogger.error("beforeShutdown lifecycle hook failed", error);
|
|
1152
1669
|
}
|
|
1153
1670
|
}
|
|
1154
|
-
|
|
1671
|
+
serverLogger.info("Phase 5: Closing infrastructure...");
|
|
1672
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1155
1673
|
if (infraConfig.database) {
|
|
1156
|
-
serverLogger.debug("Closing database connections...");
|
|
1157
1674
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1158
1675
|
}
|
|
1159
1676
|
if (infraConfig.redis) {
|
|
1160
|
-
serverLogger.debug("Closing Redis connections...");
|
|
1161
1677
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1162
1678
|
}
|
|
1163
1679
|
serverLogger.info("Server shutdown completed");
|
|
1164
1680
|
};
|
|
1165
1681
|
}
|
|
1682
|
+
async function closeHttpServer(server) {
|
|
1683
|
+
let timeoutId;
|
|
1684
|
+
await Promise.race([
|
|
1685
|
+
new Promise((resolve2, reject) => {
|
|
1686
|
+
server.close((err) => {
|
|
1687
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1688
|
+
if (err) {
|
|
1689
|
+
serverLogger.error("HTTP server close error", err);
|
|
1690
|
+
reject(err);
|
|
1691
|
+
} else {
|
|
1692
|
+
serverLogger.info("HTTP server closed");
|
|
1693
|
+
resolve2();
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
}),
|
|
1697
|
+
new Promise((_, reject) => {
|
|
1698
|
+
timeoutId = setTimeout(() => {
|
|
1699
|
+
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
1700
|
+
}, TIMEOUTS.SERVER_CLOSE);
|
|
1701
|
+
})
|
|
1702
|
+
]).catch((error) => {
|
|
1703
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1704
|
+
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1166
1707
|
async function closeInfrastructure(closeFn, name, timeout) {
|
|
1167
1708
|
let timeoutId;
|
|
1168
1709
|
try {
|
|
@@ -1182,14 +1723,14 @@ async function closeInfrastructure(closeFn, name, timeout) {
|
|
|
1182
1723
|
serverLogger.error(`${name} close failed or timed out`, error);
|
|
1183
1724
|
}
|
|
1184
1725
|
}
|
|
1185
|
-
function createGracefulShutdown(shutdownServer,
|
|
1726
|
+
function createGracefulShutdown(shutdownServer, config, shutdownState) {
|
|
1186
1727
|
return async (signal) => {
|
|
1187
1728
|
if (shutdownState.isShuttingDown) {
|
|
1188
1729
|
serverLogger.warn(`${signal} received but shutdown already in progress, ignoring`);
|
|
1189
1730
|
return;
|
|
1190
1731
|
}
|
|
1191
1732
|
serverLogger.info(`${signal} received, starting graceful shutdown...`);
|
|
1192
|
-
const shutdownTimeout = getShutdownTimeout(
|
|
1733
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
1193
1734
|
let timeoutId;
|
|
1194
1735
|
try {
|
|
1195
1736
|
await Promise.race([
|
|
@@ -1217,31 +1758,8 @@ function createGracefulShutdown(shutdownServer, config2, shutdownState) {
|
|
|
1217
1758
|
}
|
|
1218
1759
|
};
|
|
1219
1760
|
}
|
|
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
|
-
}
|
|
1761
|
+
function handleProcessError(errorType) {
|
|
1762
|
+
serverLogger.warn(`${errorType} occurred - server continues running. Check logs above for details.`);
|
|
1245
1763
|
}
|
|
1246
1764
|
function registerProcessHandlers(shutdown) {
|
|
1247
1765
|
if (processHandlersRegistered) {
|
|
@@ -1276,7 +1794,7 @@ function registerProcessHandlers(shutdown) {
|
|
|
1276
1794
|
} else {
|
|
1277
1795
|
serverLogger.error("Uncaught exception", error);
|
|
1278
1796
|
}
|
|
1279
|
-
handleProcessError("UNCAUGHT_EXCEPTION"
|
|
1797
|
+
handleProcessError("UNCAUGHT_EXCEPTION");
|
|
1280
1798
|
});
|
|
1281
1799
|
process.on("unhandledRejection", (reason, promise) => {
|
|
1282
1800
|
if (reason instanceof Error) {
|
|
@@ -1294,20 +1812,21 @@ function registerProcessHandlers(shutdown) {
|
|
|
1294
1812
|
promise
|
|
1295
1813
|
});
|
|
1296
1814
|
}
|
|
1297
|
-
handleProcessError("UNHANDLED_REJECTION"
|
|
1815
|
+
handleProcessError("UNHANDLED_REJECTION");
|
|
1298
1816
|
});
|
|
1299
1817
|
serverLogger.debug("Process-level shutdown handlers registered successfully");
|
|
1300
1818
|
}
|
|
1301
|
-
async function cleanupOnFailure(
|
|
1819
|
+
async function cleanupOnFailure(config) {
|
|
1302
1820
|
try {
|
|
1303
1821
|
serverLogger.debug("Cleaning up after initialization failure...");
|
|
1304
|
-
const infraConfig = getInfrastructureConfig(
|
|
1822
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1305
1823
|
if (infraConfig.database) {
|
|
1306
1824
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1307
1825
|
}
|
|
1308
1826
|
if (infraConfig.redis) {
|
|
1309
1827
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1310
1828
|
}
|
|
1829
|
+
resetShutdownManager();
|
|
1311
1830
|
serverLogger.debug("Cleanup completed");
|
|
1312
1831
|
} catch (cleanupError) {
|
|
1313
1832
|
serverLogger.error("Cleanup failed", cleanupError);
|
|
@@ -1433,10 +1952,10 @@ var ServerConfigBuilder = class {
|
|
|
1433
1952
|
* .build();
|
|
1434
1953
|
* ```
|
|
1435
1954
|
*/
|
|
1436
|
-
jobs(router,
|
|
1955
|
+
jobs(router, config) {
|
|
1437
1956
|
this.config.jobs = router;
|
|
1438
|
-
if (
|
|
1439
|
-
this.config.jobsConfig =
|
|
1957
|
+
if (config) {
|
|
1958
|
+
this.config.jobsConfig = config;
|
|
1440
1959
|
}
|
|
1441
1960
|
return this;
|
|
1442
1961
|
}
|
|
@@ -1468,10 +1987,10 @@ var ServerConfigBuilder = class {
|
|
|
1468
1987
|
* .events(eventRouter, { path: '/sse' })
|
|
1469
1988
|
* ```
|
|
1470
1989
|
*/
|
|
1471
|
-
events(router,
|
|
1990
|
+
events(router, config) {
|
|
1472
1991
|
this.config.events = router;
|
|
1473
|
-
if (
|
|
1474
|
-
this.config.eventsConfig =
|
|
1992
|
+
if (config) {
|
|
1993
|
+
this.config.eventsConfig = config;
|
|
1475
1994
|
}
|
|
1476
1995
|
return this;
|
|
1477
1996
|
}
|
|
@@ -1517,6 +2036,33 @@ var ServerConfigBuilder = class {
|
|
|
1517
2036
|
this.config.infrastructure = infrastructure;
|
|
1518
2037
|
return this;
|
|
1519
2038
|
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Register workflow router for workflow orchestration
|
|
2041
|
+
*
|
|
2042
|
+
* Automatically initializes the workflow engine after database is ready.
|
|
2043
|
+
*
|
|
2044
|
+
* @example
|
|
2045
|
+
* ```typescript
|
|
2046
|
+
* import { defineWorkflowRouter } from '@spfn/workflow';
|
|
2047
|
+
*
|
|
2048
|
+
* const workflowRouter = defineWorkflowRouter([
|
|
2049
|
+
* provisionTenant,
|
|
2050
|
+
* deprovisionTenant,
|
|
2051
|
+
* ]);
|
|
2052
|
+
*
|
|
2053
|
+
* export default defineServerConfig()
|
|
2054
|
+
* .routes(appRouter)
|
|
2055
|
+
* .workflows(workflowRouter)
|
|
2056
|
+
* .build();
|
|
2057
|
+
* ```
|
|
2058
|
+
*/
|
|
2059
|
+
workflows(router, config) {
|
|
2060
|
+
this.config.workflows = router;
|
|
2061
|
+
if (config) {
|
|
2062
|
+
this.config.workflowsConfig = config;
|
|
2063
|
+
}
|
|
2064
|
+
return this;
|
|
2065
|
+
}
|
|
1520
2066
|
/**
|
|
1521
2067
|
* Configure lifecycle hooks
|
|
1522
2068
|
* Can be called multiple times - hooks will be executed in registration order
|
|
@@ -1564,6 +2110,6 @@ function defineServerConfig() {
|
|
|
1564
2110
|
return new ServerConfigBuilder();
|
|
1565
2111
|
}
|
|
1566
2112
|
|
|
1567
|
-
export { createServer, defineServerConfig, loadEnvFiles, startServer };
|
|
2113
|
+
export { createServer, defineServerConfig, getShutdownManager, loadEnv, loadEnvFiles, startServer };
|
|
1568
2114
|
//# sourceMappingURL=index.js.map
|
|
1569
2115
|
//# sourceMappingURL=index.js.map
|