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

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.
@@ -6,9 +6,10 @@ import { Hono } from 'hono';
6
6
  import { cors } from 'hono/cors';
7
7
  import { registerRoutes } from '@spfn/core/route';
8
8
  import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
9
+ import { streamSSE } from 'hono/streaming';
10
+ import { logger } from '@spfn/core/logger';
9
11
  import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
10
12
  import { initCache, getCache, closeCache } from '@spfn/core/cache';
11
- import { logger } from '@spfn/core/logger';
12
13
  import { serve } from '@hono/node-server';
13
14
  import PgBoss from 'pg-boss';
14
15
  import { networkInterfaces } from 'os';
@@ -317,6 +318,8 @@ function loadEnvFiles() {
317
318
  const cwd = process.cwd();
318
319
  const nodeEnv = process.env.NODE_ENV || "development";
319
320
  const envFiles = [
321
+ ".env.server.local",
322
+ ".env.server",
320
323
  `.env.${nodeEnv}.local`,
321
324
  nodeEnv !== "test" ? ".env.local" : null,
322
325
  `.env.${nodeEnv}`,
@@ -329,6 +332,90 @@ function loadEnvFiles() {
329
332
  }
330
333
  }
331
334
  }
335
+ var sseLogger = logger.child("@spfn/core:sse");
336
+ function createSSEHandler(router, config2 = {}) {
337
+ const {
338
+ pingInterval = 3e4
339
+ // headers: customHeaders = {}, // Reserved for future use
340
+ } = config2;
341
+ return async (c) => {
342
+ const eventsParam = c.req.query("events");
343
+ if (!eventsParam) {
344
+ return c.json({ error: "Missing events parameter" }, 400);
345
+ }
346
+ const requestedEvents = eventsParam.split(",").map((e) => e.trim());
347
+ const validEventNames = router.eventNames;
348
+ const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
349
+ if (invalidEvents.length > 0) {
350
+ return c.json({
351
+ error: "Invalid event names",
352
+ invalidEvents,
353
+ validEvents: validEventNames
354
+ }, 400);
355
+ }
356
+ sseLogger.debug("SSE connection requested", {
357
+ events: requestedEvents,
358
+ clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
359
+ });
360
+ return streamSSE(c, async (stream) => {
361
+ const unsubscribes = [];
362
+ let messageId = 0;
363
+ for (const eventName of requestedEvents) {
364
+ const eventDef = router.events[eventName];
365
+ if (!eventDef) {
366
+ continue;
367
+ }
368
+ const unsubscribe = eventDef.subscribe((payload) => {
369
+ messageId++;
370
+ const message = {
371
+ event: eventName,
372
+ data: payload
373
+ };
374
+ sseLogger.debug("SSE sending event", {
375
+ event: eventName,
376
+ messageId
377
+ });
378
+ void stream.writeSSE({
379
+ id: String(messageId),
380
+ event: eventName,
381
+ data: JSON.stringify(message)
382
+ });
383
+ });
384
+ unsubscribes.push(unsubscribe);
385
+ }
386
+ sseLogger.info("SSE connection established", {
387
+ events: requestedEvents,
388
+ subscriptionCount: unsubscribes.length
389
+ });
390
+ await stream.writeSSE({
391
+ event: "connected",
392
+ data: JSON.stringify({
393
+ subscribedEvents: requestedEvents,
394
+ timestamp: Date.now()
395
+ })
396
+ });
397
+ const pingTimer = setInterval(() => {
398
+ void stream.writeSSE({
399
+ event: "ping",
400
+ data: JSON.stringify({ timestamp: Date.now() })
401
+ });
402
+ }, pingInterval);
403
+ const abortSignal = c.req.raw.signal;
404
+ while (!abortSignal.aborted) {
405
+ await stream.sleep(pingInterval);
406
+ }
407
+ clearInterval(pingTimer);
408
+ unsubscribes.forEach((fn) => fn());
409
+ sseLogger.info("SSE connection closed", {
410
+ events: requestedEvents
411
+ });
412
+ }, async (err) => {
413
+ sseLogger.error("SSE stream error", {
414
+ error: err.message
415
+ });
416
+ });
417
+ };
418
+ }
332
419
  function createHealthCheckHandler(detailed) {
333
420
  return async (c) => {
334
421
  const response = {
@@ -465,7 +552,8 @@ async function loadCustomApp(appPath, appJsPath, config2) {
465
552
  }
466
553
  const app = await appFactory();
467
554
  if (config2?.routes) {
468
- registerRoutes(app, config2.routes, config2.middlewares);
555
+ const routes = registerRoutes(app, config2.routes, config2.middlewares);
556
+ logRegisteredRoutes(routes, config2?.debug ?? false);
469
557
  }
470
558
  return app;
471
559
  }
@@ -488,6 +576,7 @@ async function createAutoConfiguredApp(config2) {
488
576
  registerHealthCheckEndpoint(app, config2);
489
577
  await executeBeforeRoutesHook(app, config2);
490
578
  await loadAppRoutes(app, config2);
579
+ registerSSEEndpoint(app, config2);
491
580
  await executeAfterRoutesHook(app, config2);
492
581
  if (enableErrorHandler) {
493
582
  app.onError(ErrorHandler());
@@ -521,19 +610,47 @@ async function executeBeforeRoutesHook(app, config2) {
521
610
  async function loadAppRoutes(app, config2) {
522
611
  const debug = isDebugMode(config2);
523
612
  if (config2?.routes) {
524
- registerRoutes(app, config2.routes, config2.middlewares);
525
- if (debug) {
526
- serverLogger.info("\u2713 Routes registered");
527
- }
613
+ const routes = registerRoutes(app, config2.routes, config2.middlewares);
614
+ logRegisteredRoutes(routes, debug);
528
615
  } else if (debug) {
529
616
  serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
530
617
  }
531
618
  }
619
+ function logRegisteredRoutes(routes, debug) {
620
+ if (routes.length === 0) {
621
+ if (debug) {
622
+ serverLogger.warn("\u26A0\uFE0F No routes registered");
623
+ }
624
+ return;
625
+ }
626
+ const sortedRoutes = [...routes].sort((a, b) => a.path.localeCompare(b.path));
627
+ const maxMethodLen = Math.max(...sortedRoutes.map((r) => r.method.length));
628
+ const routeLines = sortedRoutes.map(
629
+ (r) => ` ${r.method.padEnd(maxMethodLen)} ${r.path}`
630
+ ).join("\n");
631
+ serverLogger.info(`\u2713 Routes registered (${routes.length}):
632
+ ${routeLines}`);
633
+ }
532
634
  async function executeAfterRoutesHook(app, config2) {
533
635
  if (config2?.lifecycle?.afterRoutes) {
534
636
  await config2.lifecycle.afterRoutes(app);
535
637
  }
536
638
  }
639
+ function registerSSEEndpoint(app, config2) {
640
+ if (!config2?.events) {
641
+ return;
642
+ }
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));
647
+ if (debug) {
648
+ const eventNames = config2.events.eventNames;
649
+ serverLogger.info(`\u2713 SSE endpoint registered at ${path}`, {
650
+ events: eventNames
651
+ });
652
+ }
653
+ }
537
654
  function isDebugMode(config2) {
538
655
  return config2?.debug ?? process.env.NODE_ENV === "development";
539
656
  }
@@ -547,12 +664,15 @@ async function initBoss(options) {
547
664
  }
548
665
  jobLogger.info("Initializing pg-boss...");
549
666
  bossConfig = options;
550
- bossInstance = new PgBoss({
667
+ const pgBossOptions = {
551
668
  connectionString: options.connectionString,
552
669
  schema: options.schema ?? "spfn_queue",
553
- maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120,
554
- monitorIntervalSeconds: options.monitorIntervalSeconds
555
- });
670
+ maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
671
+ };
672
+ if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
673
+ pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
674
+ }
675
+ bossInstance = new PgBoss(pgBossOptions);
556
676
  bossInstance.on("error", (error) => {
557
677
  jobLogger.error("pg-boss error:", error);
558
678
  });
@@ -639,7 +759,11 @@ async function registerJobs(router) {
639
759
  }
640
760
  jobLogger2.info("All jobs registered successfully");
641
761
  }
762
+ async function ensureQueue(boss, queueName) {
763
+ await boss.createQueue(queueName);
764
+ }
642
765
  async function registerWorker(boss, job2, queueName) {
766
+ await ensureQueue(boss, queueName);
643
767
  await boss.work(
644
768
  queueName,
645
769
  { batchSize: 1 },
@@ -686,6 +810,7 @@ async function registerCronSchedule(boss, job2) {
686
810
  return;
687
811
  }
688
812
  jobLogger2.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
813
+ await ensureQueue(boss, job2.name);
689
814
  await boss.schedule(
690
815
  job2.name,
691
816
  job2.cronExpression,
@@ -699,6 +824,7 @@ async function queueRunOnceJob(boss, job2) {
699
824
  return;
700
825
  }
701
826
  jobLogger2.debug(`[Job:${job2.name}] Queuing runOnce job`);
827
+ await ensureQueue(boss, job2.name);
702
828
  await boss.send(
703
829
  job2.name,
704
830
  {},
@@ -1314,6 +1440,41 @@ var ServerConfigBuilder = class {
1314
1440
  }
1315
1441
  return this;
1316
1442
  }
1443
+ /**
1444
+ * Register event router for SSE (Server-Sent Events)
1445
+ *
1446
+ * Enables real-time event streaming to frontend clients.
1447
+ * Events defined with defineEvent() can be subscribed by:
1448
+ * - Backend: .subscribe() for internal handlers
1449
+ * - Jobs: .on(event) for background processing
1450
+ * - Frontend: SSE stream for real-time updates
1451
+ *
1452
+ * @example
1453
+ * ```typescript
1454
+ * import { defineEvent, defineEventRouter } from '@spfn/core/event';
1455
+ *
1456
+ * const userCreated = defineEvent('user.created', Type.Object({
1457
+ * userId: Type.String(),
1458
+ * }));
1459
+ *
1460
+ * const eventRouter = defineEventRouter({ userCreated });
1461
+ *
1462
+ * export default defineServerConfig()
1463
+ * .routes(appRouter)
1464
+ * .events(eventRouter) // → GET /events/stream
1465
+ * .build();
1466
+ *
1467
+ * // Custom path
1468
+ * .events(eventRouter, { path: '/sse' })
1469
+ * ```
1470
+ */
1471
+ events(router, config2) {
1472
+ this.config.events = router;
1473
+ if (config2) {
1474
+ this.config.eventsConfig = config2;
1475
+ }
1476
+ return this;
1477
+ }
1317
1478
  /**
1318
1479
  * Enable/disable debug mode
1319
1480
  */