@spfn/core 0.3.0-beta.23 → 0.3.0-beta.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/contract/index.d.ts +1 -1
- package/dist/contract/index.js +2 -2
- package/dist/contract/index.js.map +1 -1
- package/dist/event/index.d.ts +3 -3
- package/dist/event/index.js +160 -2
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/client.js +20 -1
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +16 -4
- package/dist/event/sse/index.js +366 -109
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/router-D5B0cpVN.d.ts +378 -0
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +366 -109
- package/dist/server/index.js.map +1 -1
- package/dist/state/index.d.ts +380 -0
- package/dist/state/index.js +571 -0
- package/dist/state/index.js.map +1 -0
- package/dist/{types-W4u_S0lF.d.ts → types-CqTsDETV.d.ts} +1 -1
- package/dist/{types-B8zAxFsE.d.ts → types-DqXYtI69.d.ts} +27 -2
- package/package.json +6 -1
- package/src/contract/README.md +5 -3
- package/src/event/README.md +150 -13
- package/src/state/README.md +350 -0
- package/dist/router-CysBmgv3.d.ts +0 -232
package/dist/event/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/event/event.ts","../../src/event/sse/route-map.ts","../../src/event/router.ts","../../src/event/ws/handler.ts","../../src/event/ws/index.ts"],"names":["logger"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Event System\n *\n * Decoupled pub/sub event system with optional cache integration for multi-instance support\n *\n * @example\n * ```typescript\n * // Define event\n * const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * // Subscribe (in-memory)\n * userCreated.subscribe((payload) => {\n * console.log('User created:', payload.userId);\n * });\n *\n * // Emit\n * await userCreated.emit({ userId: '123' });\n *\n * // With cache for multi-instance\n * const event = defineEvent('user.created', schema);\n * await event.useCache(cache); // Must await before emitting\n * await event.emit({ userId: '123' }); // Broadcast to all instances\n * ```\n */\n\nimport type { TSchema, Static } from '@sinclair/typebox';\nimport { logger } from '@spfn/core/logger';\nimport type { EventContract, EventDef, EventHandler, JobQueueSender, PubSubCache } from './types';\n\nconst eventLogger = logger.child('@spfn/core:event');\n\n/**\n * Log handler error with consistent format\n */\nfunction logHandlerError(eventName: string, error: unknown): void\n{\n eventLogger.error(`Event handler error: ${eventName}`, {\n error: error instanceof Error ? error.message : String(error),\n });\n}\n\n/**\n * Log job queue error with consistent format\n */\nfunction logJobQueueError(queueName: string, error: unknown): void\n{\n eventLogger.error(`Failed to send event to job queue: ${queueName}`, {\n error: error instanceof Error ? error.message : String(error),\n });\n}\n\n/**\n * Create handler subscription manager\n */\nfunction createHandlerManager<TPayload>(name: string)\n{\n const handlers: Set<EventHandler<TPayload>> = new Set();\n\n return {\n add: (handler: EventHandler<TPayload>): (() => void) =>\n {\n handlers.add(handler);\n eventLogger.debug(`Subscribed to event: ${name}`, { handlerCount: handlers.size });\n\n return () =>\n {\n handlers.delete(handler);\n eventLogger.debug(`Unsubscribed from event: ${name}`, { handlerCount: handlers.size });\n };\n },\n\n clear: (): void =>\n {\n handlers.clear();\n eventLogger.debug(`Unsubscribed all from event: ${name}`);\n },\n\n trigger: async (payload: TPayload): Promise<void> =>\n {\n const results = await Promise.allSettled(\n [...handlers].map((handler) => handler(payload)),\n );\n\n for (const result of results)\n {\n if (result.status === 'rejected')\n {\n logHandlerError(name, result.reason);\n }\n }\n },\n };\n}\n\n/**\n * Create job queue manager\n */\nfunction createJobQueueManager(name: string)\n{\n const jobQueues: Map<string, JobQueueSender> = new Map();\n\n return {\n register: (queueName: string, sender: JobQueueSender): void =>\n {\n jobQueues.set(queueName, sender);\n eventLogger.debug(`Registered job queue for event: ${name}`, { queueName });\n },\n\n send: async (payload: unknown): Promise<void> =>\n {\n if (jobQueues.size === 0)\n {\n return;\n }\n\n const entries = [...jobQueues.entries()];\n const results = await Promise.allSettled(\n entries.map(([queueName, sender]) => sender(queueName, payload)),\n );\n\n for (const [i, result] of results.entries())\n {\n if (result.status === 'rejected')\n {\n logJobQueueError(entries[i][0], result.reason);\n }\n }\n },\n\n get size(): number\n {\n return jobQueues.size;\n },\n };\n}\n\n/**\n * Internal: Create event implementation\n */\nfunction createEventImpl<TPayload>(\n name: string,\n schema?: TSchema,\n): EventDef<TPayload>\n{\n const handlerManager = createHandlerManager<TPayload>(name);\n const jobQueueManager = createJobQueueManager(name);\n let cache: PubSubCache | undefined;\n let cacheSubscribed = false;\n\n const emit = async (payload?: TPayload): Promise<void> =>\n {\n eventLogger.debug(`Emitting event: ${name}`, {\n payload,\n hasCache: !!cache,\n jobQueueCount: jobQueueManager.size,\n });\n\n if (cache)\n {\n await cache.publish(name, payload);\n }\n else\n {\n await handlerManager.trigger(payload as TPayload);\n }\n\n await jobQueueManager.send(payload);\n eventLogger.debug(`Event emitted: ${name}`);\n };\n\n const useCache = async (newCache: PubSubCache): Promise<EventDef<TPayload>> =>\n {\n if (cacheSubscribed)\n {\n eventLogger.warn(`Cache already configured for event: ${name}`);\n\n return self;\n }\n\n // Subscribe FIRST, then flip to cache mode. If subscribe rejects, the\n // event stays in-process (cache undefined) and cacheSubscribed stays\n // false — so emit keeps delivering locally and a later useCache can retry.\n // Setting these before the await would latch a half-broken state: emit\n // would publish to a channel this instance never subscribed to.\n await newCache.subscribe(name, async (message: unknown) =>\n {\n eventLogger.debug(`Received event from cache: ${name}`);\n await handlerManager.trigger(message as TPayload);\n });\n\n cache = newCache;\n cacheSubscribed = true;\n\n eventLogger.debug(`Cache subscription ready for event: ${name}`);\n\n return self;\n };\n\n // Drop the cache binding so a later useCache can rebind a fresh transport.\n // Without this, the cacheSubscribed latch would make a second server start in\n // the same process (tests, hot-reload) a no-op while the event still points at\n // a torn-down cache — cross-pod fan-out would silently stay dead.\n const resetCache = (): void =>\n {\n cache = undefined;\n cacheSubscribed = false;\n };\n\n let contract: EventContract | undefined;\n\n const declareContract = (next: EventContract): EventDef<TPayload> =>\n {\n if (contract)\n {\n throw new Error(\n `Event \"${name}\" already declares .contract({ since: '${contract.since}' }). `\n + 'An event is defined once and shared, so a second declaration would silently replace one of the '\n + 'two. Declare the contract once, where the event is defined.',\n );\n }\n\n contract = next;\n\n return self;\n };\n\n const self: EventDef<TPayload> = {\n name,\n schema,\n get _contract()\n {\n return contract;\n },\n contract: declareContract,\n subscribe: handlerManager.add,\n unsubscribeAll: handlerManager.clear,\n emit: emit as EventDef<TPayload>['emit'],\n useCache,\n _registerJobQueue: jobQueueManager.register,\n _resetCache: resetCache,\n _payload: undefined as unknown as TPayload,\n };\n\n return self;\n}\n\n/**\n * Define an event without payload\n */\nexport function defineEvent(name: string): EventDef<void>;\n\n/**\n * Define an event with typed payload\n */\nexport function defineEvent<T extends TSchema>(\n name: string,\n schema: T,\n): EventDef<Static<T>>;\n\n/**\n * Define an event for decoupled pub/sub\n *\n * @example\n * ```typescript\n * // Define event with payload\n * export const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * // Subscribe to event (in-memory)\n * const unsubscribe = userCreated.subscribe((payload) => {\n * console.log('User created:', payload.userId);\n * });\n *\n * // Emit event\n * await userCreated.emit({ userId: '123' });\n *\n * // Unsubscribe when done\n * unsubscribe();\n *\n * // Multi-instance with cache\n * await userCreated.useCache(cache);\n * await userCreated.emit({ userId: '123' }); // Broadcast to all instances\n * ```\n */\nexport function defineEvent<T extends TSchema>(\n name: string,\n schema?: T,\n): EventDef<Static<T>> | EventDef\n{\n if (schema)\n {\n return createEventImpl<Static<T>>(name, schema);\n }\n\n return createEventImpl<void>(name);\n}\n","/**\n * SSE Event Route Map\n *\n * Static route map for SSE token endpoint.\n * Merge into RPC proxy routeMap so `eventsToken` resolves to `POST /events/token`.\n *\n * @example\n * ```typescript\n * // app/api/rpc/[routeName]/route.ts\n * import { createRpcProxy } from '@spfn/core/nextjs/server';\n * import { eventRouteMap } from '@spfn/core/event';\n * import { routeMap } from '@/generated/route-map';\n *\n * export const { GET, POST } = createRpcProxy({\n * routeMap: { ...routeMap, ...eventRouteMap },\n * });\n * ```\n */\nexport const eventRouteMap = {\n eventsToken: { method: 'POST' as const, path: '/events/token' },\n};\n","/**\n * Event Router\n *\n * Type-safe event router for SSE subscription\n *\n * @example\n * ```typescript\n * import { defineEvent, defineEventRouter } from '@spfn/core/event';\n * import { Type } from '@sinclair/typebox';\n *\n * const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * const orderPlaced = defineEvent('order.placed', Type.Object({\n * orderId: Type.String(),\n * amount: Type.Number(),\n * }));\n *\n * export const eventRouter = defineEventRouter({\n * userCreated,\n * orderPlaced,\n * });\n *\n * export type EventRouter = typeof eventRouter;\n * ```\n */\n\nimport type { EventDef } from './types';\n\n/**\n * How a client reaches the contracted events of a router.\n *\n * Declared on the router because it is the stream's promise, not one event's:\n * `createServer` checks it against `.events()` at boot, so the document cannot\n * describe a path or an auth mode the server does not serve.\n */\nexport interface EventRouterContract\n{\n /**\n * `'tokenExchange'` when `.events()` enables `auth` — the client first posts\n * to the token path and opens the stream with `?token=`. `'none'` otherwise.\n */\n auth: 'none' | 'tokenExchange';\n\n /**\n * The stream path `.events()` registers.\n * @default '/events/stream'\n */\n streamPath?: string;\n}\n\n/**\n * What every router of events carries — the SSE event router and the\n * WebSocket router alike.\n *\n * The WebSocket router builds on this rather than on `EventRouterDef`, because\n * only an SSE event router can be contracted.\n */\nexport interface EventRouterBase<TEvents extends Record<string, EventDef<any>>>\n{\n /**\n * Event definitions\n */\n readonly events: TEvents;\n\n /**\n * Event names as array\n */\n readonly eventNames: (keyof TEvents)[];\n\n /**\n * Type inference helper - payload types by event name\n */\n readonly _types: {\n [K in keyof TEvents]: TEvents[K]['_payload'];\n };\n}\n\n/**\n * Event Router Definition\n */\nexport interface EventRouterDef<TEvents extends Record<string, EventDef<any>>> extends EventRouterBase<TEvents>\n{\n /**\n * The contract declared with `.contract()`, absent when the router declares\n * none. Underscored for the same reason as `EventDef._contract`.\n */\n readonly _contract?: EventRouterContract;\n\n /**\n * Declare how a client reaches this router's contracted events.\n *\n * Sets the contract on this router and returns it. A `streamPath` that does\n * not start with `/` throws here, where the typo is.\n */\n contract: (contract: EventRouterContract) => EventRouterDef<TEvents>;\n}\n\n/**\n * Infer event names from EventRouter\n */\nexport type InferEventNames<T> = T extends EventRouterBase<infer E>\n ? keyof E & string\n : never;\n\n/**\n * Infer payload type for specific event\n */\nexport type InferEventPayload<\n T extends EventRouterBase<any>,\n K extends InferEventNames<T>,\n> = T['_types'][K];\n\n/**\n * Infer all event payloads map\n */\nexport type InferEventPayloads<T extends EventRouterBase<any>> = T['_types'];\n\n/**\n * Define an event router for SSE subscription\n *\n * `.contract({ auth })` on the router, together with `.contract({ since })` on\n * its events, publishes those events in `contracts/current.json` — see the\n * contract README.\n *\n * @example\n * ```typescript\n * export const eventRouter = defineEventRouter({\n * userCreated,\n * orderPlaced,\n * });\n *\n * // Type inference\n * type Names = InferEventNames<typeof eventRouter>;\n * // 'userCreated' | 'orderPlaced'\n *\n * type Payload = InferEventPayload<typeof eventRouter, 'userCreated'>;\n * // { userId: string }\n * ```\n */\nexport function defineEventRouter<\n TEvents extends Record<string, EventDef<any>>,\n>(events: TEvents): EventRouterDef<TEvents>\n{\n let contract: EventRouterContract | undefined;\n\n const router: EventRouterDef<TEvents> = {\n events,\n eventNames: Object.keys(events) as (keyof TEvents)[],\n get _contract()\n {\n return contract;\n },\n contract: (next) =>\n {\n assertStreamPath(next.streamPath);\n contract = next;\n\n return router;\n },\n _types: {} as EventRouterDef<TEvents>['_types'],\n };\n\n return router;\n}\n\n/**\n * A stream path is matched against the path `.events()` registers and written\n * into the contract as a URL path, so a relative one can never be right.\n */\nfunction assertStreamPath(streamPath: string | undefined): void\n{\n if (streamPath !== undefined && !streamPath.startsWith('/'))\n {\n throw new Error(\n `defineEventRouter(...).contract({ streamPath: \"${streamPath}\" }) is not an absolute path. `\n + 'The contract publishes it as the URL path a client opens, and createServer compares it with the '\n + 'path .events() registers. Start it with \"/\".',\n );\n }\n}\n","/**\n * WebSocket Handler\n *\n * Attaches a WebSocket server to an existing Node.js http.Server.\n * Handles authentication, event subscription, and client message routing.\n */\n\nimport type { Server } from 'node:http';\nimport type { EventDef } from '../types';\nimport type {\n WSRouterDef,\n WSHandlerConfig,\n WSHandlerAuthConfig,\n WSMessageHandlers,\n WSRawConnection,\n} from './types';\nimport type { SSETokenManager } from '../sse/token-manager';\nimport { logger } from '@spfn/core/logger';\n\nconst wsLogger = logger.child('@spfn/core:ws');\n\n/**\n * Decide whether a WebSocket upgrade's Origin is permitted.\n *\n * - no allow-list configured → allowed (check disabled)\n * - no Origin header (native/non-browser client) → allowed\n * - Origin present → must be in the allow-list\n */\nexport function isOriginAllowed(\n origin: string | undefined,\n allowList: Set<string> | null,\n): boolean\n{\n if (!allowList)\n {\n return true;\n }\n\n if (!origin)\n {\n return true;\n }\n\n return allowList.has(origin);\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Attach a WebSocket server to a Node.js http.Server\n *\n * @returns cleanup function that closes the WebSocket server\n */\nexport async function attachWSHandler<\n TEvents extends Record<string, EventDef<any>>,\n TMessages extends WSMessageHandlers,\n>(\n server: Server,\n router: WSRouterDef<TEvents, TMessages>,\n config: WSHandlerConfig & { path?: string } = {},\n tokenManager?: SSETokenManager,\n): Promise<() => Promise<void>>\n{\n const WebSocketServer = await loadWSServer();\n\n const {\n pingInterval = 30000,\n path = '/ws',\n maxPayload = 1_048_576,\n maxBufferedBytes = 1_048_576,\n maxConnections = 10_000,\n maxConnectionsPerSubject = 0,\n allowedOrigins,\n auth: authConfig,\n } = config;\n\n const originAllowList = allowedOrigins && allowedOrigins.length > 0\n ? new Set(allowedOrigins)\n : null;\n\n if (authConfig?.enabled && !tokenManager)\n {\n throw new Error(\n 'WebSocket auth.enabled=true requires a tokenManager. ' +\n 'Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer.',\n );\n }\n\n const wss = new WebSocketServer({ server, path, maxPayload });\n\n // Track live connections for graceful shutdown\n const clients = new Set<any>();\n // Live connection count per authenticated subject (for the per-subject cap)\n const subjectCounts = new Map<string, number>();\n\n wss.on('connection', (ws: any, req: any) =>\n {\n // Origin allow-list (cross-site WebSocket hijacking protection). Browsers\n // always send Origin on the upgrade; reject one that isn't allowed before\n // doing any work. A missing Origin (native/non-browser client, which can't\n // be CSRF'd into using ambient cookies) passes through.\n if (!isOriginAllowed(req.headers?.origin, originAllowList))\n {\n wsLogger.warn('WebSocket rejected: disallowed origin', { origin: req.headers?.origin });\n ws.close(1008, 'Origin not allowed');\n\n return;\n }\n\n // Global connection cap — reject before doing any work\n if (clients.size >= maxConnections)\n {\n ws.close(1013, 'Server at capacity');\n\n return;\n }\n\n clients.add(ws);\n ws.on('close', () => clients.delete(ws));\n handleConnection(ws, req, router, authConfig, tokenManager, {\n pingInterval,\n maxBufferedBytes,\n maxConnectionsPerSubject,\n subjectCounts,\n })\n .catch((err: Error) =>\n {\n wsLogger.error('WebSocket connection handler error', err);\n if (ws.readyState === 1) ws.close(1011, 'Internal server error');\n });\n });\n\n wss.on('error', (err: Error) =>\n {\n wsLogger.error('WebSocket server error', err);\n });\n\n wsLogger.info(`✓ WebSocket endpoint registered at ${path}`, {\n events: router.eventNames,\n auth: !!authConfig?.enabled,\n });\n\n return () => new Promise<void>((resolve, reject) =>\n {\n // Close all existing connections with 1001 Going Away\n for (const client of clients)\n {\n client.close(1001, 'Server shutting down');\n }\n clients.clear();\n\n wss.close((err?: Error) =>\n {\n if (err) reject(err);\n else resolve();\n });\n });\n}\n\n// ============================================================================\n// Connection Handler\n// ============================================================================\n\ninterface ConnectionOptions\n{\n pingInterval: number;\n maxBufferedBytes: number;\n maxConnectionsPerSubject: number;\n subjectCounts: Map<string, number>;\n}\n\nasync function handleConnection(\n ws: any,\n req: any,\n router: WSRouterDef<any, any>,\n authConfig: WSHandlerAuthConfig | undefined,\n tokenManager: SSETokenManager | undefined,\n opts: ConnectionOptions,\n): Promise<void>\n{\n const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;\n // Register close handler before any await — ensures we never miss the event even during auth\n let pingTimer: ReturnType<typeof setInterval> | undefined;\n let connectionUnsubscribes: (() => void)[] = [];\n let subscribedEvents: string[] = [];\n ws.on('close', () =>\n {\n clearInterval(pingTimer);\n connectionUnsubscribes.forEach(fn => fn());\n if (subscribedEvents.length > 0)\n wsLogger.info('WebSocket connection closed', { events: subscribedEvents });\n });\n\n const url = parseURL(req);\n if (!url)\n {\n ws.close(1002, 'Invalid request URL');\n\n return;\n }\n\n // ── 1. Authenticate ──\n const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : undefined);\n if (subject === false)\n {\n ws.close(4001, 'Missing token');\n\n return;\n }\n if (subject === null)\n {\n ws.close(4001, 'Invalid or expired token');\n\n return;\n }\n\n // ── 2. Resolve subscribed events ──\n const requestedEvents = parseRequestedEvents(url, router.eventNames as string[]);\n if (requestedEvents.length === 0)\n {\n ws.close(4000, 'No valid event names specified');\n\n return;\n }\n\n // ── 3. Authorize ──\n const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);\n if (allowedEvents === null)\n {\n ws.close(4003, 'Not authorized for any requested events');\n\n return;\n }\n\n // ── Per-subject connection cap (authenticated subjects only) ──\n if (maxConnectionsPerSubject > 0 && typeof subject === 'string')\n {\n const current = subjectCounts.get(subject) ?? 0;\n if (current >= maxConnectionsPerSubject)\n {\n ws.close(1013, 'Too many connections for this subject');\n\n return;\n }\n\n subjectCounts.set(subject, current + 1);\n ws.on('close', () =>\n {\n const remaining = (subjectCounts.get(subject) ?? 1) - 1;\n if (remaining <= 0) subjectCounts.delete(subject);\n else subjectCounts.set(subject, remaining);\n });\n }\n\n subscribedEvents = allowedEvents;\n wsLogger.info('WebSocket connection established', {\n events: allowedEvents,\n subject: subject ?? undefined,\n });\n\n // ── 4. Build connection wrapper ──\n const connection = createConnection(ws, maxBufferedBytes);\n\n // ── 5. Subscribe to server-push events ──\n connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);\n\n // If socket closed during auth awaits, clean up and bail\n if (ws.readyState !== 1)\n {\n connectionUnsubscribes.forEach(fn => fn());\n connectionUnsubscribes = [];\n\n return;\n }\n\n // ── 6. Handle incoming messages ──\n ws.on('message', (data: Buffer | string) =>\n {\n onClientMessage(data, router, connection, subject)\n .catch((err: Error) => wsLogger.error('Unhandled message error', err));\n });\n\n // ── 7. Keep-alive ping with liveness (pong) tracking ──\n // Without reaping un-ponged sockets, a half-open connection (sleeping device,\n // NAT drop with no FIN) lingers with its subscriptions, timer, and buffers.\n if (pingInterval > 0)\n {\n ws.isAlive = true;\n ws.on('pong', () =>\n {\n ws.isAlive = true;\n });\n\n pingTimer = setInterval(() =>\n {\n if (ws.readyState !== 1) return;\n\n if (ws.isAlive === false)\n {\n // No pong since the previous tick — drop the dead/half-open socket\n ws.terminate();\n\n return;\n }\n\n ws.isAlive = false;\n ws.ping();\n }, pingInterval);\n }\n\n // ── 9. Send connected ack ──\n connection.send('__connected', {\n subscribedEvents: allowedEvents,\n timestamp: Date.now(),\n });\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction parseURL(req: any): URL | null\n{\n try\n {\n return new URL(req.url ?? '/', 'ws://localhost');\n }\n catch\n {\n return null;\n }\n}\n\n/**\n * Resolve subject from token\n * - undefined: no auth required\n * - false: token param missing (when required)\n * - null: token invalid/expired\n * - string: authenticated subject\n */\nasync function resolveSubject(\n url: URL,\n tokenManager?: SSETokenManager,\n): Promise<string | undefined | false | null>\n{\n if (!tokenManager)\n {\n return undefined;\n }\n\n const token = url.searchParams.get('token');\n if (!token)\n {\n return false;\n }\n\n return await tokenManager.verify(token);\n}\n\nfunction parseRequestedEvents(url: URL, validEventNames: string[]): string[]\n{\n const eventsParam = url.searchParams.get('events');\n if (!eventsParam)\n {\n return [];\n }\n\n return eventsParam\n .split(',')\n .map(e => e.trim())\n .filter(e => validEventNames.includes(e));\n}\n\nasync function resolveAllowedEvents(\n subject: string | undefined,\n requestedEvents: string[],\n authConfig?: WSHandlerAuthConfig,\n): Promise<string[] | null>\n{\n if (!subject || !authConfig?.authorize)\n {\n return requestedEvents;\n }\n\n const allowed = await authConfig.authorize(subject, requestedEvents);\n\n return allowed.length === 0 ? null : allowed;\n}\n\n/**\n * Send a JSON frame with backpressure protection. If the socket's outbound\n * buffer is already past the cap, the consumer is too slow — close the\n * connection (1013) instead of buffering more and risking OOM. The client\n * reconnects and re-subscribes.\n */\nexport function safeSend(ws: any, frame: unknown, maxBufferedBytes: number): void\n{\n if (ws.readyState !== 1) return;\n\n if (ws.bufferedAmount > maxBufferedBytes)\n {\n ws.close(1013, 'Send buffer overflow');\n\n return;\n }\n\n try\n {\n ws.send(JSON.stringify(frame));\n }\n catch\n {\n // Socket closed between the readyState check and send — ignore\n }\n}\n\nfunction createConnection(ws: any, maxBufferedBytes: number): WSRawConnection\n{\n return {\n send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),\n close: (code, reason) => ws.close(code, reason),\n };\n}\n\nfunction subscribeEvents(\n ws: any,\n router: WSRouterDef<any, any>,\n allowedEvents: string[],\n subject: string | undefined,\n authConfig: WSHandlerAuthConfig | undefined,\n maxBufferedBytes: number,\n): (() => void)[]\n{\n const unsubscribes: (() => void)[] = [];\n\n for (const eventName of allowedEvents)\n {\n const eventDef = router.events[eventName];\n if (!eventDef) continue;\n\n const unsubscribe = eventDef.subscribe((payload: unknown) =>\n {\n if (ws.readyState !== 1) return;\n\n if (subject && authConfig?.filter?.[eventName])\n {\n if (!authConfig.filter[eventName](subject, payload)) return;\n }\n\n safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);\n });\n\n unsubscribes.push(unsubscribe);\n }\n\n return unsubscribes;\n}\n\nasync function onClientMessage(\n data: Buffer | string,\n router: WSRouterDef<any, any>,\n connection: WSRawConnection,\n subject: string | undefined,\n): Promise<void>\n{\n let message: { type?: string; data?: unknown };\n\n try\n {\n message = JSON.parse(data.toString());\n }\n catch\n {\n return;\n }\n\n const { type, data: payload } = message;\n if (!type) return;\n\n const handler = router.messages[type];\n if (!handler) return;\n\n try\n {\n await handler({ payload, subject, ws: connection });\n }\n catch (err)\n {\n wsLogger.error(`WebSocket message handler error: ${type}`, err as Error);\n }\n}\n\n// ============================================================================\n// Dynamic import for optional 'ws' dependency\n// ============================================================================\n\n/**\n * Resolve the WebSocketServer constructor from the 'ws' module object.\n *\n * ws is a dual package, so the module shape depends on the loader path:\n * - ESM (exports.import → wrapper.mjs): WebSocketServer is a named export;\n * the default export is the bare WebSocket class with no properties on it.\n * - CJS (require interop): module.exports = WebSocket with\n * WebSocket.WebSocketServer attached, surfaced under .default.\n * Named exports must win — checking .default first crashes on the ESM path.\n */\nexport function resolveWSServerCtor(mod: any): any\n{\n return mod.WebSocketServer ?? mod.Server\n ?? mod.default?.WebSocketServer ?? mod.default?.Server;\n}\n\nasync function loadWSServer(): Promise<any>\n{\n let mod: any;\n\n try\n {\n mod = await import('ws');\n }\n catch\n {\n throw new Error(\n '@spfn/core WebSocket support requires the \"ws\" package.\\n' +\n 'Install it with: pnpm add ws',\n );\n }\n\n const WSS = resolveWSServerCtor(mod);\n\n if (typeof WSS !== 'function')\n {\n throw new Error(\n 'WebSocketServer not found in ws module. ' +\n 'Ensure ws@^8 is installed: pnpm add ws',\n );\n }\n\n return WSS;\n}\n","/**\n * WebSocket Module\n *\n * Type-safe WebSocket server with event-based pub/sub and bidirectional messaging.\n *\n * @example Server setup\n * ```typescript\n * // src/server/ws.ts\n * import { defineWSRouter } from '@spfn/core/event/ws';\n * import { defineEvent } from '@spfn/core/event';\n * import { Type } from '@sinclair/typebox';\n *\n * const userUpdated = defineEvent('user.updated', Type.Object({ userId: Type.String() }));\n * const notification = defineEvent('notification', Type.Object({ message: Type.String() }));\n *\n * export const wsRouter = defineWSRouter({\n * events: { userUpdated, notification },\n * messages: {\n * ping: ({ ws }) => ws.send('pong', {}),\n * },\n * });\n *\n * export type WSRouter = typeof wsRouter;\n *\n * // server.config.ts\n * defineServerConfig()\n * .websockets(wsRouter)\n * .build();\n * ```\n *\n * @example Client usage\n * ```typescript\n * import { createWSClient } from '@spfn/core/event/ws/client';\n * import type { WSRouter } from '@/server/ws';\n *\n * const client = createWSClient<WSRouter>();\n *\n * client.subscribe({\n * events: ['userUpdated', 'notification'],\n * handlers: {\n * userUpdated: ({ userId }) => console.log(userId),\n * notification: ({ message }) => console.log(message),\n * },\n * });\n *\n * client.send('ping', {});\n * ```\n */\n\nimport type { EventDef } from '../types';\nimport type { WSRouterDef, WSMessageHandlers } from './types';\n\nexport { attachWSHandler } from './handler';\nexport type {\n WSRouterDef,\n WSHandlerConfig,\n WSAuthConfig,\n WSHandlerAuthConfig,\n WSMessageContext,\n WSMessageHandlerFn,\n WSMessageHandlers,\n WSRawConnection,\n WSClientConfig,\n WSConnectionState,\n WSEventHandlers,\n WSSubscribeOptions,\n WSUnsubscribe,\n} from './types';\n\n/**\n * Define a WebSocket router\n *\n * Combines server→client event push with client→server message handlers.\n *\n * @example\n * ```typescript\n * export const wsRouter = defineWSRouter({\n * events: { userUpdated, notification },\n * messages: {\n * ping: ({ ws }) => ws.send('pong', {}),\n * 'chat.send': ({ payload, subject }) => handleChat(payload, subject),\n * },\n * });\n * ```\n */\nexport function defineWSRouter<\n TEvents extends Record<string, EventDef<any>>,\n TMessages extends WSMessageHandlers = WSMessageHandlers,\n>(def: {\n events: TEvents;\n messages?: TMessages;\n}): WSRouterDef<TEvents, TMessages>\n{\n return {\n events: def.events,\n eventNames: Object.keys(def.events) as (keyof TEvents)[],\n messages: (def.messages ?? {}) as TMessages,\n _types: {} as WSRouterDef<TEvents, TMessages>['_types'],\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/event/cache-transport.ts","../../src/event/event.ts","../../src/event/sse/route-map.ts","../../src/event/router.ts","../../src/event/ws/handler.ts","../../src/event/ws/index.ts"],"names":["logger"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Event cache transport (Redis/Valkey pub/sub)\n *\n * Wires each event in a router to a shared {@link PubSubCache} so that an\n * `emit` on one pod fans out to subscribers on every pod. This is the runtime\n * glue behind `EventDef.useCache` — applications don't call it directly; the\n * server's `.events()` / `.websockets()` registration calls it at startup when\n * a cache (`CACHE_URL`) is configured. Without a cache, events stay in-process\n * and this is a no-op.\n *\n * Mirrors the auto-detection pattern used by `CacheTokenStore` for SSE tokens.\n */\n\nimport { logger } from '@spfn/core/logger';\nimport type { EventRouterBase } from './router';\nimport type { PubSubCache, SubscriptionMatch } from './types';\n\nconst transportLogger = logger.child('@spfn/core:event-transport');\n\n/** Default channel prefix; isolates SPFN event channels from other Redis keys. */\nconst DEFAULT_CHANNEL_PREFIX = 'spfn:sse:';\n\n/** Cap on the subscriber `quit()` during shutdown so a wedged socket can't stall it. */\nconst SUBSCRIBER_QUIT_TIMEOUT_MS = 5000;\n\n/** Cap a Redis PUBLISH so a wedged socket (half-open, no FIN) can't hang `emit` forever. */\nconst PUBLISH_TIMEOUT_MS = 5000;\n\n/** Cap a Redis SUBSCRIBE so a wedged socket can't hang server startup; degrades to in-process. */\nconst SUBSCRIBE_TIMEOUT_MS = 5000;\n\n/** Fold repeated transport warnings during a sustained outage into one line per window. */\nconst WARN_THROTTLE_MS = 30000;\n\n/**\n * After a publish failure, skip Redis entirely (deliver locally) for this long before\n * probing again. Without it, a streaming `emit` loop pays the full PUBLISH_TIMEOUT_MS per\n * chunk during a sustained outage (50 chunks × 5s = minutes of request hang).\n */\nconst PUBLISH_BREAKER_COOLDOWN_MS = 10000;\n\n/**\n * Reject if `promise` doesn't settle within `ms`. ioredis has no command timeout by\n * default, so a half-open socket leaves publish/subscribe pending indefinitely; this\n * bounds it so the caller can fall back instead of hanging.\n */\nfunction withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>\n{\n return new Promise<T>((resolve, reject) =>\n {\n const timer = setTimeout(() =>\n {\n reject(new Error(`${label} timed out after ${ms}ms`));\n }, ms);\n timer.unref?.();\n\n promise.then(\n (value) =>\n {\n clearTimeout(timer);\n resolve(value);\n },\n (err) =>\n {\n clearTimeout(timer);\n reject(err);\n },\n );\n });\n}\n\nconst warnThrottle = new Map<string, { last: number; suppressed: number }>();\n\n/**\n * Warn at most once per {@link WARN_THROTTLE_MS} per key, folding the suppressed count\n * into the next line — a sustained Redis outage must not flood logs (one WARN per emit\n * × a fast stream = thousands/sec).\n */\nfunction throttledWarn(key: string, message: string, fields: Record<string, unknown>): void\n{\n const now = Date.now();\n const entry = warnThrottle.get(key);\n\n if (entry && now - entry.last < WARN_THROTTLE_MS)\n {\n entry.suppressed++;\n\n return;\n }\n\n transportLogger.warn(message, entry && entry.suppressed > 0\n ? { ...fields, suppressedSinceLast: entry.suppressed }\n : fields);\n warnThrottle.set(key, { last: now, suppressed: 0 });\n}\n\n/**\n * Minimal pub/sub client interface (compatible with ioredis Redis | Cluster).\n * A duplicated connection is required for SUBSCRIBE — ioredis puts a connection\n * into subscriber mode, where regular commands are rejected.\n */\ntype PubSubClient = {\n publish(channel: string, message: string): Promise<unknown>;\n duplicate(): PubSubClient;\n subscribe(...channels: string[]): Promise<unknown>;\n on(event: string, listener: (...args: any[]) => void): unknown;\n quit?(): Promise<unknown>;\n /** ioredis connection status ('ready' | 'connecting' | 'close' | ...). */\n status?: string;\n};\n\ntype MessageHandler = (message: unknown) => void | Promise<void>;\n\n/** The slice of EventDef this module wires (kept minimal + duck-typed). */\ntype WirableEvent = {\n name: string;\n useCache: (cache: PubSubCache) => Promise<unknown>;\n _resetCache?: () => void;\n};\n\n// ── globalThis singleton ──────────────────────────────────────────\n// Survives the CJS/ESM dual-package hazard (same pattern as cache-manager):\n// one subscriber connection and one wired-event registry per process. The\n// registry holds the EventDefs (not just names) so closeEventTransport can\n// reset their cache binding — otherwise the per-event cacheSubscribed latch\n// and this state would diverge across a restart in the same process.\ninterface TransportState\n{\n pubSubCache: PubSubCache | undefined;\n subscriber: PubSubClient | undefined;\n channelPrefix: string | undefined;\n wired: Map<string, WirableEvent>;\n}\n\nconst STATE_KEY = Symbol.for('@spfn/core:event-transport');\n\nconst state: TransportState = ((globalThis as any)[STATE_KEY] ??= {\n pubSubCache: undefined,\n subscriber: undefined,\n channelPrefix: undefined,\n wired: new Map<string, WirableEvent>(),\n});\n\n/**\n * Resolve the channel prefix: explicit option → env override → default.\n */\nfunction resolveChannelPrefix(override?: string): string\n{\n return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;\n}\n\n/**\n * Build a {@link PubSubCache} backed by a Redis/Valkey client.\n *\n * One duplicated connection is created lazily and shared across every channel.\n * Publish failures are logged and swallowed — SSE is lossy (at-most-once), so a\n * dropped event during a Redis blip must never crash `emit` or kill a stream.\n */\nexport function createRedisPubSubCache(\n client: PubSubClient,\n channelPrefix: string,\n onSubscriber?: (connection: PubSubClient) => void,\n): PubSubCache\n{\n let subscriber: PubSubClient | undefined;\n const handlers = new Map<string, MessageHandler>();\n // Circuit breaker: epoch-ms until which Redis is treated as down (skip publish).\n let degradedUntil = 0;\n\n const ensureSubscriber = (): PubSubClient =>\n {\n if (subscriber)\n {\n return subscriber;\n }\n\n // ioredis re-issues SUBSCRIBE after a reconnect (autoResubscribe, default\n // true). Don't pass it as a duplicate() option — Cluster.duplicate()'s\n // first positional arg is the startup-nodes array, so an options object\n // lands in the wrong slot and is silently dropped. The 'message' listener\n // is attached once and survives reconnects.\n subscriber = client.duplicate();\n onSubscriber?.(subscriber);\n\n subscriber.on('message', (channel: string, raw: string) =>\n {\n const handler = handlers.get(channel);\n if (!handler)\n {\n return;\n }\n\n let payload: unknown;\n try\n {\n payload = JSON.parse(raw);\n }\n catch\n {\n transportLogger.warn('Pub/sub message parse failed — dropped', { channel });\n\n return;\n }\n\n void handler(payload);\n });\n\n // ioredis auto-reconnects (and re-subscribes via autoResubscribe above);\n // surface the blip, throttled so a sustained outage's reconnect loop can't\n // flood logs.\n subscriber.on('error', (err: Error) =>\n {\n throttledWarn('subscriber-error', 'Pub/sub subscriber error', { error: err.message });\n });\n\n return subscriber;\n };\n\n // Deliver to this pod's own subscribers without the Redis round-trip — the\n // fallback when publish can't reach Redis. Mostly avoids double-delivery (a\n // failed publish usually means Redis never echoed back), but a reply-lost\n // race — Redis broadcast, then the publisher's reply dropped — can deliver\n // both here and via the echo. We accept that rare duplicate over dropping,\n // since SSE is lossy and a frozen stream is worse than a repeated chunk.\n // Same-pod subscribers are fed ONLY by the echo on the subscriber connection\n // (a separate socket from the write client). If it isn't connected, a successful\n // publish still won't reach this pod's own streams — so they need the local\n // fallback. A status-less subscriber (test mocks) is treated as healthy.\n const subscriberHealthy = (): boolean =>\n !!subscriber && (subscriber.status === undefined || subscriber.status === 'ready');\n\n const deliverLocal = (channel: string, message: unknown): void =>\n {\n const handler = handlers.get(channel);\n if (handler)\n {\n void handler(message);\n }\n };\n\n return {\n publish: async (name: string, message: unknown): Promise<void> =>\n {\n const channel = channelPrefix + name;\n\n // `string | undefined`: JSON.stringify returns undefined (no throw) for a\n // top-level function/symbol, and throws for bigint/circular. Both mean\n // \"can't cross Redis\" — handle each as a local-only delivery.\n let serialized: string | undefined;\n try\n {\n // `?? null` so void-payload events serialize to \"null\" rather than\n // `undefined` (JSON.stringify(undefined) → undefined → publish breaks).\n serialized = JSON.stringify(message ?? null);\n }\n catch (err)\n {\n // Non-serializable payload (bigint, circular ref). Cross-pod is\n // impossible; deliver the live object locally (best effort — a\n // downstream JSON.stringify may re-throw, but better than dropping).\n transportLogger.warn('Pub/sub payload serialization failed — local delivery only', {\n channel,\n error: err instanceof Error ? err.message : String(err),\n });\n deliverLocal(channel, message ?? null);\n\n return;\n }\n\n if (serialized === undefined)\n {\n // Top-level function/symbol payload — JSON.stringify yields undefined.\n // Publishing that is meaningless and re-parsing it later would throw,\n // so deliver locally and stop (guards the \"publish never kills emit\").\n transportLogger.warn('Pub/sub payload not serializable (function/symbol) — local delivery only', {\n channel,\n });\n deliverLocal(channel, message ?? null);\n\n return;\n }\n\n // Skip Redis when the breaker is open (recent failure) or the client isn't\n // connected. This (a) bounds a sustained outage to one timeout per cooldown\n // instead of one per emit, and (b) avoids ioredis's offline queue buffering\n // the publish and re-sending it on reconnect — which would deliver the event\n // twice (local fallback now + echo later). Treat a status-less client (test\n // mocks) as ready so behavior is unchanged there.\n const ready = client.status === undefined || client.status === 'ready';\n const now = Date.now();\n if (!ready || (degradedUntil > 0 && now < degradedUntil))\n {\n // Not connected, or breaker open → skip Redis, deliver locally.\n deliverLocal(channel, JSON.parse(serialized));\n\n return;\n }\n\n // Healthy (degradedUntil === 0) → publish directly; concurrent emits all\n // fan out. Half-open (breaker tripped but cooldown elapsed) → this emit is\n // the single probe: arm the breaker for the probe's duration so CONCURRENT\n // emits fast-path to local instead of each launching their own 5s probe.\n if (degradedUntil > 0)\n {\n degradedUntil = now + PUBLISH_TIMEOUT_MS;\n }\n\n try\n {\n // Timeout-bound: a wedged socket would otherwise leave this pending\n // forever and hang `emit` (and the request that called it).\n await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, 'pub/sub publish');\n degradedUntil = 0; // success → close the breaker\n\n // Publish reached Redis (remote pods get it via their echo), but if THIS\n // pod's subscriber socket is down its echo won't arrive — cover same-pod\n // subscribers locally. No double-delivery: Redis doesn't buffer for a\n // disconnected subscriber, so the missed echo never arrives later.\n if (!subscriberHealthy())\n {\n throttledWarn('subscriber-down', 'Subscriber connection down — same-pod local fallback', {\n channel,\n });\n deliverLocal(channel, JSON.parse(serialized));\n }\n }\n catch (err)\n {\n // Redis blip/timeout: open the breaker so subsequent emits fast-path to\n // local delivery instead of each paying the timeout. The cross-pod echo\n // won't arrive, so deliver to this pod's subscribers directly. Parse the\n // serialized copy so handlers see the SAME shape as the echo path (a JSON\n // round-trip), never the live mutable object. Throttled WARN — a sustained\n // outage emits one line per window, not one per chunk.\n degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;\n throttledWarn('publish-failed', 'Pub/sub publish failed — local delivery only', {\n channel,\n error: err instanceof Error ? err.message : String(err),\n });\n deliverLocal(channel, JSON.parse(serialized));\n }\n },\n\n subscribe: async (name: string, handler: MessageHandler): Promise<void> =>\n {\n const channel = channelPrefix + name;\n const sub = ensureSubscriber();\n\n try\n {\n // Timeout-bound: at startup this is awaited before the HTTP listener,\n // so a wedged subscriber would otherwise hang the entire boot.\n await withTimeout(sub.subscribe(channel), SUBSCRIBE_TIMEOUT_MS, 'pub/sub subscribe');\n }\n catch (err)\n {\n // SUBSCRIBE failed. If nothing else is live on this connection it\n // may be half-open — drop the memo (and quit it) so a later retry\n // rebuilds a fresh subscriber instead of reusing a dead one.\n if (handlers.size === 0 && subscriber === sub)\n {\n subscriber = undefined;\n void sub.quit?.().catch(() => undefined);\n }\n\n throw err;\n }\n\n // Register only after a confirmed subscribe — never leave a handler\n // bound to a channel the broker hasn't acknowledged.\n handlers.set(channel, handler);\n },\n };\n}\n\n// ── Subscription control channel (grant / revoke across pods) ─────\n\n/**\n * The channel `grant` / `revoke` travel on, published through the same\n * {@link PubSubCache} as events (so `<prefix>$control` on Redis — same timeouts,\n * breaker and warn throttling, no second connection). Event channels are\n * `<prefix><event name>`; an event named `control` would collide with a bare\n * `control`, and `$` never appears in a dotted event name, so it can't.\n */\nconst CONTROL_CHANNEL = '$control';\n\n/** What one `grant` / `revoke` publishes. */\nexport interface SubscriptionControlMessage\n{\n kind: 'grant' | 'revoke';\n\n /** The event's wire name (`defineEvent(name)`) — router keys differ per pod's router. */\n event: string;\n\n subject: string;\n\n /** Which of the subject's connections it reaches; absent → every one. */\n match?: SubscriptionMatch;\n\n /** The publishing binding, so a pod skips its own echo (it applied the change locally first). */\n origin: string;\n}\n\n/** Applies one control message to the local registries of one event. */\nexport type SubscriptionControlApply = (\n kind: SubscriptionControlMessage['kind'],\n subject: string,\n match?: SubscriptionMatch,\n) => void;\n\ninterface ControlBinding\n{\n origin: string;\n events: Map<string, Set<SubscriptionControlApply>>;\n subscribing: Promise<void> | undefined;\n}\n\n/** One binding per cache: in production one per process, in tests one per simulated pod. */\nconst controlBindings = new WeakMap<PubSubCache, ControlBinding>();\n\nfunction ensureControlBinding(cache: PubSubCache): ControlBinding\n{\n let binding = controlBindings.get(cache);\n if (!binding)\n {\n binding = {\n origin: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,\n events: new Map(),\n subscribing: undefined,\n };\n controlBindings.set(cache, binding);\n }\n\n return binding;\n}\n\nfunction isControlMessage(message: unknown): message is SubscriptionControlMessage\n{\n const candidate = message as Partial<SubscriptionControlMessage> | null;\n\n return !!candidate\n && (candidate.kind === 'grant' || candidate.kind === 'revoke')\n && typeof candidate.event === 'string'\n && typeof candidate.subject === 'string'\n && typeof candidate.origin === 'string'\n && (candidate.match === undefined || isSubscriptionMatch(candidate.match));\n}\n\n/**\n * `{ query: { [field]: string } }` with at least one field, and nothing looser:\n * a bad match must not widen a grant, and an empty `query` matches every\n * connection of the subject.\n */\nexport function isSubscriptionMatch(match: unknown): match is SubscriptionMatch\n{\n const query = (match as Partial<SubscriptionMatch> | null)?.query;\n\n return typeof match === 'object'\n && typeof query === 'object'\n && query !== null\n && !Array.isArray(query)\n && Object.keys(query).length > 0\n && Object.values(query).every(value => typeof value === 'string');\n}\n\nfunction dispatchControl(binding: ControlBinding, message: unknown): void\n{\n if (!isControlMessage(message))\n {\n transportLogger.debug('Malformed subscription control message ignored');\n\n return;\n }\n\n if (message.origin === binding.origin)\n {\n return;\n }\n\n const appliers = binding.events.get(message.event);\n if (!appliers)\n {\n transportLogger.debug('Subscription control for an event this pod does not serve ignored', {\n event: message.event,\n });\n\n return;\n }\n\n appliers.forEach(apply => apply(message.kind, message.subject, message.match));\n}\n\n/**\n * Let `grant` / `revoke` published by other pods reach an event on this pod.\n *\n * Called by `EventDef.useCache`. Subscribes the control channel once per cache;\n * a failed subscribe is warned and retried by the next event's bind — inbound\n * control then stays local until it succeeds, never failing `useCache`.\n */\nexport async function bindSubscriptionControl(\n cache: PubSubCache,\n eventName: string,\n apply: SubscriptionControlApply,\n): Promise<void>\n{\n const binding = ensureControlBinding(cache);\n const appliers = binding.events.get(eventName) ?? new Set<SubscriptionControlApply>();\n appliers.add(apply);\n binding.events.set(eventName, appliers);\n\n binding.subscribing ??= cache\n .subscribe(CONTROL_CHANNEL, (message) => dispatchControl(binding, message))\n .catch((err: unknown) =>\n {\n binding.subscribing = undefined;\n throttledWarn('control-subscribe-failed', 'Subscription control subscribe failed — grant/revoke from other pods not received', {\n error: err instanceof Error ? err.message : String(err),\n });\n });\n\n await binding.subscribing;\n}\n\n/**\n * Tell every other pod about a `grant` / `revoke` this pod already applied.\n * A failure is warned (throttled) and swallowed: the change stays local, and\n * the next connect re-reads permission anyway.\n */\nexport async function publishSubscriptionControl(\n cache: PubSubCache,\n kind: SubscriptionControlMessage['kind'],\n eventName: string,\n subject: string,\n match?: SubscriptionMatch,\n): Promise<void>\n{\n const message: SubscriptionControlMessage = {\n kind,\n event: eventName,\n subject,\n ...(match ? { match } : {}),\n origin: ensureControlBinding(cache).origin,\n };\n\n await cache.publish(CONTROL_CHANNEL, message).catch((err: unknown) =>\n {\n throttledWarn('control-publish-failed', 'Subscription control publish failed — applied on this pod only', {\n event: eventName,\n error: err instanceof Error ? err.message : String(err),\n });\n });\n}\n\n/**\n * Options controlling event cache transport wiring.\n */\nexport interface WireEventCacheOptions\n{\n /**\n * Force in-process mode even when a cache is configured.\n * @default true (auto: Redis when CACHE_URL is set, else in-process)\n */\n multiInstance?: boolean;\n\n /**\n * Channel prefix override (env `SPFN_SSE_CHANNEL_PREFIX`, else `spfn:sse:`).\n * Use distinct prefixes to isolate apps/tenants sharing one Redis.\n */\n channelPrefix?: string;\n}\n\n/**\n * Wire every event in a router to the shared Redis pub/sub cache.\n *\n * Returns the transport that ended up active. Idempotent per event name across\n * routers (an event shared by the SSE and WS routers is wired once), so it is\n * safe to call for both `.events()` and `.websockets()`.\n */\nexport async function wireEventRouterCache(\n router: EventRouterBase<any>,\n options: WireEventCacheOptions = {},\n): Promise<'in-process' | 'redis'>\n{\n // Logged at INFO (not debug): operators must be able to confirm from prod logs\n // whether cross-pod fan-out is actually live, and whether any event degraded.\n if (options.multiInstance === false)\n {\n // A sibling router may have already wired shared events to Redis — this flag\n // is process-global and can't un-wire them, so be honest about that.\n const events = router.events as Record<string, WirableEvent>;\n const stillRedis = Object.keys(events).filter(k => state.wired.has(events[k].name)).length;\n\n if (stillRedis > 0)\n {\n transportLogger.warn('multiInstance:false has no effect on events already wired to redis by another router', {\n redisWired: stillRedis,\n });\n }\n else\n {\n transportLogger.info('Event transport: in-process (multiInstance disabled)');\n }\n\n return 'in-process';\n }\n\n const pubSubCache = await resolvePubSubCache(options.channelPrefix);\n if (!pubSubCache)\n {\n transportLogger.info('Event transport: in-process (no cache configured)');\n\n return 'in-process';\n }\n\n const events = router.events as Record<string, WirableEvent>;\n let wired = 0;\n let degraded = 0;\n let alreadyWired = 0;\n\n for (const key of Object.keys(events))\n {\n const event = events[key];\n if (state.wired.has(event.name))\n {\n alreadyWired++; // e.g. an event shared by the SSE and WS routers\n continue;\n }\n\n try\n {\n await event.useCache(pubSubCache);\n state.wired.set(event.name, event);\n wired++;\n }\n catch (err)\n {\n // Degrade this event to in-process rather than aborting startup — a\n // transient SUBSCRIBE failure must not crash the pod. Not marked\n // wired, so a later wiring pass can retry it.\n degraded++;\n transportLogger.warn('Event cache wiring failed — staying in-process for this event', {\n event: event.name,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n // Every event degraded (and none were already wired) → fan-out is effectively\n // dead for this router; report in-process honestly instead of a false 'redis'.\n if (wired + alreadyWired === 0)\n {\n transportLogger.warn('Event transport: redis configured but no event wired — running in-process', {\n total: Object.keys(events).length,\n degraded,\n });\n\n return 'in-process';\n }\n\n transportLogger.info('Event transport: redis (cross-pod fan-out)', {\n total: Object.keys(events).length,\n wired,\n degraded,\n alreadyWired,\n });\n\n return 'redis';\n}\n\n/**\n * Resolve (and cache) the shared pub/sub cache from the global cache instance.\n * Returns undefined when no cache is available.\n */\nasync function resolvePubSubCache(channelPrefix?: string): Promise<PubSubCache | undefined>\n{\n const resolvedPrefix = resolveChannelPrefix(channelPrefix);\n\n if (state.pubSubCache)\n {\n // The transport (and its prefix) is process-global, resolved once on the\n // first wiring. Warn if a later router (.websockets() after .events())\n // asks for a different prefix — it's silently ignored, not applied.\n if (state.channelPrefix !== resolvedPrefix)\n {\n transportLogger.warn('Conflicting channelPrefix ignored — the transport prefix is process-global (first wiring wins).', {\n active: state.channelPrefix,\n ignored: resolvedPrefix,\n });\n }\n\n return state.pubSubCache;\n }\n\n let client: PubSubClient | undefined;\n try\n {\n const { getCache } = await import('@spfn/core/cache');\n client = getCache() as PubSubClient | undefined;\n }\n catch\n {\n // Cache module unavailable — fall back to in-process.\n return undefined;\n }\n\n if (!client)\n {\n return undefined;\n }\n\n // Cross-app isolation depends on the channel prefix. The default is shared by\n // every SPFN app, so warn (once, on first resolve) when running on it — apps\n // sharing one Redis with colliding event names would otherwise leak payloads.\n if (!channelPrefix && !process.env.SPFN_SSE_CHANNEL_PREFIX)\n {\n transportLogger.warn(\n 'Event fan-out is on the default channel prefix (spfn:sse:). Apps sharing one '\n + 'Redis must set channelPrefix or SPFN_SSE_CHANNEL_PREFIX to avoid cross-app leakage.',\n );\n }\n\n state.channelPrefix = resolvedPrefix;\n state.pubSubCache = createRedisPubSubCache(\n client,\n resolvedPrefix,\n (connection) =>\n {\n state.subscriber = connection;\n },\n );\n\n return state.pubSubCache;\n}\n\n/**\n * Close the shared pub/sub subscriber connection and reset transport state.\n *\n * Called during graceful shutdown — the subscriber is a `duplicate()` of the\n * cache write connection and is not tracked by the cache manager, so it must be\n * quit explicitly (before `closeCache`). No-op when no subscriber was opened.\n *\n * Also resets each wired EventDef's cache binding. EventDefs are module\n * singletons that outlive a server instance; without this, a second server start\n * in the same process (tests, hot-reload) hits the `cacheSubscribed` latch, never\n * rebinds, and silently runs with cross-pod fan-out dead.\n */\nexport async function closeEventTransport(): Promise<void>\n{\n const subscriber = state.subscriber;\n\n for (const event of state.wired.values())\n {\n event._resetCache?.();\n }\n\n state.subscriber = undefined;\n state.pubSubCache = undefined;\n state.channelPrefix = undefined;\n state.wired.clear();\n\n if (!subscriber?.quit)\n {\n return;\n }\n\n // Bound the quit: a wedged connection (broken socket, no FIN) must not stall\n // shutdown past this timeout — the process exits and the OS reclaims the socket.\n await Promise.race([\n subscriber.quit().catch(() => undefined),\n new Promise<void>((resolve) =>\n {\n const timer = setTimeout(resolve, SUBSCRIBER_QUIT_TIMEOUT_MS);\n timer.unref?.();\n }),\n ]);\n}\n","/**\n * Event System\n *\n * Decoupled pub/sub event system with optional cache integration for multi-instance support\n *\n * @example\n * ```typescript\n * // Define event\n * const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * // Subscribe (in-memory)\n * userCreated.subscribe((payload) => {\n * console.log('User created:', payload.userId);\n * });\n *\n * // Emit\n * await userCreated.emit({ userId: '123' });\n *\n * // With cache for multi-instance\n * const event = defineEvent('user.created', schema);\n * await event.useCache(cache); // Must await before emitting\n * await event.emit({ userId: '123' }); // Broadcast to all instances\n * ```\n */\n\nimport type { TSchema, Static } from '@sinclair/typebox';\nimport { logger } from '@spfn/core/logger';\nimport { bindSubscriptionControl, isSubscriptionMatch, publishSubscriptionControl } from './cache-transport';\nimport type {\n EventContract,\n EventDef,\n EventHandler,\n EventSubscribeOptions,\n FramePredicate,\n JobQueueSender,\n PubSubCache,\n SubscribePredicate,\n SubscriptionControl,\n SubscriptionMatch,\n} from './types';\n\nconst eventLogger = logger.child('@spfn/core:event');\n\n/**\n * Log handler error with consistent format\n */\nfunction logHandlerError(eventName: string, error: unknown): void\n{\n eventLogger.error(`Event handler error: ${eventName}`, {\n error: error instanceof Error ? error.message : String(error),\n });\n}\n\n/**\n * Log job queue error with consistent format\n */\nfunction logJobQueueError(queueName: string, error: unknown): void\n{\n eventLogger.error(`Failed to send event to job queue: ${queueName}`, {\n error: error instanceof Error ? error.message : String(error),\n });\n}\n\n/**\n * Create handler subscription manager\n */\nfunction createHandlerManager<TPayload>(name: string)\n{\n const handlers: Set<EventHandler<TPayload>> = new Set();\n\n return {\n add: (handler: EventHandler<TPayload>): (() => void) =>\n {\n handlers.add(handler);\n eventLogger.debug(`Subscribed to event: ${name}`, { handlerCount: handlers.size });\n\n return () =>\n {\n handlers.delete(handler);\n eventLogger.debug(`Unsubscribed from event: ${name}`, { handlerCount: handlers.size });\n };\n },\n\n clear: (): void =>\n {\n handlers.clear();\n eventLogger.debug(`Unsubscribed all from event: ${name}`);\n },\n\n trigger: async (payload: TPayload): Promise<void> =>\n {\n const results = await Promise.allSettled(\n [...handlers].map((handler) => handler(payload)),\n );\n\n for (const result of results)\n {\n if (result.status === 'rejected')\n {\n logHandlerError(name, result.reason);\n }\n }\n },\n };\n}\n\n/**\n * Create job queue manager\n */\nfunction createJobQueueManager(name: string)\n{\n const jobQueues: Map<string, JobQueueSender> = new Map();\n\n return {\n register: (queueName: string, sender: JobQueueSender): void =>\n {\n jobQueues.set(queueName, sender);\n eventLogger.debug(`Registered job queue for event: ${name}`, { queueName });\n },\n\n send: async (payload: unknown): Promise<void> =>\n {\n if (jobQueues.size === 0)\n {\n return;\n }\n\n const entries = [...jobQueues.entries()];\n const results = await Promise.allSettled(\n entries.map(([queueName, sender]) => sender(queueName, payload)),\n );\n\n for (const [i, result] of results.entries())\n {\n if (result.status === 'rejected')\n {\n logJobQueueError(entries[i][0], result.reason);\n }\n }\n },\n\n get size(): number\n {\n return jobQueues.size;\n },\n };\n}\n\n/**\n * Create the registry of SSE handlers' subscription controls for one event.\n *\n * `grant` / `revoke` apply to every registered control (an event may sit under\n * two router keys, or in two handlers in one process), then — when the event is\n * bound to a cache — publish once so every other pod applies it too.\n */\nfunction createSubscriptionControls(name: string, getCache: () => PubSubCache | undefined)\n{\n const controls: Set<SubscriptionControl> = new Set();\n\n const applyLocal = (kind: 'grant' | 'revoke', subject: string, match?: SubscriptionMatch): void =>\n {\n controls.forEach(control => control[kind](subject, match));\n };\n\n const change = (kind: 'grant' | 'revoke') => async (subject: string, match?: SubscriptionMatch): Promise<void> =>\n {\n assertMatch(name, kind, match);\n applyLocal(kind, subject, match);\n\n const cache = getCache();\n if (cache)\n {\n await publishSubscriptionControl(cache, kind, name, subject, match);\n }\n };\n\n return {\n applyLocal,\n grant: change('grant'),\n revoke: change('revoke'),\n register: (control: SubscriptionControl): (() => void) =>\n {\n controls.add(control);\n\n return () => controls.delete(control);\n },\n };\n}\n\n/**\n * Refuse a `match` before it is applied or published. JSON drops an\n * `undefined` field, so `{ workspaceId: undefined }` would open nothing here\n * and every connection of the subject on every other pod; an empty `query`\n * would open every connection everywhere.\n */\nfunction assertMatch(name: string, kind: 'grant' | 'revoke', match: SubscriptionMatch | undefined): void\n{\n if (match !== undefined && !isSubscriptionMatch(match))\n {\n throw new Error(\n `Event \"${name}\" .${kind}() refused its match: match.query must name at least one field, each with a string value. `\n + 'A match only ever narrows a grant or revoke to the connections that declared those values; '\n + 'omit match to reach every connection of the subject.',\n );\n }\n}\n\n/**\n * Throw when a set-once declaration of a shared event is made a second time.\n */\nfunction assertDeclaredOnce(name: string, method: string, declared: boolean): void\n{\n if (declared)\n {\n throw new Error(\n `Event \"${name}\" already declares .${method}(). `\n + 'An event is defined once and shared, so a second declaration would silently replace the first. '\n + `Declare .${method}() once, where the event is defined.`,\n );\n }\n}\n\n/**\n * Internal: Create event implementation\n */\nfunction createEventImpl<TPayload>(\n name: string,\n schema?: TSchema,\n): EventDef<TPayload>\n{\n const handlerManager = createHandlerManager<TPayload>(name);\n // `localOnly` handlers: run by this process's emit, never by a cache delivery.\n const localHandlerManager = createHandlerManager<TPayload>(name);\n const jobQueueManager = createJobQueueManager(name);\n let cache: PubSubCache | undefined;\n let cacheSubscribed = false;\n const subscriptionControls = createSubscriptionControls(name, () => cache);\n\n const emit = async (payload?: TPayload): Promise<void> =>\n {\n eventLogger.debug(`Emitting event: ${name}`, {\n payload,\n hasCache: !!cache,\n jobQueueCount: jobQueueManager.size,\n });\n\n if (cache)\n {\n await cache.publish(name, payload);\n }\n else\n {\n await handlerManager.trigger(payload as TPayload);\n }\n\n await localHandlerManager.trigger(payload as TPayload);\n\n await jobQueueManager.send(payload);\n eventLogger.debug(`Event emitted: ${name}`);\n };\n\n const useCache = async (newCache: PubSubCache): Promise<EventDef<TPayload>> =>\n {\n if (cacheSubscribed)\n {\n eventLogger.warn(`Cache already configured for event: ${name}`);\n\n return self;\n }\n\n // Subscribe FIRST, then flip to cache mode. If subscribe rejects, the\n // event stays in-process (cache undefined) and cacheSubscribed stays\n // false — so emit keeps delivering locally and a later useCache can retry.\n // Setting these before the await would latch a half-broken state: emit\n // would publish to a channel this instance never subscribed to.\n // A delivery from the cache — this pod's own emit echoed back, or another\n // pod's — reaches the plain handlers only; `localOnly` ones ran in emit.\n await newCache.subscribe(name, async (message: unknown) =>\n {\n eventLogger.debug(`Received event from cache: ${name}`);\n await handlerManager.trigger(message as TPayload);\n });\n\n cache = newCache;\n cacheSubscribed = true;\n\n await bindSubscriptionControl(newCache, name, subscriptionControls.applyLocal);\n\n eventLogger.debug(`Cache subscription ready for event: ${name}`);\n\n return self;\n };\n\n // Drop the cache binding so a later useCache can rebind a fresh transport.\n // Without this, the cacheSubscribed latch would make a second server start in\n // the same process (tests, hot-reload) a no-op while the event still points at\n // a torn-down cache — cross-pod fan-out would silently stay dead.\n const resetCache = (): void =>\n {\n cache = undefined;\n cacheSubscribed = false;\n };\n\n let contract: EventContract | undefined;\n\n const declareContract = (next: EventContract): EventDef<TPayload> =>\n {\n if (contract)\n {\n throw new Error(\n `Event \"${name}\" already declares .contract({ since: '${contract.since}' }). `\n + 'An event is defined once and shared, so a second declaration would silently replace one of the '\n + 'two. Declare the contract once, where the event is defined.',\n );\n }\n\n contract = next;\n\n return self;\n };\n\n let canSubscribe: SubscribePredicate<any> | undefined;\n let filter: FramePredicate<TPayload> | undefined;\n\n const declareCanSubscribe = <P>(predicate: SubscribePredicate<P>): EventDef<TPayload> =>\n {\n assertDeclaredOnce(name, 'canSubscribe', !!canSubscribe);\n canSubscribe = predicate;\n\n return self;\n };\n\n const declareFilter = (predicate: FramePredicate<TPayload>): EventDef<TPayload> =>\n {\n assertDeclaredOnce(name, 'filter', !!filter);\n filter = predicate;\n\n return self;\n };\n\n const subscribe = (handler: EventHandler<TPayload>, options?: EventSubscribeOptions): (() => void) =>\n (options?.localOnly ? localHandlerManager : handlerManager).add(handler);\n\n const unsubscribeAll = (): void =>\n {\n handlerManager.clear();\n localHandlerManager.clear();\n };\n\n const self: EventDef<TPayload> = {\n name,\n schema,\n get _contract()\n {\n return contract;\n },\n contract: declareContract,\n get _canSubscribe()\n {\n return canSubscribe;\n },\n canSubscribe: declareCanSubscribe,\n get _filter()\n {\n return filter;\n },\n filter: declareFilter,\n grant: subscriptionControls.grant,\n revoke: subscriptionControls.revoke,\n _registerSubscriptionControl: subscriptionControls.register,\n subscribe,\n unsubscribeAll,\n emit: emit as EventDef<TPayload>['emit'],\n useCache,\n _registerJobQueue: jobQueueManager.register,\n _resetCache: resetCache,\n _payload: undefined as unknown as TPayload,\n };\n\n return self;\n}\n\n/**\n * Define an event without payload\n */\nexport function defineEvent(name: string): EventDef<void>;\n\n/**\n * Define an event with typed payload\n */\nexport function defineEvent<T extends TSchema>(\n name: string,\n schema: T,\n): EventDef<Static<T>>;\n\n/**\n * Define an event for decoupled pub/sub\n *\n * @example\n * ```typescript\n * // Define event with payload\n * export const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * // Subscribe to event (in-memory)\n * const unsubscribe = userCreated.subscribe((payload) => {\n * console.log('User created:', payload.userId);\n * });\n *\n * // Emit event\n * await userCreated.emit({ userId: '123' });\n *\n * // Unsubscribe when done\n * unsubscribe();\n *\n * // Multi-instance with cache\n * await userCreated.useCache(cache);\n * await userCreated.emit({ userId: '123' }); // Broadcast to all instances\n * ```\n */\nexport function defineEvent<T extends TSchema>(\n name: string,\n schema?: T,\n): EventDef<Static<T>> | EventDef\n{\n if (schema)\n {\n return createEventImpl<Static<T>>(name, schema);\n }\n\n return createEventImpl<void>(name);\n}\n","/**\n * SSE Event Route Map\n *\n * Static route map for SSE token endpoint.\n * Merge into RPC proxy routeMap so `eventsToken` resolves to `POST /events/token`.\n *\n * @example\n * ```typescript\n * // app/api/rpc/[routeName]/route.ts\n * import { createRpcProxy } from '@spfn/core/nextjs/server';\n * import { eventRouteMap } from '@spfn/core/event';\n * import { routeMap } from '@/generated/route-map';\n *\n * export const { GET, POST } = createRpcProxy({\n * routeMap: { ...routeMap, ...eventRouteMap },\n * });\n * ```\n */\nexport const eventRouteMap = {\n eventsToken: { method: 'POST' as const, path: '/events/token' },\n};\n","/**\n * Event Router\n *\n * Type-safe event router for SSE subscription\n *\n * @example\n * ```typescript\n * import { defineEvent, defineEventRouter } from '@spfn/core/event';\n * import { Type } from '@sinclair/typebox';\n *\n * const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * const orderPlaced = defineEvent('order.placed', Type.Object({\n * orderId: Type.String(),\n * amount: Type.Number(),\n * }));\n *\n * export const eventRouter = defineEventRouter({\n * userCreated,\n * orderPlaced,\n * });\n *\n * export type EventRouter = typeof eventRouter;\n * ```\n */\n\nimport type { EventDef } from './types';\n\n/**\n * How a client reaches the contracted events of a router.\n *\n * Declared on the router because it is the stream's promise, not one event's:\n * `createServer` checks it against `.events()` at boot, so the document cannot\n * describe a path or an auth mode the server does not serve.\n */\nexport interface EventRouterContract\n{\n /**\n * `'tokenExchange'` when `.events()` enables `auth` — the client first posts\n * to the token path and opens the stream with `?token=`. `'none'` otherwise.\n */\n auth: 'none' | 'tokenExchange';\n\n /**\n * The stream path `.events()` registers.\n * @default '/events/stream'\n */\n streamPath?: string;\n}\n\n/**\n * What every router of events carries — the SSE event router and the\n * WebSocket router alike.\n *\n * The WebSocket router builds on this rather than on `EventRouterDef`, because\n * only an SSE event router can be contracted.\n */\nexport interface EventRouterBase<TEvents extends Record<string, EventDef<any>>>\n{\n /**\n * Event definitions\n */\n readonly events: TEvents;\n\n /**\n * Event names as array\n */\n readonly eventNames: (keyof TEvents)[];\n\n /**\n * Type inference helper - payload types by event name\n */\n readonly _types: {\n [K in keyof TEvents]: TEvents[K]['_payload'];\n };\n}\n\n/**\n * Event Router Definition\n */\nexport interface EventRouterDef<TEvents extends Record<string, EventDef<any>>> extends EventRouterBase<TEvents>\n{\n /**\n * The contract declared with `.contract()`, absent when the router declares\n * none. Underscored for the same reason as `EventDef._contract`.\n */\n readonly _contract?: EventRouterContract;\n\n /**\n * Declare how a client reaches this router's contracted events.\n *\n * Sets the contract on this router and returns it. A `streamPath` that does\n * not start with `/` throws here, where the typo is.\n */\n contract: (contract: EventRouterContract) => EventRouterDef<TEvents>;\n}\n\n/**\n * Infer event names from EventRouter\n */\nexport type InferEventNames<T> = T extends EventRouterBase<infer E>\n ? keyof E & string\n : never;\n\n/**\n * Infer payload type for specific event\n */\nexport type InferEventPayload<\n T extends EventRouterBase<any>,\n K extends InferEventNames<T>,\n> = T['_types'][K];\n\n/**\n * Infer all event payloads map\n */\nexport type InferEventPayloads<T extends EventRouterBase<any>> = T['_types'];\n\n/**\n * Define an event router for SSE subscription\n *\n * `.contract({ auth })` on the router, together with `.contract({ since })` on\n * its events, publishes those events in `contracts/current.json` — see the\n * contract README.\n *\n * @example\n * ```typescript\n * export const eventRouter = defineEventRouter({\n * userCreated,\n * orderPlaced,\n * });\n *\n * // Type inference\n * type Names = InferEventNames<typeof eventRouter>;\n * // 'userCreated' | 'orderPlaced'\n *\n * type Payload = InferEventPayload<typeof eventRouter, 'userCreated'>;\n * // { userId: string }\n * ```\n */\nexport function defineEventRouter<\n TEvents extends Record<string, EventDef<any>>,\n>(events: TEvents): EventRouterDef<TEvents>\n{\n let contract: EventRouterContract | undefined;\n\n const router: EventRouterDef<TEvents> = {\n events,\n eventNames: Object.keys(events) as (keyof TEvents)[],\n get _contract()\n {\n return contract;\n },\n contract: (next) =>\n {\n assertStreamPath(next.streamPath);\n contract = next;\n\n return router;\n },\n _types: {} as EventRouterDef<TEvents>['_types'],\n };\n\n return router;\n}\n\n/**\n * A stream path is matched against the path `.events()` registers and written\n * into the contract as a URL path, so a relative one can never be right.\n */\nfunction assertStreamPath(streamPath: string | undefined): void\n{\n if (streamPath !== undefined && !streamPath.startsWith('/'))\n {\n throw new Error(\n `defineEventRouter(...).contract({ streamPath: \"${streamPath}\" }) is not an absolute path. `\n + 'The contract publishes it as the URL path a client opens, and createServer compares it with the '\n + 'path .events() registers. Start it with \"/\".',\n );\n }\n}\n","/**\n * WebSocket Handler\n *\n * Attaches a WebSocket server to an existing Node.js http.Server.\n * Handles authentication, event subscription, and client message routing.\n */\n\nimport type { Server } from 'node:http';\nimport type { EventDef } from '../types';\nimport type {\n WSRouterDef,\n WSHandlerConfig,\n WSHandlerAuthConfig,\n WSMessageHandlers,\n WSRawConnection,\n} from './types';\nimport type { SSETokenManager } from '../sse/token-manager';\nimport { logger } from '@spfn/core/logger';\n\nconst wsLogger = logger.child('@spfn/core:ws');\n\n/**\n * Decide whether a WebSocket upgrade's Origin is permitted.\n *\n * - no allow-list configured → allowed (check disabled)\n * - no Origin header (native/non-browser client) → allowed\n * - Origin present → must be in the allow-list\n */\nexport function isOriginAllowed(\n origin: string | undefined,\n allowList: Set<string> | null,\n): boolean\n{\n if (!allowList)\n {\n return true;\n }\n\n if (!origin)\n {\n return true;\n }\n\n return allowList.has(origin);\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Attach a WebSocket server to a Node.js http.Server\n *\n * @returns cleanup function that closes the WebSocket server\n */\nexport async function attachWSHandler<\n TEvents extends Record<string, EventDef<any>>,\n TMessages extends WSMessageHandlers,\n>(\n server: Server,\n router: WSRouterDef<TEvents, TMessages>,\n config: WSHandlerConfig & { path?: string } = {},\n tokenManager?: SSETokenManager,\n): Promise<() => Promise<void>>\n{\n const WebSocketServer = await loadWSServer();\n\n const {\n pingInterval = 30000,\n path = '/ws',\n maxPayload = 1_048_576,\n maxBufferedBytes = 1_048_576,\n maxConnections = 10_000,\n maxConnectionsPerSubject = 0,\n allowedOrigins,\n auth: authConfig,\n } = config;\n\n const originAllowList = allowedOrigins && allowedOrigins.length > 0\n ? new Set(allowedOrigins)\n : null;\n\n if (authConfig?.enabled && !tokenManager)\n {\n throw new Error(\n 'WebSocket auth.enabled=true requires a tokenManager. ' +\n 'Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer.',\n );\n }\n\n const wss = new WebSocketServer({ server, path, maxPayload });\n\n // Track live connections for graceful shutdown\n const clients = new Set<any>();\n // Live connection count per authenticated subject (for the per-subject cap)\n const subjectCounts = new Map<string, number>();\n\n wss.on('connection', (ws: any, req: any) =>\n {\n // Origin allow-list (cross-site WebSocket hijacking protection). Browsers\n // always send Origin on the upgrade; reject one that isn't allowed before\n // doing any work. A missing Origin (native/non-browser client, which can't\n // be CSRF'd into using ambient cookies) passes through.\n if (!isOriginAllowed(req.headers?.origin, originAllowList))\n {\n wsLogger.warn('WebSocket rejected: disallowed origin', { origin: req.headers?.origin });\n ws.close(1008, 'Origin not allowed');\n\n return;\n }\n\n // Global connection cap — reject before doing any work\n if (clients.size >= maxConnections)\n {\n ws.close(1013, 'Server at capacity');\n\n return;\n }\n\n clients.add(ws);\n ws.on('close', () => clients.delete(ws));\n handleConnection(ws, req, router, authConfig, tokenManager, {\n pingInterval,\n maxBufferedBytes,\n maxConnectionsPerSubject,\n subjectCounts,\n })\n .catch((err: Error) =>\n {\n wsLogger.error('WebSocket connection handler error', err);\n if (ws.readyState === 1) ws.close(1011, 'Internal server error');\n });\n });\n\n wss.on('error', (err: Error) =>\n {\n wsLogger.error('WebSocket server error', err);\n });\n\n wsLogger.info(`✓ WebSocket endpoint registered at ${path}`, {\n events: router.eventNames,\n auth: !!authConfig?.enabled,\n });\n\n return () => new Promise<void>((resolve, reject) =>\n {\n // Close all existing connections with 1001 Going Away\n for (const client of clients)\n {\n client.close(1001, 'Server shutting down');\n }\n clients.clear();\n\n wss.close((err?: Error) =>\n {\n if (err) reject(err);\n else resolve();\n });\n });\n}\n\n// ============================================================================\n// Connection Handler\n// ============================================================================\n\ninterface ConnectionOptions\n{\n pingInterval: number;\n maxBufferedBytes: number;\n maxConnectionsPerSubject: number;\n subjectCounts: Map<string, number>;\n}\n\nasync function handleConnection(\n ws: any,\n req: any,\n router: WSRouterDef<any, any>,\n authConfig: WSHandlerAuthConfig | undefined,\n tokenManager: SSETokenManager | undefined,\n opts: ConnectionOptions,\n): Promise<void>\n{\n const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;\n // Register close handler before any await — ensures we never miss the event even during auth\n let pingTimer: ReturnType<typeof setInterval> | undefined;\n let connectionUnsubscribes: (() => void)[] = [];\n let subscribedEvents: string[] = [];\n ws.on('close', () =>\n {\n clearInterval(pingTimer);\n connectionUnsubscribes.forEach(fn => fn());\n if (subscribedEvents.length > 0)\n wsLogger.info('WebSocket connection closed', { events: subscribedEvents });\n });\n\n const url = parseURL(req);\n if (!url)\n {\n ws.close(1002, 'Invalid request URL');\n\n return;\n }\n\n // ── 1. Authenticate ──\n const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : undefined);\n if (subject === false)\n {\n ws.close(4001, 'Missing token');\n\n return;\n }\n if (subject === null)\n {\n ws.close(4001, 'Invalid or expired token');\n\n return;\n }\n\n // ── 2. Resolve subscribed events ──\n const requestedEvents = parseRequestedEvents(url, router.eventNames as string[]);\n if (requestedEvents.length === 0)\n {\n ws.close(4000, 'No valid event names specified');\n\n return;\n }\n\n // ── 3. Authorize ──\n const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);\n if (allowedEvents === null)\n {\n ws.close(4003, 'Not authorized for any requested events');\n\n return;\n }\n\n // ── Per-subject connection cap (authenticated subjects only) ──\n if (maxConnectionsPerSubject > 0 && typeof subject === 'string')\n {\n const current = subjectCounts.get(subject) ?? 0;\n if (current >= maxConnectionsPerSubject)\n {\n ws.close(1013, 'Too many connections for this subject');\n\n return;\n }\n\n subjectCounts.set(subject, current + 1);\n ws.on('close', () =>\n {\n const remaining = (subjectCounts.get(subject) ?? 1) - 1;\n if (remaining <= 0) subjectCounts.delete(subject);\n else subjectCounts.set(subject, remaining);\n });\n }\n\n subscribedEvents = allowedEvents;\n wsLogger.info('WebSocket connection established', {\n events: allowedEvents,\n subject: subject ?? undefined,\n });\n\n // ── 4. Build connection wrapper ──\n const connection = createConnection(ws, maxBufferedBytes);\n\n // ── 5. Subscribe to server-push events ──\n connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);\n\n // If socket closed during auth awaits, clean up and bail\n if (ws.readyState !== 1)\n {\n connectionUnsubscribes.forEach(fn => fn());\n connectionUnsubscribes = [];\n\n return;\n }\n\n // ── 6. Handle incoming messages ──\n ws.on('message', (data: Buffer | string) =>\n {\n onClientMessage(data, router, connection, subject)\n .catch((err: Error) => wsLogger.error('Unhandled message error', err));\n });\n\n // ── 7. Keep-alive ping with liveness (pong) tracking ──\n // Without reaping un-ponged sockets, a half-open connection (sleeping device,\n // NAT drop with no FIN) lingers with its subscriptions, timer, and buffers.\n if (pingInterval > 0)\n {\n ws.isAlive = true;\n ws.on('pong', () =>\n {\n ws.isAlive = true;\n });\n\n pingTimer = setInterval(() =>\n {\n if (ws.readyState !== 1) return;\n\n if (ws.isAlive === false)\n {\n // No pong since the previous tick — drop the dead/half-open socket\n ws.terminate();\n\n return;\n }\n\n ws.isAlive = false;\n ws.ping();\n }, pingInterval);\n }\n\n // ── 9. Send connected ack ──\n connection.send('__connected', {\n subscribedEvents: allowedEvents,\n timestamp: Date.now(),\n });\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction parseURL(req: any): URL | null\n{\n try\n {\n return new URL(req.url ?? '/', 'ws://localhost');\n }\n catch\n {\n return null;\n }\n}\n\n/**\n * Resolve subject from token\n * - undefined: no auth required\n * - false: token param missing (when required)\n * - null: token invalid/expired\n * - string: authenticated subject\n */\nasync function resolveSubject(\n url: URL,\n tokenManager?: SSETokenManager,\n): Promise<string | undefined | false | null>\n{\n if (!tokenManager)\n {\n return undefined;\n }\n\n const token = url.searchParams.get('token');\n if (!token)\n {\n return false;\n }\n\n return await tokenManager.verify(token);\n}\n\nfunction parseRequestedEvents(url: URL, validEventNames: string[]): string[]\n{\n const eventsParam = url.searchParams.get('events');\n if (!eventsParam)\n {\n return [];\n }\n\n return eventsParam\n .split(',')\n .map(e => e.trim())\n .filter(e => validEventNames.includes(e));\n}\n\nasync function resolveAllowedEvents(\n subject: string | undefined,\n requestedEvents: string[],\n authConfig?: WSHandlerAuthConfig,\n): Promise<string[] | null>\n{\n if (!subject || !authConfig?.authorize)\n {\n return requestedEvents;\n }\n\n const allowed = await authConfig.authorize(subject, requestedEvents);\n\n return allowed.length === 0 ? null : allowed;\n}\n\n/**\n * Send a JSON frame with backpressure protection. If the socket's outbound\n * buffer is already past the cap, the consumer is too slow — close the\n * connection (1013) instead of buffering more and risking OOM. The client\n * reconnects and re-subscribes.\n */\nexport function safeSend(ws: any, frame: unknown, maxBufferedBytes: number): void\n{\n if (ws.readyState !== 1) return;\n\n if (ws.bufferedAmount > maxBufferedBytes)\n {\n ws.close(1013, 'Send buffer overflow');\n\n return;\n }\n\n try\n {\n ws.send(JSON.stringify(frame));\n }\n catch\n {\n // Socket closed between the readyState check and send — ignore\n }\n}\n\nfunction createConnection(ws: any, maxBufferedBytes: number): WSRawConnection\n{\n return {\n send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),\n close: (code, reason) => ws.close(code, reason),\n };\n}\n\nfunction subscribeEvents(\n ws: any,\n router: WSRouterDef<any, any>,\n allowedEvents: string[],\n subject: string | undefined,\n authConfig: WSHandlerAuthConfig | undefined,\n maxBufferedBytes: number,\n): (() => void)[]\n{\n const unsubscribes: (() => void)[] = [];\n\n for (const eventName of allowedEvents)\n {\n const eventDef = router.events[eventName];\n if (!eventDef) continue;\n\n const unsubscribe = eventDef.subscribe((payload: unknown) =>\n {\n if (ws.readyState !== 1) return;\n\n if (subject && authConfig?.filter?.[eventName])\n {\n if (!authConfig.filter[eventName](subject, payload)) return;\n }\n\n safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);\n });\n\n unsubscribes.push(unsubscribe);\n }\n\n return unsubscribes;\n}\n\nasync function onClientMessage(\n data: Buffer | string,\n router: WSRouterDef<any, any>,\n connection: WSRawConnection,\n subject: string | undefined,\n): Promise<void>\n{\n let message: { type?: string; data?: unknown };\n\n try\n {\n message = JSON.parse(data.toString());\n }\n catch\n {\n return;\n }\n\n const { type, data: payload } = message;\n if (!type) return;\n\n const handler = router.messages[type];\n if (!handler) return;\n\n try\n {\n await handler({ payload, subject, ws: connection });\n }\n catch (err)\n {\n wsLogger.error(`WebSocket message handler error: ${type}`, err as Error);\n }\n}\n\n// ============================================================================\n// Dynamic import for optional 'ws' dependency\n// ============================================================================\n\n/**\n * Resolve the WebSocketServer constructor from the 'ws' module object.\n *\n * ws is a dual package, so the module shape depends on the loader path:\n * - ESM (exports.import → wrapper.mjs): WebSocketServer is a named export;\n * the default export is the bare WebSocket class with no properties on it.\n * - CJS (require interop): module.exports = WebSocket with\n * WebSocket.WebSocketServer attached, surfaced under .default.\n * Named exports must win — checking .default first crashes on the ESM path.\n */\nexport function resolveWSServerCtor(mod: any): any\n{\n return mod.WebSocketServer ?? mod.Server\n ?? mod.default?.WebSocketServer ?? mod.default?.Server;\n}\n\nasync function loadWSServer(): Promise<any>\n{\n let mod: any;\n\n try\n {\n mod = await import('ws');\n }\n catch\n {\n throw new Error(\n '@spfn/core WebSocket support requires the \"ws\" package.\\n' +\n 'Install it with: pnpm add ws',\n );\n }\n\n const WSS = resolveWSServerCtor(mod);\n\n if (typeof WSS !== 'function')\n {\n throw new Error(\n 'WebSocketServer not found in ws module. ' +\n 'Ensure ws@^8 is installed: pnpm add ws',\n );\n }\n\n return WSS;\n}\n","/**\n * WebSocket Module\n *\n * Type-safe WebSocket server with event-based pub/sub and bidirectional messaging.\n *\n * @example Server setup\n * ```typescript\n * // src/server/ws.ts\n * import { defineWSRouter } from '@spfn/core/event/ws';\n * import { defineEvent } from '@spfn/core/event';\n * import { Type } from '@sinclair/typebox';\n *\n * const userUpdated = defineEvent('user.updated', Type.Object({ userId: Type.String() }));\n * const notification = defineEvent('notification', Type.Object({ message: Type.String() }));\n *\n * export const wsRouter = defineWSRouter({\n * events: { userUpdated, notification },\n * messages: {\n * ping: ({ ws }) => ws.send('pong', {}),\n * },\n * });\n *\n * export type WSRouter = typeof wsRouter;\n *\n * // server.config.ts\n * defineServerConfig()\n * .websockets(wsRouter)\n * .build();\n * ```\n *\n * @example Client usage\n * ```typescript\n * import { createWSClient } from '@spfn/core/event/ws/client';\n * import type { WSRouter } from '@/server/ws';\n *\n * const client = createWSClient<WSRouter>();\n *\n * client.subscribe({\n * events: ['userUpdated', 'notification'],\n * handlers: {\n * userUpdated: ({ userId }) => console.log(userId),\n * notification: ({ message }) => console.log(message),\n * },\n * });\n *\n * client.send('ping', {});\n * ```\n */\n\nimport type { EventDef } from '../types';\nimport type { WSRouterDef, WSMessageHandlers } from './types';\n\nexport { attachWSHandler } from './handler';\nexport type {\n WSRouterDef,\n WSHandlerConfig,\n WSAuthConfig,\n WSHandlerAuthConfig,\n WSMessageContext,\n WSMessageHandlerFn,\n WSMessageHandlers,\n WSRawConnection,\n WSClientConfig,\n WSConnectionState,\n WSEventHandlers,\n WSSubscribeOptions,\n WSUnsubscribe,\n} from './types';\n\n/**\n * Define a WebSocket router\n *\n * Combines server→client event push with client→server message handlers.\n *\n * @example\n * ```typescript\n * export const wsRouter = defineWSRouter({\n * events: { userUpdated, notification },\n * messages: {\n * ping: ({ ws }) => ws.send('pong', {}),\n * 'chat.send': ({ payload, subject }) => handleChat(payload, subject),\n * },\n * });\n * ```\n */\nexport function defineWSRouter<\n TEvents extends Record<string, EventDef<any>>,\n TMessages extends WSMessageHandlers = WSMessageHandlers,\n>(def: {\n events: TEvents;\n messages?: TMessages;\n}): WSRouterDef<TEvents, TMessages>\n{\n return {\n events: def.events,\n eventNames: Object.keys(def.events) as (keyof TEvents)[],\n messages: (def.messages ?? {}) as TMessages,\n _types: {} as WSRouterDef<TEvents, TMessages>['_types'],\n };\n}\n"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { E as EventRouterDef, I as InferEventNames } from '../../router-
|
|
2
|
-
import { S as SSEClientConfig, a as SSESubscribeOptions, b as SSEUnsubscribe, c as SSEConnectionState } from '../../types-
|
|
1
|
+
import { E as EventRouterDef, I as InferEventNames } from '../../router-D5B0cpVN.js';
|
|
2
|
+
import { S as SSEClientConfig, a as SSESubscribeOptions, b as SSEUnsubscribe, c as SSEConnectionState } from '../../types-DqXYtI69.js';
|
|
3
3
|
import '@sinclair/typebox';
|
|
4
4
|
import 'hono';
|
|
5
5
|
import '../../token-manager-Ckjt2-qq.js';
|
package/dist/event/sse/client.js
CHANGED
|
@@ -69,7 +69,7 @@ function createSSEClient(config = {}) {
|
|
|
69
69
|
conn.onClose?.();
|
|
70
70
|
}
|
|
71
71
|
function subscribe(options) {
|
|
72
|
-
const { events, handlers, onOpen, onError, onReconnect, onClose } = options;
|
|
72
|
+
const { events, handlers, onOpen, onError, onReconnect, onClose, onSubscriptionChange } = options;
|
|
73
73
|
if (active) {
|
|
74
74
|
closeConn(active);
|
|
75
75
|
}
|
|
@@ -101,6 +101,7 @@ function createSSEClient(config = {}) {
|
|
|
101
101
|
withCredentials
|
|
102
102
|
});
|
|
103
103
|
setupEventHandlers(conn.eventSource, eventNames, handlers, onOpen, onError);
|
|
104
|
+
setupSubscriptionChange(conn.eventSource, onSubscriptionChange);
|
|
104
105
|
setupReconnect(onReconnect);
|
|
105
106
|
};
|
|
106
107
|
init().catch(() => {
|
|
@@ -145,6 +146,24 @@ function createSSEClient(config = {}) {
|
|
|
145
146
|
});
|
|
146
147
|
}
|
|
147
148
|
}
|
|
149
|
+
function setupSubscriptionChange(es, onChange) {
|
|
150
|
+
if (!onChange) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const frames = { "subscription.granted": "granted", "subscription.revoked": "revoked" };
|
|
154
|
+
for (const [frame, status] of Object.entries(frames)) {
|
|
155
|
+
es.addEventListener(frame, (e) => {
|
|
156
|
+
let event;
|
|
157
|
+
try {
|
|
158
|
+
({ event } = JSON.parse(e.data));
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error(`[SSE] Failed to parse "${frame}" frame:`, err);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
onChange({ event, status });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
148
167
|
function setupReconnect(onReconnectCb) {
|
|
149
168
|
if (!conn.eventSource) {
|
|
150
169
|
return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/nextjs/client/csrf.ts","../../../src/event/sse/client.ts"],"names":[],"mappings":"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","file":"client.js","sourcesContent":["/**\n * CSRF token transport for the RPC client\n *\n * The Next.js proxy sets a readable (non-HttpOnly) cookie carrying an HMAC of\n * the session's key id; this module mirrors it into a request header. The proxy\n * never compares cookie against header — it recomputes the value from the\n * session — so the cookie is transport only and reading it here is not a trust\n * decision. See the CSRF section of the @spfn/auth README.\n */\n\n/**\n * Header the readable CSRF cookie is mirrored into.\n *\n * Must match CSRF_HEADER in @spfn/auth. It is duplicated rather than imported\n * because @spfn/core cannot depend on @spfn/auth.\n */\nexport const CSRF_HEADER = 'x-spfn-csrf';\n\n/**\n * Cookie names carrying a CSRF token.\n *\n * @spfn/auth suffixes its cookie names with the server port so several local dev\n * instances on the same host do not overwrite each other's session, and the\n * browser cannot know that port — so match the family, not one name. Every\n * candidate is sent and the proxy accepts the request if any of them recomputes\n * to the expected value; a candidate an attacker tossed in never will.\n */\nconst CSRF_COOKIE_PATTERN = /^spfn_csrf(_\\d+)?$/;\n\n/**\n * Values that can be put in a header at all: printable ASCII, no space.\n *\n * A cookie jar is attacker-writable in the very case this feature exists for, and\n * `new Headers()` rejects a value outside Latin-1 — so a sibling subdomain that\n * tosses `spfn_csrf=한` would make every RPC call throw before it was sent, not\n * merely fail the check. A genuine token is 64 hex characters; anything that\n * cannot be a header value cannot be one.\n */\nconst HEADER_SAFE_VALUE = /^[!-~]+$/;\n\n/**\n * Upper bound on candidates sent, so a cookie-flooded jar stays a bounded header.\n *\n * Deliberately generous. The server recomputes the expected value once and then\n * only compares fixed-length strings, so extra candidates cost it almost nothing,\n * while every candidate dropped here is a chance to lock the user out of every\n * mutation — the far worse failure. Must not exceed the matching bound in\n * @spfn/auth's `matchesCsrfToken`, or values sent would never be looked at.\n */\nconst MAX_CANDIDATES = 32;\n\n/**\n * Choose which candidates to send when the jar holds more than the cap.\n *\n * `document.cookie` exposes neither Domain nor Path, so a tossed cookie is\n * indistinguishable from the genuine one here — the proxy is what tells them\n * apart, by recomputing. What this can do is refuse to drop the genuine value on\n * the floor. Browsers order the jar by path length descending, then by age\n * ascending, so a flood pushes the genuine cookie towards the end (attacker\n * cookies on a longer path) or towards the start (attacker cookies written later\n * on the same path). Keeping both ends survives either, where a plain prefix\n * survives only one.\n *\n * A jar flooded far past the cap is a denial of service that no client-side\n * choice fully prevents; the structural fix is a `__Host-` prefixed cookie name,\n * which a sibling subdomain cannot write at all.\n */\nfunction selectCandidates(values: string[]): string[]\n{\n // Identical duplicates carry no extra information, so collapsing them buys\n // back slots that a flood of one repeated value would otherwise burn.\n const unique = Array.from(new Set(values));\n\n if (unique.length <= MAX_CANDIDATES)\n {\n return unique;\n }\n\n const half = MAX_CANDIDATES / 2;\n\n return [...unique.slice(0, half), ...unique.slice(-half)];\n}\n\n/**\n * Collect the CSRF token candidates from cookie entries.\n *\n * Takes entries rather than a map because a name can legitimately appear twice:\n * a sibling subdomain that tosses `spfn_csrf` for the parent domain leaves the\n * browser holding two cookies of that name, and folding them into a map would\n * throw away whichever the tossed one displaced — locking the user out of every\n * mutation. Sending both keeps the real one in play, and the tossed one is inert\n * because the proxy recomputes what it expects.\n *\n * @returns Header value (comma-separated), or undefined when no cookie exists\n */\nexport function csrfHeaderValue(cookies: Iterable<[string, string]>): string | undefined\n{\n // A comma separates candidates on the wire, so a value containing one would\n // arrive as several. That cannot be a genuine token — they are hex — but it\n // would let a single tossed cookie spend the receiver's whole candidate\n // budget and bury the real value behind it. Drop those, and anything that\n // could not be a header value at all.\n const values = Array.from(cookies)\n .filter(([name, value]) => CSRF_COOKIE_PATTERN.test(name)\n && HEADER_SAFE_VALUE.test(value)\n && !value.includes(','))\n .map(([, value]) => value);\n\n const candidates = selectCandidates(values);\n\n return candidates.length > 0 ? candidates.join(',') : undefined;\n}\n\n/**\n * Read the browser cookie jar as entries, duplicates preserved.\n *\n * Empty outside a browser (SSR reads the jar it forwards instead).\n */\nexport function documentCookieEntries(): Array<[string, string]>\n{\n if (typeof document === 'undefined' || !document.cookie)\n {\n return [];\n }\n\n const entries: Array<[string, string]> = [];\n\n for (const pair of document.cookie.split(';'))\n {\n const separator = pair.indexOf('=');\n\n if (separator > 0)\n {\n entries.push([pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()]);\n }\n }\n\n return entries;\n}\n","/**\n * SSE Client\n *\n * Type-safe EventSource wrapper for event subscription\n *\n * @example\n * ```typescript\n * import { createSSEClient } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * // Uses defaults: NEXT_PUBLIC_SPFN_API_URL + /events/stream\n * const client = createSSEClient<EventRouter>();\n *\n * // Or with custom host/pathname\n * const client = createSSEClient<EventRouter>({\n * host: 'https://api.example.com',\n * pathname: '/sse',\n * });\n *\n * // With token authentication (recommended: use createAuthSSEClient)\n * import { createAuthSSEClient } from '@spfn/core/event/sse/client';\n * const client = createAuthSSEClient<EventRouter>();\n *\n * const unsubscribe = client.subscribe({\n * events: ['userCreated', 'orderPlaced'],\n * handlers: {\n * userCreated: (payload) => console.log('User:', payload.userId),\n * orderPlaced: (payload) => console.log('Order:', payload.orderId),\n * },\n * });\n *\n * // Later: cleanup\n * unsubscribe();\n * ```\n */\n\nimport type { EventRouterDef, InferEventNames } from '../router';\nimport { CSRF_HEADER, csrfHeaderValue, documentCookieEntries } from '../../nextjs/client/csrf';\nimport type {\n SSEClientConfig,\n SSESubscribeOptions,\n SSEUnsubscribe,\n SSEConnectionState,\n SSEMessage,\n} from './types';\n\n/**\n * SSE Client instance\n */\nexport interface SSEClient<TRouter extends EventRouterDef<any>>\n{\n /**\n * Subscribe to events\n */\n subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe;\n\n /**\n * Get current connection state\n */\n getState(): SSEConnectionState;\n\n /**\n * Close all connections\n */\n close(): void;\n}\n\n/**\n * Default SSE configuration\n */\nconst SSE_DEFAULTS = {\n host: typeof process !== 'undefined'\n ? (process.env.NEXT_PUBLIC_SPFN_API_URL || 'http://localhost:8790')\n : 'http://localhost:8790',\n pathname: '/events/stream',\n} as const;\n\n/**\n * Create type-safe SSE client\n *\n * @example\n * ```typescript\n * // Uses defaults (NEXT_PUBLIC_SPFN_API_URL + /events/stream)\n * const client = createSSEClient<EventRouter>();\n *\n * // Or with custom configuration\n * const client = createSSEClient<EventRouter>({\n * host: 'https://api.example.com',\n * pathname: '/sse',\n * reconnect: true,\n * reconnectDelay: 3000,\n * });\n *\n * // Subscribe to events\n * const unsubscribe = client.subscribe({\n * events: ['userCreated', 'orderPlaced'],\n * handlers: {\n * userCreated: (payload) => {\n * console.log('New user:', payload.userId);\n * },\n * orderPlaced: (payload) => {\n * console.log('New order:', payload.orderId);\n * },\n * },\n * onOpen: () => console.log('Connected'),\n * onError: (err) => console.error('Error:', err),\n * onReconnect: (attempt) => console.log('Reconnecting...', attempt),\n * });\n *\n * // Cleanup\n * unsubscribe();\n * ```\n */\n/**\n * Per-subscription connection resources.\n *\n * Grouped so an in-flight async connect can be cancelled atomically and\n * `close()` can tear down whichever subscription is currently active —\n * the shared module-level EventSource used to leak across the token await.\n */\ninterface SSEConnection\n{\n eventSource: EventSource | null;\n reconnectTimer: ReturnType<typeof setTimeout> | null;\n reconnectAttempts: number;\n closed: boolean;\n onClose?: () => void;\n}\n\nexport function createSSEClient<TRouter extends EventRouterDef<any>>(\n config: SSEClientConfig = {},\n): SSEClient<TRouter>\n{\n const {\n url,\n host = SSE_DEFAULTS.host,\n pathname = SSE_DEFAULTS.pathname,\n reconnect = true,\n reconnectDelay = 3000,\n maxReconnectAttempts = 0,\n withCredentials = false,\n acquireToken,\n } = config;\n\n // Build base URL: url takes precedence, otherwise host + pathname\n const baseUrl = url || `${host}${pathname}`;\n\n let state: SSEConnectionState = 'closed';\n let active: SSEConnection | null = null;\n\n // Idempotent teardown: closes the connection, fires onClose exactly once,\n // and clears the active slot if this was the live connection.\n function closeConn(conn: SSEConnection)\n {\n if (conn.closed)\n {\n return;\n }\n\n conn.closed = true;\n\n if (conn.reconnectTimer)\n {\n clearTimeout(conn.reconnectTimer);\n conn.reconnectTimer = null;\n }\n\n if (conn.eventSource)\n {\n conn.eventSource.close();\n conn.eventSource = null;\n }\n\n if (active === conn)\n {\n active = null;\n state = 'closed';\n }\n\n conn.onClose?.();\n }\n\n function subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe\n {\n const { events, handlers, onOpen, onError, onReconnect, onClose } = options;\n\n // A new subscription supersedes any previous one on this client.\n if (active)\n {\n closeConn(active);\n }\n\n const conn: SSEConnection = {\n eventSource: null,\n reconnectTimer: null,\n reconnectAttempts: 0,\n closed: false,\n onClose,\n };\n active = conn;\n\n const eventNames = events as string[];\n\n function connect()\n {\n state = 'connecting';\n\n const init = async () =>\n {\n let tokenParam = '';\n\n if (acquireToken)\n {\n const token = await acquireToken();\n\n // Cancelled during the token await (e.g. StrictMode cleanup) —\n // don't open a connection that has no teardown waiting for it.\n if (conn.closed)\n {\n return;\n }\n\n tokenParam = `&token=${encodeURIComponent(token)}`;\n }\n\n if (conn.closed)\n {\n return;\n }\n\n const streamUrl = `${baseUrl}?events=${eventNames.join(',')}${tokenParam}`;\n\n conn.eventSource = new EventSource(streamUrl, {\n withCredentials,\n });\n\n setupEventHandlers(conn.eventSource, eventNames, handlers, onOpen, onError);\n setupReconnect(onReconnect);\n };\n\n init().catch(() =>\n {\n // Don't resurrect a torn-down subscription via reconnect.\n if (conn.closed)\n {\n return;\n }\n\n state = 'error';\n attemptReconnect(onReconnect);\n });\n }\n\n function setupEventHandlers(\n es: EventSource,\n names: string[],\n handlerMap: SSESubscribeOptions<TRouter>['handlers'],\n onOpenCb?: () => void,\n onErrorCb?: (error: Event) => void,\n )\n {\n es.onopen = () =>\n {\n state = 'open';\n conn.reconnectAttempts = 0;\n onOpenCb?.();\n };\n\n es.onerror = (error) =>\n {\n state = 'error';\n onErrorCb?.(error);\n };\n\n // Handle connected event (server sends this on connection)\n es.addEventListener('connected', (e: MessageEvent) =>\n {\n try\n {\n const data = JSON.parse(e.data);\n console.debug('[SSE] Connected:', data);\n }\n catch\n {\n // Ignore parse errors\n }\n });\n\n // Handle ping (keep-alive)\n es.addEventListener('ping', () =>\n {\n // Ping received, connection is alive\n });\n\n // Register handlers for each event\n for (const eventName of names)\n {\n const handler = (handlerMap as Record<string, ((payload: unknown) => void) | undefined>)[eventName];\n\n if (!handler)\n {\n continue;\n }\n\n es.addEventListener(eventName, (e: MessageEvent) =>\n {\n try\n {\n const message: SSEMessage = JSON.parse(e.data);\n handler(message.data);\n }\n catch (err)\n {\n console.error(`[SSE] Failed to parse event \"${eventName}\":`, err);\n }\n });\n }\n }\n\n function setupReconnect(onReconnectCb?: (attempt: number) => void)\n {\n if (!conn.eventSource)\n {\n return;\n }\n\n const currentEs = conn.eventSource;\n const originalOnError = currentEs.onerror;\n\n currentEs.onerror = (error) =>\n {\n if (originalOnError)\n {\n (originalOnError as (ev: Event) => void)(error);\n }\n\n // Token-auth 사용 시 브라우저 auto-retry는 소비된 토큰으로 재시도하므로\n // 즉시 close하고 우리 reconnect로 새 토큰 발급받아 재연결\n if (reconnect && acquireToken)\n {\n currentEs.close();\n attemptReconnect(onReconnectCb);\n }\n else if (reconnect && currentEs.readyState === EventSource.CLOSED)\n {\n attemptReconnect(onReconnectCb);\n }\n };\n }\n\n function attemptReconnect(onReconnectCb?: (attempt: number) => void)\n {\n if (conn.closed || !reconnect)\n {\n return;\n }\n\n if (maxReconnectAttempts > 0 && conn.reconnectAttempts >= maxReconnectAttempts)\n {\n closeConn(conn);\n\n return;\n }\n\n conn.reconnectAttempts++;\n onReconnectCb?.(conn.reconnectAttempts);\n\n conn.reconnectTimer = setTimeout(() =>\n {\n connect();\n }, reconnectDelay);\n }\n\n // Start connection\n connect();\n\n // Return unsubscribe function\n return () =>\n {\n closeConn(conn);\n };\n }\n\n function getState(): SSEConnectionState\n {\n return state;\n }\n\n function close()\n {\n if (active)\n {\n closeConn(active);\n }\n else\n {\n state = 'closed';\n }\n }\n\n return {\n subscribe,\n getState,\n close,\n };\n}\n\n/**\n * Simple subscribe function for one-off subscriptions\n *\n * @example\n * ```typescript\n * import { subscribeToEvents } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * // Using defaults\n * const unsubscribe = subscribeToEvents<EventRouter>(\n * ['userCreated', 'orderPlaced'],\n * {\n * userCreated: (payload) => console.log('User:', payload),\n * orderPlaced: (payload) => console.log('Order:', payload),\n * }\n * );\n *\n * // With custom host\n * const unsubscribe = subscribeToEvents<EventRouter>(\n * ['userCreated'],\n * { userCreated: (payload) => console.log('User:', payload) },\n * { host: 'https://api.example.com' }\n * );\n * ```\n */\nexport function subscribeToEvents<TRouter extends EventRouterDef<any>>(\n events: InferEventNames<TRouter>[],\n handlers: SSESubscribeOptions<TRouter>['handlers'],\n options?: SSEClientConfig,\n): SSEUnsubscribe\n{\n const client = createSSEClient<TRouter>(options);\n\n return client.subscribe({\n events,\n handlers,\n });\n}\n\n// ============================================================================\n// Auth SSE Client\n// ============================================================================\n\n/**\n * SSE client configuration for authenticated connections\n *\n * Same as SSEClientConfig but without acquireToken (auto-configured).\n */\nexport interface AuthSSEClientConfig extends Omit<SSEClientConfig, 'acquireToken'>\n{\n /**\n * RPC proxy base URL for token acquisition\n * @default '/api/rpc'\n */\n rpcBaseUrl?: string;\n}\n\n/**\n * Headers of the one-time token request.\n *\n * The token route is a cookie-authenticated POST through the RPC proxy, so the\n * @spfn/auth CSRF rule applies to it exactly as to any other mutation: the\n * readable `spfn_csrf` cookie family is mirrored into `x-spfn-csrf`, the way\n * the api client does. Without the header, `SPFN_AUTH_CSRF=enforce` refuses\n * the request and the stream never opens.\n *\n * @internal Exported for tests\n */\nexport function tokenRequestHeaders(cookies: Iterable<[string, string]> = documentCookieEntries()): Record<string, string>\n{\n const csrf = csrfHeaderValue(cookies);\n\n return csrf === undefined\n ? { 'Content-Type': 'application/json' }\n : { 'Content-Type': 'application/json', [CSRF_HEADER]: csrf };\n}\n\n/**\n * Create SSE client with built-in token authentication\n *\n * Acquires one-time SSE tokens via RPC proxy automatically, sending the\n * `x-spfn-csrf` header whenever a `spfn_csrf` cookie is present.\n * Requires eventRouteMap to be merged into RPC proxy config.\n *\n * @example\n * ```typescript\n * import { createAuthSSEClient } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * const client = createAuthSSEClient<EventRouter>();\n *\n * client.subscribe({\n * events: ['userCreated'],\n * handlers: {\n * userCreated: (payload) => console.log(payload),\n * },\n * });\n * ```\n */\nexport function createAuthSSEClient<TRouter extends EventRouterDef<any>>(\n config: AuthSSEClientConfig = {},\n): SSEClient<TRouter>\n{\n const { rpcBaseUrl = '/api/rpc', ...sseConfig } = config;\n\n return createSSEClient<TRouter>({\n ...sseConfig,\n acquireToken: async () =>\n {\n const res = await fetch(`${rpcBaseUrl}/eventsToken`, {\n method: 'POST',\n credentials: 'include',\n headers: tokenRequestHeaders(),\n body: JSON.stringify({}),\n });\n\n if (!res.ok)\n {\n throw new Error(`Failed to acquire SSE token: ${res.status}`);\n }\n\n const data = await res.json();\n\n return data.token;\n },\n });\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../../src/nextjs/client/csrf.ts","../../../src/event/sse/client.ts"],"names":[],"mappings":"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","file":"client.js","sourcesContent":["/**\n * CSRF token transport for the RPC client\n *\n * The Next.js proxy sets a readable (non-HttpOnly) cookie carrying an HMAC of\n * the session's key id; this module mirrors it into a request header. The proxy\n * never compares cookie against header — it recomputes the value from the\n * session — so the cookie is transport only and reading it here is not a trust\n * decision. See the CSRF section of the @spfn/auth README.\n */\n\n/**\n * Header the readable CSRF cookie is mirrored into.\n *\n * Must match CSRF_HEADER in @spfn/auth. It is duplicated rather than imported\n * because @spfn/core cannot depend on @spfn/auth.\n */\nexport const CSRF_HEADER = 'x-spfn-csrf';\n\n/**\n * Cookie names carrying a CSRF token.\n *\n * @spfn/auth suffixes its cookie names with the server port so several local dev\n * instances on the same host do not overwrite each other's session, and the\n * browser cannot know that port — so match the family, not one name. Every\n * candidate is sent and the proxy accepts the request if any of them recomputes\n * to the expected value; a candidate an attacker tossed in never will.\n */\nconst CSRF_COOKIE_PATTERN = /^spfn_csrf(_\\d+)?$/;\n\n/**\n * Values that can be put in a header at all: printable ASCII, no space.\n *\n * A cookie jar is attacker-writable in the very case this feature exists for, and\n * `new Headers()` rejects a value outside Latin-1 — so a sibling subdomain that\n * tosses `spfn_csrf=한` would make every RPC call throw before it was sent, not\n * merely fail the check. A genuine token is 64 hex characters; anything that\n * cannot be a header value cannot be one.\n */\nconst HEADER_SAFE_VALUE = /^[!-~]+$/;\n\n/**\n * Upper bound on candidates sent, so a cookie-flooded jar stays a bounded header.\n *\n * Deliberately generous. The server recomputes the expected value once and then\n * only compares fixed-length strings, so extra candidates cost it almost nothing,\n * while every candidate dropped here is a chance to lock the user out of every\n * mutation — the far worse failure. Must not exceed the matching bound in\n * @spfn/auth's `matchesCsrfToken`, or values sent would never be looked at.\n */\nconst MAX_CANDIDATES = 32;\n\n/**\n * Choose which candidates to send when the jar holds more than the cap.\n *\n * `document.cookie` exposes neither Domain nor Path, so a tossed cookie is\n * indistinguishable from the genuine one here — the proxy is what tells them\n * apart, by recomputing. What this can do is refuse to drop the genuine value on\n * the floor. Browsers order the jar by path length descending, then by age\n * ascending, so a flood pushes the genuine cookie towards the end (attacker\n * cookies on a longer path) or towards the start (attacker cookies written later\n * on the same path). Keeping both ends survives either, where a plain prefix\n * survives only one.\n *\n * A jar flooded far past the cap is a denial of service that no client-side\n * choice fully prevents; the structural fix is a `__Host-` prefixed cookie name,\n * which a sibling subdomain cannot write at all.\n */\nfunction selectCandidates(values: string[]): string[]\n{\n // Identical duplicates carry no extra information, so collapsing them buys\n // back slots that a flood of one repeated value would otherwise burn.\n const unique = Array.from(new Set(values));\n\n if (unique.length <= MAX_CANDIDATES)\n {\n return unique;\n }\n\n const half = MAX_CANDIDATES / 2;\n\n return [...unique.slice(0, half), ...unique.slice(-half)];\n}\n\n/**\n * Collect the CSRF token candidates from cookie entries.\n *\n * Takes entries rather than a map because a name can legitimately appear twice:\n * a sibling subdomain that tosses `spfn_csrf` for the parent domain leaves the\n * browser holding two cookies of that name, and folding them into a map would\n * throw away whichever the tossed one displaced — locking the user out of every\n * mutation. Sending both keeps the real one in play, and the tossed one is inert\n * because the proxy recomputes what it expects.\n *\n * @returns Header value (comma-separated), or undefined when no cookie exists\n */\nexport function csrfHeaderValue(cookies: Iterable<[string, string]>): string | undefined\n{\n // A comma separates candidates on the wire, so a value containing one would\n // arrive as several. That cannot be a genuine token — they are hex — but it\n // would let a single tossed cookie spend the receiver's whole candidate\n // budget and bury the real value behind it. Drop those, and anything that\n // could not be a header value at all.\n const values = Array.from(cookies)\n .filter(([name, value]) => CSRF_COOKIE_PATTERN.test(name)\n && HEADER_SAFE_VALUE.test(value)\n && !value.includes(','))\n .map(([, value]) => value);\n\n const candidates = selectCandidates(values);\n\n return candidates.length > 0 ? candidates.join(',') : undefined;\n}\n\n/**\n * Read the browser cookie jar as entries, duplicates preserved.\n *\n * Empty outside a browser (SSR reads the jar it forwards instead).\n */\nexport function documentCookieEntries(): Array<[string, string]>\n{\n if (typeof document === 'undefined' || !document.cookie)\n {\n return [];\n }\n\n const entries: Array<[string, string]> = [];\n\n for (const pair of document.cookie.split(';'))\n {\n const separator = pair.indexOf('=');\n\n if (separator > 0)\n {\n entries.push([pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()]);\n }\n }\n\n return entries;\n}\n","/**\n * SSE Client\n *\n * Type-safe EventSource wrapper for event subscription\n *\n * @example\n * ```typescript\n * import { createSSEClient } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * // Uses defaults: NEXT_PUBLIC_SPFN_API_URL + /events/stream\n * const client = createSSEClient<EventRouter>();\n *\n * // Or with custom host/pathname\n * const client = createSSEClient<EventRouter>({\n * host: 'https://api.example.com',\n * pathname: '/sse',\n * });\n *\n * // With token authentication (recommended: use createAuthSSEClient)\n * import { createAuthSSEClient } from '@spfn/core/event/sse/client';\n * const client = createAuthSSEClient<EventRouter>();\n *\n * const unsubscribe = client.subscribe({\n * events: ['userCreated', 'orderPlaced'],\n * handlers: {\n * userCreated: (payload) => console.log('User:', payload.userId),\n * orderPlaced: (payload) => console.log('Order:', payload.orderId),\n * },\n * });\n *\n * // Later: cleanup\n * unsubscribe();\n * ```\n */\n\nimport type { EventRouterDef, InferEventNames } from '../router';\nimport { CSRF_HEADER, csrfHeaderValue, documentCookieEntries } from '../../nextjs/client/csrf';\nimport type {\n SSEClientConfig,\n SSESubscribeOptions,\n SSEUnsubscribe,\n SSEConnectionState,\n SSEMessage,\n} from './types';\n\n/**\n * SSE Client instance\n */\nexport interface SSEClient<TRouter extends EventRouterDef<any>>\n{\n /**\n * Subscribe to events\n */\n subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe;\n\n /**\n * Get current connection state\n */\n getState(): SSEConnectionState;\n\n /**\n * Close all connections\n */\n close(): void;\n}\n\n/**\n * Default SSE configuration\n */\nconst SSE_DEFAULTS = {\n host: typeof process !== 'undefined'\n ? (process.env.NEXT_PUBLIC_SPFN_API_URL || 'http://localhost:8790')\n : 'http://localhost:8790',\n pathname: '/events/stream',\n} as const;\n\n/**\n * Create type-safe SSE client\n *\n * @example\n * ```typescript\n * // Uses defaults (NEXT_PUBLIC_SPFN_API_URL + /events/stream)\n * const client = createSSEClient<EventRouter>();\n *\n * // Or with custom configuration\n * const client = createSSEClient<EventRouter>({\n * host: 'https://api.example.com',\n * pathname: '/sse',\n * reconnect: true,\n * reconnectDelay: 3000,\n * });\n *\n * // Subscribe to events\n * const unsubscribe = client.subscribe({\n * events: ['userCreated', 'orderPlaced'],\n * handlers: {\n * userCreated: (payload) => {\n * console.log('New user:', payload.userId);\n * },\n * orderPlaced: (payload) => {\n * console.log('New order:', payload.orderId);\n * },\n * },\n * onOpen: () => console.log('Connected'),\n * onError: (err) => console.error('Error:', err),\n * onReconnect: (attempt) => console.log('Reconnecting...', attempt),\n * });\n *\n * // Cleanup\n * unsubscribe();\n * ```\n */\n/**\n * Per-subscription connection resources.\n *\n * Grouped so an in-flight async connect can be cancelled atomically and\n * `close()` can tear down whichever subscription is currently active —\n * the shared module-level EventSource used to leak across the token await.\n */\ninterface SSEConnection\n{\n eventSource: EventSource | null;\n reconnectTimer: ReturnType<typeof setTimeout> | null;\n reconnectAttempts: number;\n closed: boolean;\n onClose?: () => void;\n}\n\nexport function createSSEClient<TRouter extends EventRouterDef<any>>(\n config: SSEClientConfig = {},\n): SSEClient<TRouter>\n{\n const {\n url,\n host = SSE_DEFAULTS.host,\n pathname = SSE_DEFAULTS.pathname,\n reconnect = true,\n reconnectDelay = 3000,\n maxReconnectAttempts = 0,\n withCredentials = false,\n acquireToken,\n } = config;\n\n // Build base URL: url takes precedence, otherwise host + pathname\n const baseUrl = url || `${host}${pathname}`;\n\n let state: SSEConnectionState = 'closed';\n let active: SSEConnection | null = null;\n\n // Idempotent teardown: closes the connection, fires onClose exactly once,\n // and clears the active slot if this was the live connection.\n function closeConn(conn: SSEConnection)\n {\n if (conn.closed)\n {\n return;\n }\n\n conn.closed = true;\n\n if (conn.reconnectTimer)\n {\n clearTimeout(conn.reconnectTimer);\n conn.reconnectTimer = null;\n }\n\n if (conn.eventSource)\n {\n conn.eventSource.close();\n conn.eventSource = null;\n }\n\n if (active === conn)\n {\n active = null;\n state = 'closed';\n }\n\n conn.onClose?.();\n }\n\n function subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe\n {\n const { events, handlers, onOpen, onError, onReconnect, onClose, onSubscriptionChange } = options;\n\n // A new subscription supersedes any previous one on this client.\n if (active)\n {\n closeConn(active);\n }\n\n const conn: SSEConnection = {\n eventSource: null,\n reconnectTimer: null,\n reconnectAttempts: 0,\n closed: false,\n onClose,\n };\n active = conn;\n\n const eventNames = events as string[];\n\n function connect()\n {\n state = 'connecting';\n\n const init = async () =>\n {\n let tokenParam = '';\n\n if (acquireToken)\n {\n const token = await acquireToken();\n\n // Cancelled during the token await (e.g. StrictMode cleanup) —\n // don't open a connection that has no teardown waiting for it.\n if (conn.closed)\n {\n return;\n }\n\n tokenParam = `&token=${encodeURIComponent(token)}`;\n }\n\n if (conn.closed)\n {\n return;\n }\n\n const streamUrl = `${baseUrl}?events=${eventNames.join(',')}${tokenParam}`;\n\n conn.eventSource = new EventSource(streamUrl, {\n withCredentials,\n });\n\n setupEventHandlers(conn.eventSource, eventNames, handlers, onOpen, onError);\n setupSubscriptionChange(conn.eventSource, onSubscriptionChange);\n setupReconnect(onReconnect);\n };\n\n init().catch(() =>\n {\n // Don't resurrect a torn-down subscription via reconnect.\n if (conn.closed)\n {\n return;\n }\n\n state = 'error';\n attemptReconnect(onReconnect);\n });\n }\n\n function setupEventHandlers(\n es: EventSource,\n names: string[],\n handlerMap: SSESubscribeOptions<TRouter>['handlers'],\n onOpenCb?: () => void,\n onErrorCb?: (error: Event) => void,\n )\n {\n es.onopen = () =>\n {\n state = 'open';\n conn.reconnectAttempts = 0;\n onOpenCb?.();\n };\n\n es.onerror = (error) =>\n {\n state = 'error';\n onErrorCb?.(error);\n };\n\n // Handle connected event (server sends this on connection)\n es.addEventListener('connected', (e: MessageEvent) =>\n {\n try\n {\n const data = JSON.parse(e.data);\n console.debug('[SSE] Connected:', data);\n }\n catch\n {\n // Ignore parse errors\n }\n });\n\n // Handle ping (keep-alive)\n es.addEventListener('ping', () =>\n {\n // Ping received, connection is alive\n });\n\n // Register handlers for each event\n for (const eventName of names)\n {\n const handler = (handlerMap as Record<string, ((payload: unknown) => void) | undefined>)[eventName];\n\n if (!handler)\n {\n continue;\n }\n\n es.addEventListener(eventName, (e: MessageEvent) =>\n {\n try\n {\n const message: SSEMessage = JSON.parse(e.data);\n handler(message.data);\n }\n catch (err)\n {\n console.error(`[SSE] Failed to parse event \"${eventName}\":`, err);\n }\n });\n }\n }\n\n // The server's grant/revoke frames (data `{ event }`); ignored without a callback.\n function setupSubscriptionChange(\n es: EventSource,\n onChange?: SSESubscribeOptions<TRouter>['onSubscriptionChange'],\n )\n {\n if (!onChange)\n {\n return;\n }\n\n const frames = { 'subscription.granted': 'granted', 'subscription.revoked': 'revoked' } as const;\n\n for (const [frame, status] of Object.entries(frames))\n {\n es.addEventListener(frame, (e: MessageEvent) =>\n {\n let event: InferEventNames<TRouter>;\n try\n {\n ({ event } = JSON.parse(e.data));\n }\n catch (err)\n {\n console.error(`[SSE] Failed to parse \"${frame}\" frame:`, err);\n\n return;\n }\n\n onChange({ event, status });\n });\n }\n }\n\n function setupReconnect(onReconnectCb?: (attempt: number) => void)\n {\n if (!conn.eventSource)\n {\n return;\n }\n\n const currentEs = conn.eventSource;\n const originalOnError = currentEs.onerror;\n\n currentEs.onerror = (error) =>\n {\n if (originalOnError)\n {\n (originalOnError as (ev: Event) => void)(error);\n }\n\n // Token-auth 사용 시 브라우저 auto-retry는 소비된 토큰으로 재시도하므로\n // 즉시 close하고 우리 reconnect로 새 토큰 발급받아 재연결\n if (reconnect && acquireToken)\n {\n currentEs.close();\n attemptReconnect(onReconnectCb);\n }\n else if (reconnect && currentEs.readyState === EventSource.CLOSED)\n {\n attemptReconnect(onReconnectCb);\n }\n };\n }\n\n function attemptReconnect(onReconnectCb?: (attempt: number) => void)\n {\n if (conn.closed || !reconnect)\n {\n return;\n }\n\n if (maxReconnectAttempts > 0 && conn.reconnectAttempts >= maxReconnectAttempts)\n {\n closeConn(conn);\n\n return;\n }\n\n conn.reconnectAttempts++;\n onReconnectCb?.(conn.reconnectAttempts);\n\n conn.reconnectTimer = setTimeout(() =>\n {\n connect();\n }, reconnectDelay);\n }\n\n // Start connection\n connect();\n\n // Return unsubscribe function\n return () =>\n {\n closeConn(conn);\n };\n }\n\n function getState(): SSEConnectionState\n {\n return state;\n }\n\n function close()\n {\n if (active)\n {\n closeConn(active);\n }\n else\n {\n state = 'closed';\n }\n }\n\n return {\n subscribe,\n getState,\n close,\n };\n}\n\n/**\n * Simple subscribe function for one-off subscriptions\n *\n * @example\n * ```typescript\n * import { subscribeToEvents } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * // Using defaults\n * const unsubscribe = subscribeToEvents<EventRouter>(\n * ['userCreated', 'orderPlaced'],\n * {\n * userCreated: (payload) => console.log('User:', payload),\n * orderPlaced: (payload) => console.log('Order:', payload),\n * }\n * );\n *\n * // With custom host\n * const unsubscribe = subscribeToEvents<EventRouter>(\n * ['userCreated'],\n * { userCreated: (payload) => console.log('User:', payload) },\n * { host: 'https://api.example.com' }\n * );\n * ```\n */\nexport function subscribeToEvents<TRouter extends EventRouterDef<any>>(\n events: InferEventNames<TRouter>[],\n handlers: SSESubscribeOptions<TRouter>['handlers'],\n options?: SSEClientConfig,\n): SSEUnsubscribe\n{\n const client = createSSEClient<TRouter>(options);\n\n return client.subscribe({\n events,\n handlers,\n });\n}\n\n// ============================================================================\n// Auth SSE Client\n// ============================================================================\n\n/**\n * SSE client configuration for authenticated connections\n *\n * Same as SSEClientConfig but without acquireToken (auto-configured).\n */\nexport interface AuthSSEClientConfig extends Omit<SSEClientConfig, 'acquireToken'>\n{\n /**\n * RPC proxy base URL for token acquisition\n * @default '/api/rpc'\n */\n rpcBaseUrl?: string;\n}\n\n/**\n * Headers of the one-time token request.\n *\n * The token route is a cookie-authenticated POST through the RPC proxy, so the\n * @spfn/auth CSRF rule applies to it exactly as to any other mutation: the\n * readable `spfn_csrf` cookie family is mirrored into `x-spfn-csrf`, the way\n * the api client does. Without the header, `SPFN_AUTH_CSRF=enforce` refuses\n * the request and the stream never opens.\n *\n * @internal Exported for tests\n */\nexport function tokenRequestHeaders(cookies: Iterable<[string, string]> = documentCookieEntries()): Record<string, string>\n{\n const csrf = csrfHeaderValue(cookies);\n\n return csrf === undefined\n ? { 'Content-Type': 'application/json' }\n : { 'Content-Type': 'application/json', [CSRF_HEADER]: csrf };\n}\n\n/**\n * Create SSE client with built-in token authentication\n *\n * Acquires one-time SSE tokens via RPC proxy automatically, sending the\n * `x-spfn-csrf` header whenever a `spfn_csrf` cookie is present.\n * Requires eventRouteMap to be merged into RPC proxy config.\n *\n * @example\n * ```typescript\n * import { createAuthSSEClient } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * const client = createAuthSSEClient<EventRouter>();\n *\n * client.subscribe({\n * events: ['userCreated'],\n * handlers: {\n * userCreated: (payload) => console.log(payload),\n * },\n * });\n * ```\n */\nexport function createAuthSSEClient<TRouter extends EventRouterDef<any>>(\n config: AuthSSEClientConfig = {},\n): SSEClient<TRouter>\n{\n const { rpcBaseUrl = '/api/rpc', ...sseConfig } = config;\n\n return createSSEClient<TRouter>({\n ...sseConfig,\n acquireToken: async () =>\n {\n const res = await fetch(`${rpcBaseUrl}/eventsToken`, {\n method: 'POST',\n credentials: 'include',\n headers: tokenRequestHeaders(),\n body: JSON.stringify({}),\n });\n\n if (!res.ok)\n {\n throw new Error(`Failed to acquire SSE token: ${res.status}`);\n }\n\n const data = await res.json();\n\n return data.token;\n },\n });\n}\n"]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Context } from 'hono';
|
|
2
|
-
import { E as EventRouterDef } from '../../router-
|
|
3
|
-
import { d as SSEHandlerConfig } from '../../types-
|
|
4
|
-
export { e as SSEAuthConfig, S as SSEClientConfig, c as SSEConnectionState, f as SSEEventHandler, g as SSEEventHandlers, h as SSEHandlerAuthConfig, i as SSEMessage, a as SSESubscribeOptions, b as SSEUnsubscribe } from '../../types-
|
|
2
|
+
import { E as EventRouterDef } from '../../router-D5B0cpVN.js';
|
|
3
|
+
import { d as SSEHandlerConfig } from '../../types-DqXYtI69.js';
|
|
4
|
+
export { e as SSEAuthConfig, S as SSEClientConfig, c as SSEConnectionState, f as SSEEventHandler, g as SSEEventHandlers, h as SSEHandlerAuthConfig, i as SSEMessage, a as SSESubscribeOptions, j as SSESubscriptionChange, b as SSEUnsubscribe } from '../../types-DqXYtI69.js';
|
|
5
5
|
import { S as SSETokenManager } from '../../token-manager-Ckjt2-qq.js';
|
|
6
6
|
export { C as CacheTokenStore, a as SSEToken, b as SSETokenManagerConfig, c as SSETokenStore } from '../../token-manager-Ckjt2-qq.js';
|
|
7
7
|
import '@sinclair/typebox';
|
|
@@ -36,6 +36,13 @@ declare module 'hono' {
|
|
|
36
36
|
* - events: Comma-separated list of event names to subscribe
|
|
37
37
|
* - token: One-time auth token (when auth is enabled)
|
|
38
38
|
*
|
|
39
|
+
* Who receives what, in order: the global `authorize` (once, at connect) →
|
|
40
|
+
* each requested event's `.canSubscribe()` (once per event, at connect) → the
|
|
41
|
+
* global `filter` map and the event's `.filter()` (sync, per frame). An event
|
|
42
|
+
* denied at connect stays in `deniedEvents`, so `EventDef.grant` can activate
|
|
43
|
+
* it later; `EventDef.revoke` deactivates an allowed one. The handler
|
|
44
|
+
* registers that control on every event of the router.
|
|
45
|
+
*
|
|
39
46
|
* @example
|
|
40
47
|
* ```typescript
|
|
41
48
|
* app.get('/events/stream', createSSEHandler(eventRouter, {
|
|
@@ -43,6 +50,11 @@ declare module 'hono' {
|
|
|
43
50
|
* }));
|
|
44
51
|
* ```
|
|
45
52
|
*/
|
|
46
|
-
declare function createSSEHandler<TRouter extends EventRouterDef<any>>(router: TRouter, config?: SSEHandlerConfig, tokenManager?: SSETokenManager): (c: Context) => Promise<Response
|
|
53
|
+
declare function createSSEHandler<TRouter extends EventRouterDef<any>>(router: TRouter, config?: SSEHandlerConfig, tokenManager?: SSETokenManager): ((c: Context) => Promise<Response>) & {
|
|
54
|
+
/** @internal Open registered connections of a subject (tests). */
|
|
55
|
+
_openConnections: (subject: string) => number;
|
|
56
|
+
/** @internal Connects of a subject still in admission (tests). */
|
|
57
|
+
_pendingAdmissions: (subject: string) => number;
|
|
58
|
+
};
|
|
47
59
|
|
|
48
60
|
export { SSEHandlerConfig, SSETokenManager, createSSEHandler };
|