@spfn/core 0.2.0-beta.15 → 0.2.0-beta.17
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/dist/config/index.js +3 -3
- package/dist/config/index.js.map +1 -1
- package/dist/middleware/index.d.ts +23 -1
- package/dist/middleware/index.js +39 -5
- package/dist/middleware/index.js.map +1 -1
- package/dist/server/index.d.ts +142 -4
- package/dist/server/index.js +264 -37
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -580,8 +580,231 @@ var SSETokenManager = class {
|
|
|
580
580
|
}
|
|
581
581
|
}
|
|
582
582
|
};
|
|
583
|
+
var serverLogger = logger.child("@spfn/core:server");
|
|
584
|
+
|
|
585
|
+
// src/server/shutdown-manager.ts
|
|
586
|
+
var DEFAULT_HOOK_TIMEOUT = 1e4;
|
|
587
|
+
var DEFAULT_HOOK_ORDER = 100;
|
|
588
|
+
var DRAIN_POLL_INTERVAL = 500;
|
|
589
|
+
var ShutdownManager = class {
|
|
590
|
+
state = "running";
|
|
591
|
+
hooks = [];
|
|
592
|
+
operations = /* @__PURE__ */ new Map();
|
|
593
|
+
operationCounter = 0;
|
|
594
|
+
/**
|
|
595
|
+
* Register a shutdown hook
|
|
596
|
+
*
|
|
597
|
+
* Hooks run in order during shutdown, after all tracked operations drain.
|
|
598
|
+
* Each hook has its own timeout — failure does not block subsequent hooks.
|
|
599
|
+
*
|
|
600
|
+
* @example
|
|
601
|
+
* shutdown.onShutdown('ai-service', async () => {
|
|
602
|
+
* await aiService.cancelPending();
|
|
603
|
+
* }, { timeout: 30000, order: 10 });
|
|
604
|
+
*/
|
|
605
|
+
onShutdown(name, handler, options) {
|
|
606
|
+
this.hooks.push({
|
|
607
|
+
name,
|
|
608
|
+
handler,
|
|
609
|
+
timeout: options?.timeout ?? DEFAULT_HOOK_TIMEOUT,
|
|
610
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER
|
|
611
|
+
});
|
|
612
|
+
this.hooks.sort((a, b) => a.order - b.order);
|
|
613
|
+
serverLogger.debug(`Shutdown hook registered: ${name}`, {
|
|
614
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER,
|
|
615
|
+
timeout: `${options?.timeout ?? DEFAULT_HOOK_TIMEOUT}ms`
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Track a long-running operation
|
|
620
|
+
*
|
|
621
|
+
* During shutdown (drain phase), the process waits for ALL tracked
|
|
622
|
+
* operations to complete before proceeding with cleanup.
|
|
623
|
+
*
|
|
624
|
+
* If shutdown has already started, the operation is rejected immediately.
|
|
625
|
+
*
|
|
626
|
+
* @returns The operation result (pass-through)
|
|
627
|
+
*
|
|
628
|
+
* @example
|
|
629
|
+
* const result = await shutdown.trackOperation(
|
|
630
|
+
* 'ai-generate',
|
|
631
|
+
* aiService.generate(prompt)
|
|
632
|
+
* );
|
|
633
|
+
*/
|
|
634
|
+
async trackOperation(name, operation) {
|
|
635
|
+
if (this.state !== "running") {
|
|
636
|
+
throw new Error(`Cannot start operation '${name}': server is shutting down`);
|
|
637
|
+
}
|
|
638
|
+
const id = `${name}-${++this.operationCounter}`;
|
|
639
|
+
this.operations.set(id, {
|
|
640
|
+
name,
|
|
641
|
+
startedAt: Date.now()
|
|
642
|
+
});
|
|
643
|
+
serverLogger.debug(`Operation tracked: ${id}`, {
|
|
644
|
+
activeOperations: this.operations.size
|
|
645
|
+
});
|
|
646
|
+
try {
|
|
647
|
+
return await operation;
|
|
648
|
+
} finally {
|
|
649
|
+
this.operations.delete(id);
|
|
650
|
+
serverLogger.debug(`Operation completed: ${id}`, {
|
|
651
|
+
activeOperations: this.operations.size
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Whether the server is shutting down
|
|
657
|
+
*
|
|
658
|
+
* Use this to reject new work early (e.g., return 503 in route handlers).
|
|
659
|
+
*/
|
|
660
|
+
isShuttingDown() {
|
|
661
|
+
return this.state !== "running";
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Number of currently active tracked operations
|
|
665
|
+
*/
|
|
666
|
+
getActiveOperationCount() {
|
|
667
|
+
return this.operations.size;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Mark shutdown as started immediately
|
|
671
|
+
*
|
|
672
|
+
* Call this at the very beginning of the shutdown sequence so that:
|
|
673
|
+
* - Health check returns 503 right away
|
|
674
|
+
* - trackOperation() rejects new work
|
|
675
|
+
* - isShuttingDown() returns true
|
|
676
|
+
*/
|
|
677
|
+
beginShutdown() {
|
|
678
|
+
if (this.state !== "running") {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
this.state = "draining";
|
|
682
|
+
serverLogger.info("Shutdown manager: state set to draining");
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Execute the full shutdown sequence
|
|
686
|
+
*
|
|
687
|
+
* 1. State → draining (reject new operations)
|
|
688
|
+
* 2. Wait for all tracked operations to complete (drain)
|
|
689
|
+
* 3. Run shutdown hooks in order
|
|
690
|
+
* 4. State → closed
|
|
691
|
+
*
|
|
692
|
+
* @param drainTimeout - Max time to wait for operations to drain (ms)
|
|
693
|
+
*/
|
|
694
|
+
async execute(drainTimeout) {
|
|
695
|
+
if (this.state === "closed") {
|
|
696
|
+
serverLogger.warn("ShutdownManager.execute() called but already closed");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
this.state = "draining";
|
|
700
|
+
serverLogger.info("Shutdown manager: draining started", {
|
|
701
|
+
activeOperations: this.operations.size,
|
|
702
|
+
registeredHooks: this.hooks.length,
|
|
703
|
+
drainTimeout: `${drainTimeout}ms`
|
|
704
|
+
});
|
|
705
|
+
await this.drain(drainTimeout);
|
|
706
|
+
await this.executeHooks();
|
|
707
|
+
this.state = "closed";
|
|
708
|
+
serverLogger.info("Shutdown manager: all hooks executed");
|
|
709
|
+
}
|
|
710
|
+
// ========================================================================
|
|
711
|
+
// Private
|
|
712
|
+
// ========================================================================
|
|
713
|
+
/**
|
|
714
|
+
* Wait for all tracked operations to complete, up to drainTimeout
|
|
715
|
+
*/
|
|
716
|
+
async drain(drainTimeout) {
|
|
717
|
+
if (this.operations.size === 0) {
|
|
718
|
+
serverLogger.info("Shutdown manager: no active operations, drain skipped");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
serverLogger.info(`Shutdown manager: waiting for ${this.operations.size} operations to drain...`);
|
|
722
|
+
const deadline = Date.now() + drainTimeout;
|
|
723
|
+
while (this.operations.size > 0 && Date.now() < deadline) {
|
|
724
|
+
const remaining = deadline - Date.now();
|
|
725
|
+
const ops = Array.from(this.operations.values()).map((op) => ({
|
|
726
|
+
name: op.name,
|
|
727
|
+
elapsed: `${Math.round((Date.now() - op.startedAt) / 1e3)}s`
|
|
728
|
+
}));
|
|
729
|
+
serverLogger.info("Shutdown manager: drain in progress", {
|
|
730
|
+
activeOperations: this.operations.size,
|
|
731
|
+
remainingTimeout: `${Math.round(remaining / 1e3)}s`,
|
|
732
|
+
operations: ops
|
|
733
|
+
});
|
|
734
|
+
await sleep(Math.min(DRAIN_POLL_INTERVAL, remaining));
|
|
735
|
+
}
|
|
736
|
+
if (this.operations.size > 0) {
|
|
737
|
+
const abandoned = Array.from(this.operations.values()).map((op) => op.name);
|
|
738
|
+
serverLogger.warn("Shutdown manager: drain timeout \u2014 abandoning operations", {
|
|
739
|
+
abandoned
|
|
740
|
+
});
|
|
741
|
+
} else {
|
|
742
|
+
serverLogger.info("Shutdown manager: all operations drained successfully");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Execute registered shutdown hooks in order
|
|
747
|
+
*/
|
|
748
|
+
async executeHooks() {
|
|
749
|
+
if (this.hooks.length === 0) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
serverLogger.info(`Shutdown manager: executing ${this.hooks.length} hooks...`);
|
|
753
|
+
for (const hook of this.hooks) {
|
|
754
|
+
serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
|
|
755
|
+
try {
|
|
756
|
+
await withTimeout(
|
|
757
|
+
hook.handler(),
|
|
758
|
+
hook.timeout,
|
|
759
|
+
`Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
|
|
760
|
+
);
|
|
761
|
+
serverLogger.info(`Shutdown hook [${hook.name}] completed`);
|
|
762
|
+
} catch (error) {
|
|
763
|
+
serverLogger.error(
|
|
764
|
+
`Shutdown hook [${hook.name}] failed`,
|
|
765
|
+
error
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
var instance = null;
|
|
772
|
+
function getShutdownManager() {
|
|
773
|
+
if (!instance) {
|
|
774
|
+
instance = new ShutdownManager();
|
|
775
|
+
}
|
|
776
|
+
return instance;
|
|
777
|
+
}
|
|
778
|
+
function resetShutdownManager() {
|
|
779
|
+
instance = null;
|
|
780
|
+
}
|
|
781
|
+
function sleep(ms) {
|
|
782
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
783
|
+
}
|
|
784
|
+
async function withTimeout(promise, timeout, message) {
|
|
785
|
+
let timeoutId;
|
|
786
|
+
return Promise.race([
|
|
787
|
+
promise.finally(() => {
|
|
788
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
789
|
+
}),
|
|
790
|
+
new Promise((_, reject) => {
|
|
791
|
+
timeoutId = setTimeout(() => {
|
|
792
|
+
reject(new Error(message));
|
|
793
|
+
}, timeout);
|
|
794
|
+
})
|
|
795
|
+
]);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// src/server/helpers.ts
|
|
583
799
|
function createHealthCheckHandler(detailed) {
|
|
584
800
|
return async (c) => {
|
|
801
|
+
const shutdownManager = getShutdownManager();
|
|
802
|
+
if (shutdownManager.isShuttingDown()) {
|
|
803
|
+
return c.json({
|
|
804
|
+
status: "shutting_down",
|
|
805
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
806
|
+
}, 503);
|
|
807
|
+
}
|
|
585
808
|
const response = {
|
|
586
809
|
status: "ok",
|
|
587
810
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -695,7 +918,6 @@ function buildStartupConfig(config, timeouts) {
|
|
|
695
918
|
}
|
|
696
919
|
};
|
|
697
920
|
}
|
|
698
|
-
var serverLogger = logger.child("@spfn/core:server");
|
|
699
921
|
|
|
700
922
|
// src/server/create-server.ts
|
|
701
923
|
async function createServer(config) {
|
|
@@ -743,7 +965,7 @@ async function createAutoConfiguredApp(config) {
|
|
|
743
965
|
registerSSEEndpoint(app, config);
|
|
744
966
|
await executeAfterRoutesHook(app, config);
|
|
745
967
|
if (enableErrorHandler) {
|
|
746
|
-
app.onError(ErrorHandler());
|
|
968
|
+
app.onError(ErrorHandler({ onError: config?.middleware?.onError }));
|
|
747
969
|
}
|
|
748
970
|
return app;
|
|
749
971
|
}
|
|
@@ -1162,11 +1384,6 @@ async function startServer(config) {
|
|
|
1162
1384
|
config: finalConfig,
|
|
1163
1385
|
close: async () => {
|
|
1164
1386
|
serverLogger.info("Manual server shutdown requested");
|
|
1165
|
-
if (shutdownState.isShuttingDown) {
|
|
1166
|
-
serverLogger.warn("Shutdown already in progress, ignoring manual close request");
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
shutdownState.isShuttingDown = true;
|
|
1170
1387
|
await shutdownServer();
|
|
1171
1388
|
}
|
|
1172
1389
|
};
|
|
@@ -1310,58 +1527,67 @@ function createShutdownHandler(server, config, shutdownState) {
|
|
|
1310
1527
|
return;
|
|
1311
1528
|
}
|
|
1312
1529
|
shutdownState.isShuttingDown = true;
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
1319
|
-
if (err) {
|
|
1320
|
-
serverLogger.error("HTTP server close error", err);
|
|
1321
|
-
reject(err);
|
|
1322
|
-
} else {
|
|
1323
|
-
serverLogger.info("HTTP server closed");
|
|
1324
|
-
resolve2();
|
|
1325
|
-
}
|
|
1326
|
-
});
|
|
1327
|
-
}),
|
|
1328
|
-
new Promise((_, reject) => {
|
|
1329
|
-
timeoutId = setTimeout(() => {
|
|
1330
|
-
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
1331
|
-
}, TIMEOUTS.SERVER_CLOSE);
|
|
1332
|
-
})
|
|
1333
|
-
]).catch((error) => {
|
|
1334
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
1335
|
-
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
1336
|
-
});
|
|
1530
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
1531
|
+
const shutdownManager = getShutdownManager();
|
|
1532
|
+
shutdownManager.beginShutdown();
|
|
1533
|
+
serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
|
|
1534
|
+
await closeHttpServer(server);
|
|
1337
1535
|
if (config.jobs) {
|
|
1338
|
-
serverLogger.
|
|
1536
|
+
serverLogger.info("Phase 2: Stopping pg-boss...");
|
|
1339
1537
|
try {
|
|
1340
1538
|
await stopBoss();
|
|
1539
|
+
serverLogger.info("pg-boss stopped");
|
|
1341
1540
|
} catch (error) {
|
|
1342
1541
|
serverLogger.error("pg-boss stop failed", error);
|
|
1343
1542
|
}
|
|
1344
1543
|
}
|
|
1544
|
+
const drainTimeout = Math.floor(shutdownTimeout * 0.8);
|
|
1545
|
+
serverLogger.info(`Phase 3: Draining tracked operations (timeout: ${drainTimeout}ms)...`);
|
|
1546
|
+
await shutdownManager.execute(drainTimeout);
|
|
1345
1547
|
if (config.lifecycle?.beforeShutdown) {
|
|
1346
|
-
serverLogger.
|
|
1548
|
+
serverLogger.info("Phase 4: Executing beforeShutdown lifecycle hook...");
|
|
1347
1549
|
try {
|
|
1348
1550
|
await config.lifecycle.beforeShutdown();
|
|
1349
1551
|
} catch (error) {
|
|
1350
|
-
serverLogger.error("beforeShutdown hook failed", error);
|
|
1552
|
+
serverLogger.error("beforeShutdown lifecycle hook failed", error);
|
|
1351
1553
|
}
|
|
1352
1554
|
}
|
|
1555
|
+
serverLogger.info("Phase 5: Closing infrastructure...");
|
|
1353
1556
|
const infraConfig = getInfrastructureConfig(config);
|
|
1354
1557
|
if (infraConfig.database) {
|
|
1355
|
-
serverLogger.debug("Closing database connections...");
|
|
1356
1558
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1357
1559
|
}
|
|
1358
1560
|
if (infraConfig.redis) {
|
|
1359
|
-
serverLogger.debug("Closing Redis connections...");
|
|
1360
1561
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1361
1562
|
}
|
|
1362
1563
|
serverLogger.info("Server shutdown completed");
|
|
1363
1564
|
};
|
|
1364
1565
|
}
|
|
1566
|
+
async function closeHttpServer(server) {
|
|
1567
|
+
let timeoutId;
|
|
1568
|
+
await Promise.race([
|
|
1569
|
+
new Promise((resolve2, reject) => {
|
|
1570
|
+
server.close((err) => {
|
|
1571
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1572
|
+
if (err) {
|
|
1573
|
+
serverLogger.error("HTTP server close error", err);
|
|
1574
|
+
reject(err);
|
|
1575
|
+
} else {
|
|
1576
|
+
serverLogger.info("HTTP server closed");
|
|
1577
|
+
resolve2();
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
}),
|
|
1581
|
+
new Promise((_, reject) => {
|
|
1582
|
+
timeoutId = setTimeout(() => {
|
|
1583
|
+
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
1584
|
+
}, TIMEOUTS.SERVER_CLOSE);
|
|
1585
|
+
})
|
|
1586
|
+
]).catch((error) => {
|
|
1587
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1588
|
+
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1365
1591
|
async function closeInfrastructure(closeFn, name, timeout) {
|
|
1366
1592
|
let timeoutId;
|
|
1367
1593
|
try {
|
|
@@ -1484,6 +1710,7 @@ async function cleanupOnFailure(config) {
|
|
|
1484
1710
|
if (infraConfig.redis) {
|
|
1485
1711
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1486
1712
|
}
|
|
1713
|
+
resetShutdownManager();
|
|
1487
1714
|
serverLogger.debug("Cleanup completed");
|
|
1488
1715
|
} catch (cleanupError) {
|
|
1489
1716
|
serverLogger.error("Cleanup failed", cleanupError);
|
|
@@ -1767,6 +1994,6 @@ function defineServerConfig() {
|
|
|
1767
1994
|
return new ServerConfigBuilder();
|
|
1768
1995
|
}
|
|
1769
1996
|
|
|
1770
|
-
export { createServer, defineServerConfig, loadEnv, loadEnvFiles, startServer };
|
|
1997
|
+
export { createServer, defineServerConfig, getShutdownManager, loadEnv, loadEnvFiles, startServer };
|
|
1771
1998
|
//# sourceMappingURL=index.js.map
|
|
1772
1999
|
//# sourceMappingURL=index.js.map
|