@spfn/core 0.2.0-beta.9 → 0.3.0-beta.10

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 (119) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +693 -279
  3. package/dist/app-config/index.d.ts +96 -0
  4. package/dist/app-config/index.js +58 -0
  5. package/dist/app-config/index.js.map +1 -0
  6. package/dist/authz/index.d.ts +34 -0
  7. package/dist/authz/index.js +430 -0
  8. package/dist/authz/index.js.map +1 -0
  9. package/dist/boss-BiDCbbAQ.d.ts +337 -0
  10. package/dist/cache/index.js +42 -30
  11. package/dist/cache/index.js.map +1 -1
  12. package/dist/codegen/index.d.ts +121 -13
  13. package/dist/codegen/index.js +212 -15
  14. package/dist/codegen/index.js.map +1 -1
  15. package/dist/config/index.d.ts +576 -30
  16. package/dist/config/index.js +136 -17
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/contract/index.d.ts +221 -0
  19. package/dist/contract/index.js +558 -0
  20. package/dist/contract/index.js.map +1 -0
  21. package/dist/db/index.d.ts +704 -94
  22. package/dist/db/index.js +1105 -260
  23. package/dist/db/index.js.map +1 -1
  24. package/dist/define-middleware-CVKgqo8S.d.ts +167 -0
  25. package/dist/env/index.d.ts +26 -2
  26. package/dist/env/index.js +15 -5
  27. package/dist/env/index.js.map +1 -1
  28. package/dist/env/loader.d.ts +26 -19
  29. package/dist/env/loader.js +55 -25
  30. package/dist/env/loader.js.map +1 -1
  31. package/dist/errors/index.d.ts +10 -0
  32. package/dist/errors/index.js +40 -7
  33. package/dist/errors/index.js.map +1 -1
  34. package/dist/event/index.d.ts +33 -3
  35. package/dist/event/index.js +24 -3
  36. package/dist/event/index.js.map +1 -1
  37. package/dist/event/sse/client.d.ts +55 -3
  38. package/dist/event/sse/client.js +164 -45
  39. package/dist/event/sse/client.js.map +1 -1
  40. package/dist/event/sse/index.d.ts +12 -5
  41. package/dist/event/sse/index.js +280 -32
  42. package/dist/event/sse/index.js.map +1 -1
  43. package/dist/event/ws/client.d.ts +59 -0
  44. package/dist/event/ws/client.js +273 -0
  45. package/dist/event/ws/client.js.map +1 -0
  46. package/dist/event/ws/index.d.ts +94 -0
  47. package/dist/event/ws/index.js +272 -0
  48. package/dist/event/ws/index.js.map +1 -0
  49. package/dist/job/index.d.ts +48 -3
  50. package/dist/job/index.js +265 -74
  51. package/dist/job/index.js.map +1 -1
  52. package/dist/logger/index.d.ts +5 -0
  53. package/dist/logger/index.js +14 -12
  54. package/dist/logger/index.js.map +1 -1
  55. package/dist/middleware/index.d.ts +352 -10
  56. package/dist/middleware/index.js +1079 -13
  57. package/dist/middleware/index.js.map +1 -1
  58. package/dist/nextjs/index.d.ts +39 -3
  59. package/dist/nextjs/index.js +82 -29
  60. package/dist/nextjs/index.js.map +1 -1
  61. package/dist/nextjs/server.d.ts +69 -52
  62. package/dist/nextjs/server.js +140 -60
  63. package/dist/nextjs/server.js.map +1 -1
  64. package/dist/ops/index.d.ts +208 -0
  65. package/dist/ops/index.js +800 -0
  66. package/dist/ops/index.js.map +1 -0
  67. package/dist/route/index.d.ts +10 -694
  68. package/dist/route/index.js +112 -23
  69. package/dist/route/index.js.map +1 -1
  70. package/dist/route-builder-2ani2jEI.d.ts +553 -0
  71. package/dist/router-DJdpwuB6.d.ts +127 -0
  72. package/dist/security/index.d.ts +83 -0
  73. package/dist/security/index.js +173 -0
  74. package/dist/security/index.js.map +1 -0
  75. package/dist/server/index.d.ts +619 -31
  76. package/dist/server/index.js +2583 -360
  77. package/dist/server/index.js.map +1 -1
  78. package/dist/token-manager-BT5EnUAR.d.ts +278 -0
  79. package/dist/types-2AbaW4Ie.d.ts +205 -0
  80. package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
  81. package/dist/types-ClQVomgV.d.ts +115 -0
  82. package/dist/types-ZQODsBft.d.ts +282 -0
  83. package/docs/file-upload.md +195 -333
  84. package/package.json +250 -208
  85. package/src/cache/README.md +330 -0
  86. package/src/codegen/README.md +516 -0
  87. package/src/config/README.md +326 -0
  88. package/src/contract/README.md +326 -0
  89. package/src/db/README.md +589 -0
  90. package/src/db/manager/README.md +500 -0
  91. package/src/db/schema/README.md +354 -0
  92. package/src/db/transaction/README.md +822 -0
  93. package/src/env/README.md +651 -0
  94. package/src/errors/README.md +429 -0
  95. package/src/event/README.md +741 -0
  96. package/src/job/README.md +613 -0
  97. package/src/logger/README.md +321 -0
  98. package/src/middleware/README.md +634 -0
  99. package/src/nextjs/README.md +608 -0
  100. package/src/route/README.md +738 -0
  101. package/src/security/README.md +100 -0
  102. package/src/server/README.md +704 -0
  103. package/dist/boss-DI1r4kTS.d.ts +0 -244
  104. package/dist/router-Di7ENoah.d.ts +0 -151
  105. package/dist/types-B-e_f2dQ.d.ts +0 -121
  106. package/docs/cache.md +0 -133
  107. package/docs/codegen.md +0 -74
  108. package/docs/database.md +0 -346
  109. package/docs/entity.md +0 -539
  110. package/docs/env.md +0 -477
  111. package/docs/errors.md +0 -319
  112. package/docs/event.md +0 -116
  113. package/docs/job.md +0 -131
  114. package/docs/logger.md +0 -108
  115. package/docs/middleware.md +0 -337
  116. package/docs/nextjs.md +0 -241
  117. package/docs/repository.md +0 -496
  118. package/docs/route.md +0 -497
  119. package/docs/server.md +0 -307
@@ -1,26 +1,59 @@
1
- import { MiddlewareHandler, Hono } from 'hono';
1
+ export { loadEnv } from '../env/loader.js';
2
+ import { Hono, MiddlewareHandler } from 'hono';
2
3
  import { cors } from 'hono/cors';
3
4
  import { serve } from '@hono/node-server';
4
5
  import { NamedMiddleware, Router } from '@spfn/core/route';
5
- import { J as JobRouter, B as BossOptions } from '../boss-DI1r4kTS.js';
6
- import { E as EventRouterDef } from '../router-Di7ENoah.js';
7
- import { S as SSEHandlerConfig } from '../types-B-e_f2dQ.js';
8
- import '@sinclair/typebox';
6
+ import { OnErrorContext, ProxyGuardConfig, RateLimitOptions } from '@spfn/core/middleware';
7
+ import { SafeFetchPolicy } from '@spfn/core/security';
8
+ import { c as JobRouter, f as BossOptions } from '../boss-BiDCbbAQ.js';
9
+ import { b as EventRouterDef, E as EventDef } from '../token-manager-BT5EnUAR.js';
10
+ import { d as SSEHandlerConfig, e as SSEAuthConfig } from '../types-ZQODsBft.js';
11
+ import { W as WSRouterDef, f as WSHandlerConfig, e as WSMessageHandlers, g as WSAuthConfig } from '../types-2AbaW4Ie.js';
12
+ import { DatabaseProvider, MigrationStatus, MigrationStatusDb } from '@spfn/core/db';
13
+ import * as _sinclair_typebox from '@sinclair/typebox';
14
+ import { Static } from '@sinclair/typebox';
15
+ import { R as RouteDef } from '../route-builder-2ani2jEI.js';
9
16
  import 'pg-boss';
17
+ import '../define-middleware-CVKgqo8S.js';
18
+ import 'hono/utils/http-status';
19
+ import '../route/types.js';
10
20
 
11
21
  /**
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
22
+ * @deprecated Use `loadEnv` from '@spfn/core/env/loader' instead.
23
+ * This module will be removed in the next major version.
24
+ */
25
+ /**
26
+ * @deprecated Use `loadEnv()` from '@spfn/core/env/loader' instead.
21
27
  */
22
28
  declare function loadEnvFiles(): void;
23
29
 
30
+ /** Stable operation identity used by separately deployed clients. */
31
+ declare const CORE_TIME_OPERATION_ID = "core.time";
32
+ /**
33
+ * Closed response shape for the server-time wire capability.
34
+ *
35
+ * Millisecond timestamps use integers throughout SPFN's external contracts.
36
+ */
37
+ declare const ServerTimeResponseSchema: _sinclair_typebox.TObject<{
38
+ serverTimeMillis: _sinclair_typebox.TInteger;
39
+ }>;
40
+ type ServerTimeResponse = Static<typeof ServerTimeResponseSchema>;
41
+ /** Injectable source of Unix epoch milliseconds. */
42
+ interface ServerClock {
43
+ now(): number;
44
+ }
45
+ /**
46
+ * Build the route from a clock so tests and alternate runtimes can supply the
47
+ * epoch source without replacing global time.
48
+ */
49
+ declare function createCoreTimeRoute(clock?: ServerClock): RouteDef<{}, {}, {
50
+ serverTimeMillis: number;
51
+ }>;
52
+ /** The production route and the public wire-contract source of truth. */
53
+ declare const CORE_TIME_ROUTE: RouteDef<{}, {}, {
54
+ serverTimeMillis: number;
55
+ }>;
56
+
24
57
  /**
25
58
  * Workflow router interface for @spfn/core integration
26
59
  *
@@ -77,11 +110,86 @@ interface ServerConfig {
77
110
  * Error handler (default: true)
78
111
  */
79
112
  errorHandler?: boolean;
113
+ /**
114
+ * Callback invoked when an error occurs (passed to ErrorHandler)
115
+ *
116
+ * Called asynchronously without blocking the response.
117
+ *
118
+ * @example
119
+ * ```typescript
120
+ * import { createErrorSlackNotifier } from '@spfn/notification/server';
121
+ *
122
+ * middleware: {
123
+ * onError: createErrorSlackNotifier({ minStatusCode: 500 }),
124
+ * }
125
+ * ```
126
+ */
127
+ onError?: (err: Error, context: OnErrorContext) => Promise<void> | void;
80
128
  };
81
129
  /**
82
130
  * Additional custom middleware
83
131
  */
84
132
  use?: MiddlewareHandler[];
133
+ /**
134
+ * Proxy-guard: verify requests came through the trusted Next.js RPC proxy
135
+ * (HMAC signature) and/or an allowed browser origin, then tag `clientType`.
136
+ * Lets the backend reject direct-to-backend calls that bypass the proxy.
137
+ *
138
+ * Disabled by default (`mode: 'off'`). Requires the same `SPFN_PROXY_SECRET`
139
+ * on the proxy and the backend. See PROXY-BACKEND-AUTH-SPEC.md.
140
+ *
141
+ * @example
142
+ * ```typescript
143
+ * .proxyGuard({ mode: 'strict', allowedOrigins: ['https://app.example.com'] })
144
+ * ```
145
+ */
146
+ proxyGuard?: Omit<ProxyGuardConfig, 'nonceStore'> & {
147
+ /**
148
+ * Enable hard replay rejection via a Redis nonce store. Evaluated in BOTH modes
149
+ * (tag observes replays, strict rejects). Requires a cache (CACHE_URL); without
150
+ * one, falls back to the timestamp window. Degrades to the window if the store
151
+ * is briefly unavailable. @default false
152
+ */
153
+ nonce?: boolean;
154
+ };
155
+ /**
156
+ * Rate limiting: an optional global default limiter plus named policies.
157
+ *
158
+ * `mode: 'on'` applies `default` to every named-middleware route (opt out
159
+ * with `.skip(['rateLimit'])`); `policies` lets packages tag sensitive routes
160
+ * via `rateLimitPolicy(name, fallback)` while this app tunes the numbers in
161
+ * one place. Backed by the shared cache (CACHE_URL); without a cache it fails
162
+ * open unless `default.failClosed` is set. Disabled by default (`mode: 'off'`).
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * .rateLimit({
167
+ * mode: 'on',
168
+ * default: { limit: 100, windowMs: 60_000 },
169
+ * policies: { 'auth-login': { limit: 5, windowMs: 60_000 } },
170
+ * })
171
+ * ```
172
+ */
173
+ rateLimit?: {
174
+ /** 'on' applies the default limiter to every route. @default 'off' */
175
+ mode?: 'off' | 'on';
176
+ /** Default policy applied to all routes when `mode` is 'on'. */
177
+ default?: RateLimitOptions;
178
+ /** Named policies referenced by `rateLimitPolicy(name, fallback)` tags. */
179
+ policies?: Record<string, RateLimitOptions>;
180
+ };
181
+ /**
182
+ * SSRF policy for outbound requests made via `safeFetch` (`@spfn/core/security`).
183
+ * Sets the process-wide default used by webhook/callback senders. Private and
184
+ * reserved IPs are blocked by default; set `allowHosts` to restrict to a known
185
+ * set of upstreams, or `blockPrivateIps: false` for trusted internal calls.
186
+ *
187
+ * @example
188
+ * ```typescript
189
+ * .outboundFetch({ allowHosts: ['hooks.slack.com'] })
190
+ * ```
191
+ */
192
+ outboundFetch?: SafeFetchPolicy;
85
193
  /**
86
194
  * Global middlewares with names for route-level skip control
87
195
  * Use defineMiddleware() for type-safe middleware definitions
@@ -126,6 +234,8 @@ interface ServerConfig {
126
234
  * Jobs defined with job()...handler() style
127
235
  * Uses pg-boss for PostgreSQL-based job queue
128
236
  *
237
+ * Merged across every `.jobs()` call, so this holds all of them.
238
+ *
129
239
  * @example
130
240
  * ```typescript
131
241
  * import { job, defineJobRouter } from '@spfn/core/job';
@@ -180,6 +290,30 @@ interface ServerConfig {
180
290
  */
181
291
  path?: string;
182
292
  };
293
+ /**
294
+ * WebSocket router for bidirectional real-time communication
295
+ *
296
+ * @example
297
+ * ```typescript
298
+ * import { defineWSRouter } from '@spfn/core/event/ws';
299
+ *
300
+ * export default defineServerConfig()
301
+ * .websockets(wsRouter) // → WS /ws
302
+ * .build();
303
+ * ```
304
+ */
305
+ websockets?: WSRouterDef<any, any>;
306
+ /**
307
+ * WebSocket configuration options
308
+ * Only used if websockets router is provided
309
+ */
310
+ websocketsConfig?: WSHandlerConfig & {
311
+ /**
312
+ * WebSocket endpoint path
313
+ * @default '/ws'
314
+ */
315
+ path?: string;
316
+ };
183
317
  /**
184
318
  * Enable debug mode (default: NODE_ENV === 'development')
185
319
  */
@@ -188,6 +322,13 @@ interface ServerConfig {
188
322
  * Database configuration
189
323
  */
190
324
  database?: {
325
+ /**
326
+ * Externally owned PostgreSQL Drizzle provider.
327
+ *
328
+ * When supplied, SPFN skips DATABASE_URL/postgres.js initialization
329
+ * and closes the provider during graceful shutdown.
330
+ */
331
+ provider?: DatabaseProvider;
191
332
  /**
192
333
  * Connection pool configuration
193
334
  * Overrides environment variables and defaults
@@ -299,6 +440,34 @@ interface ServerConfig {
299
440
  */
300
441
  headers?: number;
301
442
  };
443
+ /**
444
+ * Fetch (outbound HTTP) timeout configuration
445
+ * Controls Node.js undici global dispatcher timeouts for fetch() calls
446
+ * Applies to all outbound HTTP requests made via fetch() in this process
447
+ */
448
+ fetchTimeout?: {
449
+ /**
450
+ * TCP connection timeout in milliseconds
451
+ * Time to establish socket connection to upstream server
452
+ * @default 10000 (10 seconds)
453
+ * @env FETCH_CONNECT_TIMEOUT
454
+ */
455
+ connect?: number;
456
+ /**
457
+ * Response headers timeout in milliseconds
458
+ * Time to receive complete response headers after request sent
459
+ * @default 300000 (5 minutes)
460
+ * @env FETCH_HEADERS_TIMEOUT
461
+ */
462
+ headers?: number;
463
+ /**
464
+ * Body data timeout in milliseconds
465
+ * Maximum time between body data chunks from upstream server
466
+ * @default 300000 (5 minutes)
467
+ * @env FETCH_BODY_TIMEOUT
468
+ */
469
+ body?: number;
470
+ };
302
471
  /**
303
472
  * Graceful shutdown configuration
304
473
  * Controls server shutdown behavior during SIGTERM/SIGINT signals
@@ -306,16 +475,22 @@ interface ServerConfig {
306
475
  shutdown?: {
307
476
  /**
308
477
  * Graceful shutdown timeout in milliseconds
309
- * Maximum time to wait for ongoing requests and resource cleanup
310
- * After timeout, forces process termination
311
- * @default 30000 (30 seconds)
478
+ * Maximum time to wait for in-flight operations to drain and resource cleanup
479
+ * After timeout, forces process.exit() before k8s SIGKILL
480
+ *
481
+ * Formula: terminationGracePeriodSeconds - preStopSleep - safetyMargin
482
+ * Default: 300s - 5s - 15s = 280s
483
+ *
484
+ * @default 280000 (280 seconds)
312
485
  * @env SHUTDOWN_TIMEOUT
313
486
  */
314
487
  timeout?: number;
315
488
  };
316
489
  /**
317
490
  * Health check endpoint configuration
318
- * Provides monitoring endpoints for Kubernetes probes and load balancers
491
+ *
492
+ * The endpoint answers at `/_core/health`, which no app route can take from
493
+ * it. Point Kubernetes probes and load balancers there.
319
494
  */
320
495
  healthCheck?: {
321
496
  /**
@@ -325,9 +500,19 @@ interface ServerConfig {
325
500
  */
326
501
  enabled?: boolean;
327
502
  /**
328
- * Health check endpoint path
329
- * @default '/health'
330
- * @env HEALTH_CHECK_PATH
503
+ * An additional path to answer on, for a deployment whose probe path is
504
+ * fixed somewhere you cannot change it.
505
+ *
506
+ * BREAKING (@spfn/core 0.4): unset, the endpoint answers at
507
+ * `/_core/health` and nowhere else. It used to default to `/health` and
508
+ * swallow any app route declared there. `/health` now belongs to your
509
+ * app; set this to `'/health'` to have the built-in answer there again.
510
+ *
511
+ * The path is registered before app routes, so an app route on the same
512
+ * path will not run and the server says so at boot.
513
+ *
514
+ * @default undefined — `/_core/health` only
515
+ * @example '/health'
331
516
  */
332
517
  path?: string;
333
518
  /**
@@ -338,20 +523,56 @@ interface ServerConfig {
338
523
  */
339
524
  detailed?: boolean;
340
525
  };
526
+ /**
527
+ * Server-time capability dependencies.
528
+ *
529
+ * The endpoint is always enabled at `GET /_core/time`. Supplying a clock is
530
+ * primarily a deterministic test seam; production defaults to `Date.now()`.
531
+ */
532
+ serverTime?: {
533
+ clock?: ServerClock;
534
+ };
535
+ /**
536
+ * Migration boot gate
537
+ *
538
+ * Before serving, the server compares the migrations shipped by installed
539
+ * function packages (and by `src/server/drizzle`) against what the database
540
+ * records as applied, and refuses to start when any are still pending —
541
+ * otherwise the mismatch surfaces only at request time, as an opaque 500.
542
+ *
543
+ * Skipped when the app initializes no database or ships no migrations.
544
+ */
545
+ migrations?: {
546
+ /**
547
+ * Start anyway when migrations are pending, logging a warning that lists
548
+ * them. The environment equivalent is `SPFN_ALLOW_PENDING_MIGRATIONS=true`;
549
+ * this field wins over it.
550
+ *
551
+ * @default false
552
+ * @env SPFN_ALLOW_PENDING_MIGRATIONS
553
+ */
554
+ allowPending?: boolean;
555
+ };
341
556
  /**
342
557
  * Infrastructure initialization control
343
558
  * Controls automatic initialization of database and Redis
344
- * @default Both enabled if credentials exist
559
+ *
560
+ * Both are initialized unless set to `false` — the credentials are not
561
+ * sniffed. A server that needs no database must say so, otherwise boot
562
+ * fails with `No database configuration found`. A component set to `false`
563
+ * is reported as `disabled` by the health endpoint and never degrades it.
564
+ *
565
+ * @default Both enabled
345
566
  */
346
567
  infrastructure?: {
347
568
  /**
348
569
  * Enable/disable automatic database initialization
349
- * @default true if DATABASE_URL exists
570
+ * @default true
350
571
  */
351
572
  database?: boolean;
352
573
  /**
353
574
  * Enable/disable automatic Redis initialization
354
- * @default true if REDIS_URL exists
575
+ * @default true
355
576
  */
356
577
  redis?: boolean;
357
578
  };
@@ -550,6 +771,43 @@ declare module 'hono' {
550
771
  */
551
772
  declare function createServer(config?: ServerConfig): Promise<Hono>;
552
773
 
774
+ /**
775
+ * The paths `@spfn/core` registers for itself.
776
+ *
777
+ * Its own module because both `create-server.ts` (which registers them) and
778
+ * `helpers.ts` (which reports them in the startup banner) need them, and
779
+ * `create-server.ts` already imports `helpers.ts` — putting them in either file
780
+ * would close a cycle, which `pnpm check:circular` fails the build on.
781
+ *
782
+ * @module server/namespace
783
+ */
784
+ /**
785
+ * The path prefix `@spfn/core` registers its own endpoints under.
786
+ *
787
+ * An app declaring a route in here is declaring something it does not own, and
788
+ * the route will not run — core's endpoints are registered before app routes.
789
+ * `@spfn/auth` (`/_auth/`) and ops routes (`/_ops/`) follow the same convention,
790
+ * and neither has ever had a shadowing defect.
791
+ */
792
+ declare const CORE_NAMESPACE = "/_core";
793
+ /**
794
+ * Where the built-in health endpoint always answers.
795
+ *
796
+ * Point a readiness probe, a Dockerfile `HEALTHCHECK` or an uptime monitor here
797
+ * rather than at `/health`: no app route can claim this path, so the answer does
798
+ * not depend on what the app happens to declare. A probe's path is fixed in
799
+ * places this repository cannot change — a GitOps manifest, a Dockerfile, a load
800
+ * balancer console — and a version bump migrates none of them.
801
+ */
802
+ declare const CORE_HEALTH_PATH = "/_core/health";
803
+ /**
804
+ * Where clients obtain the server's current Unix epoch in milliseconds.
805
+ *
806
+ * This endpoint is registered before application routes and application auth
807
+ * middleware so a client can call it before it has a proof or session.
808
+ */
809
+ declare const CORE_TIME_PATH = "/_core/time";
810
+
553
811
  /**
554
812
  * Start SPFN Server
555
813
  *
@@ -567,6 +825,236 @@ declare function createServer(config?: ServerConfig): Promise<Hono>;
567
825
  */
568
826
  declare function startServer(config?: ServerConfig): Promise<ServerInstance>;
569
827
 
828
+ /**
829
+ * Shutdown Manager
830
+ *
831
+ * Manages graceful shutdown with drain behavior.
832
+ * All tracked operations must complete before shutdown proceeds.
833
+ *
834
+ * Features:
835
+ * - Hook registry: Multiple modules can register independent cleanup handlers
836
+ * - Operation tracking: Long-running tasks are awaited during shutdown (drain)
837
+ * - State management: isShuttingDown() for rejecting new work
838
+ */
839
+ interface ShutdownHookOptions {
840
+ /**
841
+ * Timeout for this hook in milliseconds
842
+ * If the hook exceeds this time, it is skipped and the next hook runs
843
+ * @default 10000 (10s)
844
+ */
845
+ timeout?: number;
846
+ /**
847
+ * Execution order (lower runs first)
848
+ * @default 100
849
+ */
850
+ order?: number;
851
+ }
852
+ declare class ShutdownManager {
853
+ private state;
854
+ private hooks;
855
+ private operations;
856
+ private operationCounter;
857
+ /**
858
+ * Register a shutdown hook
859
+ *
860
+ * Hooks run in order during shutdown, after all tracked operations drain.
861
+ * Each hook has its own timeout — failure does not block subsequent hooks.
862
+ *
863
+ * @example
864
+ * shutdown.onShutdown('ai-service', async () => {
865
+ * await aiService.cancelPending();
866
+ * }, { timeout: 30000, order: 10 });
867
+ */
868
+ onShutdown(name: string, handler: () => Promise<void>, options?: ShutdownHookOptions): void;
869
+ /**
870
+ * Track a long-running operation
871
+ *
872
+ * During shutdown (drain phase), the process waits for ALL tracked
873
+ * operations to complete before proceeding with cleanup.
874
+ *
875
+ * If shutdown has already started, the operation is rejected immediately.
876
+ *
877
+ * @returns The operation result (pass-through)
878
+ *
879
+ * @example
880
+ * const result = await shutdown.trackOperation(
881
+ * 'ai-generate',
882
+ * aiService.generate(prompt)
883
+ * );
884
+ */
885
+ trackOperation<T>(name: string, operation: Promise<T>): Promise<T>;
886
+ /**
887
+ * Whether the server is shutting down
888
+ *
889
+ * Use this to reject new work early (e.g., return 503 in route handlers).
890
+ */
891
+ isShuttingDown(): boolean;
892
+ /**
893
+ * Number of currently active tracked operations
894
+ */
895
+ getActiveOperationCount(): number;
896
+ /**
897
+ * Mark shutdown as started immediately
898
+ *
899
+ * Call this at the very beginning of the shutdown sequence so that:
900
+ * - Health check returns 503 right away
901
+ * - trackOperation() rejects new work
902
+ * - isShuttingDown() returns true
903
+ */
904
+ beginShutdown(): void;
905
+ /**
906
+ * Execute the full shutdown sequence
907
+ *
908
+ * 1. State → draining (reject new operations)
909
+ * 2. Wait for all tracked operations to complete (drain)
910
+ * 3. Run shutdown hooks in order
911
+ * 4. State → closed
912
+ *
913
+ * @param drainTimeout - Max time to wait for operations to drain (ms)
914
+ */
915
+ execute(drainTimeout: number): Promise<void>;
916
+ /**
917
+ * Wait for all tracked operations to complete, up to drainTimeout
918
+ */
919
+ private drain;
920
+ /**
921
+ * Execute registered shutdown hooks in order
922
+ */
923
+ private executeHooks;
924
+ }
925
+ /**
926
+ * Get the global ShutdownManager instance
927
+ *
928
+ * Available after server starts. Use this to register shutdown hooks
929
+ * or track long-running operations.
930
+ *
931
+ * @example
932
+ * import { getShutdownManager } from '@spfn/core/server';
933
+ *
934
+ * const shutdown = getShutdownManager();
935
+ *
936
+ * // Register cleanup
937
+ * shutdown.onShutdown('my-service', async () => {
938
+ * await myService.close();
939
+ * });
940
+ *
941
+ * // Track long operation
942
+ * await shutdown.trackOperation('ai-task', longRunningPromise);
943
+ */
944
+ declare function getShutdownManager(): ShutdownManager;
945
+
946
+ /**
947
+ * Serverless target for SPFN.
948
+ *
949
+ * Produces a listen-free, initialized Hono app for serverless platforms (Vercel,
950
+ * AWS Lambda, Cloudflare) — wrap the result with a hono platform adapter, e.g.
951
+ * `handle(app)` from `hono/vercel`.
952
+ *
953
+ * Unlike {@link startServer} (which serve()s a long-lived process and is the
954
+ * always-on / container path), this:
955
+ * - initializes the database in-handler (startServer welds DB init to serve()),
956
+ * - runs at most once per warm container (memoized),
957
+ * - disables the periodic DB health-check (pointless — and a timer leak — on
958
+ * frozen invocations),
959
+ * - does NOT start the in-process pg-boss worker (it cannot run on a serverless
960
+ * platform); enqueue still works, but nothing drains the queue here,
961
+ * - does NOT run seed/RBAC provisioning per cold start — that moves to a
962
+ * deploy-time step, see {@link provisionInfrastructure}.
963
+ *
964
+ * `startServer()` / `spfn start` (the always-on path) is unchanged by this module.
965
+ */
966
+
967
+ /**
968
+ * Build — once per warm container — the initialized, listen-free Hono app for a
969
+ * serverless platform. Wrap the result with the platform adapter:
970
+ *
971
+ * ```ts
972
+ * import { handle } from 'hono/vercel';
973
+ * import { createServerlessApp } from '@spfn/core/server';
974
+ * import serverConfig from '@/server/server.config';
975
+ *
976
+ * const handler = async (req: Request) => handle(await createServerlessApp(serverConfig))(req);
977
+ * export const GET = handler;
978
+ * export const POST = handler;
979
+ * ```
980
+ */
981
+ declare function createServerlessApp(config?: ServerConfig): Promise<Hono>;
982
+ /**
983
+ * Reset the memoized serverless app. Tests only.
984
+ */
985
+ declare function resetServerlessApp(): void;
986
+ /**
987
+ * Deploy-time provisioning — run ONCE per deploy, not per request.
988
+ *
989
+ * Initializes the database and runs the config's provisioning lifecycle hooks
990
+ * (`beforeInfrastructure` / `afterInfrastructure`, e.g. admin seeding + RBAC init).
991
+ * Intended for a build/deploy step (`spfn provision`), keeping per-cold-start work
992
+ * out of the serverless handler. On always-on targets this is equally useful: it
993
+ * avoids re-seeding on every pod restart / replica.
994
+ *
995
+ * Does NOT start the HTTP server, jobs worker, or health-check.
996
+ */
997
+ declare function provisionInfrastructure(config?: ServerConfig): Promise<void>;
998
+
999
+ /**
1000
+ * Migration Boot Gate
1001
+ *
1002
+ * A server that boots with pending migrations passes its health check and then
1003
+ * fails every request that touches a missing column, as an opaque 500. The gate
1004
+ * moves that failure to boot, where it is one line to read and one command to
1005
+ * fix.
1006
+ *
1007
+ * The check runs on the database the server already connected to. When no
1008
+ * database was initialized — an app that uses none — there is nothing to check
1009
+ * and boot proceeds. When the database is configured but unreachable,
1010
+ * `initDatabase()` has already failed before the gate runs, so the gate never
1011
+ * turns a database outage into a migration message.
1012
+ */
1013
+
1014
+ type MigrationSnapshot =
1015
+ /** Checked successfully — `status` carries per-target applied/pending counts. */
1016
+ {
1017
+ state: 'ok';
1018
+ checkedAt: string;
1019
+ status: MigrationStatus;
1020
+ pending: number;
1021
+ }
1022
+ /** Nothing to check: no database in use, or no migrations shipped. */
1023
+ | {
1024
+ state: 'skipped';
1025
+ checkedAt: string;
1026
+ reason: string;
1027
+ }
1028
+ /** Could not check — distinct from "checked and pending". */
1029
+ | {
1030
+ state: 'unavailable';
1031
+ checkedAt: string;
1032
+ reason: string;
1033
+ };
1034
+ /**
1035
+ * Thrown when the gate refuses a boot. Not an HTTP error — it never reaches a
1036
+ * request.
1037
+ */
1038
+ declare class PendingMigrationsError extends Error {
1039
+ readonly targets: string[];
1040
+ constructor(message: string, targets: string[]);
1041
+ }
1042
+ /**
1043
+ * Forget the cached snapshot — used by tests and after a manual migration run.
1044
+ */
1045
+ declare function resetMigrationSnapshot(): void;
1046
+ /**
1047
+ * Current migration snapshot, recomputed at most once per TTL.
1048
+ *
1049
+ * The boot gate seeds it, so the first health probe after startup costs nothing.
1050
+ */
1051
+ declare function getMigrationSnapshot(options?: {
1052
+ cwd?: string;
1053
+ db?: MigrationStatusDb;
1054
+ force?: boolean;
1055
+ ttlMs?: number;
1056
+ }): Promise<MigrationSnapshot>;
1057
+
570
1058
  /**
571
1059
  * Server Config Builder
572
1060
  *
@@ -600,12 +1088,47 @@ declare class ServerConfigBuilder {
600
1088
  * Add named middlewares for route-level skip control
601
1089
  */
602
1090
  middlewares(middlewares: ServerConfig['middlewares']): this;
1091
+ /**
1092
+ * Configure proxy-guard (verify trusted-proxy signature + origin → clientType)
1093
+ */
1094
+ proxyGuard(proxyGuard: ServerConfig['proxyGuard']): this;
1095
+ /**
1096
+ * Configure rate limiting: an optional global default limiter plus the named
1097
+ * policies that `rateLimitPolicy(name, fallback)` tags resolve against.
1098
+ *
1099
+ * @example
1100
+ * ```typescript
1101
+ * .rateLimit({
1102
+ * mode: 'on',
1103
+ * default: { limit: 100, windowMs: 60_000 },
1104
+ * policies: { 'auth-login': { limit: 5, windowMs: 60_000 } },
1105
+ * })
1106
+ * ```
1107
+ */
1108
+ rateLimit(rateLimit: ServerConfig['rateLimit']): this;
1109
+ /**
1110
+ * Configure the SSRF policy for outbound `safeFetch` calls (webhooks,
1111
+ * callbacks). Private/reserved IPs are blocked by default.
1112
+ *
1113
+ * @example
1114
+ * ```typescript
1115
+ * .outboundFetch({ allowHosts: ['hooks.slack.com'] })
1116
+ * ```
1117
+ */
1118
+ outboundFetch(outboundFetch: ServerConfig['outboundFetch']): this;
603
1119
  /**
604
1120
  * Register define-route based router
605
1121
  *
606
- * Automatically applies:
607
- * - Global middlewares from router._globalMiddlewares (via .use())
608
- * - Package routers from router._packageRouters (via .packages())
1122
+ * Router-level middleware (`.use()`) and package routers (`.packages()`) travel
1123
+ * with the router itself and are applied by `registerRoutes` when the routes are
1124
+ * mounted — this method only records which router to mount.
1125
+ *
1126
+ * It deliberately does **not** copy `router._globalMiddlewares` into
1127
+ * `config.middlewares`: that copy used to make `registerRoutes` see the same
1128
+ * middleware twice (once from the config list, once from the router it was
1129
+ * handed) and attach both to every route. Middleware that verifies a JWT survives
1130
+ * running twice; middleware that consumes one-shot state — a nonce replay ledger —
1131
+ * rejects its own request the second time round.
609
1132
  *
610
1133
  * @example
611
1134
  * ```typescript
@@ -616,7 +1139,7 @@ declare class ServerConfigBuilder {
616
1139
  * .use([authMiddleware]);
617
1140
  *
618
1141
  * export default defineServerConfig()
619
- * .routes(appRouter) // middlewares auto-applied
1142
+ * .routes(appRouter) // .use() middleware applied once, at registration
620
1143
  * .build();
621
1144
  * ```
622
1145
  */
@@ -624,6 +1147,20 @@ declare class ServerConfigBuilder {
624
1147
  /**
625
1148
  * Register background jobs router
626
1149
  *
1150
+ * May be called once per domain router: repeated calls **merge** into one
1151
+ * router, the way `.routes()` composes. Calling it several times used to
1152
+ * keep only the last router and register nothing else, without warning.
1153
+ *
1154
+ * The pg-boss `config` is per server, so pass it on one call only.
1155
+ *
1156
+ * Two names can collide, and both fail loudly:
1157
+ * - a router **key** present in two calls throws here, at config time;
1158
+ * - two jobs sharing a **`job.name`** (the pg-boss queue name) throw in
1159
+ * `registerJobs()` at boot, before any queue or worker is created.
1160
+ *
1161
+ * @throws if a router key was already registered by an earlier call
1162
+ * @throws if `config` is given when an earlier call already gave one
1163
+ *
627
1164
  * @example
628
1165
  * ```typescript
629
1166
  * import { job, defineJobRouter } from '@spfn/core/job';
@@ -636,7 +1173,8 @@ declare class ServerConfigBuilder {
636
1173
  *
637
1174
  * export default defineServerConfig()
638
1175
  * .routes(appRouter)
639
- * .jobs(jobRouter)
1176
+ * .jobs(jobRouter, { clearOnStart: isDev }) // config on one call only
1177
+ * .jobs(billingJobRouter) // merged, not replaced
640
1178
  * .build();
641
1179
  * ```
642
1180
  */
@@ -669,8 +1207,40 @@ declare class ServerConfigBuilder {
669
1207
  * .events(eventRouter, { path: '/sse' })
670
1208
  * ```
671
1209
  */
672
- events(router: EventRouterDef<any>, config?: SSEHandlerConfig & {
1210
+ events<TRouter extends EventRouterDef<any>>(router: TRouter, config?: Omit<SSEHandlerConfig, 'auth'> & {
673
1211
  path?: string;
1212
+ auth?: SSEAuthConfig<TRouter>;
1213
+ }): this;
1214
+ /**
1215
+ * Register WebSocket router for bidirectional real-time communication
1216
+ *
1217
+ * Enables type-safe WebSocket connections with:
1218
+ * - Server→client event push (via defineEvent + emit)
1219
+ * - Client→server message handling (via messages in defineWSRouter)
1220
+ *
1221
+ * @example
1222
+ * ```typescript
1223
+ * // src/server/ws.ts
1224
+ * export const wsRouter = defineWSRouter({
1225
+ * events: { userUpdated, notification },
1226
+ * messages: {
1227
+ * ping: ({ ws }) => ws.send('pong', {}),
1228
+ * },
1229
+ * });
1230
+ *
1231
+ * // server.config.ts
1232
+ * export default defineServerConfig()
1233
+ * .websockets(wsRouter) // → WS /ws
1234
+ * .websockets(wsRouter, {
1235
+ * path: '/realtime', // custom path
1236
+ * auth: { enabled: true }, // token authentication
1237
+ * })
1238
+ * .build();
1239
+ * ```
1240
+ */
1241
+ websockets<TEvents extends Record<string, EventDef<any>>, TMessages extends WSMessageHandlers>(router: WSRouterDef<TEvents, TMessages>, config?: Omit<WSHandlerConfig, 'auth'> & {
1242
+ path?: string;
1243
+ auth?: WSAuthConfig<WSRouterDef<TEvents, TMessages>>;
674
1244
  }): this;
675
1245
  /**
676
1246
  * Enable/disable debug mode
@@ -692,10 +1262,28 @@ declare class ServerConfigBuilder {
692
1262
  * Configure health check endpoint
693
1263
  */
694
1264
  healthCheck(healthCheck: ServerConfig['healthCheck']): this;
1265
+ /**
1266
+ * Supply the clock used by the built-in `GET /_core/time` capability.
1267
+ * Production servers normally keep the default `Date.now()` clock; this is
1268
+ * exposed so tests can assert an exact wire value without replacing globals.
1269
+ */
1270
+ serverTime(serverTime: ServerConfig['serverTime']): this;
695
1271
  /**
696
1272
  * Configure infrastructure initialization
697
1273
  */
698
1274
  infrastructure(infrastructure: ServerConfig['infrastructure']): this;
1275
+ /**
1276
+ * Configure the migration boot gate
1277
+ *
1278
+ * @example
1279
+ * ```typescript
1280
+ * // A harness that applies migrations itself, after the server is up
1281
+ * export default defineServerConfig()
1282
+ * .migrations({ allowPending: true })
1283
+ * .build();
1284
+ * ```
1285
+ */
1286
+ migrations(migrations: ServerConfig['migrations']): this;
699
1287
  /**
700
1288
  * Register workflow router for workflow orchestration
701
1289
  *
@@ -756,4 +1344,4 @@ declare class ServerConfigBuilder {
756
1344
  */
757
1345
  declare function defineServerConfig(): ServerConfigBuilder;
758
1346
 
759
- export { type AppFactory, type ServerConfig, type ServerInstance, createServer, defineServerConfig, loadEnvFiles, startServer };
1347
+ export { type AppFactory, CORE_HEALTH_PATH, CORE_NAMESPACE, CORE_TIME_OPERATION_ID, CORE_TIME_PATH, CORE_TIME_ROUTE, type MigrationSnapshot, PendingMigrationsError, type ServerClock, type ServerConfig, type ServerInstance, type ServerTimeResponse, ServerTimeResponseSchema, type ShutdownHookOptions, createCoreTimeRoute, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };