@spfn/core 0.2.0-beta.52 → 0.2.0-beta.54

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 (42) hide show
  1. package/dist/{boss-Cxqc-Oiw.d.ts → boss-CEik0yq-.d.ts} +7 -0
  2. package/dist/cache/index.js +10 -1
  3. package/dist/cache/index.js.map +1 -1
  4. package/dist/config/index.d.ts +285 -0
  5. package/dist/config/index.js +60 -1
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/db/index.d.ts +57 -0
  8. package/dist/db/index.js +55 -8
  9. package/dist/db/index.js.map +1 -1
  10. package/dist/define-middleware-DuXD8Hvu.d.ts +167 -0
  11. package/dist/env/index.js +4 -3
  12. package/dist/env/index.js.map +1 -1
  13. package/dist/errors/index.d.ts +10 -0
  14. package/dist/errors/index.js +20 -2
  15. package/dist/errors/index.js.map +1 -1
  16. package/dist/event/index.d.ts +3 -3
  17. package/dist/event/index.js.map +1 -1
  18. package/dist/event/sse/client.d.ts +2 -2
  19. package/dist/event/sse/index.d.ts +4 -4
  20. package/dist/event/sse/index.js +41 -16
  21. package/dist/event/sse/index.js.map +1 -1
  22. package/dist/event/ws/client.d.ts +2 -2
  23. package/dist/event/ws/index.d.ts +3 -3
  24. package/dist/event/ws/index.js +75 -16
  25. package/dist/event/ws/index.js.map +1 -1
  26. package/dist/job/index.d.ts +2 -2
  27. package/dist/job/index.js +4 -4
  28. package/dist/job/index.js.map +1 -1
  29. package/dist/middleware/index.d.ts +171 -2
  30. package/dist/middleware/index.js +1186 -10
  31. package/dist/middleware/index.js.map +1 -1
  32. package/dist/nextjs/server.d.ts +10 -0
  33. package/dist/nextjs/server.js +115 -1
  34. package/dist/nextjs/server.js.map +1 -1
  35. package/dist/route/index.d.ts +3 -165
  36. package/dist/server/index.d.ts +31 -5
  37. package/dist/server/index.js +172 -36
  38. package/dist/server/index.js.map +1 -1
  39. package/dist/{token-manager-C2Ag5-s8.d.ts → token-manager-jKD_EsSE.d.ts} +0 -1
  40. package/dist/{types-VpVQIsyB.d.ts → types-BFB72jbM.d.ts} +1 -1
  41. package/dist/{types-CKsmzaB8.d.ts → types-DVjf37yO.d.ts} +37 -1
  42. package/package.json +6 -6
@@ -2,6 +2,8 @@ import * as _sinclair_typebox from '@sinclair/typebox';
2
2
  import { TSchema, Static, Kind } from '@sinclair/typebox';
3
3
  import { Context, MiddlewareHandler, Hono } from 'hono';
4
4
  import { ContentfulStatusCode, RedirectStatusCode } from 'hono/utils/http-status';
5
+ import { N as NamedMiddleware } from '../define-middleware-DuXD8Hvu.js';
6
+ export { E as ExtractMiddlewareNames, b as NamedMiddlewareFactory, d as defineMiddleware, a as defineMiddlewareFactory } from '../define-middleware-DuXD8Hvu.js';
5
7
  import { HttpMethod } from './types.js';
6
8
 
7
9
  /**
@@ -209,170 +211,6 @@ type RouteBuilderContext<TInput extends RouteInput = RouteInput, TInterceptor ex
209
211
  raw: Context;
210
212
  };
211
213
 
212
- /**
213
- * Middleware Definition Helper
214
- *
215
- * Provides type-safe middleware definition with name inference
216
- */
217
-
218
- /**
219
- * Named middleware with type inference
220
- *
221
- * @example
222
- * ```ts
223
- * export const authMiddleware = defineMiddleware('auth', async (c, next) => {
224
- * // auth logic
225
- * c.set('user', { id: 1 });
226
- * await next();
227
- * });
228
- *
229
- * export const rateLimitMiddleware = defineMiddleware('rateLimit', async (c, next) => {
230
- * // rate limit logic
231
- * await next();
232
- * });
233
- * ```
234
- */
235
- type NamedMiddleware<TName extends string = string> = {
236
- name: TName;
237
- handler: MiddlewareHandler;
238
- _name: TName;
239
- skips?: string[];
240
- };
241
- /**
242
- * Named middleware factory with type inference
243
- *
244
- * Factory function that creates middleware instances with parameters
245
- *
246
- * @example
247
- * ```ts
248
- * export const requirePermissions = defineMiddleware('permission',
249
- * (...permissions: string[]) => async (c, next) => {
250
- * // permission check logic
251
- * await next();
252
- * }
253
- * );
254
- * ```
255
- */
256
- type NamedMiddlewareFactory<TName extends string = string, TArgs extends any[] = any[]> = {
257
- name: TName;
258
- _name: TName;
259
- } & ((...args: TArgs) => MiddlewareHandler);
260
- /**
261
- * Define a named middleware
262
- *
263
- * Creates a middleware with a unique name that can be referenced
264
- * in route-level skip() calls with full type safety.
265
- *
266
- * Supports two patterns:
267
- * 1. Regular middleware: Direct middleware handler
268
- * 2. Factory middleware: Function that creates middleware with parameters
269
- *
270
- * @param name - Unique middleware name
271
- * @param handler - Middleware handler function
272
- * @returns Named middleware with type inference
273
- *
274
- * @example
275
- * ```ts
276
- * // Regular middleware
277
- * export const authMiddleware = defineMiddleware('auth', async (c, next) => {
278
- * const token = c.req.header('authorization');
279
- * if (!token) {
280
- * return c.json({ error: 'Unauthorized' }, 401);
281
- * }
282
- * c.set('user', await verifyToken(token));
283
- * await next();
284
- * });
285
- *
286
- * // Factory middleware
287
- * export const requirePermissions = defineMiddleware('permission',
288
- * (...permissions: string[]) => async (c, next) => {
289
- * const user = c.get('user');
290
- * if (!hasPermissions(user, permissions)) {
291
- * return c.json({ error: 'Forbidden' }, 403);
292
- * }
293
- * await next();
294
- * }
295
- * );
296
- *
297
- * // server.config.ts
298
- * export default defineServerConfig()
299
- * .middlewares([authMiddleware])
300
- * .routes(appRouter)
301
- * .build();
302
- *
303
- * // routes.ts - skip by name
304
- * export const publicRoute = route.get('/health')
305
- * .skip(['auth']) // ✅ Type-safe! Autocomplete!
306
- * .handler(async (c) => c.success({ status: 'ok' }));
307
- *
308
- * // Use factory middleware inline
309
- * export const protectedRoute = route.get('/admin')
310
- * .use([requirePermissions('admin:write')])
311
- * .handler(async (c) => { ... });
312
- * ```
313
- */
314
- /**
315
- * Options for defineMiddleware
316
- */
317
- interface DefineMiddlewareOptions {
318
- /**
319
- * Server-level middleware names to auto-skip when this middleware is used at route level.
320
- *
321
- * @example
322
- * ```ts
323
- * // optionalAuth auto-skips the global 'auth' middleware
324
- * export const optionalAuth = defineMiddleware('optionalAuth', handler, {
325
- * skips: ['auth']
326
- * });
327
- *
328
- * // Usage: .use([optionalAuth]) — no need for .skip(['auth'])
329
- * ```
330
- */
331
- skips?: string[];
332
- }
333
- declare function defineMiddleware<TName extends string>(name: TName, handler: MiddlewareHandler, options?: DefineMiddlewareOptions): NamedMiddleware<TName>;
334
- declare function defineMiddleware<TName extends string, TArgs extends any[]>(name: TName, factory: (...args: TArgs) => MiddlewareHandler, options?: DefineMiddlewareOptions): NamedMiddlewareFactory<TName, TArgs>;
335
- /**
336
- * Define a middleware factory explicitly
337
- *
338
- * Use this when your factory function has exactly 2 parameters,
339
- * which would be incorrectly detected as a regular middleware handler.
340
- *
341
- * @param name - Unique middleware name
342
- * @param factory - Factory function that returns a middleware handler
343
- * @returns Named middleware factory with type inference
344
- *
345
- * @example
346
- * ```ts
347
- * // Factory with 2 params (would be misdetected by defineMiddleware)
348
- * export const rateLimiter = defineMiddlewareFactory('rateLimit',
349
- * (limit: number, window: number) => async (c, next) => {
350
- * // rate limit logic using limit and window
351
- * await next();
352
- * }
353
- * );
354
- *
355
- * // Usage
356
- * route.get('/api')
357
- * .use([rateLimiter(100, 60000)]) // 100 requests per minute
358
- * .handler(...)
359
- * ```
360
- */
361
- declare function defineMiddlewareFactory<TName extends string, TArgs extends unknown[]>(name: TName, factory: (...args: TArgs) => MiddlewareHandler): NamedMiddlewareFactory<TName, TArgs>;
362
- /**
363
- * Extract middleware names from an array of named middlewares
364
- *
365
- * @example
366
- * ```ts
367
- * type Middlewares = [
368
- * NamedMiddleware<'auth'>,
369
- * NamedMiddleware<'rateLimit'>
370
- * ];
371
- * type Names = ExtractMiddlewareNames<Middlewares>; // 'auth' | 'rateLimit'
372
- * ```
373
- */
374
- type ExtractMiddlewareNames<T extends readonly NamedMiddleware<string>[]> = T[number]['_name'];
375
-
376
214
  /**
377
215
  * Route Builder
378
216
  *
@@ -911,4 +749,4 @@ declare function getFileOptions(schema: TSchema): FileSchemaOptions | FileArrayS
911
749
  */
912
750
  declare function formatFileSize(bytes: number): string;
913
751
 
914
- export { type ExtractMiddlewareNames, FileArraySchema, type FileArraySchemaOptions, type FileArraySchemaType, FileSchema, type FileSchemaOptions, type FileSchemaType, HttpMethod, type MergedInput, type NamedMiddleware, type NamedMiddlewareFactory, Nullable, OptionalFileSchema, OptionalNullable, type PaginatedResult, type RegisteredRoute, type RouteBuilderContext, type RouteDef, type RouteHandlerFn, type RouteInput, type Router, defineMiddleware, defineMiddlewareFactory, defineRouter, formatFileSize, getFileOptions, isFileArraySchema, isFileSchema, isHttpMethod, registerRoutes, route };
752
+ export { FileArraySchema, type FileArraySchemaOptions, type FileArraySchemaType, FileSchema, type FileSchemaOptions, type FileSchemaType, HttpMethod, type MergedInput, NamedMiddleware, Nullable, OptionalFileSchema, OptionalNullable, type PaginatedResult, type RegisteredRoute, type RouteBuilderContext, type RouteDef, type RouteHandlerFn, type RouteInput, type Router, defineRouter, formatFileSize, getFileOptions, isFileArraySchema, isFileSchema, isHttpMethod, registerRoutes, route };
@@ -3,11 +3,11 @@ import { MiddlewareHandler, Hono } from 'hono';
3
3
  import { cors } from 'hono/cors';
4
4
  import { serve } from '@hono/node-server';
5
5
  import { NamedMiddleware, Router } from '@spfn/core/route';
6
- import { OnErrorContext } from '@spfn/core/middleware';
7
- import { J as JobRouter, B as BossOptions } from '../boss-Cxqc-Oiw.js';
8
- import { E as EventRouterDef } from '../token-manager-C2Ag5-s8.js';
9
- import { S as SSEHandlerConfig, a as SSEAuthConfig } from '../types-VpVQIsyB.js';
10
- import { W as WSRouterDef, a as WSHandlerConfig, b as WSMessageHandlers, c as WSAuthConfig } from '../types-CKsmzaB8.js';
6
+ import { OnErrorContext, ProxyGuardConfig } from '@spfn/core/middleware';
7
+ import { J as JobRouter, B as BossOptions } from '../boss-CEik0yq-.js';
8
+ import { E as EventRouterDef } from '../token-manager-jKD_EsSE.js';
9
+ import { S as SSEHandlerConfig, a as SSEAuthConfig } from '../types-BFB72jbM.js';
10
+ import { W as WSRouterDef, a as WSHandlerConfig, b as WSMessageHandlers, c as WSAuthConfig } from '../types-DVjf37yO.js';
11
11
  import '@sinclair/typebox';
12
12
  import 'pg-boss';
13
13
 
@@ -96,6 +96,28 @@ interface ServerConfig {
96
96
  * Additional custom middleware
97
97
  */
98
98
  use?: MiddlewareHandler[];
99
+ /**
100
+ * Proxy-guard: verify requests came through the trusted Next.js RPC proxy
101
+ * (HMAC signature) and/or an allowed browser origin, then tag `clientType`.
102
+ * Lets the backend reject direct-to-backend calls that bypass the proxy.
103
+ *
104
+ * Disabled by default (`mode: 'off'`). Requires the same `SPFN_PROXY_SECRET`
105
+ * on the proxy and the backend. See PROXY-BACKEND-AUTH-SPEC.md.
106
+ *
107
+ * @example
108
+ * ```typescript
109
+ * .proxyGuard({ mode: 'strict', allowedOrigins: ['https://app.example.com'] })
110
+ * ```
111
+ */
112
+ proxyGuard?: Omit<ProxyGuardConfig, 'nonceStore'> & {
113
+ /**
114
+ * Enable hard replay rejection via a Redis nonce store. Evaluated in BOTH modes
115
+ * (tag observes replays, strict rejects). Requires a cache (CACHE_URL); without
116
+ * one, falls back to the timestamp window. Degrades to the window if the store
117
+ * is briefly unavailable. @default false
118
+ */
119
+ nonce?: boolean;
120
+ };
99
121
  /**
100
122
  * Global middlewares with names for route-level skip control
101
123
  * Use defineMiddleware() for type-safe middleware definitions
@@ -788,6 +810,10 @@ declare class ServerConfigBuilder {
788
810
  * Add named middlewares for route-level skip control
789
811
  */
790
812
  middlewares(middlewares: ServerConfig['middlewares']): this;
813
+ /**
814
+ * Configure proxy-guard (verify trusted-proxy signature + origin → clientType)
815
+ */
816
+ proxyGuard(proxyGuard: ServerConfig['proxyGuard']): this;
791
817
  /**
792
818
  * Register define-route based router
793
819
  *
@@ -8,7 +8,7 @@ import { cors } from 'hono/cors';
8
8
  import { registerRoutes } from '@spfn/core/route';
9
9
  import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
10
10
  import { streamSSE } from 'hono/streaming';
11
- import { randomBytes } from 'crypto';
11
+ import { randomBytes, createHash } from 'crypto';
12
12
  import { Agent, setGlobalDispatcher } from 'undici';
13
13
  import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
14
14
  import { initCache, getCache, closeCache } from '@spfn/core/cache';
@@ -445,6 +445,23 @@ function createBoundedWriter(stream, maxQueue, onClose) {
445
445
 
446
446
  // src/event/sse/handler.ts
447
447
  var sseLogger = logger.child("@spfn/core:sse");
448
+ var frameCache = /* @__PURE__ */ new WeakMap();
449
+ function serializeFrame(eventName, payload) {
450
+ if (payload === null || typeof payload !== "object") {
451
+ return JSON.stringify({ event: eventName, data: payload });
452
+ }
453
+ let byEvent = frameCache.get(payload);
454
+ if (!byEvent) {
455
+ byEvent = /* @__PURE__ */ new Map();
456
+ frameCache.set(payload, byEvent);
457
+ }
458
+ let serialized = byEvent.get(eventName);
459
+ if (serialized === void 0) {
460
+ serialized = JSON.stringify({ event: eventName, data: payload });
461
+ byEvent.set(eventName, serialized);
462
+ }
463
+ return serialized;
464
+ }
448
465
  var DEFAULT_MAX_QUEUE = 1e3;
449
466
  function createSSEHandler(router, config = {}, tokenManager) {
450
467
  const {
@@ -495,12 +512,14 @@ function createSSEHandler(router, config = {}, tokenManager) {
495
512
  let connectionDead = false;
496
513
  let pingTimer;
497
514
  let writer;
515
+ let onClosed;
498
516
  const cleanup = (reason) => {
499
517
  if (connectionDead) return;
500
518
  connectionDead = true;
501
519
  clearInterval(pingTimer);
502
520
  unsubscribes.forEach((fn) => fn());
503
521
  writer?.close();
522
+ onClosed?.();
504
523
  sseLogger.info("SSE dead connection cleaned up", {
505
524
  events: allowedEvents,
506
525
  reason
@@ -530,10 +549,6 @@ function createSSEHandler(router, config = {}, tokenManager) {
530
549
  }
531
550
  }
532
551
  messageId++;
533
- const message = {
534
- event: eventName,
535
- data: payload
536
- };
537
552
  sseLogger.debug("SSE sending event", {
538
553
  event: eventName,
539
554
  messageId
@@ -541,7 +556,7 @@ function createSSEHandler(router, config = {}, tokenManager) {
541
556
  writer.enqueue({
542
557
  id: String(messageId),
543
558
  event: eventName,
544
- data: JSON.stringify(message)
559
+ data: serializeFrame(eventName, payload)
545
560
  });
546
561
  });
547
562
  unsubscribes.push(unsubscribe);
@@ -558,8 +573,15 @@ function createSSEHandler(router, config = {}, tokenManager) {
558
573
  });
559
574
  }, pingInterval);
560
575
  const abortSignal = c.req.raw.signal;
561
- while (!abortSignal.aborted && !connectionDead) {
562
- await stream.sleep(pingInterval);
576
+ if (!abortSignal.aborted && !connectionDead) {
577
+ await new Promise((resolve2) => {
578
+ const onAbort = () => resolve2();
579
+ abortSignal.addEventListener("abort", onAbort, { once: true });
580
+ onClosed = () => {
581
+ abortSignal.removeEventListener("abort", onAbort);
582
+ resolve2();
583
+ };
584
+ });
563
585
  }
564
586
  cleanup();
565
587
  }, async (err) => {
@@ -596,24 +618,28 @@ async function authorizeEvents(subject, requestedEvents, authConfig) {
596
618
  }
597
619
  return allowed;
598
620
  }
621
+ function hashToken(token) {
622
+ return createHash("sha256").update(token).digest("hex");
623
+ }
599
624
  var InMemoryTokenStore = class {
600
625
  tokens = /* @__PURE__ */ new Map();
601
626
  async set(token, data) {
602
- this.tokens.set(token, data);
627
+ this.tokens.set(hashToken(token), data);
603
628
  }
604
629
  async consume(token) {
605
- const data = this.tokens.get(token);
630
+ const key = hashToken(token);
631
+ const data = this.tokens.get(key);
606
632
  if (!data) {
607
633
  return null;
608
634
  }
609
- this.tokens.delete(token);
635
+ this.tokens.delete(key);
610
636
  return data;
611
637
  }
612
638
  async cleanup() {
613
639
  const now = Date.now();
614
- for (const [token, data] of this.tokens) {
640
+ for (const [key, data] of this.tokens) {
615
641
  if (data.expiresAt <= now) {
616
- this.tokens.delete(token);
642
+ this.tokens.delete(key);
617
643
  }
618
644
  }
619
645
  }
@@ -626,14 +652,14 @@ var CacheTokenStore = class {
626
652
  async set(token, data) {
627
653
  const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
628
654
  await this.cache.set(
629
- this.prefix + token,
655
+ this.prefix + hashToken(token),
630
656
  JSON.stringify(data),
631
657
  "EX",
632
658
  ttlSeconds
633
659
  );
634
660
  }
635
661
  async consume(token) {
636
- const key = this.prefix + token;
662
+ const key = this.prefix + hashToken(token);
637
663
  let raw = null;
638
664
  if (this.cache.getdel) {
639
665
  raw = await this.cache.getdel(key);
@@ -668,7 +694,6 @@ var SSETokenManager = class {
668
694
  async issue(subject) {
669
695
  const token = randomBytes(32).toString("hex");
670
696
  await this.store.set(token, {
671
- token,
672
697
  subject,
673
698
  expiresAt: Date.now() + this.ttl
674
699
  });
@@ -1343,6 +1368,7 @@ async function createAutoConfiguredApp(config) {
1343
1368
  });
1344
1369
  }
1345
1370
  applyDefaultMiddleware(app, config, enableLogger, enableCors);
1371
+ await applyProxyGuard(app, config);
1346
1372
  if (Array.isArray(config?.use)) {
1347
1373
  config.use.forEach((mw) => app.use("*", mw));
1348
1374
  }
@@ -1365,6 +1391,51 @@ function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
1365
1391
  app.use("*", cors(corsOptions));
1366
1392
  }
1367
1393
  }
1394
+ async function applyProxyGuard(app, config) {
1395
+ const proxyGuardConfig = config?.proxyGuard;
1396
+ const mode = proxyGuardConfig?.mode ?? "off";
1397
+ if (mode === "off") {
1398
+ return;
1399
+ }
1400
+ const { createProxyGuard, createCacheNonceStore, createInMemoryNonceStore } = await import('@spfn/core/middleware');
1401
+ let nonceStore;
1402
+ if (proxyGuardConfig?.nonce) {
1403
+ try {
1404
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
1405
+ const cache = getCache2();
1406
+ if (cache) {
1407
+ nonceStore = createCacheNonceStore(cache);
1408
+ serverLogger.info("Proxy-guard nonce replay rejection: cache (Redis/Valkey)");
1409
+ } else {
1410
+ nonceStore = createInMemoryNonceStore();
1411
+ serverLogger.info("Proxy-guard nonce replay rejection: in-memory (single instance only \u2014 use a cache for multi-instance)");
1412
+ }
1413
+ } catch {
1414
+ nonceStore = createInMemoryNonceStore();
1415
+ serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
1416
+ }
1417
+ }
1418
+ const autoSkip = [config?.healthCheck?.path ?? "/health"];
1419
+ if (config?.events) {
1420
+ autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
1421
+ }
1422
+ if (config?.websockets) {
1423
+ autoSkip.push(config.websocketsConfig?.path ?? "/ws");
1424
+ }
1425
+ const skipPaths = [...proxyGuardConfig?.skipPaths ?? [], ...autoSkip];
1426
+ app.use("*", createProxyGuard({
1427
+ mode,
1428
+ secret: proxyGuardConfig?.secret,
1429
+ previousSecrets: proxyGuardConfig?.previousSecrets,
1430
+ windowMs: proxyGuardConfig?.windowMs,
1431
+ allowedOrigins: proxyGuardConfig?.allowedOrigins,
1432
+ maxBodyBytes: proxyGuardConfig?.maxBodyBytes,
1433
+ nonceStore,
1434
+ nonceFailClosed: proxyGuardConfig?.nonceFailClosed,
1435
+ skipPaths
1436
+ }));
1437
+ serverLogger.info(`\u2713 Proxy-guard enabled (mode: ${mode})`);
1438
+ }
1368
1439
  function registerHealthCheckEndpoint(app, config) {
1369
1440
  const healthCheckConfig = config?.healthCheck ?? {};
1370
1441
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
@@ -1606,9 +1677,7 @@ async function registerJobs(router) {
1606
1677
  }
1607
1678
  jobLogger2.info("Existing jobs cleared");
1608
1679
  }
1609
- for (const job2 of jobs) {
1610
- await registerJob(job2);
1611
- }
1680
+ await Promise.all(jobs.map((job2) => registerJob(job2)));
1612
1681
  jobLogger2.info("All jobs registered successfully");
1613
1682
  }
1614
1683
  async function ensureQueue(boss, queueName) {
@@ -1641,9 +1710,10 @@ async function executeJobHandler(job2, pgBossJob) {
1641
1710
  async function registerWorker(boss, job2, queueName) {
1642
1711
  await ensureQueue(boss, queueName);
1643
1712
  const batchSize = job2.options?.batchSize ?? 1;
1713
+ const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
1644
1714
  await boss.work(
1645
1715
  queueName,
1646
- { batchSize },
1716
+ { batchSize, pollingIntervalSeconds },
1647
1717
  async (pgBossJobs) => {
1648
1718
  if (batchSize <= 1) {
1649
1719
  await executeJobHandler(job2, pgBossJobs[0]);
@@ -1721,24 +1791,54 @@ async function registerJob(job2) {
1721
1791
  jobLogger2.debug(`Job registered: ${job2.name}`);
1722
1792
  }
1723
1793
  var wsLogger = logger.child("@spfn/core:ws");
1794
+ function isOriginAllowed(origin, allowList) {
1795
+ if (!allowList) {
1796
+ return true;
1797
+ }
1798
+ if (!origin) {
1799
+ return true;
1800
+ }
1801
+ return allowList.has(origin);
1802
+ }
1724
1803
  async function attachWSHandler(server, router, config = {}, tokenManager) {
1725
1804
  const WebSocketServer = await loadWSServer();
1726
1805
  const {
1727
1806
  pingInterval = 3e4,
1728
1807
  path = "/ws",
1808
+ maxPayload = 1048576,
1809
+ maxBufferedBytes = 1048576,
1810
+ maxConnections = 1e4,
1811
+ maxConnectionsPerSubject = 0,
1812
+ allowedOrigins,
1729
1813
  auth: authConfig
1730
1814
  } = config;
1815
+ const originAllowList = allowedOrigins && allowedOrigins.length > 0 ? new Set(allowedOrigins) : null;
1731
1816
  if (authConfig?.enabled && !tokenManager) {
1732
1817
  throw new Error(
1733
1818
  "WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
1734
1819
  );
1735
1820
  }
1736
- const wss = new WebSocketServer({ server, path });
1821
+ const wss = new WebSocketServer({ server, path, maxPayload });
1737
1822
  const clients = /* @__PURE__ */ new Set();
1823
+ const subjectCounts = /* @__PURE__ */ new Map();
1738
1824
  wss.on("connection", (ws, req) => {
1825
+ if (!isOriginAllowed(req.headers?.origin, originAllowList)) {
1826
+ wsLogger.warn("WebSocket rejected: disallowed origin", { origin: req.headers?.origin });
1827
+ ws.close(1008, "Origin not allowed");
1828
+ return;
1829
+ }
1830
+ if (clients.size >= maxConnections) {
1831
+ ws.close(1013, "Server at capacity");
1832
+ return;
1833
+ }
1739
1834
  clients.add(ws);
1740
1835
  ws.on("close", () => clients.delete(ws));
1741
- handleConnection(ws, req, router, authConfig, tokenManager, pingInterval).catch((err) => {
1836
+ handleConnection(ws, req, router, authConfig, tokenManager, {
1837
+ pingInterval,
1838
+ maxBufferedBytes,
1839
+ maxConnectionsPerSubject,
1840
+ subjectCounts
1841
+ }).catch((err) => {
1742
1842
  wsLogger.error("WebSocket connection handler error", err);
1743
1843
  if (ws.readyState === 1) ws.close(1011, "Internal server error");
1744
1844
  });
@@ -1761,7 +1861,8 @@ async function attachWSHandler(server, router, config = {}, tokenManager) {
1761
1861
  });
1762
1862
  });
1763
1863
  }
1764
- async function handleConnection(ws, req, router, authConfig, tokenManager, pingInterval) {
1864
+ async function handleConnection(ws, req, router, authConfig, tokenManager, opts) {
1865
+ const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;
1765
1866
  let pingTimer;
1766
1867
  let connectionUnsubscribes = [];
1767
1868
  let subscribedEvents = [];
@@ -1795,13 +1896,26 @@ async function handleConnection(ws, req, router, authConfig, tokenManager, pingI
1795
1896
  ws.close(4003, "Not authorized for any requested events");
1796
1897
  return;
1797
1898
  }
1899
+ if (maxConnectionsPerSubject > 0 && typeof subject === "string") {
1900
+ const current = subjectCounts.get(subject) ?? 0;
1901
+ if (current >= maxConnectionsPerSubject) {
1902
+ ws.close(1013, "Too many connections for this subject");
1903
+ return;
1904
+ }
1905
+ subjectCounts.set(subject, current + 1);
1906
+ ws.on("close", () => {
1907
+ const remaining = (subjectCounts.get(subject) ?? 1) - 1;
1908
+ if (remaining <= 0) subjectCounts.delete(subject);
1909
+ else subjectCounts.set(subject, remaining);
1910
+ });
1911
+ }
1798
1912
  subscribedEvents = allowedEvents;
1799
1913
  wsLogger.info("WebSocket connection established", {
1800
1914
  events: allowedEvents,
1801
1915
  subject: subject ?? void 0
1802
1916
  });
1803
- const connection = createConnection(ws);
1804
- connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig);
1917
+ const connection = createConnection(ws, maxBufferedBytes);
1918
+ connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);
1805
1919
  if (ws.readyState !== 1) {
1806
1920
  connectionUnsubscribes.forEach((fn) => fn());
1807
1921
  connectionUnsubscribes = [];
@@ -1811,8 +1925,18 @@ async function handleConnection(ws, req, router, authConfig, tokenManager, pingI
1811
1925
  onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
1812
1926
  });
1813
1927
  if (pingInterval > 0) {
1928
+ ws.isAlive = true;
1929
+ ws.on("pong", () => {
1930
+ ws.isAlive = true;
1931
+ });
1814
1932
  pingTimer = setInterval(() => {
1815
- if (ws.readyState === 1) ws.ping();
1933
+ if (ws.readyState !== 1) return;
1934
+ if (ws.isAlive === false) {
1935
+ ws.terminate();
1936
+ return;
1937
+ }
1938
+ ws.isAlive = false;
1939
+ ws.ping();
1816
1940
  }, pingInterval);
1817
1941
  }
1818
1942
  connection.send("__connected", {
@@ -1851,16 +1975,24 @@ async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
1851
1975
  const allowed = await authConfig.authorize(subject, requestedEvents);
1852
1976
  return allowed.length === 0 ? null : allowed;
1853
1977
  }
1854
- function createConnection(ws) {
1978
+ function safeSend(ws, frame, maxBufferedBytes) {
1979
+ if (ws.readyState !== 1) return;
1980
+ if (ws.bufferedAmount > maxBufferedBytes) {
1981
+ ws.close(1013, "Send buffer overflow");
1982
+ return;
1983
+ }
1984
+ try {
1985
+ ws.send(JSON.stringify(frame));
1986
+ } catch {
1987
+ }
1988
+ }
1989
+ function createConnection(ws, maxBufferedBytes) {
1855
1990
  return {
1856
- send: (type, payload) => {
1857
- if (ws.readyState !== 1) return;
1858
- ws.send(JSON.stringify({ type, data: payload }));
1859
- },
1991
+ send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),
1860
1992
  close: (code, reason) => ws.close(code, reason)
1861
1993
  };
1862
1994
  }
1863
- function subscribeEvents(ws, router, allowedEvents, subject, authConfig) {
1995
+ function subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes) {
1864
1996
  const unsubscribes = [];
1865
1997
  for (const eventName of allowedEvents) {
1866
1998
  const eventDef = router.events[eventName];
@@ -1870,10 +2002,7 @@ function subscribeEvents(ws, router, allowedEvents, subject, authConfig) {
1870
2002
  if (subject && authConfig?.filter?.[eventName]) {
1871
2003
  if (!authConfig.filter[eventName](subject, payload)) return;
1872
2004
  }
1873
- try {
1874
- ws.send(JSON.stringify({ type: eventName, data: payload }));
1875
- } catch {
1876
- }
2005
+ safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);
1877
2006
  });
1878
2007
  unsubscribes.push(unsubscribe);
1879
2008
  }
@@ -2493,6 +2622,13 @@ var ServerConfigBuilder = class {
2493
2622
  this.config.middlewares = middlewares;
2494
2623
  return this;
2495
2624
  }
2625
+ /**
2626
+ * Configure proxy-guard (verify trusted-proxy signature + origin → clientType)
2627
+ */
2628
+ proxyGuard(proxyGuard) {
2629
+ this.config.proxyGuard = proxyGuard;
2630
+ return this;
2631
+ }
2496
2632
  /**
2497
2633
  * Register define-route based router
2498
2634
  *