@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.
@@ -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
- serverLogger.debug("Closing HTTP server...");
1314
- let timeoutId;
1315
- await Promise.race([
1316
- new Promise((resolve2, reject) => {
1317
- server.close((err) => {
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.debug("Stopping pg-boss...");
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.debug("Executing beforeShutdown hook...");
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