@spfn/core 0.2.0-beta.4 → 0.2.0-beta.40

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.
Files changed (68) hide show
  1. package/README.md +260 -1175
  2. package/dist/{boss-BO8ty33K.d.ts → boss-DI1r4kTS.d.ts} +24 -7
  3. package/dist/cache/index.js +32 -29
  4. package/dist/cache/index.js.map +1 -1
  5. package/dist/codegen/index.d.ts +55 -8
  6. package/dist/codegen/index.js +179 -5
  7. package/dist/codegen/index.js.map +1 -1
  8. package/dist/config/index.d.ts +168 -6
  9. package/dist/config/index.js +29 -5
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/db/index.d.ts +128 -4
  12. package/dist/db/index.js +177 -50
  13. package/dist/db/index.js.map +1 -1
  14. package/dist/env/index.d.ts +55 -1
  15. package/dist/env/index.js +71 -3
  16. package/dist/env/index.js.map +1 -1
  17. package/dist/env/loader.d.ts +27 -19
  18. package/dist/env/loader.js +33 -25
  19. package/dist/env/loader.js.map +1 -1
  20. package/dist/event/index.d.ts +27 -1
  21. package/dist/event/index.js +6 -1
  22. package/dist/event/index.js.map +1 -1
  23. package/dist/event/sse/client.d.ts +77 -2
  24. package/dist/event/sse/client.js +87 -24
  25. package/dist/event/sse/client.js.map +1 -1
  26. package/dist/event/sse/index.d.ts +10 -4
  27. package/dist/event/sse/index.js +158 -12
  28. package/dist/event/sse/index.js.map +1 -1
  29. package/dist/job/index.d.ts +23 -8
  30. package/dist/job/index.js +96 -20
  31. package/dist/job/index.js.map +1 -1
  32. package/dist/logger/index.d.ts +5 -0
  33. package/dist/logger/index.js +14 -0
  34. package/dist/logger/index.js.map +1 -1
  35. package/dist/middleware/index.d.ts +23 -1
  36. package/dist/middleware/index.js +58 -5
  37. package/dist/middleware/index.js.map +1 -1
  38. package/dist/nextjs/index.d.ts +2 -2
  39. package/dist/nextjs/index.js +77 -31
  40. package/dist/nextjs/index.js.map +1 -1
  41. package/dist/nextjs/server.d.ts +44 -23
  42. package/dist/nextjs/server.js +83 -65
  43. package/dist/nextjs/server.js.map +1 -1
  44. package/dist/route/index.d.ts +158 -4
  45. package/dist/route/index.js +251 -12
  46. package/dist/route/index.js.map +1 -1
  47. package/dist/server/index.d.ts +251 -16
  48. package/dist/server/index.js +774 -228
  49. package/dist/server/index.js.map +1 -1
  50. package/dist/{types-D_N_U-Py.d.ts → types-7Mhoxnnt.d.ts} +21 -1
  51. package/dist/types-DKQ90YL7.d.ts +372 -0
  52. package/docs/cache.md +133 -0
  53. package/docs/codegen.md +74 -0
  54. package/docs/database.md +370 -0
  55. package/docs/entity.md +539 -0
  56. package/docs/env.md +499 -0
  57. package/docs/errors.md +319 -0
  58. package/docs/event.md +443 -0
  59. package/docs/file-upload.md +717 -0
  60. package/docs/job.md +131 -0
  61. package/docs/logger.md +108 -0
  62. package/docs/middleware.md +337 -0
  63. package/docs/nextjs.md +247 -0
  64. package/docs/repository.md +496 -0
  65. package/docs/route.md +497 -0
  66. package/docs/server.md +429 -0
  67. package/package.json +2 -1
  68. package/dist/types-B-e_f2dQ.d.ts +0 -121
@@ -1,13 +1,15 @@
1
1
  import { env } from '@spfn/core/config';
2
- import { config } from 'dotenv';
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 { logger } from '@spfn/core/logger';
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
- function loadEnvFiles() {
318
- const cwd = process.cwd();
319
- const nodeEnv = process.env.NODE_ENV || "development";
320
- const envFiles = [
321
- ".env.server.local",
322
- ".env.server",
323
- `.env.${nodeEnv}.local`,
324
- nodeEnv !== "test" ? ".env.local" : null,
325
- `.env.${nodeEnv}`,
326
- ".env"
327
- ].filter((file) => file !== null);
328
- for (const file of envFiles) {
329
- const filePath = resolve(cwd, file);
330
- if (existsSync(filePath)) {
331
- config({ path: filePath });
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, config2 = {}) {
403
+ function createSSEHandler(router, config = {}, tokenManager) {
337
404
  const {
338
- pingInterval = 3e4
339
- // headers: customHeaders = {}, // Reserved for future use
340
- } = config2;
405
+ pingInterval = 3e4,
406
+ auth: authConfig
407
+ } = config;
341
408
  return async (c) => {
342
- const eventsParam = c.req.query("events");
343
- if (!eventsParam) {
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: requestedEvents,
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 requestedEvents) {
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: requestedEvents,
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: requestedEvents,
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: requestedEvents
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(config2) {
912
+ function getTimeoutConfig(config) {
478
913
  return {
479
- request: config2?.request ?? env.SERVER_TIMEOUT,
480
- keepAlive: config2?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
481
- headers: config2?.headers ?? env.SERVER_HEADERS_TIMEOUT
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(config2) {
485
- return config2?.timeout ?? env.SHUTDOWN_TIMEOUT;
919
+ function getShutdownTimeout(config) {
920
+ return config?.timeout ?? env.SHUTDOWN_TIMEOUT;
486
921
  }
487
- function buildMiddlewareOrder(config2) {
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 = config2.middleware ?? {};
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
- config2.use?.forEach((_, i) => order.push(`Custom[${i}]`));
496
- if (config2.beforeRoutes) order.push("beforeRoutes hook");
945
+ config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
946
+ if (config.beforeRoutes) order.push("beforeRoutes hook");
497
947
  order.push("Routes");
498
- if (config2.afterRoutes) order.push("afterRoutes hook");
948
+ if (config.afterRoutes) order.push("afterRoutes hook");
499
949
  if (enableErrorHandler) order.push("ErrorHandler");
500
950
  return order;
501
951
  }
502
- function buildStartupConfig(config2, timeouts) {
503
- const middlewareConfig = config2.middleware ?? {};
504
- const healthCheckConfig = config2.healthCheck ?? {};
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: config2.use?.length ?? 0
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: !!config2.beforeRoutes,
522
- afterRoutes: !!config2.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: `${config2.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
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(config2) {
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, config2);
991
+ return await loadCustomApp(appPath, appJsPath, config);
543
992
  }
544
- return await createAutoConfiguredApp(config2);
993
+ return await createAutoConfiguredApp(config);
545
994
  }
546
- async function loadCustomApp(appPath, appJsPath, config2) {
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 (config2?.routes) {
555
- const routes = registerRoutes(app, config2.routes, config2.middlewares);
556
- logRegisteredRoutes(routes, config2?.debug ?? false);
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(config2) {
1009
+ async function createAutoConfiguredApp(config) {
561
1010
  const app = new Hono();
562
- const middlewareConfig = config2?.middleware ?? {};
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, config2, enableLogger, enableCors);
573
- if (Array.isArray(config2?.use)) {
574
- config2.use.forEach((mw) => app.use("*", mw));
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, config2);
577
- await executeBeforeRoutesHook(app, config2);
578
- await loadAppRoutes(app, config2);
579
- registerSSEEndpoint(app, config2);
580
- await executeAfterRoutesHook(app, config2);
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, config2, enableLogger, enableCors) {
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 = config2?.cors !== false ? config2?.cors : void 0;
1040
+ const corsOptions = config?.cors !== false ? config?.cors : void 0;
592
1041
  app.use("*", cors(corsOptions));
593
1042
  }
594
1043
  }
595
- function registerHealthCheckEndpoint(app, config2) {
596
- const healthCheckConfig = config2?.healthCheck ?? {};
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, config2) {
606
- if (config2?.lifecycle?.beforeRoutes) {
607
- await config2.lifecycle.beforeRoutes(app);
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, config2) {
611
- const debug = isDebugMode(config2);
612
- if (config2?.routes) {
613
- const routes = registerRoutes(app, config2.routes, config2.middlewares);
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, config2) {
635
- if (config2?.lifecycle?.afterRoutes) {
636
- await config2.lifecycle.afterRoutes(app);
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, config2) {
640
- if (!config2?.events) {
1088
+ async function registerSSEEndpoint(app, config) {
1089
+ if (!config?.events) {
641
1090
  return;
642
1091
  }
643
- const eventsConfig = config2.eventsConfig ?? {};
644
- const path = eventsConfig.path ?? "/events/stream";
645
- const debug = isDebugMode(config2);
646
- app.get(path, createSSEHandler(config2.events, eventsConfig));
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.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 = config2.events.eventNames;
649
- serverLogger.info(`\u2713 SSE endpoint registered at ${path}`, {
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(config2) {
655
- return config2?.debug ?? process.env.NODE_ENV === "development";
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
- var bossInstance = null;
659
- var bossConfig = null;
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
- if (bossInstance) {
1175
+ const existing = getBossInstance();
1176
+ if (existing) {
662
1177
  jobLogger.warn("pg-boss already initialized, returning existing instance");
663
- return bossInstance;
1178
+ return existing;
664
1179
  }
665
1180
  jobLogger.info("Initializing pg-boss...");
666
- bossConfig = options;
1181
+ setBossConfig(options);
1182
+ const needsSSL = requiresSSLWithoutVerification(options.connectionString);
667
1183
  const pgBossOptions = {
668
- connectionString: options.connectionString,
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
- bossInstance = new PgBoss(pgBossOptions);
676
- bossInstance.on("error", (error) => {
1196
+ const boss = new PgBoss(pgBossOptions);
1197
+ boss.on("error", (error) => {
677
1198
  jobLogger.error("pg-boss error:", error);
678
1199
  });
679
- await bossInstance.start();
1200
+ await boss.start();
1201
+ setBossInstance(boss);
680
1202
  jobLogger.info("pg-boss started successfully");
681
- return bossInstance;
1203
+ return boss;
682
1204
  }
683
1205
  function getBoss() {
684
- return bossInstance;
1206
+ return getBossInstance();
685
1207
  }
686
1208
  async function stopBoss() {
687
- if (!bossInstance) {
1209
+ const boss = getBossInstance();
1210
+ if (!boss) {
688
1211
  return;
689
1212
  }
690
1213
  jobLogger.info("Stopping pg-boss...");
691
1214
  try {
692
- await bossInstance.stop({ graceful: true, timeout: 3e4 });
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
- bossInstance = null;
699
- bossConfig = null;
1221
+ setBossInstance(null);
1222
+ setBossConfig(null);
700
1223
  }
701
1224
  }
702
1225
  function shouldClearOnStart() {
703
- return bossConfig?.clearOnStart ?? false;
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(config2) {
892
- if (config2.port !== void 0) {
893
- if (!Number.isInteger(config2.port) || config2.port < 0 || config2.port > 65535) {
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: ${config2.port}. Port must be an integer between 0 and 65535.`
1418
+ `Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
896
1419
  );
897
1420
  }
898
1421
  }
899
- if (config2.timeout) {
900
- const { request, keepAlive, headers } = config2.timeout;
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 (config2.shutdown?.timeout !== void 0) {
917
- const timeout = config2.shutdown.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 (config2.healthCheck?.path) {
923
- if (!config2.healthCheck.path.startsWith("/")) {
1445
+ if (config.healthCheck?.path) {
1446
+ if (!config.healthCheck.path.startsWith("/")) {
924
1447
  throw new Error(
925
- `Invalid healthCheck.path: "${config2.healthCheck.path}". Must start with "/".`
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(config2) {
945
- loadEnvFiles();
946
- const finalConfig = await loadAndMergeConfig(config2);
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(config2) {
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
- ...config2,
1029
- port: config2?.port ?? fileConfig?.port ?? env.PORT,
1030
- host: config2?.host ?? fileConfig?.host ?? env.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(config2) {
1551
+ function getInfrastructureConfig(config) {
1034
1552
  return {
1035
- database: config2.infrastructure?.database !== false,
1036
- redis: config2.infrastructure?.redis !== false
1553
+ database: config.infrastructure?.database !== false,
1554
+ redis: config.infrastructure?.redis !== false
1037
1555
  };
1038
1556
  }
1039
- async function initializeInfrastructure(config2) {
1040
- if (config2.lifecycle?.beforeInfrastructure) {
1557
+ async function initializeInfrastructure(config) {
1558
+ if (config.lifecycle?.beforeInfrastructure) {
1041
1559
  serverLogger.debug("Executing beforeInfrastructure hook...");
1042
- await config2.lifecycle.beforeInfrastructure(config2);
1560
+ await config.lifecycle.beforeInfrastructure(config);
1043
1561
  }
1044
- const infraConfig = getInfrastructureConfig(config2);
1562
+ const infraConfig = getInfrastructureConfig(config);
1045
1563
  if (infraConfig.database) {
1046
1564
  serverLogger.debug("Initializing database...");
1047
- await initDatabase(config2.database);
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 (config2.lifecycle?.afterInfrastructure) {
1575
+ if (config.lifecycle?.afterInfrastructure) {
1058
1576
  serverLogger.debug("Executing afterInfrastructure hook...");
1059
- await config2.lifecycle.afterInfrastructure();
1577
+ await config.lifecycle.afterInfrastructure();
1060
1578
  }
1061
- if (config2.jobs) {
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
- ...config2.jobsConfig
1589
+ ...config.jobsConfig
1072
1590
  });
1073
1591
  serverLogger.debug("Registering jobs...");
1074
- await registerJobs(config2.jobs);
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(config2) {
1086
- const middlewareOrder = buildMiddlewareOrder(config2);
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, config2, timeouts) {
1099
- const startupConfig = buildStartupConfig(config2, timeouts);
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, config2, shutdownState) {
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
- serverLogger.debug("Closing HTTP server...");
1115
- let timeoutId;
1116
- await Promise.race([
1117
- new Promise((resolve2, reject) => {
1118
- server.close((err) => {
1119
- if (timeoutId) clearTimeout(timeoutId);
1120
- if (err) {
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
- if (config2.lifecycle?.beforeShutdown) {
1147
- serverLogger.debug("Executing beforeShutdown hook...");
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 config2.lifecycle.beforeShutdown();
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
- const infraConfig = getInfrastructureConfig(config2);
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, config2, shutdownState) {
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(config2.shutdown);
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, shutdown) {
1221
- const isProduction = env.NODE_ENV === "production";
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", shutdown);
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", shutdown);
1815
+ handleProcessError("UNHANDLED_REJECTION");
1298
1816
  });
1299
1817
  serverLogger.debug("Process-level shutdown handlers registered successfully");
1300
1818
  }
1301
- async function cleanupOnFailure(config2) {
1819
+ async function cleanupOnFailure(config) {
1302
1820
  try {
1303
1821
  serverLogger.debug("Cleaning up after initialization failure...");
1304
- const infraConfig = getInfrastructureConfig(config2);
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, config2) {
1955
+ jobs(router, config) {
1437
1956
  this.config.jobs = router;
1438
- if (config2) {
1439
- this.config.jobsConfig = config2;
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, config2) {
1990
+ events(router, config) {
1472
1991
  this.config.events = router;
1473
- if (config2) {
1474
- this.config.eventsConfig = config2;
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