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

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 (43) hide show
  1. package/README.md +91 -6
  2. package/dist/{boss-D-fGtVgM.d.ts → boss-BO8ty33K.d.ts} +45 -5
  3. package/dist/config/index.d.ts +36 -0
  4. package/dist/config/index.js +15 -6
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/env/index.d.ts +29 -3
  7. package/dist/env/index.js +13 -13
  8. package/dist/env/index.js.map +1 -1
  9. package/dist/env/loader.d.ts +87 -0
  10. package/dist/env/loader.js +70 -0
  11. package/dist/env/loader.js.map +1 -0
  12. package/dist/event/index.d.ts +3 -70
  13. package/dist/event/index.js +10 -1
  14. package/dist/event/index.js.map +1 -1
  15. package/dist/event/sse/client.d.ts +82 -0
  16. package/dist/event/sse/client.js +115 -0
  17. package/dist/event/sse/client.js.map +1 -0
  18. package/dist/event/sse/index.d.ts +40 -0
  19. package/dist/event/sse/index.js +92 -0
  20. package/dist/event/sse/index.js.map +1 -0
  21. package/dist/job/index.d.ts +34 -3
  22. package/dist/job/index.js +18 -9
  23. package/dist/job/index.js.map +1 -1
  24. package/dist/middleware/index.d.ts +102 -11
  25. package/dist/middleware/index.js +2 -2
  26. package/dist/middleware/index.js.map +1 -1
  27. package/dist/nextjs/index.d.ts +2 -2
  28. package/dist/nextjs/index.js +1 -1
  29. package/dist/nextjs/index.js.map +1 -1
  30. package/dist/nextjs/server.d.ts +2 -2
  31. package/dist/nextjs/server.js +4 -1
  32. package/dist/nextjs/server.js.map +1 -1
  33. package/dist/route/index.d.ts +72 -13
  34. package/dist/route/index.js +82 -27
  35. package/dist/route/index.js.map +1 -1
  36. package/dist/route/types.d.ts +2 -31
  37. package/dist/router-Di7ENoah.d.ts +151 -0
  38. package/dist/server/index.d.ts +82 -6
  39. package/dist/server/index.js +175 -14
  40. package/dist/server/index.js.map +1 -1
  41. package/dist/types-B-e_f2dQ.d.ts +121 -0
  42. package/dist/{types-DRG2XMTR.d.ts → types-D_N_U-Py.d.ts} +76 -3
  43. package/package.json +18 -3
@@ -2,10 +2,23 @@ import { MiddlewareHandler, Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
3
  import { serve } from '@hono/node-server';
4
4
  import { NamedMiddleware, Router } from '@spfn/core/route';
5
- import { J as JobRouter, B as BossConfig } from '../boss-D-fGtVgM.js';
5
+ import { J as JobRouter, B as BossOptions } from '../boss-BO8ty33K.js';
6
+ import { E as EventRouterDef } from '../router-Di7ENoah.js';
7
+ import { S as SSEHandlerConfig } from '../types-B-e_f2dQ.js';
6
8
  import '@sinclair/typebox';
7
9
  import 'pg-boss';
8
10
 
11
+ /**
12
+ * Load environment files for SPFN server
13
+ *
14
+ * Priority (high → low, later files don't override):
15
+ * 1. .env.server.local - Server-only secrets (gitignored)
16
+ * 2. .env.server - Server-only defaults
17
+ * 3. .env.{NODE_ENV}.local
18
+ * 4. .env.local - Local overrides (gitignored)
19
+ * 5. .env.{NODE_ENV}
20
+ * 6. .env - Defaults
21
+ */
9
22
  declare function loadEnvFiles(): void;
10
23
 
11
24
  /**
@@ -117,7 +130,39 @@ interface ServerConfig {
117
130
  * pg-boss configuration options
118
131
  * Only used if jobs router is provided
119
132
  */
120
- jobsConfig?: Omit<BossConfig, 'connectionString'>;
133
+ jobsConfig?: Omit<BossOptions, 'connectionString'>;
134
+ /**
135
+ * Event router for SSE (Server-Sent Events) subscription
136
+ * Enables real-time event streaming to frontend clients
137
+ *
138
+ * @example
139
+ * ```typescript
140
+ * import { defineEvent, defineEventRouter } from '@spfn/core/event';
141
+ *
142
+ * const userCreated = defineEvent('user.created', Type.Object({
143
+ * userId: Type.String(),
144
+ * }));
145
+ *
146
+ * const eventRouter = defineEventRouter({ userCreated });
147
+ *
148
+ * export default defineServerConfig()
149
+ * .routes(appRouter)
150
+ * .events(eventRouter) // → GET /events/stream
151
+ * .build();
152
+ * ```
153
+ */
154
+ events?: EventRouterDef<any>;
155
+ /**
156
+ * SSE configuration options
157
+ * Only used if events router is provided
158
+ */
159
+ eventsConfig?: SSEHandlerConfig & {
160
+ /**
161
+ * SSE endpoint path
162
+ * @default '/events/stream'
163
+ */
164
+ path?: string;
165
+ };
121
166
  /**
122
167
  * Enable debug mode (default: NODE_ENV === 'development')
123
168
  */
@@ -545,7 +590,38 @@ declare class ServerConfigBuilder {
545
590
  * .build();
546
591
  * ```
547
592
  */
548
- jobs(router: JobRouter<any>, config?: Omit<BossConfig, 'connectionString'>): this;
593
+ jobs(router: JobRouter<any>, config?: Omit<BossOptions, 'connectionString'>): this;
594
+ /**
595
+ * Register event router for SSE (Server-Sent Events)
596
+ *
597
+ * Enables real-time event streaming to frontend clients.
598
+ * Events defined with defineEvent() can be subscribed by:
599
+ * - Backend: .subscribe() for internal handlers
600
+ * - Jobs: .on(event) for background processing
601
+ * - Frontend: SSE stream for real-time updates
602
+ *
603
+ * @example
604
+ * ```typescript
605
+ * import { defineEvent, defineEventRouter } from '@spfn/core/event';
606
+ *
607
+ * const userCreated = defineEvent('user.created', Type.Object({
608
+ * userId: Type.String(),
609
+ * }));
610
+ *
611
+ * const eventRouter = defineEventRouter({ userCreated });
612
+ *
613
+ * export default defineServerConfig()
614
+ * .routes(appRouter)
615
+ * .events(eventRouter) // → GET /events/stream
616
+ * .build();
617
+ *
618
+ * // Custom path
619
+ * .events(eventRouter, { path: '/sse' })
620
+ * ```
621
+ */
622
+ events(router: EventRouterDef<any>, config?: SSEHandlerConfig & {
623
+ path?: string;
624
+ }): this;
549
625
  /**
550
626
  * Enable/disable debug mode
551
627
  */
@@ -592,10 +668,10 @@ declare class ServerConfigBuilder {
592
668
  *
593
669
  * const appRouter = defineRouter({
594
670
  * getUser: route.get('/users/:id')
595
- * .input(Type.Object({ id: Type.String() }))
671
+ * .input({ params: Type.Object({ id: Type.String() }) })
596
672
  * .handler(async (c) => {
597
- * const { id } = await c.data();
598
- * return c.success({ id, name: 'John' });
673
+ * const { params } = await c.data();
674
+ * return { id: params.id, name: 'John' };
599
675
  * }),
600
676
  * });
601
677
  *
@@ -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,38 +610,69 @@ 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
  }
540
657
  var jobLogger = logger.child("@spfn/core:job");
541
658
  var bossInstance = null;
542
659
  var bossConfig = null;
543
- async function initBoss(config2) {
660
+ async function initBoss(options) {
544
661
  if (bossInstance) {
545
662
  jobLogger.warn("pg-boss already initialized, returning existing instance");
546
663
  return bossInstance;
547
664
  }
548
665
  jobLogger.info("Initializing pg-boss...");
549
- bossConfig = config2;
550
- bossInstance = new PgBoss({
551
- connectionString: config2.connectionString,
552
- schema: config2.schema ?? "spfn_queue",
553
- maintenanceIntervalSeconds: config2.maintenanceIntervalSeconds ?? 120,
554
- monitorIntervalSeconds: config2.monitorIntervalSeconds
555
- });
666
+ bossConfig = options;
667
+ const pgBossOptions = {
668
+ connectionString: options.connectionString,
669
+ schema: options.schema ?? "spfn_queue",
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
  */