@spfn/core 0.3.0-beta.13 → 0.3.0-beta.14
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 +7 -5
- package/dist/codegen/index.d.ts +9 -0
- package/dist/codegen/index.js +130 -35
- package/dist/codegen/index.js.map +1 -1
- package/dist/contract/index.d.ts +1 -1
- package/dist/event/index.d.ts +1 -2
- package/dist/event/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +10 -5
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +1 -1
- package/dist/ops/index.js.map +1 -1
- package/dist/route/index.d.ts +2 -2
- package/dist/route/index.js.map +1 -1
- package/dist/{router-9L3El7nO.d.ts → router-BqyEARoY.d.ts} +25 -23
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
- package/src/codegen/README.md +29 -15
- package/src/nextjs/README.md +13 -10
- package/src/route/README.md +14 -9
package/dist/contract/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { R as Router } from '../router-
|
|
1
|
+
import { R as Router } from '../router-BqyEARoY.js';
|
|
2
2
|
import { C as ContractDocument, a as ContractViolation, b as ContractOperation, c as ContractSnapshot } from '../types-ClQVomgV.js';
|
|
3
3
|
export { d as CompatibilityPolicy, e as ContractRequest, f as ContractViolationKind, J as JsonSchema } from '../types-ClQVomgV.js';
|
|
4
4
|
import '../define-middleware-CVKgqo8S.js';
|
package/dist/event/index.d.ts
CHANGED
|
@@ -53,11 +53,10 @@ declare function defineEvent<T extends TSchema>(name: string, schema: T): EventD
|
|
|
53
53
|
* // app/api/rpc/[routeName]/route.ts
|
|
54
54
|
* import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
55
55
|
* import { eventRouteMap } from '@spfn/core/event';
|
|
56
|
-
* import { authRouteMap } from '@spfn/auth';
|
|
57
56
|
* import { routeMap } from '@/generated/route-map';
|
|
58
57
|
*
|
|
59
58
|
* export const { GET, POST } = createRpcProxy({
|
|
60
|
-
* routeMap: { ...routeMap, ...
|
|
59
|
+
* routeMap: { ...routeMap, ...eventRouteMap },
|
|
61
60
|
* });
|
|
62
61
|
* ```
|
|
63
62
|
*/
|
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 { 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 const self: EventDef<TPayload> = {\n name,\n schema,\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 { authRouteMap } from '@spfn/auth';\n * import { routeMap } from '@/generated/route-map';\n *\n * export const { GET, POST } = createRpcProxy({\n * routeMap: { ...routeMap, ...authRouteMap, ...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 * Event Router Definition\n */\nexport interface EventRouterDef<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 * Infer event names from EventRouter\n */\nexport type InferEventNames<T> = T extends EventRouterDef<infer E>\n ? keyof E & string\n : never;\n\n/**\n * Infer payload type for specific event\n */\nexport type InferEventPayload<\n T extends EventRouterDef<any>,\n K extends InferEventNames<T>,\n> = T['_types'][K];\n\n/**\n * Infer all event payloads map\n */\nexport type InferEventPayloads<T extends EventRouterDef<any>> = T['_types'];\n\n/**\n * Define an event router for SSE subscription\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 return {\n events,\n eventNames: Object.keys(events) as (keyof TEvents)[],\n _types: {} as EventRouterDef<TEvents>['_types'],\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/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 { 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 const self: EventDef<TPayload> = {\n name,\n schema,\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 * Event Router Definition\n */\nexport interface EventRouterDef<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 * Infer event names from EventRouter\n */\nexport type InferEventNames<T> = T extends EventRouterDef<infer E>\n ? keyof E & string\n : never;\n\n/**\n * Infer payload type for specific event\n */\nexport type InferEventPayload<\n T extends EventRouterDef<any>,\n K extends InferEventNames<T>,\n> = T['_types'][K];\n\n/**\n * Infer all event payloads map\n */\nexport type InferEventPayloads<T extends EventRouterDef<any>> = T['_types'];\n\n/**\n * Define an event router for SSE subscription\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 return {\n events,\n eventNames: Object.keys(events) as (keyof TEvents)[],\n _types: {} as EventRouterDef<TEvents>['_types'],\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"]}
|
package/dist/nextjs/server.d.ts
CHANGED
|
@@ -121,14 +121,18 @@ interface TypedProxyConfig {
|
|
|
121
121
|
* ```typescript
|
|
122
122
|
* // app/api/rpc/[routeName]/route.ts
|
|
123
123
|
* import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
124
|
-
* import { authRouteMap } from '@spfn/auth';
|
|
125
124
|
* import { eventRouteMap } from '@spfn/core/event';
|
|
126
125
|
* import { routeMap } from '@/generated/route-map';
|
|
127
126
|
*
|
|
128
127
|
* export const { GET, POST } = createRpcProxy({
|
|
129
|
-
* routeMap: { ...routeMap, ...
|
|
128
|
+
* routeMap: { ...routeMap, ...eventRouteMap },
|
|
130
129
|
* });
|
|
131
130
|
* ```
|
|
131
|
+
*
|
|
132
|
+
* The generated `routeMap` already holds the routes of every package router the
|
|
133
|
+
* app mounts with `.packages()`, so no package map is merged by hand.
|
|
134
|
+
* `eventRouteMap` is the exception: a hand-written constant, not a mounted
|
|
135
|
+
* router.
|
|
132
136
|
*/
|
|
133
137
|
|
|
134
138
|
/**
|
|
@@ -149,16 +153,17 @@ interface RpcProxyConfig extends Omit<TypedProxyConfig, 'onRequest' | 'onRespons
|
|
|
149
153
|
/**
|
|
150
154
|
* Route map containing routeName → {method, path} mappings
|
|
151
155
|
*
|
|
152
|
-
*
|
|
156
|
+
* The generated map carries the app's own routes and those of every package
|
|
157
|
+
* router it mounts with `.packages()`. Merge `eventRouteMap` beside it when
|
|
158
|
+
* the app uses SSE — that one is a constant rather than a mounted router.
|
|
153
159
|
*
|
|
154
160
|
* @example
|
|
155
161
|
* ```typescript
|
|
156
|
-
* import { authRouteMap } from '@spfn/auth';
|
|
157
162
|
* import { eventRouteMap } from '@spfn/core/event';
|
|
158
163
|
* import { routeMap } from '@/generated/route-map';
|
|
159
164
|
*
|
|
160
165
|
* export const { GET, POST } = createRpcProxy({
|
|
161
|
-
* routeMap: { ...routeMap, ...
|
|
166
|
+
* routeMap: { ...routeMap, ...eventRouteMap },
|
|
162
167
|
* });
|
|
163
168
|
* ```
|
|
164
169
|
*/
|