@spfn/core 0.2.0-beta.3 → 0.2.0-beta.30

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 (65) hide show
  1. package/README.md +260 -1175
  2. package/dist/{boss-BO8ty33K.d.ts → boss-DI1r4kTS.d.ts} +24 -7
  3. package/dist/codegen/index.d.ts +55 -8
  4. package/dist/codegen/index.js +179 -5
  5. package/dist/codegen/index.js.map +1 -1
  6. package/dist/config/index.d.ts +204 -6
  7. package/dist/config/index.js +44 -11
  8. package/dist/config/index.js.map +1 -1
  9. package/dist/db/index.d.ts +24 -3
  10. package/dist/db/index.js +118 -45
  11. package/dist/db/index.js.map +1 -1
  12. package/dist/env/index.d.ts +83 -3
  13. package/dist/env/index.js +83 -15
  14. package/dist/env/index.js.map +1 -1
  15. package/dist/env/loader.d.ts +95 -0
  16. package/dist/env/loader.js +78 -0
  17. package/dist/env/loader.js.map +1 -0
  18. package/dist/event/index.d.ts +29 -70
  19. package/dist/event/index.js +15 -1
  20. package/dist/event/index.js.map +1 -1
  21. package/dist/event/sse/client.d.ts +157 -0
  22. package/dist/event/sse/client.js +169 -0
  23. package/dist/event/sse/client.js.map +1 -0
  24. package/dist/event/sse/index.d.ts +46 -0
  25. package/dist/event/sse/index.js +238 -0
  26. package/dist/event/sse/index.js.map +1 -0
  27. package/dist/job/index.d.ts +23 -8
  28. package/dist/job/index.js +108 -23
  29. package/dist/job/index.js.map +1 -1
  30. package/dist/logger/index.js +9 -0
  31. package/dist/logger/index.js.map +1 -1
  32. package/dist/middleware/index.d.ts +23 -1
  33. package/dist/middleware/index.js +58 -5
  34. package/dist/middleware/index.js.map +1 -1
  35. package/dist/nextjs/index.d.ts +2 -2
  36. package/dist/nextjs/index.js +37 -5
  37. package/dist/nextjs/index.js.map +1 -1
  38. package/dist/nextjs/server.d.ts +44 -23
  39. package/dist/nextjs/server.js +87 -66
  40. package/dist/nextjs/server.js.map +1 -1
  41. package/dist/route/index.d.ts +168 -5
  42. package/dist/route/index.js +262 -17
  43. package/dist/route/index.js.map +1 -1
  44. package/dist/router-Di7ENoah.d.ts +151 -0
  45. package/dist/server/index.d.ts +316 -5
  46. package/dist/server/index.js +892 -200
  47. package/dist/server/index.js.map +1 -1
  48. package/dist/{types-BVxUIkcU.d.ts → types-7Mhoxnnt.d.ts} +68 -2
  49. package/dist/types-DAVwA-_7.d.ts +339 -0
  50. package/docs/cache.md +133 -0
  51. package/docs/codegen.md +74 -0
  52. package/docs/database.md +346 -0
  53. package/docs/entity.md +539 -0
  54. package/docs/env.md +499 -0
  55. package/docs/errors.md +319 -0
  56. package/docs/event.md +443 -0
  57. package/docs/file-upload.md +717 -0
  58. package/docs/job.md +131 -0
  59. package/docs/logger.md +108 -0
  60. package/docs/middleware.md +337 -0
  61. package/docs/nextjs.md +247 -0
  62. package/docs/repository.md +496 -0
  63. package/docs/route.md +497 -0
  64. package/docs/server.md +429 -0
  65. package/package.json +18 -2
@@ -1,14 +1,17 @@
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';
10
+ import { streamSSE } from 'hono/streaming';
11
+ import { randomBytes } from 'crypto';
12
+ import { Agent, setGlobalDispatcher } from 'undici';
9
13
  import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
10
14
  import { initCache, getCache, closeCache } from '@spfn/core/cache';
11
- import { logger } from '@spfn/core/logger';
12
15
  import { serve } from '@hono/node-server';
13
16
  import PgBoss from 'pg-boss';
14
17
  import { networkInterfaces } from 'os';
@@ -313,24 +316,529 @@ var init_formatters = __esm({
313
316
  };
314
317
  }
315
318
  });
319
+ var envLogger = logger.child("@spfn/core:env-loader");
320
+ function getEnvFiles(nodeEnv, server) {
321
+ const files = [
322
+ ".env",
323
+ `.env.${nodeEnv}`
324
+ ];
325
+ if (nodeEnv !== "test") {
326
+ files.push(".env.local");
327
+ }
328
+ files.push(`.env.${nodeEnv}.local`);
329
+ if (server) {
330
+ files.push(".env.server");
331
+ files.push(".env.server.local");
332
+ }
333
+ return files;
334
+ }
335
+ function parseEnvFile(filePath) {
336
+ if (!existsSync(filePath)) {
337
+ return null;
338
+ }
339
+ return parse(readFileSync(filePath, "utf-8"));
340
+ }
341
+ function loadEnv(options = {}) {
342
+ const {
343
+ cwd = process.cwd(),
344
+ nodeEnv = process.env.NODE_ENV || "local",
345
+ server = true,
346
+ debug = false,
347
+ override = false
348
+ } = options;
349
+ const envFiles = getEnvFiles(nodeEnv, server);
350
+ const loadedFiles = [];
351
+ const existingKeys = new Set(Object.keys(process.env));
352
+ const merged = {};
353
+ for (const fileName of envFiles) {
354
+ const filePath = resolve(cwd, fileName);
355
+ const parsed = parseEnvFile(filePath);
356
+ if (parsed === null) {
357
+ continue;
358
+ }
359
+ loadedFiles.push(fileName);
360
+ Object.assign(merged, parsed);
361
+ }
362
+ const loadedKeys = [];
363
+ for (const [key, value] of Object.entries(merged)) {
364
+ if (!override && existingKeys.has(key)) {
365
+ continue;
366
+ }
367
+ process.env[key] = value;
368
+ loadedKeys.push(key);
369
+ }
370
+ if (debug && loadedFiles.length > 0) {
371
+ envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
372
+ envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
373
+ }
374
+ return { loadedFiles, loadedKeys };
375
+ }
376
+
377
+ // src/server/dotenv-loader.ts
378
+ var warned = false;
316
379
  function loadEnvFiles() {
317
- const cwd = process.cwd();
318
- const nodeEnv = process.env.NODE_ENV || "development";
319
- const envFiles = [
320
- `.env.${nodeEnv}.local`,
321
- nodeEnv !== "test" ? ".env.local" : null,
322
- `.env.${nodeEnv}`,
323
- ".env"
324
- ].filter((file) => file !== null);
325
- for (const file of envFiles) {
326
- const filePath = resolve(cwd, file);
327
- if (existsSync(filePath)) {
328
- config({ path: filePath });
380
+ if (!warned) {
381
+ warned = true;
382
+ console.warn(
383
+ '[SPFN] loadEnvFiles() is deprecated. Use loadEnv() from "@spfn/core/env/loader" instead.'
384
+ );
385
+ }
386
+ loadEnv();
387
+ }
388
+ var sseLogger = logger.child("@spfn/core:sse");
389
+ function createSSEHandler(router, config = {}, tokenManager) {
390
+ const {
391
+ pingInterval = 3e4,
392
+ auth: authConfig
393
+ } = config;
394
+ return async (c) => {
395
+ const subject = await authenticateToken(c, tokenManager);
396
+ if (subject === false) {
397
+ return c.json({ error: "Missing token parameter" }, 401);
398
+ }
399
+ if (subject === null) {
400
+ return c.json({ error: "Invalid or expired token" }, 401);
401
+ }
402
+ if (subject) {
403
+ c.set("sseSubject", subject);
404
+ }
405
+ const requestedEvents = parseRequestedEvents(c);
406
+ if (!requestedEvents) {
407
+ return c.json({ error: "Missing events parameter" }, 400);
408
+ }
409
+ const validEventNames = router.eventNames;
410
+ const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
411
+ if (invalidEvents.length > 0) {
412
+ return c.json({
413
+ error: "Invalid event names",
414
+ invalidEvents,
415
+ validEvents: validEventNames
416
+ }, 400);
417
+ }
418
+ const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
419
+ if (allowedEvents === null) {
420
+ return c.json({ error: "Not authorized for any requested events" }, 403);
421
+ }
422
+ sseLogger.debug("SSE connection requested", {
423
+ events: allowedEvents,
424
+ subject: subject || void 0,
425
+ clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
426
+ });
427
+ return streamSSE(c, async (stream) => {
428
+ const unsubscribes = [];
429
+ let messageId = 0;
430
+ for (const eventName of allowedEvents) {
431
+ const eventDef = router.events[eventName];
432
+ if (!eventDef) {
433
+ continue;
434
+ }
435
+ const unsubscribe = eventDef.subscribe((payload) => {
436
+ if (subject && authConfig?.filter?.[eventName]) {
437
+ if (!authConfig.filter[eventName](subject, payload)) {
438
+ return;
439
+ }
440
+ }
441
+ messageId++;
442
+ const message = {
443
+ event: eventName,
444
+ data: payload
445
+ };
446
+ sseLogger.debug("SSE sending event", {
447
+ event: eventName,
448
+ messageId
449
+ });
450
+ void stream.writeSSE({
451
+ id: String(messageId),
452
+ event: eventName,
453
+ data: JSON.stringify(message)
454
+ });
455
+ });
456
+ unsubscribes.push(unsubscribe);
457
+ }
458
+ sseLogger.info("SSE connection established", {
459
+ events: allowedEvents,
460
+ subscriptionCount: unsubscribes.length
461
+ });
462
+ await stream.writeSSE({
463
+ event: "connected",
464
+ data: JSON.stringify({
465
+ subscribedEvents: allowedEvents,
466
+ timestamp: Date.now()
467
+ })
468
+ });
469
+ const pingTimer = setInterval(() => {
470
+ void stream.writeSSE({
471
+ event: "ping",
472
+ data: JSON.stringify({ timestamp: Date.now() })
473
+ });
474
+ }, pingInterval);
475
+ const abortSignal = c.req.raw.signal;
476
+ while (!abortSignal.aborted) {
477
+ await stream.sleep(pingInterval);
478
+ }
479
+ clearInterval(pingTimer);
480
+ unsubscribes.forEach((fn) => fn());
481
+ sseLogger.info("SSE connection closed", {
482
+ events: allowedEvents
483
+ });
484
+ }, async (err) => {
485
+ sseLogger.error("SSE stream error", {
486
+ error: err.message
487
+ });
488
+ });
489
+ };
490
+ }
491
+ async function authenticateToken(c, tokenManager) {
492
+ if (!tokenManager) {
493
+ return void 0;
494
+ }
495
+ const token = c.req.query("token");
496
+ if (!token) {
497
+ return false;
498
+ }
499
+ return await tokenManager.verify(token);
500
+ }
501
+ function parseRequestedEvents(c) {
502
+ const eventsParam = c.req.query("events");
503
+ if (!eventsParam) {
504
+ return null;
505
+ }
506
+ return eventsParam.split(",").map((e) => e.trim());
507
+ }
508
+ async function authorizeEvents(subject, requestedEvents, authConfig) {
509
+ if (!subject || !authConfig?.authorize) {
510
+ return requestedEvents;
511
+ }
512
+ const allowed = await authConfig.authorize(subject, requestedEvents);
513
+ if (allowed.length === 0) {
514
+ return null;
515
+ }
516
+ return allowed;
517
+ }
518
+ var InMemoryTokenStore = class {
519
+ tokens = /* @__PURE__ */ new Map();
520
+ async set(token, data) {
521
+ this.tokens.set(token, data);
522
+ }
523
+ async consume(token) {
524
+ const data = this.tokens.get(token);
525
+ if (!data) {
526
+ return null;
527
+ }
528
+ this.tokens.delete(token);
529
+ return data;
530
+ }
531
+ async cleanup() {
532
+ const now = Date.now();
533
+ for (const [token, data] of this.tokens) {
534
+ if (data.expiresAt <= now) {
535
+ this.tokens.delete(token);
536
+ }
537
+ }
538
+ }
539
+ };
540
+ var CacheTokenStore = class {
541
+ constructor(cache) {
542
+ this.cache = cache;
543
+ }
544
+ prefix = "sse:token:";
545
+ async set(token, data) {
546
+ const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
547
+ await this.cache.set(
548
+ this.prefix + token,
549
+ JSON.stringify(data),
550
+ "EX",
551
+ ttlSeconds
552
+ );
553
+ }
554
+ async consume(token) {
555
+ const key = this.prefix + token;
556
+ let raw = null;
557
+ if (this.cache.getdel) {
558
+ raw = await this.cache.getdel(key);
559
+ } else {
560
+ raw = await this.cache.get(key);
561
+ if (raw) {
562
+ await this.cache.del(key);
563
+ }
564
+ }
565
+ if (!raw) {
566
+ return null;
567
+ }
568
+ return JSON.parse(raw);
569
+ }
570
+ async cleanup() {
571
+ }
572
+ };
573
+ var SSETokenManager = class {
574
+ store;
575
+ ttl;
576
+ cleanupTimer = null;
577
+ constructor(config) {
578
+ this.ttl = config?.ttl ?? 3e4;
579
+ this.store = config?.store ?? new InMemoryTokenStore();
580
+ const cleanupInterval = config?.cleanupInterval ?? 6e4;
581
+ this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
582
+ this.cleanupTimer.unref();
583
+ }
584
+ /**
585
+ * Issue a new one-time-use token for the given subject
586
+ */
587
+ async issue(subject) {
588
+ const token = randomBytes(32).toString("hex");
589
+ await this.store.set(token, {
590
+ token,
591
+ subject,
592
+ expiresAt: Date.now() + this.ttl
593
+ });
594
+ return token;
595
+ }
596
+ /**
597
+ * Verify and consume a token
598
+ * @returns subject string if valid, null if invalid/expired/already consumed
599
+ */
600
+ async verify(token) {
601
+ const data = await this.store.consume(token);
602
+ if (!data || data.expiresAt <= Date.now()) {
603
+ return null;
604
+ }
605
+ return data.subject;
606
+ }
607
+ /**
608
+ * Cleanup timer and resources
609
+ */
610
+ destroy() {
611
+ if (this.cleanupTimer) {
612
+ clearInterval(this.cleanupTimer);
613
+ this.cleanupTimer = null;
614
+ }
615
+ }
616
+ };
617
+ var serverLogger = logger.child("@spfn/core:server");
618
+
619
+ // src/server/shutdown-manager.ts
620
+ var DEFAULT_HOOK_TIMEOUT = 1e4;
621
+ var DEFAULT_HOOK_ORDER = 100;
622
+ var DRAIN_POLL_INTERVAL = 500;
623
+ var ShutdownManager = class {
624
+ state = "running";
625
+ hooks = [];
626
+ operations = /* @__PURE__ */ new Map();
627
+ operationCounter = 0;
628
+ /**
629
+ * Register a shutdown hook
630
+ *
631
+ * Hooks run in order during shutdown, after all tracked operations drain.
632
+ * Each hook has its own timeout — failure does not block subsequent hooks.
633
+ *
634
+ * @example
635
+ * shutdown.onShutdown('ai-service', async () => {
636
+ * await aiService.cancelPending();
637
+ * }, { timeout: 30000, order: 10 });
638
+ */
639
+ onShutdown(name, handler, options) {
640
+ this.hooks.push({
641
+ name,
642
+ handler,
643
+ timeout: options?.timeout ?? DEFAULT_HOOK_TIMEOUT,
644
+ order: options?.order ?? DEFAULT_HOOK_ORDER
645
+ });
646
+ this.hooks.sort((a, b) => a.order - b.order);
647
+ serverLogger.debug(`Shutdown hook registered: ${name}`, {
648
+ order: options?.order ?? DEFAULT_HOOK_ORDER,
649
+ timeout: `${options?.timeout ?? DEFAULT_HOOK_TIMEOUT}ms`
650
+ });
651
+ }
652
+ /**
653
+ * Track a long-running operation
654
+ *
655
+ * During shutdown (drain phase), the process waits for ALL tracked
656
+ * operations to complete before proceeding with cleanup.
657
+ *
658
+ * If shutdown has already started, the operation is rejected immediately.
659
+ *
660
+ * @returns The operation result (pass-through)
661
+ *
662
+ * @example
663
+ * const result = await shutdown.trackOperation(
664
+ * 'ai-generate',
665
+ * aiService.generate(prompt)
666
+ * );
667
+ */
668
+ async trackOperation(name, operation) {
669
+ if (this.state !== "running") {
670
+ throw new Error(`Cannot start operation '${name}': server is shutting down`);
671
+ }
672
+ const id = `${name}-${++this.operationCounter}`;
673
+ this.operations.set(id, {
674
+ name,
675
+ startedAt: Date.now()
676
+ });
677
+ serverLogger.debug(`Operation tracked: ${id}`, {
678
+ activeOperations: this.operations.size
679
+ });
680
+ try {
681
+ return await operation;
682
+ } finally {
683
+ this.operations.delete(id);
684
+ serverLogger.debug(`Operation completed: ${id}`, {
685
+ activeOperations: this.operations.size
686
+ });
687
+ }
688
+ }
689
+ /**
690
+ * Whether the server is shutting down
691
+ *
692
+ * Use this to reject new work early (e.g., return 503 in route handlers).
693
+ */
694
+ isShuttingDown() {
695
+ return this.state !== "running";
696
+ }
697
+ /**
698
+ * Number of currently active tracked operations
699
+ */
700
+ getActiveOperationCount() {
701
+ return this.operations.size;
702
+ }
703
+ /**
704
+ * Mark shutdown as started immediately
705
+ *
706
+ * Call this at the very beginning of the shutdown sequence so that:
707
+ * - Health check returns 503 right away
708
+ * - trackOperation() rejects new work
709
+ * - isShuttingDown() returns true
710
+ */
711
+ beginShutdown() {
712
+ if (this.state !== "running") {
713
+ return;
714
+ }
715
+ this.state = "draining";
716
+ serverLogger.info("Shutdown manager: state set to draining");
717
+ }
718
+ /**
719
+ * Execute the full shutdown sequence
720
+ *
721
+ * 1. State → draining (reject new operations)
722
+ * 2. Wait for all tracked operations to complete (drain)
723
+ * 3. Run shutdown hooks in order
724
+ * 4. State → closed
725
+ *
726
+ * @param drainTimeout - Max time to wait for operations to drain (ms)
727
+ */
728
+ async execute(drainTimeout) {
729
+ if (this.state === "closed") {
730
+ serverLogger.warn("ShutdownManager.execute() called but already closed");
731
+ return;
732
+ }
733
+ this.state = "draining";
734
+ serverLogger.info("Shutdown manager: draining started", {
735
+ activeOperations: this.operations.size,
736
+ registeredHooks: this.hooks.length,
737
+ drainTimeout: `${drainTimeout}ms`
738
+ });
739
+ await this.drain(drainTimeout);
740
+ await this.executeHooks();
741
+ this.state = "closed";
742
+ serverLogger.info("Shutdown manager: all hooks executed");
743
+ }
744
+ // ========================================================================
745
+ // Private
746
+ // ========================================================================
747
+ /**
748
+ * Wait for all tracked operations to complete, up to drainTimeout
749
+ */
750
+ async drain(drainTimeout) {
751
+ if (this.operations.size === 0) {
752
+ serverLogger.info("Shutdown manager: no active operations, drain skipped");
753
+ return;
754
+ }
755
+ serverLogger.info(`Shutdown manager: waiting for ${this.operations.size} operations to drain...`);
756
+ const deadline = Date.now() + drainTimeout;
757
+ while (this.operations.size > 0 && Date.now() < deadline) {
758
+ const remaining = deadline - Date.now();
759
+ const ops = Array.from(this.operations.values()).map((op) => ({
760
+ name: op.name,
761
+ elapsed: `${Math.round((Date.now() - op.startedAt) / 1e3)}s`
762
+ }));
763
+ serverLogger.info("Shutdown manager: drain in progress", {
764
+ activeOperations: this.operations.size,
765
+ remainingTimeout: `${Math.round(remaining / 1e3)}s`,
766
+ operations: ops
767
+ });
768
+ await sleep(Math.min(DRAIN_POLL_INTERVAL, remaining));
769
+ }
770
+ if (this.operations.size > 0) {
771
+ const abandoned = Array.from(this.operations.values()).map((op) => op.name);
772
+ serverLogger.warn("Shutdown manager: drain timeout \u2014 abandoning operations", {
773
+ abandoned
774
+ });
775
+ } else {
776
+ serverLogger.info("Shutdown manager: all operations drained successfully");
777
+ }
778
+ }
779
+ /**
780
+ * Execute registered shutdown hooks in order
781
+ */
782
+ async executeHooks() {
783
+ if (this.hooks.length === 0) {
784
+ return;
785
+ }
786
+ serverLogger.info(`Shutdown manager: executing ${this.hooks.length} hooks...`);
787
+ for (const hook of this.hooks) {
788
+ serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
789
+ try {
790
+ await withTimeout(
791
+ hook.handler(),
792
+ hook.timeout,
793
+ `Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
794
+ );
795
+ serverLogger.info(`Shutdown hook [${hook.name}] completed`);
796
+ } catch (error) {
797
+ serverLogger.error(
798
+ `Shutdown hook [${hook.name}] failed`,
799
+ error
800
+ );
801
+ }
329
802
  }
330
803
  }
804
+ };
805
+ var instance = null;
806
+ function getShutdownManager() {
807
+ if (!instance) {
808
+ instance = new ShutdownManager();
809
+ }
810
+ return instance;
811
+ }
812
+ function resetShutdownManager() {
813
+ instance = null;
814
+ }
815
+ function sleep(ms) {
816
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
331
817
  }
818
+ async function withTimeout(promise, timeout, message) {
819
+ let timeoutId;
820
+ return Promise.race([
821
+ promise.finally(() => {
822
+ if (timeoutId) clearTimeout(timeoutId);
823
+ }),
824
+ new Promise((_, reject) => {
825
+ timeoutId = setTimeout(() => {
826
+ reject(new Error(message));
827
+ }, timeout);
828
+ })
829
+ ]);
830
+ }
831
+
832
+ // src/server/helpers.ts
332
833
  function createHealthCheckHandler(detailed) {
333
834
  return async (c) => {
835
+ const shutdownManager = getShutdownManager();
836
+ if (shutdownManager.isShuttingDown()) {
837
+ return c.json({
838
+ status: "shutting_down",
839
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
840
+ }, 503);
841
+ }
334
842
  const response = {
335
843
  status: "ok",
336
844
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -387,34 +895,49 @@ function applyServerTimeouts(server, timeouts) {
387
895
  server.headersTimeout = timeouts.headers;
388
896
  }
389
897
  }
390
- function getTimeoutConfig(config2) {
898
+ function getTimeoutConfig(config) {
899
+ return {
900
+ request: config?.request ?? env.SERVER_TIMEOUT,
901
+ keepAlive: config?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
902
+ headers: config?.headers ?? env.SERVER_HEADERS_TIMEOUT
903
+ };
904
+ }
905
+ function getShutdownTimeout(config) {
906
+ return config?.timeout ?? env.SHUTDOWN_TIMEOUT;
907
+ }
908
+ function getFetchTimeoutConfig(config) {
391
909
  return {
392
- request: config2?.request ?? env.SERVER_TIMEOUT,
393
- keepAlive: config2?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
394
- headers: config2?.headers ?? env.SERVER_HEADERS_TIMEOUT
910
+ connect: config?.connect ?? env.FETCH_CONNECT_TIMEOUT,
911
+ headers: config?.headers ?? env.FETCH_HEADERS_TIMEOUT,
912
+ body: config?.body ?? env.FETCH_BODY_TIMEOUT
395
913
  };
396
914
  }
397
- function getShutdownTimeout(config2) {
398
- return config2?.timeout ?? env.SHUTDOWN_TIMEOUT;
915
+ function applyGlobalFetchTimeouts(timeouts) {
916
+ const agent = new Agent({
917
+ connect: { timeout: timeouts.connect },
918
+ headersTimeout: timeouts.headers,
919
+ bodyTimeout: timeouts.body
920
+ });
921
+ setGlobalDispatcher(agent);
399
922
  }
400
- function buildMiddlewareOrder(config2) {
923
+ function buildMiddlewareOrder(config) {
401
924
  const order = [];
402
- const middlewareConfig = config2.middleware ?? {};
925
+ const middlewareConfig = config.middleware ?? {};
403
926
  const enableLogger = middlewareConfig.logger !== false;
404
927
  const enableCors = middlewareConfig.cors !== false;
405
928
  const enableErrorHandler = middlewareConfig.errorHandler !== false;
406
929
  if (enableLogger) order.push("RequestLogger");
407
930
  if (enableCors) order.push("CORS");
408
- config2.use?.forEach((_, i) => order.push(`Custom[${i}]`));
409
- if (config2.beforeRoutes) order.push("beforeRoutes hook");
931
+ config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
932
+ if (config.beforeRoutes) order.push("beforeRoutes hook");
410
933
  order.push("Routes");
411
- if (config2.afterRoutes) order.push("afterRoutes hook");
934
+ if (config.afterRoutes) order.push("afterRoutes hook");
412
935
  if (enableErrorHandler) order.push("ErrorHandler");
413
936
  return order;
414
937
  }
415
- function buildStartupConfig(config2, timeouts) {
416
- const middlewareConfig = config2.middleware ?? {};
417
- const healthCheckConfig = config2.healthCheck ?? {};
938
+ function buildStartupConfig(config, timeouts) {
939
+ const middlewareConfig = config.middleware ?? {};
940
+ const healthCheckConfig = config.healthCheck ?? {};
418
941
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
419
942
  const healthCheckPath = healthCheckConfig.path ?? "/health";
420
943
  const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
@@ -423,7 +946,7 @@ function buildStartupConfig(config2, timeouts) {
423
946
  logger: middlewareConfig.logger !== false,
424
947
  cors: middlewareConfig.cors !== false,
425
948
  errorHandler: middlewareConfig.errorHandler !== false,
426
- custom: config2.use?.length ?? 0
949
+ custom: config.use?.length ?? 0
427
950
  },
428
951
  healthCheck: healthCheckEnabled ? {
429
952
  enabled: true,
@@ -431,8 +954,8 @@ function buildStartupConfig(config2, timeouts) {
431
954
  detailed: healthCheckDetailed
432
955
  } : { enabled: false },
433
956
  hooks: {
434
- beforeRoutes: !!config2.beforeRoutes,
435
- afterRoutes: !!config2.afterRoutes
957
+ beforeRoutes: !!config.beforeRoutes,
958
+ afterRoutes: !!config.afterRoutes
436
959
  },
437
960
  timeout: {
438
961
  request: `${timeouts.request}ms`,
@@ -440,23 +963,22 @@ function buildStartupConfig(config2, timeouts) {
440
963
  headers: `${timeouts.headers}ms`
441
964
  },
442
965
  shutdown: {
443
- timeout: `${config2.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
966
+ timeout: `${config.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
444
967
  }
445
968
  };
446
969
  }
447
- var serverLogger = logger.child("@spfn/core:server");
448
970
 
449
971
  // src/server/create-server.ts
450
- async function createServer(config2) {
972
+ async function createServer(config) {
451
973
  const cwd = process.cwd();
452
974
  const appPath = join(cwd, "src", "server", "app.ts");
453
975
  const appJsPath = join(cwd, "src", "server", "app");
454
976
  if (existsSync(appPath) || existsSync(appJsPath)) {
455
- return await loadCustomApp(appPath, appJsPath, config2);
977
+ return await loadCustomApp(appPath, appJsPath, config);
456
978
  }
457
- return await createAutoConfiguredApp(config2);
979
+ return await createAutoConfiguredApp(config);
458
980
  }
459
- async function loadCustomApp(appPath, appJsPath, config2) {
981
+ async function loadCustomApp(appPath, appJsPath, config) {
460
982
  const actualPath = existsSync(appPath) ? appPath : appJsPath;
461
983
  const appModule = await import(actualPath);
462
984
  const appFactory = appModule.default;
@@ -464,14 +986,15 @@ async function loadCustomApp(appPath, appJsPath, config2) {
464
986
  throw new Error("app.ts must export a default function that returns a Hono app");
465
987
  }
466
988
  const app = await appFactory();
467
- if (config2?.routes) {
468
- registerRoutes(app, config2.routes, config2.middlewares);
989
+ if (config?.routes) {
990
+ const routes = registerRoutes(app, config.routes, config.middlewares);
991
+ logRegisteredRoutes(routes, config?.debug ?? false);
469
992
  }
470
993
  return app;
471
994
  }
472
- async function createAutoConfiguredApp(config2) {
995
+ async function createAutoConfiguredApp(config) {
473
996
  const app = new Hono();
474
- const middlewareConfig = config2?.middleware ?? {};
997
+ const middlewareConfig = config?.middleware ?? {};
475
998
  const enableLogger = middlewareConfig.logger !== false;
476
999
  const enableCors = middlewareConfig.cors !== false;
477
1000
  const enableErrorHandler = middlewareConfig.errorHandler !== false;
@@ -481,30 +1004,31 @@ async function createAutoConfiguredApp(config2) {
481
1004
  await next();
482
1005
  });
483
1006
  }
484
- applyDefaultMiddleware(app, config2, enableLogger, enableCors);
485
- if (Array.isArray(config2?.use)) {
486
- config2.use.forEach((mw) => app.use("*", mw));
1007
+ applyDefaultMiddleware(app, config, enableLogger, enableCors);
1008
+ if (Array.isArray(config?.use)) {
1009
+ config.use.forEach((mw) => app.use("*", mw));
487
1010
  }
488
- registerHealthCheckEndpoint(app, config2);
489
- await executeBeforeRoutesHook(app, config2);
490
- await loadAppRoutes(app, config2);
491
- await executeAfterRoutesHook(app, config2);
1011
+ registerHealthCheckEndpoint(app, config);
1012
+ await executeBeforeRoutesHook(app, config);
1013
+ await loadAppRoutes(app, config);
1014
+ await registerSSEEndpoint(app, config);
1015
+ await executeAfterRoutesHook(app, config);
492
1016
  if (enableErrorHandler) {
493
- app.onError(ErrorHandler());
1017
+ app.onError(ErrorHandler({ onError: config?.middleware?.onError }));
494
1018
  }
495
1019
  return app;
496
1020
  }
497
- function applyDefaultMiddleware(app, config2, enableLogger, enableCors) {
1021
+ function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
498
1022
  if (enableLogger) {
499
1023
  app.use("*", RequestLogger());
500
1024
  }
501
1025
  if (enableCors) {
502
- const corsOptions = config2?.cors !== false ? config2?.cors : void 0;
1026
+ const corsOptions = config?.cors !== false ? config?.cors : void 0;
503
1027
  app.use("*", cors(corsOptions));
504
1028
  }
505
1029
  }
506
- function registerHealthCheckEndpoint(app, config2) {
507
- const healthCheckConfig = config2?.healthCheck ?? {};
1030
+ function registerHealthCheckEndpoint(app, config) {
1031
+ const healthCheckConfig = config?.healthCheck ?? {};
508
1032
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
509
1033
  const healthCheckPath = healthCheckConfig.path ?? "/health";
510
1034
  const healthCheckDetailed = healthCheckConfig.detailed ?? process.env.NODE_ENV === "development";
@@ -513,74 +1037,178 @@ function registerHealthCheckEndpoint(app, config2) {
513
1037
  serverLogger.debug(`Health check endpoint enabled at ${healthCheckPath}`);
514
1038
  }
515
1039
  }
516
- async function executeBeforeRoutesHook(app, config2) {
517
- if (config2?.lifecycle?.beforeRoutes) {
518
- await config2.lifecycle.beforeRoutes(app);
1040
+ async function executeBeforeRoutesHook(app, config) {
1041
+ if (config?.lifecycle?.beforeRoutes) {
1042
+ await config.lifecycle.beforeRoutes(app);
519
1043
  }
520
1044
  }
521
- async function loadAppRoutes(app, config2) {
522
- const debug = isDebugMode(config2);
523
- if (config2?.routes) {
524
- registerRoutes(app, config2.routes, config2.middlewares);
525
- if (debug) {
526
- serverLogger.info("\u2713 Routes registered");
527
- }
1045
+ async function loadAppRoutes(app, config) {
1046
+ const debug = isDebugMode(config);
1047
+ if (config?.routes) {
1048
+ const routes = registerRoutes(app, config.routes, config.middlewares);
1049
+ logRegisteredRoutes(routes, debug);
528
1050
  } else if (debug) {
529
1051
  serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
530
1052
  }
531
1053
  }
532
- async function executeAfterRoutesHook(app, config2) {
533
- if (config2?.lifecycle?.afterRoutes) {
534
- await config2.lifecycle.afterRoutes(app);
1054
+ function logRegisteredRoutes(routes, debug) {
1055
+ if (routes.length === 0) {
1056
+ if (debug) {
1057
+ serverLogger.warn("\u26A0\uFE0F No routes registered");
1058
+ }
1059
+ return;
1060
+ }
1061
+ const sortedRoutes = [...routes].sort((a, b) => a.path.localeCompare(b.path));
1062
+ const maxMethodLen = Math.max(...sortedRoutes.map((r) => r.method.length));
1063
+ const routeLines = sortedRoutes.map(
1064
+ (r) => ` ${r.method.padEnd(maxMethodLen)} ${r.path}`
1065
+ ).join("\n");
1066
+ serverLogger.info(`\u2713 Routes registered (${routes.length}):
1067
+ ${routeLines}`);
1068
+ }
1069
+ async function executeAfterRoutesHook(app, config) {
1070
+ if (config?.lifecycle?.afterRoutes) {
1071
+ await config.lifecycle.afterRoutes(app);
1072
+ }
1073
+ }
1074
+ async function registerSSEEndpoint(app, config) {
1075
+ if (!config?.events) {
1076
+ return;
1077
+ }
1078
+ const eventsConfig = config.eventsConfig ?? {};
1079
+ const streamPath = eventsConfig.path ?? "/events/stream";
1080
+ const authConfig = eventsConfig.auth;
1081
+ const debug = isDebugMode(config);
1082
+ let tokenManager;
1083
+ if (authConfig?.enabled) {
1084
+ let store = authConfig.store;
1085
+ if (!store) {
1086
+ try {
1087
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
1088
+ const cache = getCache2();
1089
+ if (cache) {
1090
+ store = new CacheTokenStore(cache);
1091
+ if (debug) {
1092
+ serverLogger.info("SSE token store: cache (Redis/Valkey)");
1093
+ }
1094
+ }
1095
+ } catch {
1096
+ }
1097
+ }
1098
+ tokenManager = new SSETokenManager({
1099
+ ttl: authConfig.tokenTtl,
1100
+ store
1101
+ });
1102
+ const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
1103
+ const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
1104
+ const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
1105
+ app.post(tokenPath, ...mwHandlers, async (c) => {
1106
+ const subject = getSubject(c);
1107
+ if (!subject) {
1108
+ return c.json({ error: "Unable to identify subject" }, 401);
1109
+ }
1110
+ const token = await tokenManager.issue(subject);
1111
+ return c.json({ token });
1112
+ });
1113
+ if (debug) {
1114
+ serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
1115
+ }
1116
+ }
1117
+ app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
1118
+ if (debug) {
1119
+ const eventNames = config.events.eventNames;
1120
+ serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
1121
+ events: eventNames,
1122
+ auth: !!authConfig?.enabled
1123
+ });
535
1124
  }
536
1125
  }
537
- function isDebugMode(config2) {
538
- return config2?.debug ?? process.env.NODE_ENV === "development";
1126
+ function isDebugMode(config) {
1127
+ return config?.debug ?? process.env.NODE_ENV === "development";
539
1128
  }
540
1129
  var jobLogger = logger.child("@spfn/core:job");
541
- var bossInstance = null;
542
- var bossConfig = null;
1130
+ function requiresSSLWithoutVerification(connectionString) {
1131
+ try {
1132
+ const url = new URL(connectionString);
1133
+ const sslmode = url.searchParams.get("sslmode");
1134
+ return sslmode === "require" || sslmode === "prefer";
1135
+ } catch {
1136
+ return false;
1137
+ }
1138
+ }
1139
+ function stripSslModeFromUrl(connectionString) {
1140
+ const url = new URL(connectionString);
1141
+ url.searchParams.delete("sslmode");
1142
+ return url.toString();
1143
+ }
1144
+ var BOSS_KEY = Symbol.for("spfn:boss-instance");
1145
+ var CONFIG_KEY = Symbol.for("spfn:boss-config");
1146
+ var g = globalThis;
1147
+ function getBossInstance() {
1148
+ return g[BOSS_KEY] ?? null;
1149
+ }
1150
+ function setBossInstance(instance2) {
1151
+ g[BOSS_KEY] = instance2;
1152
+ }
1153
+ function getBossConfig() {
1154
+ return g[CONFIG_KEY] ?? null;
1155
+ }
1156
+ function setBossConfig(config) {
1157
+ g[CONFIG_KEY] = config;
1158
+ }
543
1159
  async function initBoss(options) {
544
- if (bossInstance) {
1160
+ const existing = getBossInstance();
1161
+ if (existing) {
545
1162
  jobLogger.warn("pg-boss already initialized, returning existing instance");
546
- return bossInstance;
1163
+ return existing;
547
1164
  }
548
1165
  jobLogger.info("Initializing pg-boss...");
549
- bossConfig = options;
550
- bossInstance = new PgBoss({
551
- connectionString: options.connectionString,
1166
+ setBossConfig(options);
1167
+ const needsSSL = requiresSSLWithoutVerification(options.connectionString);
1168
+ const pgBossOptions = {
1169
+ // pg 드라이버가 URL의 sslmode=require를 verify-full로 해석해서
1170
+ // ssl 옵션을 무시하므로, URL에서 sslmode를 빼고 ssl 객체만 전달
1171
+ connectionString: needsSSL ? stripSslModeFromUrl(options.connectionString) : options.connectionString,
552
1172
  schema: options.schema ?? "spfn_queue",
553
- maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120,
554
- monitorIntervalSeconds: options.monitorIntervalSeconds
555
- });
556
- bossInstance.on("error", (error) => {
1173
+ maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
1174
+ };
1175
+ if (needsSSL) {
1176
+ pgBossOptions.ssl = { rejectUnauthorized: false };
1177
+ }
1178
+ if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
1179
+ pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
1180
+ }
1181
+ const boss = new PgBoss(pgBossOptions);
1182
+ boss.on("error", (error) => {
557
1183
  jobLogger.error("pg-boss error:", error);
558
1184
  });
559
- await bossInstance.start();
1185
+ await boss.start();
1186
+ setBossInstance(boss);
560
1187
  jobLogger.info("pg-boss started successfully");
561
- return bossInstance;
1188
+ return boss;
562
1189
  }
563
1190
  function getBoss() {
564
- return bossInstance;
1191
+ return getBossInstance();
565
1192
  }
566
1193
  async function stopBoss() {
567
- if (!bossInstance) {
1194
+ const boss = getBossInstance();
1195
+ if (!boss) {
568
1196
  return;
569
1197
  }
570
1198
  jobLogger.info("Stopping pg-boss...");
571
1199
  try {
572
- await bossInstance.stop({ graceful: true, timeout: 3e4 });
1200
+ await boss.stop({ graceful: true, timeout: 3e4 });
573
1201
  jobLogger.info("pg-boss stopped gracefully");
574
1202
  } catch (error) {
575
1203
  jobLogger.error("Error stopping pg-boss:", error);
576
1204
  throw error;
577
1205
  } finally {
578
- bossInstance = null;
579
- bossConfig = null;
1206
+ setBossInstance(null);
1207
+ setBossConfig(null);
580
1208
  }
581
1209
  }
582
1210
  function shouldClearOnStart() {
583
- return bossConfig?.clearOnStart ?? false;
1211
+ return getBossConfig()?.clearOnStart ?? false;
584
1212
  }
585
1213
 
586
1214
  // src/job/job-router.ts
@@ -639,7 +1267,11 @@ async function registerJobs(router) {
639
1267
  }
640
1268
  jobLogger2.info("All jobs registered successfully");
641
1269
  }
1270
+ async function ensureQueue(boss, queueName) {
1271
+ await boss.createQueue(queueName);
1272
+ }
642
1273
  async function registerWorker(boss, job2, queueName) {
1274
+ await ensureQueue(boss, queueName);
643
1275
  await boss.work(
644
1276
  queueName,
645
1277
  { batchSize: 1 },
@@ -686,6 +1318,7 @@ async function registerCronSchedule(boss, job2) {
686
1318
  return;
687
1319
  }
688
1320
  jobLogger2.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
1321
+ await ensureQueue(boss, job2.name);
689
1322
  await boss.schedule(
690
1323
  job2.name,
691
1324
  job2.cronExpression,
@@ -699,6 +1332,7 @@ async function queueRunOnceJob(boss, job2) {
699
1332
  return;
700
1333
  }
701
1334
  jobLogger2.debug(`[Job:${job2.name}] Queuing runOnce job`);
1335
+ await ensureQueue(boss, job2.name);
702
1336
  await boss.send(
703
1337
  job2.name,
704
1338
  {},
@@ -762,16 +1396,16 @@ function printBanner(options) {
762
1396
  }
763
1397
 
764
1398
  // src/server/validation.ts
765
- function validateServerConfig(config2) {
766
- if (config2.port !== void 0) {
767
- if (!Number.isInteger(config2.port) || config2.port < 0 || config2.port > 65535) {
1399
+ function validateServerConfig(config) {
1400
+ if (config.port !== void 0) {
1401
+ if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
768
1402
  throw new Error(
769
- `Invalid port: ${config2.port}. Port must be an integer between 0 and 65535.`
1403
+ `Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
770
1404
  );
771
1405
  }
772
1406
  }
773
- if (config2.timeout) {
774
- const { request, keepAlive, headers } = config2.timeout;
1407
+ if (config.timeout) {
1408
+ const { request, keepAlive, headers } = config.timeout;
775
1409
  if (request !== void 0 && (request < 0 || !Number.isFinite(request))) {
776
1410
  throw new Error(`Invalid timeout.request: ${request}. Must be a positive number.`);
777
1411
  }
@@ -787,16 +1421,16 @@ function validateServerConfig(config2) {
787
1421
  );
788
1422
  }
789
1423
  }
790
- if (config2.shutdown?.timeout !== void 0) {
791
- const timeout = config2.shutdown.timeout;
1424
+ if (config.shutdown?.timeout !== void 0) {
1425
+ const timeout = config.shutdown.timeout;
792
1426
  if (timeout < 0 || !Number.isFinite(timeout)) {
793
1427
  throw new Error(`Invalid shutdown.timeout: ${timeout}. Must be a positive number.`);
794
1428
  }
795
1429
  }
796
- if (config2.healthCheck?.path) {
797
- if (!config2.healthCheck.path.startsWith("/")) {
1430
+ if (config.healthCheck?.path) {
1431
+ if (!config.healthCheck.path.startsWith("/")) {
798
1432
  throw new Error(
799
- `Invalid healthCheck.path: "${config2.healthCheck.path}". Must start with "/".`
1433
+ `Invalid healthCheck.path: "${config.healthCheck.path}". Must start with "/".`
800
1434
  );
801
1435
  }
802
1436
  }
@@ -805,9 +1439,7 @@ var DEFAULT_MAX_LISTENERS = 15;
805
1439
  var TIMEOUTS = {
806
1440
  SERVER_CLOSE: 5e3,
807
1441
  DATABASE_CLOSE: 5e3,
808
- REDIS_CLOSE: 5e3,
809
- PRODUCTION_ERROR_SHUTDOWN: 1e4
810
- };
1442
+ REDIS_CLOSE: 5e3};
811
1443
  var CONFIG_FILE_PATHS = [
812
1444
  ".spfn/server/server.config.mjs",
813
1445
  ".spfn/server/server.config",
@@ -815,9 +1447,9 @@ var CONFIG_FILE_PATHS = [
815
1447
  "src/server/server.config.ts"
816
1448
  ];
817
1449
  var processHandlersRegistered = false;
818
- async function startServer(config2) {
819
- loadEnvFiles();
820
- const finalConfig = await loadAndMergeConfig(config2);
1450
+ async function startServer(config) {
1451
+ loadEnv();
1452
+ const finalConfig = await loadAndMergeConfig(config);
821
1453
  const { host, port, debug } = finalConfig;
822
1454
  validateServerConfig(finalConfig);
823
1455
  if (!host || !port) {
@@ -835,6 +1467,8 @@ async function startServer(config2) {
835
1467
  const server = startHttpServer(app, host, port);
836
1468
  const timeouts = getTimeoutConfig(finalConfig.timeout);
837
1469
  applyServerTimeouts(server, timeouts);
1470
+ const fetchTimeouts = getFetchTimeoutConfig(finalConfig.fetchTimeout);
1471
+ applyGlobalFetchTimeouts(fetchTimeouts);
838
1472
  logServerTimeouts(timeouts);
839
1473
  printBanner({
840
1474
  mode: debug ? "Development" : "Production",
@@ -851,11 +1485,6 @@ async function startServer(config2) {
851
1485
  config: finalConfig,
852
1486
  close: async () => {
853
1487
  serverLogger.info("Manual server shutdown requested");
854
- if (shutdownState.isShuttingDown) {
855
- serverLogger.warn("Shutdown already in progress, ignoring manual close request");
856
- return;
857
- }
858
- shutdownState.isShuttingDown = true;
859
1488
  await shutdownServer();
860
1489
  }
861
1490
  };
@@ -875,7 +1504,7 @@ async function startServer(config2) {
875
1504
  throw error;
876
1505
  }
877
1506
  }
878
- async function loadAndMergeConfig(config2) {
1507
+ async function loadAndMergeConfig(config) {
879
1508
  const cwd = process.cwd();
880
1509
  let fileConfig = {};
881
1510
  let loadedConfigPath = null;
@@ -899,26 +1528,26 @@ async function loadAndMergeConfig(config2) {
899
1528
  }
900
1529
  return {
901
1530
  ...fileConfig,
902
- ...config2,
903
- port: config2?.port ?? fileConfig?.port ?? env.PORT,
904
- host: config2?.host ?? fileConfig?.host ?? env.HOST
1531
+ ...config,
1532
+ port: config?.port ?? fileConfig?.port ?? env.PORT,
1533
+ host: config?.host ?? fileConfig?.host ?? env.HOST
905
1534
  };
906
1535
  }
907
- function getInfrastructureConfig(config2) {
1536
+ function getInfrastructureConfig(config) {
908
1537
  return {
909
- database: config2.infrastructure?.database !== false,
910
- redis: config2.infrastructure?.redis !== false
1538
+ database: config.infrastructure?.database !== false,
1539
+ redis: config.infrastructure?.redis !== false
911
1540
  };
912
1541
  }
913
- async function initializeInfrastructure(config2) {
914
- if (config2.lifecycle?.beforeInfrastructure) {
1542
+ async function initializeInfrastructure(config) {
1543
+ if (config.lifecycle?.beforeInfrastructure) {
915
1544
  serverLogger.debug("Executing beforeInfrastructure hook...");
916
- await config2.lifecycle.beforeInfrastructure(config2);
1545
+ await config.lifecycle.beforeInfrastructure(config);
917
1546
  }
918
- const infraConfig = getInfrastructureConfig(config2);
1547
+ const infraConfig = getInfrastructureConfig(config);
919
1548
  if (infraConfig.database) {
920
1549
  serverLogger.debug("Initializing database...");
921
- await initDatabase(config2.database);
1550
+ await initDatabase(config.database);
922
1551
  } else {
923
1552
  serverLogger.debug("Database initialization disabled");
924
1553
  }
@@ -928,11 +1557,11 @@ async function initializeInfrastructure(config2) {
928
1557
  } else {
929
1558
  serverLogger.debug("Redis initialization disabled");
930
1559
  }
931
- if (config2.lifecycle?.afterInfrastructure) {
1560
+ if (config.lifecycle?.afterInfrastructure) {
932
1561
  serverLogger.debug("Executing afterInfrastructure hook...");
933
- await config2.lifecycle.afterInfrastructure();
1562
+ await config.lifecycle.afterInfrastructure();
934
1563
  }
935
- if (config2.jobs) {
1564
+ if (config.jobs) {
936
1565
  const dbUrl = env.DATABASE_URL;
937
1566
  if (!dbUrl) {
938
1567
  throw new Error(
@@ -942,10 +1571,24 @@ async function initializeInfrastructure(config2) {
942
1571
  serverLogger.debug("Initializing pg-boss...");
943
1572
  await initBoss({
944
1573
  connectionString: dbUrl,
945
- ...config2.jobsConfig
1574
+ ...config.jobsConfig
946
1575
  });
947
1576
  serverLogger.debug("Registering jobs...");
948
- await registerJobs(config2.jobs);
1577
+ await registerJobs(config.jobs);
1578
+ }
1579
+ if (config.workflows) {
1580
+ const infraConfig2 = getInfrastructureConfig(config);
1581
+ if (!infraConfig2.database) {
1582
+ throw new Error(
1583
+ "Workflows require database connection. Ensure database is enabled in infrastructure config."
1584
+ );
1585
+ }
1586
+ serverLogger.debug("Initializing workflow engine...");
1587
+ config.workflows._init(
1588
+ getDatabase(),
1589
+ config.workflowsConfig
1590
+ );
1591
+ serverLogger.info("Workflow engine initialized");
949
1592
  }
950
1593
  }
951
1594
  function startHttpServer(app, host, port) {
@@ -956,8 +1599,8 @@ function startHttpServer(app, host, port) {
956
1599
  hostname: host
957
1600
  });
958
1601
  }
959
- function logMiddlewareOrder(config2) {
960
- const middlewareOrder = buildMiddlewareOrder(config2);
1602
+ function logMiddlewareOrder(config) {
1603
+ const middlewareOrder = buildMiddlewareOrder(config);
961
1604
  serverLogger.debug("Middleware execution order", {
962
1605
  order: middlewareOrder
963
1606
  });
@@ -969,8 +1612,8 @@ function logServerTimeouts(timeouts) {
969
1612
  headers: `${timeouts.headers}ms`
970
1613
  });
971
1614
  }
972
- function logServerStarted(debug, host, port, config2, timeouts) {
973
- const startupConfig = buildStartupConfig(config2, timeouts);
1615
+ function logServerStarted(debug, host, port, config, timeouts) {
1616
+ const startupConfig = buildStartupConfig(config, timeouts);
974
1617
  serverLogger.info("Server started successfully", {
975
1618
  mode: debug ? "development" : "production",
976
1619
  host,
@@ -978,65 +1621,74 @@ function logServerStarted(debug, host, port, config2, timeouts) {
978
1621
  config: startupConfig
979
1622
  });
980
1623
  }
981
- function createShutdownHandler(server, config2, shutdownState) {
1624
+ function createShutdownHandler(server, config, shutdownState) {
982
1625
  return async () => {
983
1626
  if (shutdownState.isShuttingDown) {
984
1627
  serverLogger.debug("Shutdown already in progress for this instance, skipping");
985
1628
  return;
986
1629
  }
987
1630
  shutdownState.isShuttingDown = true;
988
- serverLogger.debug("Closing HTTP server...");
989
- let timeoutId;
990
- await Promise.race([
991
- new Promise((resolve2, reject) => {
992
- server.close((err) => {
993
- if (timeoutId) clearTimeout(timeoutId);
994
- if (err) {
995
- serverLogger.error("HTTP server close error", err);
996
- reject(err);
997
- } else {
998
- serverLogger.info("HTTP server closed");
999
- resolve2();
1000
- }
1001
- });
1002
- }),
1003
- new Promise((_, reject) => {
1004
- timeoutId = setTimeout(() => {
1005
- reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
1006
- }, TIMEOUTS.SERVER_CLOSE);
1007
- })
1008
- ]).catch((error) => {
1009
- if (timeoutId) clearTimeout(timeoutId);
1010
- serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
1011
- });
1012
- if (config2.jobs) {
1013
- serverLogger.debug("Stopping pg-boss...");
1631
+ const shutdownTimeout = getShutdownTimeout(config.shutdown);
1632
+ const shutdownManager = getShutdownManager();
1633
+ shutdownManager.beginShutdown();
1634
+ serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
1635
+ await closeHttpServer(server);
1636
+ if (config.jobs) {
1637
+ serverLogger.info("Phase 2: Stopping pg-boss...");
1014
1638
  try {
1015
1639
  await stopBoss();
1640
+ serverLogger.info("pg-boss stopped");
1016
1641
  } catch (error) {
1017
1642
  serverLogger.error("pg-boss stop failed", error);
1018
1643
  }
1019
1644
  }
1020
- if (config2.lifecycle?.beforeShutdown) {
1021
- serverLogger.debug("Executing beforeShutdown hook...");
1645
+ const drainTimeout = Math.floor(shutdownTimeout * 0.8);
1646
+ serverLogger.info(`Phase 3: Draining tracked operations (timeout: ${drainTimeout}ms)...`);
1647
+ await shutdownManager.execute(drainTimeout);
1648
+ if (config.lifecycle?.beforeShutdown) {
1649
+ serverLogger.info("Phase 4: Executing beforeShutdown lifecycle hook...");
1022
1650
  try {
1023
- await config2.lifecycle.beforeShutdown();
1651
+ await config.lifecycle.beforeShutdown();
1024
1652
  } catch (error) {
1025
- serverLogger.error("beforeShutdown hook failed", error);
1653
+ serverLogger.error("beforeShutdown lifecycle hook failed", error);
1026
1654
  }
1027
1655
  }
1028
- const infraConfig = getInfrastructureConfig(config2);
1656
+ serverLogger.info("Phase 5: Closing infrastructure...");
1657
+ const infraConfig = getInfrastructureConfig(config);
1029
1658
  if (infraConfig.database) {
1030
- serverLogger.debug("Closing database connections...");
1031
1659
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
1032
1660
  }
1033
1661
  if (infraConfig.redis) {
1034
- serverLogger.debug("Closing Redis connections...");
1035
1662
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
1036
1663
  }
1037
1664
  serverLogger.info("Server shutdown completed");
1038
1665
  };
1039
1666
  }
1667
+ async function closeHttpServer(server) {
1668
+ let timeoutId;
1669
+ await Promise.race([
1670
+ new Promise((resolve2, reject) => {
1671
+ server.close((err) => {
1672
+ if (timeoutId) clearTimeout(timeoutId);
1673
+ if (err) {
1674
+ serverLogger.error("HTTP server close error", err);
1675
+ reject(err);
1676
+ } else {
1677
+ serverLogger.info("HTTP server closed");
1678
+ resolve2();
1679
+ }
1680
+ });
1681
+ }),
1682
+ new Promise((_, reject) => {
1683
+ timeoutId = setTimeout(() => {
1684
+ reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
1685
+ }, TIMEOUTS.SERVER_CLOSE);
1686
+ })
1687
+ ]).catch((error) => {
1688
+ if (timeoutId) clearTimeout(timeoutId);
1689
+ serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
1690
+ });
1691
+ }
1040
1692
  async function closeInfrastructure(closeFn, name, timeout) {
1041
1693
  let timeoutId;
1042
1694
  try {
@@ -1056,14 +1708,14 @@ async function closeInfrastructure(closeFn, name, timeout) {
1056
1708
  serverLogger.error(`${name} close failed or timed out`, error);
1057
1709
  }
1058
1710
  }
1059
- function createGracefulShutdown(shutdownServer, config2, shutdownState) {
1711
+ function createGracefulShutdown(shutdownServer, config, shutdownState) {
1060
1712
  return async (signal) => {
1061
1713
  if (shutdownState.isShuttingDown) {
1062
1714
  serverLogger.warn(`${signal} received but shutdown already in progress, ignoring`);
1063
1715
  return;
1064
1716
  }
1065
1717
  serverLogger.info(`${signal} received, starting graceful shutdown...`);
1066
- const shutdownTimeout = getShutdownTimeout(config2.shutdown);
1718
+ const shutdownTimeout = getShutdownTimeout(config.shutdown);
1067
1719
  let timeoutId;
1068
1720
  try {
1069
1721
  await Promise.race([
@@ -1091,31 +1743,8 @@ function createGracefulShutdown(shutdownServer, config2, shutdownState) {
1091
1743
  }
1092
1744
  };
1093
1745
  }
1094
- function handleProcessError(errorType, shutdown) {
1095
- const isProduction = env.NODE_ENV === "production";
1096
- const isDevelopment = env.NODE_ENV === "development";
1097
- if (isDevelopment || process.env.WATCH_MODE === "true") {
1098
- serverLogger.info("Exiting immediately for clean restart");
1099
- process.exit(1);
1100
- } else if (isProduction) {
1101
- serverLogger.info(`Attempting graceful shutdown after ${errorType}`);
1102
- const forceExitTimer = setTimeout(() => {
1103
- serverLogger.error(`Forced exit after ${TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN}ms - graceful shutdown did not complete`);
1104
- process.exit(1);
1105
- }, TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN);
1106
- shutdown(errorType).then(() => {
1107
- clearTimeout(forceExitTimer);
1108
- serverLogger.info("Graceful shutdown completed, exiting");
1109
- process.exit(0);
1110
- }).catch((shutdownError) => {
1111
- clearTimeout(forceExitTimer);
1112
- serverLogger.error("Graceful shutdown failed", shutdownError);
1113
- process.exit(1);
1114
- });
1115
- } else {
1116
- serverLogger.info("Exiting immediately");
1117
- process.exit(1);
1118
- }
1746
+ function handleProcessError(errorType) {
1747
+ serverLogger.warn(`${errorType} occurred - server continues running. Check logs above for details.`);
1119
1748
  }
1120
1749
  function registerProcessHandlers(shutdown) {
1121
1750
  if (processHandlersRegistered) {
@@ -1150,7 +1779,7 @@ function registerProcessHandlers(shutdown) {
1150
1779
  } else {
1151
1780
  serverLogger.error("Uncaught exception", error);
1152
1781
  }
1153
- handleProcessError("UNCAUGHT_EXCEPTION", shutdown);
1782
+ handleProcessError("UNCAUGHT_EXCEPTION");
1154
1783
  });
1155
1784
  process.on("unhandledRejection", (reason, promise) => {
1156
1785
  if (reason instanceof Error) {
@@ -1168,20 +1797,21 @@ function registerProcessHandlers(shutdown) {
1168
1797
  promise
1169
1798
  });
1170
1799
  }
1171
- handleProcessError("UNHANDLED_REJECTION", shutdown);
1800
+ handleProcessError("UNHANDLED_REJECTION");
1172
1801
  });
1173
1802
  serverLogger.debug("Process-level shutdown handlers registered successfully");
1174
1803
  }
1175
- async function cleanupOnFailure(config2) {
1804
+ async function cleanupOnFailure(config) {
1176
1805
  try {
1177
1806
  serverLogger.debug("Cleaning up after initialization failure...");
1178
- const infraConfig = getInfrastructureConfig(config2);
1807
+ const infraConfig = getInfrastructureConfig(config);
1179
1808
  if (infraConfig.database) {
1180
1809
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
1181
1810
  }
1182
1811
  if (infraConfig.redis) {
1183
1812
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
1184
1813
  }
1814
+ resetShutdownManager();
1185
1815
  serverLogger.debug("Cleanup completed");
1186
1816
  } catch (cleanupError) {
1187
1817
  serverLogger.error("Cleanup failed", cleanupError);
@@ -1307,10 +1937,45 @@ var ServerConfigBuilder = class {
1307
1937
  * .build();
1308
1938
  * ```
1309
1939
  */
1310
- jobs(router, config2) {
1940
+ jobs(router, config) {
1311
1941
  this.config.jobs = router;
1312
- if (config2) {
1313
- this.config.jobsConfig = config2;
1942
+ if (config) {
1943
+ this.config.jobsConfig = config;
1944
+ }
1945
+ return this;
1946
+ }
1947
+ /**
1948
+ * Register event router for SSE (Server-Sent Events)
1949
+ *
1950
+ * Enables real-time event streaming to frontend clients.
1951
+ * Events defined with defineEvent() can be subscribed by:
1952
+ * - Backend: .subscribe() for internal handlers
1953
+ * - Jobs: .on(event) for background processing
1954
+ * - Frontend: SSE stream for real-time updates
1955
+ *
1956
+ * @example
1957
+ * ```typescript
1958
+ * import { defineEvent, defineEventRouter } from '@spfn/core/event';
1959
+ *
1960
+ * const userCreated = defineEvent('user.created', Type.Object({
1961
+ * userId: Type.String(),
1962
+ * }));
1963
+ *
1964
+ * const eventRouter = defineEventRouter({ userCreated });
1965
+ *
1966
+ * export default defineServerConfig()
1967
+ * .routes(appRouter)
1968
+ * .events(eventRouter) // → GET /events/stream
1969
+ * .build();
1970
+ *
1971
+ * // Custom path
1972
+ * .events(eventRouter, { path: '/sse' })
1973
+ * ```
1974
+ */
1975
+ events(router, config) {
1976
+ this.config.events = router;
1977
+ if (config) {
1978
+ this.config.eventsConfig = config;
1314
1979
  }
1315
1980
  return this;
1316
1981
  }
@@ -1356,6 +2021,33 @@ var ServerConfigBuilder = class {
1356
2021
  this.config.infrastructure = infrastructure;
1357
2022
  return this;
1358
2023
  }
2024
+ /**
2025
+ * Register workflow router for workflow orchestration
2026
+ *
2027
+ * Automatically initializes the workflow engine after database is ready.
2028
+ *
2029
+ * @example
2030
+ * ```typescript
2031
+ * import { defineWorkflowRouter } from '@spfn/workflow';
2032
+ *
2033
+ * const workflowRouter = defineWorkflowRouter([
2034
+ * provisionTenant,
2035
+ * deprovisionTenant,
2036
+ * ]);
2037
+ *
2038
+ * export default defineServerConfig()
2039
+ * .routes(appRouter)
2040
+ * .workflows(workflowRouter)
2041
+ * .build();
2042
+ * ```
2043
+ */
2044
+ workflows(router, config) {
2045
+ this.config.workflows = router;
2046
+ if (config) {
2047
+ this.config.workflowsConfig = config;
2048
+ }
2049
+ return this;
2050
+ }
1359
2051
  /**
1360
2052
  * Configure lifecycle hooks
1361
2053
  * Can be called multiple times - hooks will be executed in registration order
@@ -1403,6 +2095,6 @@ function defineServerConfig() {
1403
2095
  return new ServerConfigBuilder();
1404
2096
  }
1405
2097
 
1406
- export { createServer, defineServerConfig, loadEnvFiles, startServer };
2098
+ export { createServer, defineServerConfig, getShutdownManager, loadEnv, loadEnvFiles, startServer };
1407
2099
  //# sourceMappingURL=index.js.map
1408
2100
  //# sourceMappingURL=index.js.map