@spfn/core 0.2.0-beta.5 → 0.2.0-beta.50

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 (64) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +181 -1281
  3. package/dist/{boss-BO8ty33K.d.ts → boss-Cxqc-Oiw.d.ts} +37 -7
  4. package/dist/cache/index.js +32 -29
  5. package/dist/cache/index.js.map +1 -1
  6. package/dist/codegen/index.d.ts +55 -8
  7. package/dist/codegen/index.js +179 -5
  8. package/dist/codegen/index.js.map +1 -1
  9. package/dist/config/index.d.ts +168 -6
  10. package/dist/config/index.js +29 -5
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/db/index.d.ts +218 -4
  13. package/dist/db/index.js +351 -57
  14. package/dist/db/index.js.map +1 -1
  15. package/dist/env/index.d.ts +2 -1
  16. package/dist/env/index.js +2 -1
  17. package/dist/env/index.js.map +1 -1
  18. package/dist/env/loader.d.ts +26 -19
  19. package/dist/env/loader.js +32 -25
  20. package/dist/env/loader.js.map +1 -1
  21. package/dist/errors/index.js.map +1 -1
  22. package/dist/event/index.d.ts +33 -3
  23. package/dist/event/index.js +17 -1
  24. package/dist/event/index.js.map +1 -1
  25. package/dist/event/sse/client.d.ts +42 -3
  26. package/dist/event/sse/client.js +128 -45
  27. package/dist/event/sse/client.js.map +1 -1
  28. package/dist/event/sse/index.d.ts +12 -5
  29. package/dist/event/sse/index.js +188 -20
  30. package/dist/event/sse/index.js.map +1 -1
  31. package/dist/event/ws/client.d.ts +59 -0
  32. package/dist/event/ws/client.js +273 -0
  33. package/dist/event/ws/client.js.map +1 -0
  34. package/dist/event/ws/index.d.ts +94 -0
  35. package/dist/event/ws/index.js +213 -0
  36. package/dist/event/ws/index.js.map +1 -0
  37. package/dist/job/index.d.ts +23 -8
  38. package/dist/job/index.js +154 -44
  39. package/dist/job/index.js.map +1 -1
  40. package/dist/logger/index.d.ts +5 -0
  41. package/dist/logger/index.js +14 -0
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.d.ts +23 -1
  44. package/dist/middleware/index.js +58 -5
  45. package/dist/middleware/index.js.map +1 -1
  46. package/dist/nextjs/index.d.ts +2 -2
  47. package/dist/nextjs/index.js +77 -31
  48. package/dist/nextjs/index.js.map +1 -1
  49. package/dist/nextjs/server.d.ts +44 -23
  50. package/dist/nextjs/server.js +83 -65
  51. package/dist/nextjs/server.js.map +1 -1
  52. package/dist/route/index.d.ts +158 -4
  53. package/dist/route/index.js +238 -22
  54. package/dist/route/index.js.map +1 -1
  55. package/dist/server/index.d.ts +308 -17
  56. package/dist/server/index.js +1128 -261
  57. package/dist/server/index.js.map +1 -1
  58. package/dist/{router-Di7ENoah.d.ts → token-manager-CyG7la3p.d.ts} +116 -1
  59. package/dist/{types-D_N_U-Py.d.ts → types-7Mhoxnnt.d.ts} +21 -1
  60. package/dist/types-C1jMLGwK.d.ts +257 -0
  61. package/dist/types-Cfj--lfr.d.ts +151 -0
  62. package/docs/file-upload.md +717 -0
  63. package/package.json +18 -5
  64. 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,95 @@ 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
+ }
346
+ return files;
347
+ }
348
+ function parseEnvFile(filePath) {
349
+ if (!existsSync(filePath)) {
350
+ return null;
351
+ }
352
+ return parse(readFileSync(filePath, "utf-8"));
353
+ }
354
+ function loadEnv(options = {}) {
355
+ const {
356
+ cwd = process.cwd(),
357
+ nodeEnv = process.env.NODE_ENV || "local",
358
+ server = true,
359
+ debug = false,
360
+ override = false
361
+ } = options;
362
+ const envFiles = getEnvFiles(nodeEnv, server);
363
+ const loadedFiles = [];
364
+ const existingKeys = new Set(Object.keys(process.env));
365
+ const merged = {};
366
+ for (const fileName of envFiles) {
367
+ const filePath = resolve(cwd, fileName);
368
+ const parsed = parseEnvFile(filePath);
369
+ if (parsed === null) {
370
+ continue;
371
+ }
372
+ loadedFiles.push(fileName);
373
+ Object.assign(merged, parsed);
374
+ }
375
+ const loadedKeys = [];
376
+ for (const [key, value] of Object.entries(merged)) {
377
+ if (!override && existingKeys.has(key)) {
378
+ continue;
332
379
  }
380
+ process.env[key] = value;
381
+ loadedKeys.push(key);
382
+ }
383
+ if (debug && loadedFiles.length > 0) {
384
+ envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
385
+ envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
333
386
  }
387
+ return { loadedFiles, loadedKeys };
388
+ }
389
+
390
+ // src/server/dotenv-loader.ts
391
+ var warned = false;
392
+ function loadEnvFiles() {
393
+ if (!warned) {
394
+ warned = true;
395
+ console.warn(
396
+ '[SPFN] loadEnvFiles() is deprecated. Use loadEnv() from "@spfn/core/env/loader" instead.'
397
+ );
398
+ }
399
+ loadEnv();
334
400
  }
335
401
  var sseLogger = logger.child("@spfn/core:sse");
336
- function createSSEHandler(router, config2 = {}) {
402
+ function createSSEHandler(router, config = {}, tokenManager) {
337
403
  const {
338
- pingInterval = 3e4
339
- // headers: customHeaders = {}, // Reserved for future use
340
- } = config2;
404
+ pingInterval = 3e4,
405
+ auth: authConfig
406
+ } = config;
341
407
  return async (c) => {
342
- const eventsParam = c.req.query("events");
343
- if (!eventsParam) {
408
+ const subject = await authenticateToken(c, tokenManager);
409
+ if (subject === false) {
410
+ return c.json({ error: "Missing token parameter" }, 401);
411
+ }
412
+ if (subject === null) {
413
+ return c.json({ error: "Invalid or expired token" }, 401);
414
+ }
415
+ if (subject) {
416
+ c.set("sseSubject", subject);
417
+ }
418
+ const requestedEvents = parseRequestedEvents(c);
419
+ if (!requestedEvents) {
344
420
  return c.json({ error: "Missing events parameter" }, 400);
345
421
  }
346
- const requestedEvents = eventsParam.split(",").map((e) => e.trim());
347
422
  const validEventNames = router.eventNames;
348
423
  const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
349
424
  if (invalidEvents.length > 0) {
@@ -353,19 +428,42 @@ function createSSEHandler(router, config2 = {}) {
353
428
  validEvents: validEventNames
354
429
  }, 400);
355
430
  }
431
+ const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
432
+ if (allowedEvents === null) {
433
+ return c.json({ error: "Not authorized for any requested events" }, 403);
434
+ }
356
435
  sseLogger.debug("SSE connection requested", {
357
- events: requestedEvents,
436
+ events: allowedEvents,
437
+ subject: subject || void 0,
358
438
  clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
359
439
  });
440
+ c.header("X-Accel-Buffering", "no");
360
441
  return streamSSE(c, async (stream) => {
361
442
  const unsubscribes = [];
362
443
  let messageId = 0;
363
- for (const eventName of requestedEvents) {
444
+ let connectionDead = false;
445
+ let pingTimer;
446
+ const cleanup = () => {
447
+ if (connectionDead) return;
448
+ connectionDead = true;
449
+ clearInterval(pingTimer);
450
+ unsubscribes.forEach((fn) => fn());
451
+ sseLogger.info("SSE dead connection cleaned up", {
452
+ events: allowedEvents
453
+ });
454
+ };
455
+ for (const eventName of allowedEvents) {
364
456
  const eventDef = router.events[eventName];
365
457
  if (!eventDef) {
366
458
  continue;
367
459
  }
368
460
  const unsubscribe = eventDef.subscribe((payload) => {
461
+ if (connectionDead) return;
462
+ if (subject && authConfig?.filter?.[eventName]) {
463
+ if (!authConfig.filter[eventName](subject, payload)) {
464
+ return;
465
+ }
466
+ }
369
467
  messageId++;
370
468
  const message = {
371
469
  event: eventName,
@@ -375,40 +473,49 @@ function createSSEHandler(router, config2 = {}) {
375
473
  event: eventName,
376
474
  messageId
377
475
  });
378
- void stream.writeSSE({
476
+ stream.writeSSE({
379
477
  id: String(messageId),
380
478
  event: eventName,
381
479
  data: JSON.stringify(message)
480
+ }).catch((err) => {
481
+ sseLogger.warn("SSE write failed", {
482
+ event: eventName,
483
+ messageId,
484
+ error: err.message
485
+ });
486
+ cleanup();
382
487
  });
383
488
  });
384
489
  unsubscribes.push(unsubscribe);
385
490
  }
386
491
  sseLogger.info("SSE connection established", {
387
- events: requestedEvents,
492
+ events: allowedEvents,
388
493
  subscriptionCount: unsubscribes.length
389
494
  });
390
495
  await stream.writeSSE({
391
496
  event: "connected",
392
497
  data: JSON.stringify({
393
- subscribedEvents: requestedEvents,
498
+ subscribedEvents: allowedEvents,
394
499
  timestamp: Date.now()
395
500
  })
396
501
  });
397
- const pingTimer = setInterval(() => {
398
- void stream.writeSSE({
502
+ pingTimer = setInterval(() => {
503
+ if (connectionDead) return;
504
+ stream.writeSSE({
399
505
  event: "ping",
400
506
  data: JSON.stringify({ timestamp: Date.now() })
507
+ }).catch((err) => {
508
+ sseLogger.warn("SSE ping failed", {
509
+ error: err.message
510
+ });
511
+ cleanup();
401
512
  });
402
513
  }, pingInterval);
403
514
  const abortSignal = c.req.raw.signal;
404
- while (!abortSignal.aborted) {
515
+ while (!abortSignal.aborted && !connectionDead) {
405
516
  await stream.sleep(pingInterval);
406
517
  }
407
- clearInterval(pingTimer);
408
- unsubscribes.forEach((fn) => fn());
409
- sseLogger.info("SSE connection closed", {
410
- events: requestedEvents
411
- });
518
+ cleanup();
412
519
  }, async (err) => {
413
520
  sseLogger.error("SSE stream error", {
414
521
  error: err.message
@@ -416,8 +523,357 @@ function createSSEHandler(router, config2 = {}) {
416
523
  });
417
524
  };
418
525
  }
526
+ async function authenticateToken(c, tokenManager) {
527
+ if (!tokenManager) {
528
+ return void 0;
529
+ }
530
+ const token = c.req.query("token");
531
+ if (!token) {
532
+ return false;
533
+ }
534
+ return await tokenManager.verify(token);
535
+ }
536
+ function parseRequestedEvents(c) {
537
+ const eventsParam = c.req.query("events");
538
+ if (!eventsParam) {
539
+ return null;
540
+ }
541
+ return eventsParam.split(",").map((e) => e.trim());
542
+ }
543
+ async function authorizeEvents(subject, requestedEvents, authConfig) {
544
+ if (!subject || !authConfig?.authorize) {
545
+ return requestedEvents;
546
+ }
547
+ const allowed = await authConfig.authorize(subject, requestedEvents);
548
+ if (allowed.length === 0) {
549
+ return null;
550
+ }
551
+ return allowed;
552
+ }
553
+ var InMemoryTokenStore = class {
554
+ tokens = /* @__PURE__ */ new Map();
555
+ async set(token, data) {
556
+ this.tokens.set(token, data);
557
+ }
558
+ async consume(token) {
559
+ const data = this.tokens.get(token);
560
+ if (!data) {
561
+ return null;
562
+ }
563
+ this.tokens.delete(token);
564
+ return data;
565
+ }
566
+ async cleanup() {
567
+ const now = Date.now();
568
+ for (const [token, data] of this.tokens) {
569
+ if (data.expiresAt <= now) {
570
+ this.tokens.delete(token);
571
+ }
572
+ }
573
+ }
574
+ };
575
+ var CacheTokenStore = class {
576
+ constructor(cache) {
577
+ this.cache = cache;
578
+ }
579
+ prefix = "sse:token:";
580
+ async set(token, data) {
581
+ const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
582
+ await this.cache.set(
583
+ this.prefix + token,
584
+ JSON.stringify(data),
585
+ "EX",
586
+ ttlSeconds
587
+ );
588
+ }
589
+ async consume(token) {
590
+ const key = this.prefix + token;
591
+ let raw = null;
592
+ if (this.cache.getdel) {
593
+ raw = await this.cache.getdel(key);
594
+ } else {
595
+ raw = await this.cache.get(key);
596
+ if (raw) {
597
+ await this.cache.del(key);
598
+ }
599
+ }
600
+ if (!raw) {
601
+ return null;
602
+ }
603
+ return JSON.parse(raw);
604
+ }
605
+ async cleanup() {
606
+ }
607
+ };
608
+ var SSETokenManager = class {
609
+ store;
610
+ ttl;
611
+ cleanupTimer = null;
612
+ constructor(config) {
613
+ this.ttl = config?.ttl ?? 3e4;
614
+ this.store = config?.store ?? new InMemoryTokenStore();
615
+ const cleanupInterval = config?.cleanupInterval ?? 6e4;
616
+ this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
617
+ this.cleanupTimer.unref();
618
+ }
619
+ /**
620
+ * Issue a new one-time-use token for the given subject
621
+ */
622
+ async issue(subject) {
623
+ const token = randomBytes(32).toString("hex");
624
+ await this.store.set(token, {
625
+ token,
626
+ subject,
627
+ expiresAt: Date.now() + this.ttl
628
+ });
629
+ return token;
630
+ }
631
+ /**
632
+ * Verify and consume a token
633
+ * @returns subject string if valid, null if invalid/expired/already consumed
634
+ */
635
+ async verify(token) {
636
+ const data = await this.store.consume(token);
637
+ if (!data || data.expiresAt <= Date.now()) {
638
+ return null;
639
+ }
640
+ return data.subject;
641
+ }
642
+ /**
643
+ * Cleanup timer and resources
644
+ */
645
+ destroy() {
646
+ if (this.cleanupTimer) {
647
+ clearInterval(this.cleanupTimer);
648
+ this.cleanupTimer = null;
649
+ }
650
+ }
651
+ };
652
+ var serverLogger = logger.child("@spfn/core:server");
653
+
654
+ // src/server/shutdown-manager.ts
655
+ var DEFAULT_HOOK_TIMEOUT = 1e4;
656
+ var DEFAULT_HOOK_ORDER = 100;
657
+ var DRAIN_POLL_INTERVAL = 500;
658
+ var ShutdownManager = class {
659
+ state = "running";
660
+ hooks = [];
661
+ operations = /* @__PURE__ */ new Map();
662
+ operationCounter = 0;
663
+ /**
664
+ * Register a shutdown hook
665
+ *
666
+ * Hooks run in order during shutdown, after all tracked operations drain.
667
+ * Each hook has its own timeout — failure does not block subsequent hooks.
668
+ *
669
+ * @example
670
+ * shutdown.onShutdown('ai-service', async () => {
671
+ * await aiService.cancelPending();
672
+ * }, { timeout: 30000, order: 10 });
673
+ */
674
+ onShutdown(name, handler, options) {
675
+ this.hooks.push({
676
+ name,
677
+ handler,
678
+ timeout: options?.timeout ?? DEFAULT_HOOK_TIMEOUT,
679
+ order: options?.order ?? DEFAULT_HOOK_ORDER
680
+ });
681
+ this.hooks.sort((a, b) => a.order - b.order);
682
+ serverLogger.debug(`Shutdown hook registered: ${name}`, {
683
+ order: options?.order ?? DEFAULT_HOOK_ORDER,
684
+ timeout: `${options?.timeout ?? DEFAULT_HOOK_TIMEOUT}ms`
685
+ });
686
+ }
687
+ /**
688
+ * Track a long-running operation
689
+ *
690
+ * During shutdown (drain phase), the process waits for ALL tracked
691
+ * operations to complete before proceeding with cleanup.
692
+ *
693
+ * If shutdown has already started, the operation is rejected immediately.
694
+ *
695
+ * @returns The operation result (pass-through)
696
+ *
697
+ * @example
698
+ * const result = await shutdown.trackOperation(
699
+ * 'ai-generate',
700
+ * aiService.generate(prompt)
701
+ * );
702
+ */
703
+ async trackOperation(name, operation) {
704
+ if (this.state !== "running") {
705
+ throw new Error(`Cannot start operation '${name}': server is shutting down`);
706
+ }
707
+ const id = `${name}-${++this.operationCounter}`;
708
+ this.operations.set(id, {
709
+ name,
710
+ startedAt: Date.now()
711
+ });
712
+ serverLogger.debug(`Operation tracked: ${id}`, {
713
+ activeOperations: this.operations.size
714
+ });
715
+ try {
716
+ return await operation;
717
+ } finally {
718
+ this.operations.delete(id);
719
+ serverLogger.debug(`Operation completed: ${id}`, {
720
+ activeOperations: this.operations.size
721
+ });
722
+ }
723
+ }
724
+ /**
725
+ * Whether the server is shutting down
726
+ *
727
+ * Use this to reject new work early (e.g., return 503 in route handlers).
728
+ */
729
+ isShuttingDown() {
730
+ return this.state !== "running";
731
+ }
732
+ /**
733
+ * Number of currently active tracked operations
734
+ */
735
+ getActiveOperationCount() {
736
+ return this.operations.size;
737
+ }
738
+ /**
739
+ * Mark shutdown as started immediately
740
+ *
741
+ * Call this at the very beginning of the shutdown sequence so that:
742
+ * - Health check returns 503 right away
743
+ * - trackOperation() rejects new work
744
+ * - isShuttingDown() returns true
745
+ */
746
+ beginShutdown() {
747
+ if (this.state !== "running") {
748
+ return;
749
+ }
750
+ this.state = "draining";
751
+ serverLogger.info("Shutdown manager: state set to draining");
752
+ }
753
+ /**
754
+ * Execute the full shutdown sequence
755
+ *
756
+ * 1. State → draining (reject new operations)
757
+ * 2. Wait for all tracked operations to complete (drain)
758
+ * 3. Run shutdown hooks in order
759
+ * 4. State → closed
760
+ *
761
+ * @param drainTimeout - Max time to wait for operations to drain (ms)
762
+ */
763
+ async execute(drainTimeout) {
764
+ if (this.state === "closed") {
765
+ serverLogger.warn("ShutdownManager.execute() called but already closed");
766
+ return;
767
+ }
768
+ this.state = "draining";
769
+ serverLogger.info("Shutdown manager: draining started", {
770
+ activeOperations: this.operations.size,
771
+ registeredHooks: this.hooks.length,
772
+ drainTimeout: `${drainTimeout}ms`
773
+ });
774
+ await this.drain(drainTimeout);
775
+ await this.executeHooks();
776
+ this.state = "closed";
777
+ serverLogger.info("Shutdown manager: all hooks executed");
778
+ }
779
+ // ========================================================================
780
+ // Private
781
+ // ========================================================================
782
+ /**
783
+ * Wait for all tracked operations to complete, up to drainTimeout
784
+ */
785
+ async drain(drainTimeout) {
786
+ if (this.operations.size === 0) {
787
+ serverLogger.info("Shutdown manager: no active operations, drain skipped");
788
+ return;
789
+ }
790
+ serverLogger.info(`Shutdown manager: waiting for ${this.operations.size} operations to drain...`);
791
+ const deadline = Date.now() + drainTimeout;
792
+ while (this.operations.size > 0 && Date.now() < deadline) {
793
+ const remaining = deadline - Date.now();
794
+ const ops = Array.from(this.operations.values()).map((op) => ({
795
+ name: op.name,
796
+ elapsed: `${Math.round((Date.now() - op.startedAt) / 1e3)}s`
797
+ }));
798
+ serverLogger.info("Shutdown manager: drain in progress", {
799
+ activeOperations: this.operations.size,
800
+ remainingTimeout: `${Math.round(remaining / 1e3)}s`,
801
+ operations: ops
802
+ });
803
+ await sleep(Math.min(DRAIN_POLL_INTERVAL, remaining));
804
+ }
805
+ if (this.operations.size > 0) {
806
+ const abandoned = Array.from(this.operations.values()).map((op) => op.name);
807
+ serverLogger.warn("Shutdown manager: drain timeout \u2014 abandoning operations", {
808
+ abandoned
809
+ });
810
+ } else {
811
+ serverLogger.info("Shutdown manager: all operations drained successfully");
812
+ }
813
+ }
814
+ /**
815
+ * Execute registered shutdown hooks in order
816
+ */
817
+ async executeHooks() {
818
+ if (this.hooks.length === 0) {
819
+ return;
820
+ }
821
+ serverLogger.info(`Shutdown manager: executing ${this.hooks.length} hooks...`);
822
+ for (const hook of this.hooks) {
823
+ serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
824
+ try {
825
+ await withTimeout(
826
+ hook.handler(),
827
+ hook.timeout,
828
+ `Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
829
+ );
830
+ serverLogger.info(`Shutdown hook [${hook.name}] completed`);
831
+ } catch (error) {
832
+ serverLogger.error(
833
+ `Shutdown hook [${hook.name}] failed`,
834
+ error
835
+ );
836
+ }
837
+ }
838
+ }
839
+ };
840
+ var instance = null;
841
+ function getShutdownManager() {
842
+ if (!instance) {
843
+ instance = new ShutdownManager();
844
+ }
845
+ return instance;
846
+ }
847
+ function resetShutdownManager() {
848
+ instance = null;
849
+ }
850
+ function sleep(ms) {
851
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
852
+ }
853
+ async function withTimeout(promise, timeout, message) {
854
+ let timeoutId;
855
+ return Promise.race([
856
+ promise.finally(() => {
857
+ if (timeoutId) clearTimeout(timeoutId);
858
+ }),
859
+ new Promise((_, reject) => {
860
+ timeoutId = setTimeout(() => {
861
+ reject(new Error(message));
862
+ }, timeout);
863
+ })
864
+ ]);
865
+ }
866
+
867
+ // src/server/helpers.ts
419
868
  function createHealthCheckHandler(detailed) {
420
869
  return async (c) => {
870
+ const shutdownManager = getShutdownManager();
871
+ if (shutdownManager.isShuttingDown()) {
872
+ return c.json({
873
+ status: "shutting_down",
874
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
875
+ }, 503);
876
+ }
421
877
  const response = {
422
878
  status: "ok",
423
879
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -474,34 +930,49 @@ function applyServerTimeouts(server, timeouts) {
474
930
  server.headersTimeout = timeouts.headers;
475
931
  }
476
932
  }
477
- function getTimeoutConfig(config2) {
933
+ function getTimeoutConfig(config) {
934
+ return {
935
+ request: config?.request ?? env.SERVER_TIMEOUT,
936
+ keepAlive: config?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
937
+ headers: config?.headers ?? env.SERVER_HEADERS_TIMEOUT
938
+ };
939
+ }
940
+ function getShutdownTimeout(config) {
941
+ return config?.timeout ?? env.SHUTDOWN_TIMEOUT;
942
+ }
943
+ function getFetchTimeoutConfig(config) {
478
944
  return {
479
- request: config2?.request ?? env.SERVER_TIMEOUT,
480
- keepAlive: config2?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
481
- headers: config2?.headers ?? env.SERVER_HEADERS_TIMEOUT
945
+ connect: config?.connect ?? env.FETCH_CONNECT_TIMEOUT,
946
+ headers: config?.headers ?? env.FETCH_HEADERS_TIMEOUT,
947
+ body: config?.body ?? env.FETCH_BODY_TIMEOUT
482
948
  };
483
949
  }
484
- function getShutdownTimeout(config2) {
485
- return config2?.timeout ?? env.SHUTDOWN_TIMEOUT;
950
+ function applyGlobalFetchTimeouts(timeouts) {
951
+ const agent = new Agent({
952
+ connect: { timeout: timeouts.connect },
953
+ headersTimeout: timeouts.headers,
954
+ bodyTimeout: timeouts.body
955
+ });
956
+ setGlobalDispatcher(agent);
486
957
  }
487
- function buildMiddlewareOrder(config2) {
958
+ function buildMiddlewareOrder(config) {
488
959
  const order = [];
489
- const middlewareConfig = config2.middleware ?? {};
960
+ const middlewareConfig = config.middleware ?? {};
490
961
  const enableLogger = middlewareConfig.logger !== false;
491
962
  const enableCors = middlewareConfig.cors !== false;
492
963
  const enableErrorHandler = middlewareConfig.errorHandler !== false;
493
964
  if (enableLogger) order.push("RequestLogger");
494
965
  if (enableCors) order.push("CORS");
495
- config2.use?.forEach((_, i) => order.push(`Custom[${i}]`));
496
- if (config2.beforeRoutes) order.push("beforeRoutes hook");
966
+ config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
967
+ if (config.beforeRoutes) order.push("beforeRoutes hook");
497
968
  order.push("Routes");
498
- if (config2.afterRoutes) order.push("afterRoutes hook");
969
+ if (config.afterRoutes) order.push("afterRoutes hook");
499
970
  if (enableErrorHandler) order.push("ErrorHandler");
500
971
  return order;
501
972
  }
502
- function buildStartupConfig(config2, timeouts) {
503
- const middlewareConfig = config2.middleware ?? {};
504
- const healthCheckConfig = config2.healthCheck ?? {};
973
+ function buildStartupConfig(config, timeouts) {
974
+ const middlewareConfig = config.middleware ?? {};
975
+ const healthCheckConfig = config.healthCheck ?? {};
505
976
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
506
977
  const healthCheckPath = healthCheckConfig.path ?? "/health";
507
978
  const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
@@ -510,7 +981,7 @@ function buildStartupConfig(config2, timeouts) {
510
981
  logger: middlewareConfig.logger !== false,
511
982
  cors: middlewareConfig.cors !== false,
512
983
  errorHandler: middlewareConfig.errorHandler !== false,
513
- custom: config2.use?.length ?? 0
984
+ custom: config.use?.length ?? 0
514
985
  },
515
986
  healthCheck: healthCheckEnabled ? {
516
987
  enabled: true,
@@ -518,8 +989,8 @@ function buildStartupConfig(config2, timeouts) {
518
989
  detailed: healthCheckDetailed
519
990
  } : { enabled: false },
520
991
  hooks: {
521
- beforeRoutes: !!config2.beforeRoutes,
522
- afterRoutes: !!config2.afterRoutes
992
+ beforeRoutes: !!config.beforeRoutes,
993
+ afterRoutes: !!config.afterRoutes
523
994
  },
524
995
  timeout: {
525
996
  request: `${timeouts.request}ms`,
@@ -527,23 +998,22 @@ function buildStartupConfig(config2, timeouts) {
527
998
  headers: `${timeouts.headers}ms`
528
999
  },
529
1000
  shutdown: {
530
- timeout: `${config2.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
1001
+ timeout: `${config.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
531
1002
  }
532
1003
  };
533
1004
  }
534
- var serverLogger = logger.child("@spfn/core:server");
535
1005
 
536
1006
  // src/server/create-server.ts
537
- async function createServer(config2) {
1007
+ async function createServer(config) {
538
1008
  const cwd = process.cwd();
539
1009
  const appPath = join(cwd, "src", "server", "app.ts");
540
1010
  const appJsPath = join(cwd, "src", "server", "app");
541
1011
  if (existsSync(appPath) || existsSync(appJsPath)) {
542
- return await loadCustomApp(appPath, appJsPath, config2);
1012
+ return await loadCustomApp(appPath, appJsPath, config);
543
1013
  }
544
- return await createAutoConfiguredApp(config2);
1014
+ return await createAutoConfiguredApp(config);
545
1015
  }
546
- async function loadCustomApp(appPath, appJsPath, config2) {
1016
+ async function loadCustomApp(appPath, appJsPath, config) {
547
1017
  const actualPath = existsSync(appPath) ? appPath : appJsPath;
548
1018
  const appModule = await import(actualPath);
549
1019
  const appFactory = appModule.default;
@@ -551,15 +1021,15 @@ async function loadCustomApp(appPath, appJsPath, config2) {
551
1021
  throw new Error("app.ts must export a default function that returns a Hono app");
552
1022
  }
553
1023
  const app = await appFactory();
554
- if (config2?.routes) {
555
- const routes = registerRoutes(app, config2.routes, config2.middlewares);
556
- logRegisteredRoutes(routes, config2?.debug ?? false);
1024
+ if (config?.routes) {
1025
+ const routes = registerRoutes(app, config.routes, config.middlewares);
1026
+ logRegisteredRoutes(routes, config?.debug ?? false);
557
1027
  }
558
1028
  return app;
559
1029
  }
560
- async function createAutoConfiguredApp(config2) {
1030
+ async function createAutoConfiguredApp(config) {
561
1031
  const app = new Hono();
562
- const middlewareConfig = config2?.middleware ?? {};
1032
+ const middlewareConfig = config?.middleware ?? {};
563
1033
  const enableLogger = middlewareConfig.logger !== false;
564
1034
  const enableCors = middlewareConfig.cors !== false;
565
1035
  const enableErrorHandler = middlewareConfig.errorHandler !== false;
@@ -569,31 +1039,31 @@ async function createAutoConfiguredApp(config2) {
569
1039
  await next();
570
1040
  });
571
1041
  }
572
- applyDefaultMiddleware(app, config2, enableLogger, enableCors);
573
- if (Array.isArray(config2?.use)) {
574
- config2.use.forEach((mw) => app.use("*", mw));
1042
+ applyDefaultMiddleware(app, config, enableLogger, enableCors);
1043
+ if (Array.isArray(config?.use)) {
1044
+ config.use.forEach((mw) => app.use("*", mw));
575
1045
  }
576
- registerHealthCheckEndpoint(app, config2);
577
- await executeBeforeRoutesHook(app, config2);
578
- await loadAppRoutes(app, config2);
579
- registerSSEEndpoint(app, config2);
580
- await executeAfterRoutesHook(app, config2);
1046
+ registerHealthCheckEndpoint(app, config);
1047
+ await executeBeforeRoutesHook(app, config);
1048
+ await loadAppRoutes(app, config);
1049
+ await registerSSEEndpoint(app, config);
1050
+ await executeAfterRoutesHook(app, config);
581
1051
  if (enableErrorHandler) {
582
- app.onError(ErrorHandler());
1052
+ app.onError(ErrorHandler({ onError: config?.middleware?.onError }));
583
1053
  }
584
1054
  return app;
585
1055
  }
586
- function applyDefaultMiddleware(app, config2, enableLogger, enableCors) {
1056
+ function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
587
1057
  if (enableLogger) {
588
1058
  app.use("*", RequestLogger());
589
1059
  }
590
1060
  if (enableCors) {
591
- const corsOptions = config2?.cors !== false ? config2?.cors : void 0;
1061
+ const corsOptions = config?.cors !== false ? config?.cors : void 0;
592
1062
  app.use("*", cors(corsOptions));
593
1063
  }
594
1064
  }
595
- function registerHealthCheckEndpoint(app, config2) {
596
- const healthCheckConfig = config2?.healthCheck ?? {};
1065
+ function registerHealthCheckEndpoint(app, config) {
1066
+ const healthCheckConfig = config?.healthCheck ?? {};
597
1067
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
598
1068
  const healthCheckPath = healthCheckConfig.path ?? "/health";
599
1069
  const healthCheckDetailed = healthCheckConfig.detailed ?? process.env.NODE_ENV === "development";
@@ -602,15 +1072,15 @@ function registerHealthCheckEndpoint(app, config2) {
602
1072
  serverLogger.debug(`Health check endpoint enabled at ${healthCheckPath}`);
603
1073
  }
604
1074
  }
605
- async function executeBeforeRoutesHook(app, config2) {
606
- if (config2?.lifecycle?.beforeRoutes) {
607
- await config2.lifecycle.beforeRoutes(app);
1075
+ async function executeBeforeRoutesHook(app, config) {
1076
+ if (config?.lifecycle?.beforeRoutes) {
1077
+ await config.lifecycle.beforeRoutes(app);
608
1078
  }
609
1079
  }
610
- async function loadAppRoutes(app, config2) {
611
- const debug = isDebugMode(config2);
612
- if (config2?.routes) {
613
- const routes = registerRoutes(app, config2.routes, config2.middlewares);
1080
+ async function loadAppRoutes(app, config) {
1081
+ const debug = isDebugMode(config);
1082
+ if (config?.routes) {
1083
+ const routes = registerRoutes(app, config.routes, config.middlewares);
614
1084
  logRegisteredRoutes(routes, debug);
615
1085
  } else if (debug) {
616
1086
  serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
@@ -631,76 +1101,150 @@ function logRegisteredRoutes(routes, debug) {
631
1101
  serverLogger.info(`\u2713 Routes registered (${routes.length}):
632
1102
  ${routeLines}`);
633
1103
  }
634
- async function executeAfterRoutesHook(app, config2) {
635
- if (config2?.lifecycle?.afterRoutes) {
636
- await config2.lifecycle.afterRoutes(app);
1104
+ async function executeAfterRoutesHook(app, config) {
1105
+ if (config?.lifecycle?.afterRoutes) {
1106
+ await config.lifecycle.afterRoutes(app);
637
1107
  }
638
1108
  }
639
- function registerSSEEndpoint(app, config2) {
640
- if (!config2?.events) {
1109
+ async function registerSSEEndpoint(app, config) {
1110
+ if (!config?.events) {
641
1111
  return;
642
1112
  }
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));
1113
+ const eventsConfig = config.eventsConfig ?? {};
1114
+ const streamPath = eventsConfig.path ?? "/events/stream";
1115
+ const authConfig = eventsConfig.auth;
1116
+ const debug = isDebugMode(config);
1117
+ let tokenManager;
1118
+ if (authConfig?.enabled) {
1119
+ let store = authConfig.store;
1120
+ if (!store) {
1121
+ try {
1122
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
1123
+ const cache = getCache2();
1124
+ if (cache) {
1125
+ store = new CacheTokenStore(cache);
1126
+ if (debug) {
1127
+ serverLogger.info("SSE token store: cache (Redis/Valkey)");
1128
+ }
1129
+ }
1130
+ } catch {
1131
+ }
1132
+ }
1133
+ const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
1134
+ tokenManager = externalManager ?? new SSETokenManager({
1135
+ ttl: authConfig.tokenTtl,
1136
+ store
1137
+ });
1138
+ const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
1139
+ const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
1140
+ const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
1141
+ app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
1142
+ const subject = getSubject(c);
1143
+ if (!subject) {
1144
+ return c.json({ error: "Unable to identify subject" }, 401);
1145
+ }
1146
+ const token = await tokenManager.issue(subject);
1147
+ return c.json({ token });
1148
+ });
1149
+ if (debug) {
1150
+ serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
1151
+ }
1152
+ }
1153
+ app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
647
1154
  if (debug) {
648
- const eventNames = config2.events.eventNames;
649
- serverLogger.info(`\u2713 SSE endpoint registered at ${path}`, {
650
- events: eventNames
1155
+ const eventNames = config.events.eventNames;
1156
+ serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
1157
+ events: eventNames,
1158
+ auth: !!authConfig?.enabled
651
1159
  });
652
1160
  }
653
1161
  }
654
- function isDebugMode(config2) {
655
- return config2?.debug ?? process.env.NODE_ENV === "development";
1162
+ function isDebugMode(config) {
1163
+ return config?.debug ?? process.env.NODE_ENV === "development";
656
1164
  }
657
1165
  var jobLogger = logger.child("@spfn/core:job");
658
- var bossInstance = null;
659
- var bossConfig = null;
1166
+ function requiresSSLWithoutVerification(connectionString) {
1167
+ try {
1168
+ const url = new URL(connectionString);
1169
+ const sslmode = url.searchParams.get("sslmode");
1170
+ return sslmode === "require" || sslmode === "prefer";
1171
+ } catch {
1172
+ return false;
1173
+ }
1174
+ }
1175
+ function stripSslModeFromUrl(connectionString) {
1176
+ const url = new URL(connectionString);
1177
+ url.searchParams.delete("sslmode");
1178
+ return url.toString();
1179
+ }
1180
+ var BOSS_KEY = Symbol.for("spfn:boss-instance");
1181
+ var CONFIG_KEY = Symbol.for("spfn:boss-config");
1182
+ var g = globalThis;
1183
+ function getBossInstance() {
1184
+ return g[BOSS_KEY] ?? null;
1185
+ }
1186
+ function setBossInstance(instance2) {
1187
+ g[BOSS_KEY] = instance2;
1188
+ }
1189
+ function getBossConfig() {
1190
+ return g[CONFIG_KEY] ?? null;
1191
+ }
1192
+ function setBossConfig(config) {
1193
+ g[CONFIG_KEY] = config;
1194
+ }
660
1195
  async function initBoss(options) {
661
- if (bossInstance) {
1196
+ const existing = getBossInstance();
1197
+ if (existing) {
662
1198
  jobLogger.warn("pg-boss already initialized, returning existing instance");
663
- return bossInstance;
1199
+ return existing;
664
1200
  }
665
1201
  jobLogger.info("Initializing pg-boss...");
666
- bossConfig = options;
1202
+ setBossConfig(options);
1203
+ const needsSSL = requiresSSLWithoutVerification(options.connectionString);
667
1204
  const pgBossOptions = {
668
- connectionString: options.connectionString,
1205
+ // pg 드라이버가 URL의 sslmode=require를 verify-full로 해석해서
1206
+ // ssl 옵션을 무시하므로, URL에서 sslmode를 빼고 ssl 객체만 전달
1207
+ connectionString: needsSSL ? stripSslModeFromUrl(options.connectionString) : options.connectionString,
669
1208
  schema: options.schema ?? "spfn_queue",
670
1209
  maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
671
1210
  };
1211
+ if (needsSSL) {
1212
+ pgBossOptions.ssl = { rejectUnauthorized: false };
1213
+ }
672
1214
  if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
673
1215
  pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
674
1216
  }
675
- bossInstance = new PgBoss(pgBossOptions);
676
- bossInstance.on("error", (error) => {
1217
+ const boss = new PgBoss(pgBossOptions);
1218
+ boss.on("error", (error) => {
677
1219
  jobLogger.error("pg-boss error:", error);
678
1220
  });
679
- await bossInstance.start();
1221
+ await boss.start();
1222
+ setBossInstance(boss);
680
1223
  jobLogger.info("pg-boss started successfully");
681
- return bossInstance;
1224
+ return boss;
682
1225
  }
683
1226
  function getBoss() {
684
- return bossInstance;
1227
+ return getBossInstance();
685
1228
  }
686
1229
  async function stopBoss() {
687
- if (!bossInstance) {
1230
+ const boss = getBossInstance();
1231
+ if (!boss) {
688
1232
  return;
689
1233
  }
690
1234
  jobLogger.info("Stopping pg-boss...");
691
1235
  try {
692
- await bossInstance.stop({ graceful: true, timeout: 3e4 });
1236
+ await boss.stop({ graceful: true, timeout: 3e4 });
693
1237
  jobLogger.info("pg-boss stopped gracefully");
694
1238
  } catch (error) {
695
1239
  jobLogger.error("Error stopping pg-boss:", error);
696
1240
  throw error;
697
1241
  } finally {
698
- bossInstance = null;
699
- bossConfig = null;
1242
+ setBossInstance(null);
1243
+ setBossConfig(null);
700
1244
  }
701
1245
  }
702
1246
  function shouldClearOnStart() {
703
- return bossConfig?.clearOnStart ?? false;
1247
+ return getBossConfig()?.clearOnStart ?? false;
704
1248
  }
705
1249
 
706
1250
  // src/job/job-router.ts
@@ -762,36 +1306,53 @@ async function registerJobs(router) {
762
1306
  async function ensureQueue(boss, queueName) {
763
1307
  await boss.createQueue(queueName);
764
1308
  }
1309
+ async function executeJobHandler(job2, pgBossJob) {
1310
+ jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
1311
+ const startTime = Date.now();
1312
+ try {
1313
+ if (job2.inputSchema) {
1314
+ await job2.handler(pgBossJob.data);
1315
+ } else {
1316
+ await job2.handler();
1317
+ }
1318
+ const duration = Date.now() - startTime;
1319
+ jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
1320
+ jobId: pgBossJob.id,
1321
+ duration
1322
+ });
1323
+ } catch (error) {
1324
+ const duration = Date.now() - startTime;
1325
+ jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
1326
+ jobId: pgBossJob.id,
1327
+ duration,
1328
+ error: error instanceof Error ? error.message : String(error)
1329
+ });
1330
+ throw error;
1331
+ }
1332
+ }
765
1333
  async function registerWorker(boss, job2, queueName) {
766
1334
  await ensureQueue(boss, queueName);
1335
+ const batchSize = job2.options?.batchSize ?? 1;
767
1336
  await boss.work(
768
1337
  queueName,
769
- { batchSize: 1 },
770
- async (jobs) => {
771
- for (const pgBossJob of jobs) {
772
- jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
773
- const startTime = Date.now();
774
- try {
775
- if (job2.inputSchema) {
776
- await job2.handler(pgBossJob.data);
777
- } else {
778
- await job2.handler();
779
- }
780
- const duration = Date.now() - startTime;
781
- jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
782
- jobId: pgBossJob.id,
783
- duration
784
- });
785
- } catch (error) {
786
- const duration = Date.now() - startTime;
787
- jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
788
- jobId: pgBossJob.id,
789
- duration,
790
- error: error instanceof Error ? error.message : String(error)
791
- });
792
- throw error;
1338
+ { batchSize },
1339
+ async (pgBossJobs) => {
1340
+ if (batchSize <= 1) {
1341
+ await executeJobHandler(job2, pgBossJobs[0]);
1342
+ return;
1343
+ }
1344
+ const results = await Promise.allSettled(
1345
+ pgBossJobs.map((pgBossJob) => executeJobHandler(job2, pgBossJob))
1346
+ );
1347
+ const failedIds = [];
1348
+ for (let i = 0; i < results.length; i++) {
1349
+ if (results[i].status === "rejected") {
1350
+ failedIds.push(pgBossJobs[i].id);
793
1351
  }
794
1352
  }
1353
+ if (failedIds.length > 0) {
1354
+ await boss.fail(queueName, failedIds);
1355
+ }
795
1356
  }
796
1357
  );
797
1358
  }
@@ -851,6 +1412,202 @@ async function registerJob(job2) {
851
1412
  await queueRunOnceJob(boss, job2);
852
1413
  jobLogger2.debug(`Job registered: ${job2.name}`);
853
1414
  }
1415
+ var wsLogger = logger.child("@spfn/core:ws");
1416
+ async function attachWSHandler(server, router, config = {}, tokenManager) {
1417
+ const WebSocketServer = await loadWSServer();
1418
+ const {
1419
+ pingInterval = 3e4,
1420
+ path = "/ws",
1421
+ auth: authConfig
1422
+ } = config;
1423
+ if (authConfig?.enabled && !tokenManager) {
1424
+ throw new Error(
1425
+ "WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
1426
+ );
1427
+ }
1428
+ const wss = new WebSocketServer({ server, path });
1429
+ const clients = /* @__PURE__ */ new Set();
1430
+ wss.on("connection", (ws, req) => {
1431
+ clients.add(ws);
1432
+ ws.on("close", () => clients.delete(ws));
1433
+ handleConnection(ws, req, router, authConfig, tokenManager, pingInterval).catch((err) => {
1434
+ wsLogger.error("WebSocket connection handler error", err);
1435
+ if (ws.readyState === 1) ws.close(1011, "Internal server error");
1436
+ });
1437
+ });
1438
+ wss.on("error", (err) => {
1439
+ wsLogger.error("WebSocket server error", err);
1440
+ });
1441
+ wsLogger.info(`\u2713 WebSocket endpoint registered at ${path}`, {
1442
+ events: router.eventNames,
1443
+ auth: !!authConfig?.enabled
1444
+ });
1445
+ return () => new Promise((resolve2, reject) => {
1446
+ for (const client of clients) {
1447
+ client.close(1001, "Server shutting down");
1448
+ }
1449
+ clients.clear();
1450
+ wss.close((err) => {
1451
+ if (err) reject(err);
1452
+ else resolve2();
1453
+ });
1454
+ });
1455
+ }
1456
+ async function handleConnection(ws, req, router, authConfig, tokenManager, pingInterval) {
1457
+ let pingTimer;
1458
+ let connectionUnsubscribes = [];
1459
+ let subscribedEvents = [];
1460
+ ws.on("close", () => {
1461
+ clearInterval(pingTimer);
1462
+ connectionUnsubscribes.forEach((fn) => fn());
1463
+ if (subscribedEvents.length > 0)
1464
+ wsLogger.info("WebSocket connection closed", { events: subscribedEvents });
1465
+ });
1466
+ const url = parseURL(req);
1467
+ if (!url) {
1468
+ ws.close(1002, "Invalid request URL");
1469
+ return;
1470
+ }
1471
+ const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : void 0);
1472
+ if (subject === false) {
1473
+ ws.close(4001, "Missing token");
1474
+ return;
1475
+ }
1476
+ if (subject === null) {
1477
+ ws.close(4001, "Invalid or expired token");
1478
+ return;
1479
+ }
1480
+ const requestedEvents = parseRequestedEvents2(url, router.eventNames);
1481
+ if (requestedEvents.length === 0) {
1482
+ ws.close(4e3, "No valid event names specified");
1483
+ return;
1484
+ }
1485
+ const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);
1486
+ if (allowedEvents === null) {
1487
+ ws.close(4003, "Not authorized for any requested events");
1488
+ return;
1489
+ }
1490
+ subscribedEvents = allowedEvents;
1491
+ wsLogger.info("WebSocket connection established", {
1492
+ events: allowedEvents,
1493
+ subject: subject ?? void 0
1494
+ });
1495
+ const connection = createConnection(ws);
1496
+ connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig);
1497
+ if (ws.readyState !== 1) {
1498
+ connectionUnsubscribes.forEach((fn) => fn());
1499
+ connectionUnsubscribes = [];
1500
+ return;
1501
+ }
1502
+ ws.on("message", (data) => {
1503
+ onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
1504
+ });
1505
+ if (pingInterval > 0) {
1506
+ pingTimer = setInterval(() => {
1507
+ if (ws.readyState === 1) ws.ping();
1508
+ }, pingInterval);
1509
+ }
1510
+ connection.send("__connected", {
1511
+ subscribedEvents: allowedEvents,
1512
+ timestamp: Date.now()
1513
+ });
1514
+ }
1515
+ function parseURL(req) {
1516
+ try {
1517
+ return new URL(req.url ?? "/", "ws://localhost");
1518
+ } catch {
1519
+ return null;
1520
+ }
1521
+ }
1522
+ async function resolveSubject(url, tokenManager) {
1523
+ if (!tokenManager) {
1524
+ return void 0;
1525
+ }
1526
+ const token = url.searchParams.get("token");
1527
+ if (!token) {
1528
+ return false;
1529
+ }
1530
+ return await tokenManager.verify(token);
1531
+ }
1532
+ function parseRequestedEvents2(url, validEventNames) {
1533
+ const eventsParam = url.searchParams.get("events");
1534
+ if (!eventsParam) {
1535
+ return [];
1536
+ }
1537
+ return eventsParam.split(",").map((e) => e.trim()).filter((e) => validEventNames.includes(e));
1538
+ }
1539
+ async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
1540
+ if (!subject || !authConfig?.authorize) {
1541
+ return requestedEvents;
1542
+ }
1543
+ const allowed = await authConfig.authorize(subject, requestedEvents);
1544
+ return allowed.length === 0 ? null : allowed;
1545
+ }
1546
+ function createConnection(ws) {
1547
+ return {
1548
+ send: (type, payload) => {
1549
+ if (ws.readyState !== 1) return;
1550
+ ws.send(JSON.stringify({ type, data: payload }));
1551
+ },
1552
+ close: (code, reason) => ws.close(code, reason)
1553
+ };
1554
+ }
1555
+ function subscribeEvents(ws, router, allowedEvents, subject, authConfig) {
1556
+ const unsubscribes = [];
1557
+ for (const eventName of allowedEvents) {
1558
+ const eventDef = router.events[eventName];
1559
+ if (!eventDef) continue;
1560
+ const unsubscribe = eventDef.subscribe((payload) => {
1561
+ if (ws.readyState !== 1) return;
1562
+ if (subject && authConfig?.filter?.[eventName]) {
1563
+ if (!authConfig.filter[eventName](subject, payload)) return;
1564
+ }
1565
+ try {
1566
+ ws.send(JSON.stringify({ type: eventName, data: payload }));
1567
+ } catch {
1568
+ }
1569
+ });
1570
+ unsubscribes.push(unsubscribe);
1571
+ }
1572
+ return unsubscribes;
1573
+ }
1574
+ async function onClientMessage(data, router, connection, subject) {
1575
+ let message;
1576
+ try {
1577
+ message = JSON.parse(data.toString());
1578
+ } catch {
1579
+ return;
1580
+ }
1581
+ const { type, data: payload } = message;
1582
+ if (!type) return;
1583
+ const handler = router.messages[type];
1584
+ if (!handler) return;
1585
+ try {
1586
+ await handler({ payload, subject, ws: connection });
1587
+ } catch (err) {
1588
+ wsLogger.error(`WebSocket message handler error: ${type}`, err);
1589
+ }
1590
+ }
1591
+ async function loadWSServer() {
1592
+ try {
1593
+ const mod = await import('ws');
1594
+ const WS = mod.default ?? mod;
1595
+ const WSS = WS.WebSocketServer ?? WS.Server;
1596
+ if (typeof WSS !== "function") {
1597
+ throw new Error(
1598
+ "WebSocketServer not found in ws module. Ensure ws@^8 is installed: pnpm add ws"
1599
+ );
1600
+ }
1601
+ return WSS;
1602
+ } catch (err) {
1603
+ if (err instanceof Error && err.message.includes("WebSocketServer not found")) {
1604
+ throw err;
1605
+ }
1606
+ throw new Error(
1607
+ '@spfn/core WebSocket support requires the "ws" package.\nInstall it with: pnpm add ws'
1608
+ );
1609
+ }
1610
+ }
854
1611
  function getNetworkAddress() {
855
1612
  const nets = networkInterfaces();
856
1613
  for (const name of Object.keys(nets)) {
@@ -888,16 +1645,16 @@ function printBanner(options) {
888
1645
  }
889
1646
 
890
1647
  // 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) {
1648
+ function validateServerConfig(config) {
1649
+ if (config.port !== void 0) {
1650
+ if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
894
1651
  throw new Error(
895
- `Invalid port: ${config2.port}. Port must be an integer between 0 and 65535.`
1652
+ `Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
896
1653
  );
897
1654
  }
898
1655
  }
899
- if (config2.timeout) {
900
- const { request, keepAlive, headers } = config2.timeout;
1656
+ if (config.timeout) {
1657
+ const { request, keepAlive, headers } = config.timeout;
901
1658
  if (request !== void 0 && (request < 0 || !Number.isFinite(request))) {
902
1659
  throw new Error(`Invalid timeout.request: ${request}. Must be a positive number.`);
903
1660
  }
@@ -913,16 +1670,16 @@ function validateServerConfig(config2) {
913
1670
  );
914
1671
  }
915
1672
  }
916
- if (config2.shutdown?.timeout !== void 0) {
917
- const timeout = config2.shutdown.timeout;
1673
+ if (config.shutdown?.timeout !== void 0) {
1674
+ const timeout = config.shutdown.timeout;
918
1675
  if (timeout < 0 || !Number.isFinite(timeout)) {
919
1676
  throw new Error(`Invalid shutdown.timeout: ${timeout}. Must be a positive number.`);
920
1677
  }
921
1678
  }
922
- if (config2.healthCheck?.path) {
923
- if (!config2.healthCheck.path.startsWith("/")) {
1679
+ if (config.healthCheck?.path) {
1680
+ if (!config.healthCheck.path.startsWith("/")) {
924
1681
  throw new Error(
925
- `Invalid healthCheck.path: "${config2.healthCheck.path}". Must start with "/".`
1682
+ `Invalid healthCheck.path: "${config.healthCheck.path}". Must start with "/".`
926
1683
  );
927
1684
  }
928
1685
  }
@@ -931,9 +1688,7 @@ var DEFAULT_MAX_LISTENERS = 15;
931
1688
  var TIMEOUTS = {
932
1689
  SERVER_CLOSE: 5e3,
933
1690
  DATABASE_CLOSE: 5e3,
934
- REDIS_CLOSE: 5e3,
935
- PRODUCTION_ERROR_SHUTDOWN: 1e4
936
- };
1691
+ REDIS_CLOSE: 5e3};
937
1692
  var CONFIG_FILE_PATHS = [
938
1693
  ".spfn/server/server.config.mjs",
939
1694
  ".spfn/server/server.config",
@@ -941,9 +1696,9 @@ var CONFIG_FILE_PATHS = [
941
1696
  "src/server/server.config.ts"
942
1697
  ];
943
1698
  var processHandlersRegistered = false;
944
- async function startServer(config2) {
945
- loadEnvFiles();
946
- const finalConfig = await loadAndMergeConfig(config2);
1699
+ async function startServer(config) {
1700
+ loadEnv();
1701
+ const finalConfig = await loadAndMergeConfig(config);
947
1702
  const { host, port, debug } = finalConfig;
948
1703
  validateServerConfig(finalConfig);
949
1704
  if (!host || !port) {
@@ -959,8 +1714,14 @@ async function startServer(config2) {
959
1714
  await initializeInfrastructure(finalConfig);
960
1715
  const app = await createServer(finalConfig);
961
1716
  const server = startHttpServer(app, host, port);
1717
+ let wsCleanup;
1718
+ if (finalConfig.websockets) {
1719
+ wsCleanup = await initializeWebSocket(server, app, finalConfig);
1720
+ }
962
1721
  const timeouts = getTimeoutConfig(finalConfig.timeout);
963
1722
  applyServerTimeouts(server, timeouts);
1723
+ const fetchTimeouts = getFetchTimeoutConfig(finalConfig.fetchTimeout);
1724
+ applyGlobalFetchTimeouts(fetchTimeouts);
964
1725
  logServerTimeouts(timeouts);
965
1726
  printBanner({
966
1727
  mode: debug ? "Development" : "Production",
@@ -968,7 +1729,7 @@ async function startServer(config2) {
968
1729
  port
969
1730
  });
970
1731
  logServerStarted(debug, host, port, finalConfig, timeouts);
971
- const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState);
1732
+ const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState, wsCleanup);
972
1733
  const shutdown = createGracefulShutdown(shutdownServer, finalConfig, shutdownState);
973
1734
  registerProcessHandlers(shutdown);
974
1735
  const serverInstance = {
@@ -977,11 +1738,6 @@ async function startServer(config2) {
977
1738
  config: finalConfig,
978
1739
  close: async () => {
979
1740
  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
1741
  await shutdownServer();
986
1742
  }
987
1743
  };
@@ -1001,7 +1757,7 @@ async function startServer(config2) {
1001
1757
  throw error;
1002
1758
  }
1003
1759
  }
1004
- async function loadAndMergeConfig(config2) {
1760
+ async function loadAndMergeConfig(config) {
1005
1761
  const cwd = process.cwd();
1006
1762
  let fileConfig = {};
1007
1763
  let loadedConfigPath = null;
@@ -1025,26 +1781,26 @@ async function loadAndMergeConfig(config2) {
1025
1781
  }
1026
1782
  return {
1027
1783
  ...fileConfig,
1028
- ...config2,
1029
- port: config2?.port ?? fileConfig?.port ?? env.PORT,
1030
- host: config2?.host ?? fileConfig?.host ?? env.HOST
1784
+ ...config,
1785
+ port: config?.port ?? fileConfig?.port ?? env.PORT,
1786
+ host: config?.host ?? fileConfig?.host ?? env.HOST
1031
1787
  };
1032
1788
  }
1033
- function getInfrastructureConfig(config2) {
1789
+ function getInfrastructureConfig(config) {
1034
1790
  return {
1035
- database: config2.infrastructure?.database !== false,
1036
- redis: config2.infrastructure?.redis !== false
1791
+ database: config.infrastructure?.database !== false,
1792
+ redis: config.infrastructure?.redis !== false
1037
1793
  };
1038
1794
  }
1039
- async function initializeInfrastructure(config2) {
1040
- if (config2.lifecycle?.beforeInfrastructure) {
1795
+ async function initializeInfrastructure(config) {
1796
+ if (config.lifecycle?.beforeInfrastructure) {
1041
1797
  serverLogger.debug("Executing beforeInfrastructure hook...");
1042
- await config2.lifecycle.beforeInfrastructure(config2);
1798
+ await config.lifecycle.beforeInfrastructure(config);
1043
1799
  }
1044
- const infraConfig = getInfrastructureConfig(config2);
1800
+ const infraConfig = getInfrastructureConfig(config);
1045
1801
  if (infraConfig.database) {
1046
1802
  serverLogger.debug("Initializing database...");
1047
- await initDatabase(config2.database);
1803
+ await initDatabase(config.database);
1048
1804
  } else {
1049
1805
  serverLogger.debug("Database initialization disabled");
1050
1806
  }
@@ -1054,11 +1810,11 @@ async function initializeInfrastructure(config2) {
1054
1810
  } else {
1055
1811
  serverLogger.debug("Redis initialization disabled");
1056
1812
  }
1057
- if (config2.lifecycle?.afterInfrastructure) {
1813
+ if (config.lifecycle?.afterInfrastructure) {
1058
1814
  serverLogger.debug("Executing afterInfrastructure hook...");
1059
- await config2.lifecycle.afterInfrastructure();
1815
+ await config.lifecycle.afterInfrastructure();
1060
1816
  }
1061
- if (config2.jobs) {
1817
+ if (config.jobs) {
1062
1818
  const dbUrl = env.DATABASE_URL;
1063
1819
  if (!dbUrl) {
1064
1820
  throw new Error(
@@ -1068,10 +1824,24 @@ async function initializeInfrastructure(config2) {
1068
1824
  serverLogger.debug("Initializing pg-boss...");
1069
1825
  await initBoss({
1070
1826
  connectionString: dbUrl,
1071
- ...config2.jobsConfig
1827
+ ...config.jobsConfig
1072
1828
  });
1073
1829
  serverLogger.debug("Registering jobs...");
1074
- await registerJobs(config2.jobs);
1830
+ await registerJobs(config.jobs);
1831
+ }
1832
+ if (config.workflows) {
1833
+ const infraConfig2 = getInfrastructureConfig(config);
1834
+ if (!infraConfig2.database) {
1835
+ throw new Error(
1836
+ "Workflows require database connection. Ensure database is enabled in infrastructure config."
1837
+ );
1838
+ }
1839
+ serverLogger.debug("Initializing workflow engine...");
1840
+ config.workflows._init(
1841
+ getDatabase(),
1842
+ config.workflowsConfig
1843
+ );
1844
+ serverLogger.info("Workflow engine initialized");
1075
1845
  }
1076
1846
  }
1077
1847
  function startHttpServer(app, host, port) {
@@ -1082,8 +1852,48 @@ function startHttpServer(app, host, port) {
1082
1852
  hostname: host
1083
1853
  });
1084
1854
  }
1085
- function logMiddlewareOrder(config2) {
1086
- const middlewareOrder = buildMiddlewareOrder(config2);
1855
+ async function initializeWebSocket(server, app, config) {
1856
+ const wsRouter = config.websockets;
1857
+ const wsConfig = config.websocketsConfig ?? {};
1858
+ const authConfig = wsConfig.auth;
1859
+ const wsPath = wsConfig.path ?? "/ws";
1860
+ const debug = config.debug ?? process.env.NODE_ENV === "development";
1861
+ let tokenManager;
1862
+ if (authConfig?.enabled) {
1863
+ let store = authConfig.store;
1864
+ if (!store) {
1865
+ try {
1866
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
1867
+ const cache = getCache2();
1868
+ if (cache) {
1869
+ store = new CacheTokenStore(cache);
1870
+ if (debug) serverLogger.info("WS token store: cache (Redis/Valkey)");
1871
+ }
1872
+ } catch {
1873
+ }
1874
+ }
1875
+ const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
1876
+ tokenManager = externalManager ?? new SSETokenManager({
1877
+ ttl: authConfig.tokenTtl,
1878
+ store
1879
+ });
1880
+ const tokenPath = wsPath.replace(/\/[^/]+$/, "/token");
1881
+ const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
1882
+ const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
1883
+ app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
1884
+ const subject = getSubject(c);
1885
+ if (!subject) {
1886
+ return c.json({ error: "Unable to identify subject" }, 401);
1887
+ }
1888
+ const token = await tokenManager.issue(subject);
1889
+ return c.json({ token });
1890
+ });
1891
+ if (debug) serverLogger.info(`\u2713 WS token endpoint registered at POST ${tokenPath}`);
1892
+ }
1893
+ return await attachWSHandler(server, wsRouter, wsConfig, tokenManager);
1894
+ }
1895
+ function logMiddlewareOrder(config) {
1896
+ const middlewareOrder = buildMiddlewareOrder(config);
1087
1897
  serverLogger.debug("Middleware execution order", {
1088
1898
  order: middlewareOrder
1089
1899
  });
@@ -1095,8 +1905,8 @@ function logServerTimeouts(timeouts) {
1095
1905
  headers: `${timeouts.headers}ms`
1096
1906
  });
1097
1907
  }
1098
- function logServerStarted(debug, host, port, config2, timeouts) {
1099
- const startupConfig = buildStartupConfig(config2, timeouts);
1908
+ function logServerStarted(debug, host, port, config, timeouts) {
1909
+ const startupConfig = buildStartupConfig(config, timeouts);
1100
1910
  serverLogger.info("Server started successfully", {
1101
1911
  mode: debug ? "development" : "production",
1102
1912
  host,
@@ -1104,65 +1914,83 @@ function logServerStarted(debug, host, port, config2, timeouts) {
1104
1914
  config: startupConfig
1105
1915
  });
1106
1916
  }
1107
- function createShutdownHandler(server, config2, shutdownState) {
1917
+ function createShutdownHandler(server, config, shutdownState, wsCleanup) {
1108
1918
  return async () => {
1109
1919
  if (shutdownState.isShuttingDown) {
1110
1920
  serverLogger.debug("Shutdown already in progress for this instance, skipping");
1111
1921
  return;
1112
1922
  }
1113
1923
  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...");
1924
+ const shutdownTimeout = getShutdownTimeout(config.shutdown);
1925
+ const shutdownManager = getShutdownManager();
1926
+ shutdownManager.beginShutdown();
1927
+ serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
1928
+ await closeHttpServer(server);
1929
+ if (wsCleanup) {
1930
+ serverLogger.info("Phase 1.5: Closing WebSocket server...");
1931
+ try {
1932
+ await wsCleanup();
1933
+ serverLogger.info("WebSocket server closed");
1934
+ } catch (error) {
1935
+ serverLogger.error("WebSocket server close failed", error);
1936
+ }
1937
+ }
1938
+ if (config.jobs) {
1939
+ serverLogger.info("Phase 2: Stopping pg-boss...");
1140
1940
  try {
1141
1941
  await stopBoss();
1942
+ serverLogger.info("pg-boss stopped");
1142
1943
  } catch (error) {
1143
1944
  serverLogger.error("pg-boss stop failed", error);
1144
1945
  }
1145
1946
  }
1146
- if (config2.lifecycle?.beforeShutdown) {
1147
- serverLogger.debug("Executing beforeShutdown hook...");
1947
+ const drainTimeout = Math.floor(shutdownTimeout * 0.8);
1948
+ serverLogger.info(`Phase 3: Draining tracked operations (timeout: ${drainTimeout}ms)...`);
1949
+ await shutdownManager.execute(drainTimeout);
1950
+ if (config.lifecycle?.beforeShutdown) {
1951
+ serverLogger.info("Phase 4: Executing beforeShutdown lifecycle hook...");
1148
1952
  try {
1149
- await config2.lifecycle.beforeShutdown();
1953
+ await config.lifecycle.beforeShutdown();
1150
1954
  } catch (error) {
1151
- serverLogger.error("beforeShutdown hook failed", error);
1955
+ serverLogger.error("beforeShutdown lifecycle hook failed", error);
1152
1956
  }
1153
1957
  }
1154
- const infraConfig = getInfrastructureConfig(config2);
1958
+ serverLogger.info("Phase 5: Closing infrastructure...");
1959
+ const infraConfig = getInfrastructureConfig(config);
1155
1960
  if (infraConfig.database) {
1156
- serverLogger.debug("Closing database connections...");
1157
1961
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
1158
1962
  }
1159
1963
  if (infraConfig.redis) {
1160
- serverLogger.debug("Closing Redis connections...");
1161
1964
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
1162
1965
  }
1163
1966
  serverLogger.info("Server shutdown completed");
1164
1967
  };
1165
1968
  }
1969
+ async function closeHttpServer(server) {
1970
+ let timeoutId;
1971
+ await Promise.race([
1972
+ new Promise((resolve2, reject) => {
1973
+ server.close((err) => {
1974
+ if (timeoutId) clearTimeout(timeoutId);
1975
+ if (err) {
1976
+ serverLogger.error("HTTP server close error", err);
1977
+ reject(err);
1978
+ } else {
1979
+ serverLogger.info("HTTP server closed");
1980
+ resolve2();
1981
+ }
1982
+ });
1983
+ }),
1984
+ new Promise((_, reject) => {
1985
+ timeoutId = setTimeout(() => {
1986
+ reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
1987
+ }, TIMEOUTS.SERVER_CLOSE);
1988
+ })
1989
+ ]).catch((error) => {
1990
+ if (timeoutId) clearTimeout(timeoutId);
1991
+ serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
1992
+ });
1993
+ }
1166
1994
  async function closeInfrastructure(closeFn, name, timeout) {
1167
1995
  let timeoutId;
1168
1996
  try {
@@ -1182,14 +2010,14 @@ async function closeInfrastructure(closeFn, name, timeout) {
1182
2010
  serverLogger.error(`${name} close failed or timed out`, error);
1183
2011
  }
1184
2012
  }
1185
- function createGracefulShutdown(shutdownServer, config2, shutdownState) {
2013
+ function createGracefulShutdown(shutdownServer, config, shutdownState) {
1186
2014
  return async (signal) => {
1187
2015
  if (shutdownState.isShuttingDown) {
1188
2016
  serverLogger.warn(`${signal} received but shutdown already in progress, ignoring`);
1189
2017
  return;
1190
2018
  }
1191
2019
  serverLogger.info(`${signal} received, starting graceful shutdown...`);
1192
- const shutdownTimeout = getShutdownTimeout(config2.shutdown);
2020
+ const shutdownTimeout = getShutdownTimeout(config.shutdown);
1193
2021
  let timeoutId;
1194
2022
  try {
1195
2023
  await Promise.race([
@@ -1217,31 +2045,8 @@ function createGracefulShutdown(shutdownServer, config2, shutdownState) {
1217
2045
  }
1218
2046
  };
1219
2047
  }
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
- }
2048
+ function handleProcessError(errorType) {
2049
+ serverLogger.warn(`${errorType} occurred - server continues running. Check logs above for details.`);
1245
2050
  }
1246
2051
  function registerProcessHandlers(shutdown) {
1247
2052
  if (processHandlersRegistered) {
@@ -1276,7 +2081,7 @@ function registerProcessHandlers(shutdown) {
1276
2081
  } else {
1277
2082
  serverLogger.error("Uncaught exception", error);
1278
2083
  }
1279
- handleProcessError("UNCAUGHT_EXCEPTION", shutdown);
2084
+ handleProcessError("UNCAUGHT_EXCEPTION");
1280
2085
  });
1281
2086
  process.on("unhandledRejection", (reason, promise) => {
1282
2087
  if (reason instanceof Error) {
@@ -1294,20 +2099,21 @@ function registerProcessHandlers(shutdown) {
1294
2099
  promise
1295
2100
  });
1296
2101
  }
1297
- handleProcessError("UNHANDLED_REJECTION", shutdown);
2102
+ handleProcessError("UNHANDLED_REJECTION");
1298
2103
  });
1299
2104
  serverLogger.debug("Process-level shutdown handlers registered successfully");
1300
2105
  }
1301
- async function cleanupOnFailure(config2) {
2106
+ async function cleanupOnFailure(config) {
1302
2107
  try {
1303
2108
  serverLogger.debug("Cleaning up after initialization failure...");
1304
- const infraConfig = getInfrastructureConfig(config2);
2109
+ const infraConfig = getInfrastructureConfig(config);
1305
2110
  if (infraConfig.database) {
1306
2111
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
1307
2112
  }
1308
2113
  if (infraConfig.redis) {
1309
2114
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
1310
2115
  }
2116
+ resetShutdownManager();
1311
2117
  serverLogger.debug("Cleanup completed");
1312
2118
  } catch (cleanupError) {
1313
2119
  serverLogger.error("Cleanup failed", cleanupError);
@@ -1433,10 +2239,10 @@ var ServerConfigBuilder = class {
1433
2239
  * .build();
1434
2240
  * ```
1435
2241
  */
1436
- jobs(router, config2) {
2242
+ jobs(router, config) {
1437
2243
  this.config.jobs = router;
1438
- if (config2) {
1439
- this.config.jobsConfig = config2;
2244
+ if (config) {
2245
+ this.config.jobsConfig = config;
1440
2246
  }
1441
2247
  return this;
1442
2248
  }
@@ -1468,10 +2274,44 @@ var ServerConfigBuilder = class {
1468
2274
  * .events(eventRouter, { path: '/sse' })
1469
2275
  * ```
1470
2276
  */
1471
- events(router, config2) {
2277
+ events(router, config) {
1472
2278
  this.config.events = router;
1473
- if (config2) {
1474
- this.config.eventsConfig = config2;
2279
+ if (config) {
2280
+ this.config.eventsConfig = config;
2281
+ }
2282
+ return this;
2283
+ }
2284
+ /**
2285
+ * Register WebSocket router for bidirectional real-time communication
2286
+ *
2287
+ * Enables type-safe WebSocket connections with:
2288
+ * - Server→client event push (via defineEvent + emit)
2289
+ * - Client→server message handling (via messages in defineWSRouter)
2290
+ *
2291
+ * @example
2292
+ * ```typescript
2293
+ * // src/server/ws.ts
2294
+ * export const wsRouter = defineWSRouter({
2295
+ * events: { userUpdated, notification },
2296
+ * messages: {
2297
+ * ping: ({ ws }) => ws.send('pong', {}),
2298
+ * },
2299
+ * });
2300
+ *
2301
+ * // server.config.ts
2302
+ * export default defineServerConfig()
2303
+ * .websockets(wsRouter) // → WS /ws
2304
+ * .websockets(wsRouter, {
2305
+ * path: '/realtime', // custom path
2306
+ * auth: { enabled: true }, // token authentication
2307
+ * })
2308
+ * .build();
2309
+ * ```
2310
+ */
2311
+ websockets(router, config) {
2312
+ this.config.websockets = router;
2313
+ if (config) {
2314
+ this.config.websocketsConfig = config;
1475
2315
  }
1476
2316
  return this;
1477
2317
  }
@@ -1517,6 +2357,33 @@ var ServerConfigBuilder = class {
1517
2357
  this.config.infrastructure = infrastructure;
1518
2358
  return this;
1519
2359
  }
2360
+ /**
2361
+ * Register workflow router for workflow orchestration
2362
+ *
2363
+ * Automatically initializes the workflow engine after database is ready.
2364
+ *
2365
+ * @example
2366
+ * ```typescript
2367
+ * import { defineWorkflowRouter } from '@spfn/workflow';
2368
+ *
2369
+ * const workflowRouter = defineWorkflowRouter([
2370
+ * provisionTenant,
2371
+ * deprovisionTenant,
2372
+ * ]);
2373
+ *
2374
+ * export default defineServerConfig()
2375
+ * .routes(appRouter)
2376
+ * .workflows(workflowRouter)
2377
+ * .build();
2378
+ * ```
2379
+ */
2380
+ workflows(router, config) {
2381
+ this.config.workflows = router;
2382
+ if (config) {
2383
+ this.config.workflowsConfig = config;
2384
+ }
2385
+ return this;
2386
+ }
1520
2387
  /**
1521
2388
  * Configure lifecycle hooks
1522
2389
  * Can be called multiple times - hooks will be executed in registration order
@@ -1564,6 +2431,6 @@ function defineServerConfig() {
1564
2431
  return new ServerConfigBuilder();
1565
2432
  }
1566
2433
 
1567
- export { createServer, defineServerConfig, loadEnvFiles, startServer };
2434
+ export { createServer, defineServerConfig, getShutdownManager, loadEnv, loadEnvFiles, startServer };
1568
2435
  //# sourceMappingURL=index.js.map
1569
2436
  //# sourceMappingURL=index.js.map