@spfn/core 0.2.0-beta.3 → 0.2.0-beta.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -1175
- package/dist/{boss-BO8ty33K.d.ts → boss-DI1r4kTS.d.ts} +24 -7
- package/dist/codegen/index.d.ts +55 -8
- package/dist/codegen/index.js +179 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +204 -6
- package/dist/config/index.js +44 -11
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +24 -3
- package/dist/db/index.js +118 -45
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +83 -3
- package/dist/env/index.js +83 -15
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +95 -0
- package/dist/env/loader.js +78 -0
- package/dist/env/loader.js.map +1 -0
- package/dist/event/index.d.ts +29 -70
- package/dist/event/index.js +15 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +157 -0
- package/dist/event/sse/client.js +169 -0
- package/dist/event/sse/client.js.map +1 -0
- package/dist/event/sse/index.d.ts +46 -0
- package/dist/event/sse/index.js +238 -0
- package/dist/event/sse/index.js.map +1 -0
- package/dist/job/index.d.ts +23 -8
- package/dist/job/index.js +108 -23
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.d.ts +5 -0
- package/dist/logger/index.js +9 -0
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +23 -1
- package/dist/middleware/index.js +58 -5
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +37 -5
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +44 -23
- package/dist/nextjs/server.js +87 -66
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +168 -5
- package/dist/route/index.js +262 -17
- package/dist/route/index.js.map +1 -1
- package/dist/router-Di7ENoah.d.ts +151 -0
- package/dist/server/index.d.ts +316 -5
- package/dist/server/index.js +892 -200
- package/dist/server/index.js.map +1 -1
- package/dist/{types-BVxUIkcU.d.ts → types-7Mhoxnnt.d.ts} +68 -2
- package/dist/types-DAVwA-_7.d.ts +339 -0
- package/docs/cache.md +133 -0
- package/docs/codegen.md +74 -0
- package/docs/database.md +346 -0
- package/docs/entity.md +539 -0
- package/docs/env.md +499 -0
- package/docs/errors.md +319 -0
- package/docs/event.md +443 -0
- package/docs/file-upload.md +717 -0
- package/docs/job.md +131 -0
- package/docs/logger.md +108 -0
- package/docs/middleware.md +337 -0
- package/docs/nextjs.md +247 -0
- package/docs/repository.md +496 -0
- package/docs/route.md +497 -0
- package/docs/server.md +429 -0
- package/package.json +18 -2
package/dist/server/index.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { env } from '@spfn/core/config';
|
|
2
|
-
import {
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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(
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
398
|
-
|
|
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(
|
|
923
|
+
function buildMiddlewareOrder(config) {
|
|
401
924
|
const order = [];
|
|
402
|
-
const middlewareConfig =
|
|
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
|
-
|
|
409
|
-
if (
|
|
931
|
+
config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
|
|
932
|
+
if (config.beforeRoutes) order.push("beforeRoutes hook");
|
|
410
933
|
order.push("Routes");
|
|
411
|
-
if (
|
|
934
|
+
if (config.afterRoutes) order.push("afterRoutes hook");
|
|
412
935
|
if (enableErrorHandler) order.push("ErrorHandler");
|
|
413
936
|
return order;
|
|
414
937
|
}
|
|
415
|
-
function buildStartupConfig(
|
|
416
|
-
const middlewareConfig =
|
|
417
|
-
const healthCheckConfig =
|
|
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:
|
|
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: !!
|
|
435
|
-
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: `${
|
|
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(
|
|
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,
|
|
977
|
+
return await loadCustomApp(appPath, appJsPath, config);
|
|
456
978
|
}
|
|
457
|
-
return await createAutoConfiguredApp(
|
|
979
|
+
return await createAutoConfiguredApp(config);
|
|
458
980
|
}
|
|
459
|
-
async function loadCustomApp(appPath, appJsPath,
|
|
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 (
|
|
468
|
-
registerRoutes(app,
|
|
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(
|
|
995
|
+
async function createAutoConfiguredApp(config) {
|
|
473
996
|
const app = new Hono();
|
|
474
|
-
const middlewareConfig =
|
|
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,
|
|
485
|
-
if (Array.isArray(
|
|
486
|
-
|
|
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,
|
|
489
|
-
await executeBeforeRoutesHook(app,
|
|
490
|
-
await loadAppRoutes(app,
|
|
491
|
-
await
|
|
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,
|
|
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 =
|
|
1026
|
+
const corsOptions = config?.cors !== false ? config?.cors : void 0;
|
|
503
1027
|
app.use("*", cors(corsOptions));
|
|
504
1028
|
}
|
|
505
1029
|
}
|
|
506
|
-
function registerHealthCheckEndpoint(app,
|
|
507
|
-
const healthCheckConfig =
|
|
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,
|
|
517
|
-
if (
|
|
518
|
-
await
|
|
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,
|
|
522
|
-
const debug = isDebugMode(
|
|
523
|
-
if (
|
|
524
|
-
registerRoutes(app,
|
|
525
|
-
|
|
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
|
-
|
|
533
|
-
if (
|
|
534
|
-
|
|
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(
|
|
538
|
-
return
|
|
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
|
-
|
|
542
|
-
|
|
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
|
-
|
|
1160
|
+
const existing = getBossInstance();
|
|
1161
|
+
if (existing) {
|
|
545
1162
|
jobLogger.warn("pg-boss already initialized, returning existing instance");
|
|
546
|
-
return
|
|
1163
|
+
return existing;
|
|
547
1164
|
}
|
|
548
1165
|
jobLogger.info("Initializing pg-boss...");
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
1185
|
+
await boss.start();
|
|
1186
|
+
setBossInstance(boss);
|
|
560
1187
|
jobLogger.info("pg-boss started successfully");
|
|
561
|
-
return
|
|
1188
|
+
return boss;
|
|
562
1189
|
}
|
|
563
1190
|
function getBoss() {
|
|
564
|
-
return
|
|
1191
|
+
return getBossInstance();
|
|
565
1192
|
}
|
|
566
1193
|
async function stopBoss() {
|
|
567
|
-
|
|
1194
|
+
const boss = getBossInstance();
|
|
1195
|
+
if (!boss) {
|
|
568
1196
|
return;
|
|
569
1197
|
}
|
|
570
1198
|
jobLogger.info("Stopping pg-boss...");
|
|
571
1199
|
try {
|
|
572
|
-
await
|
|
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
|
-
|
|
579
|
-
|
|
1206
|
+
setBossInstance(null);
|
|
1207
|
+
setBossConfig(null);
|
|
580
1208
|
}
|
|
581
1209
|
}
|
|
582
1210
|
function shouldClearOnStart() {
|
|
583
|
-
return
|
|
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(
|
|
766
|
-
if (
|
|
767
|
-
if (!Number.isInteger(
|
|
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: ${
|
|
1403
|
+
`Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
|
|
770
1404
|
);
|
|
771
1405
|
}
|
|
772
1406
|
}
|
|
773
|
-
if (
|
|
774
|
-
const { request, keepAlive, headers } =
|
|
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 (
|
|
791
|
-
const 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 (
|
|
797
|
-
if (!
|
|
1430
|
+
if (config.healthCheck?.path) {
|
|
1431
|
+
if (!config.healthCheck.path.startsWith("/")) {
|
|
798
1432
|
throw new Error(
|
|
799
|
-
`Invalid healthCheck.path: "${
|
|
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(
|
|
819
|
-
|
|
820
|
-
const finalConfig = await loadAndMergeConfig(
|
|
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(
|
|
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
|
-
...
|
|
903
|
-
port:
|
|
904
|
-
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(
|
|
1536
|
+
function getInfrastructureConfig(config) {
|
|
908
1537
|
return {
|
|
909
|
-
database:
|
|
910
|
-
redis:
|
|
1538
|
+
database: config.infrastructure?.database !== false,
|
|
1539
|
+
redis: config.infrastructure?.redis !== false
|
|
911
1540
|
};
|
|
912
1541
|
}
|
|
913
|
-
async function initializeInfrastructure(
|
|
914
|
-
if (
|
|
1542
|
+
async function initializeInfrastructure(config) {
|
|
1543
|
+
if (config.lifecycle?.beforeInfrastructure) {
|
|
915
1544
|
serverLogger.debug("Executing beforeInfrastructure hook...");
|
|
916
|
-
await
|
|
1545
|
+
await config.lifecycle.beforeInfrastructure(config);
|
|
917
1546
|
}
|
|
918
|
-
const infraConfig = getInfrastructureConfig(
|
|
1547
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
919
1548
|
if (infraConfig.database) {
|
|
920
1549
|
serverLogger.debug("Initializing database...");
|
|
921
|
-
await initDatabase(
|
|
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 (
|
|
1560
|
+
if (config.lifecycle?.afterInfrastructure) {
|
|
932
1561
|
serverLogger.debug("Executing afterInfrastructure hook...");
|
|
933
|
-
await
|
|
1562
|
+
await config.lifecycle.afterInfrastructure();
|
|
934
1563
|
}
|
|
935
|
-
if (
|
|
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
|
-
...
|
|
1574
|
+
...config.jobsConfig
|
|
946
1575
|
});
|
|
947
1576
|
serverLogger.debug("Registering jobs...");
|
|
948
|
-
await registerJobs(
|
|
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(
|
|
960
|
-
const middlewareOrder = buildMiddlewareOrder(
|
|
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,
|
|
973
|
-
const startupConfig = buildStartupConfig(
|
|
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,
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
1095
|
-
|
|
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"
|
|
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"
|
|
1800
|
+
handleProcessError("UNHANDLED_REJECTION");
|
|
1172
1801
|
});
|
|
1173
1802
|
serverLogger.debug("Process-level shutdown handlers registered successfully");
|
|
1174
1803
|
}
|
|
1175
|
-
async function cleanupOnFailure(
|
|
1804
|
+
async function cleanupOnFailure(config) {
|
|
1176
1805
|
try {
|
|
1177
1806
|
serverLogger.debug("Cleaning up after initialization failure...");
|
|
1178
|
-
const infraConfig = getInfrastructureConfig(
|
|
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,
|
|
1940
|
+
jobs(router, config) {
|
|
1311
1941
|
this.config.jobs = router;
|
|
1312
|
-
if (
|
|
1313
|
-
this.config.jobsConfig =
|
|
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
|