@spfn/core 0.2.0-beta.45 → 0.2.0-beta.47
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/dist/event/index.d.ts +6 -2
- package/dist/event/index.js +12 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/index.d.ts +4 -3
- package/dist/event/ws/client.d.ts +59 -0
- package/dist/event/ws/client.js +273 -0
- package/dist/event/ws/client.js.map +1 -0
- package/dist/event/ws/index.d.ts +94 -0
- package/dist/event/ws/index.js +213 -0
- package/dist/event/ws/index.js.map +1 -0
- package/dist/server/index.d.ts +58 -2
- package/dist/server/index.js +285 -2
- package/dist/server/index.js.map +1 -1
- package/dist/{router-Di7ENoah.d.ts → token-manager-DSwIDD-_.d.ts} +116 -1
- package/dist/{types-DKQ90YL7.d.ts → types-BOOUBu9l.d.ts} +2 -117
- package/dist/types-FuJb3yrP.d.ts +151 -0
- package/package.json +14 -2
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { logger } from '@spfn/core/logger';
|
|
2
|
+
|
|
3
|
+
// src/event/ws/handler.ts
|
|
4
|
+
var wsLogger = logger.child("@spfn/core:ws");
|
|
5
|
+
async function attachWSHandler(server, router, config = {}, tokenManager) {
|
|
6
|
+
const WebSocketServer = await loadWSServer();
|
|
7
|
+
const {
|
|
8
|
+
pingInterval = 3e4,
|
|
9
|
+
path = "/ws",
|
|
10
|
+
auth: authConfig
|
|
11
|
+
} = config;
|
|
12
|
+
if (authConfig?.enabled && !tokenManager) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const wss = new WebSocketServer({ server, path });
|
|
18
|
+
const clients = /* @__PURE__ */ new Set();
|
|
19
|
+
wss.on("connection", (ws, req) => {
|
|
20
|
+
clients.add(ws);
|
|
21
|
+
ws.on("close", () => clients.delete(ws));
|
|
22
|
+
handleConnection(ws, req, router, authConfig, tokenManager, pingInterval).catch((err) => {
|
|
23
|
+
wsLogger.error("WebSocket connection handler error", err);
|
|
24
|
+
if (ws.readyState === 1) ws.close(1011, "Internal server error");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
wss.on("error", (err) => {
|
|
28
|
+
wsLogger.error("WebSocket server error", err);
|
|
29
|
+
});
|
|
30
|
+
wsLogger.info(`\u2713 WebSocket endpoint registered at ${path}`, {
|
|
31
|
+
events: router.eventNames,
|
|
32
|
+
auth: !!authConfig?.enabled
|
|
33
|
+
});
|
|
34
|
+
return () => new Promise((resolve, reject) => {
|
|
35
|
+
for (const client of clients) {
|
|
36
|
+
client.close(1001, "Server shutting down");
|
|
37
|
+
}
|
|
38
|
+
clients.clear();
|
|
39
|
+
wss.close((err) => {
|
|
40
|
+
if (err) reject(err);
|
|
41
|
+
else resolve();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function handleConnection(ws, req, router, authConfig, tokenManager, pingInterval) {
|
|
46
|
+
let pingTimer;
|
|
47
|
+
let connectionUnsubscribes = [];
|
|
48
|
+
let subscribedEvents = [];
|
|
49
|
+
ws.on("close", () => {
|
|
50
|
+
clearInterval(pingTimer);
|
|
51
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
52
|
+
if (subscribedEvents.length > 0)
|
|
53
|
+
wsLogger.info("WebSocket connection closed", { events: subscribedEvents });
|
|
54
|
+
});
|
|
55
|
+
const url = parseURL(req);
|
|
56
|
+
if (!url) {
|
|
57
|
+
ws.close(1002, "Invalid request URL");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : void 0);
|
|
61
|
+
if (subject === false) {
|
|
62
|
+
ws.close(4001, "Missing token");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (subject === null) {
|
|
66
|
+
ws.close(4001, "Invalid or expired token");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const requestedEvents = parseRequestedEvents(url, router.eventNames);
|
|
70
|
+
if (requestedEvents.length === 0) {
|
|
71
|
+
ws.close(4e3, "No valid event names specified");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);
|
|
75
|
+
if (allowedEvents === null) {
|
|
76
|
+
ws.close(4003, "Not authorized for any requested events");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
subscribedEvents = allowedEvents;
|
|
80
|
+
wsLogger.info("WebSocket connection established", {
|
|
81
|
+
events: allowedEvents,
|
|
82
|
+
subject: subject ?? void 0
|
|
83
|
+
});
|
|
84
|
+
const connection = createConnection(ws);
|
|
85
|
+
connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig);
|
|
86
|
+
if (ws.readyState !== 1) {
|
|
87
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
88
|
+
connectionUnsubscribes = [];
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
ws.on("message", (data) => {
|
|
92
|
+
onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
|
|
93
|
+
});
|
|
94
|
+
if (pingInterval > 0) {
|
|
95
|
+
pingTimer = setInterval(() => {
|
|
96
|
+
if (ws.readyState === 1) ws.ping();
|
|
97
|
+
}, pingInterval);
|
|
98
|
+
}
|
|
99
|
+
connection.send("__connected", {
|
|
100
|
+
subscribedEvents: allowedEvents,
|
|
101
|
+
timestamp: Date.now()
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function parseURL(req) {
|
|
105
|
+
try {
|
|
106
|
+
return new URL(req.url ?? "/", "ws://localhost");
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function resolveSubject(url, tokenManager) {
|
|
112
|
+
if (!tokenManager) {
|
|
113
|
+
return void 0;
|
|
114
|
+
}
|
|
115
|
+
const token = url.searchParams.get("token");
|
|
116
|
+
if (!token) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return await tokenManager.verify(token);
|
|
120
|
+
}
|
|
121
|
+
function parseRequestedEvents(url, validEventNames) {
|
|
122
|
+
const eventsParam = url.searchParams.get("events");
|
|
123
|
+
if (!eventsParam) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
return eventsParam.split(",").map((e) => e.trim()).filter((e) => validEventNames.includes(e));
|
|
127
|
+
}
|
|
128
|
+
async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
|
|
129
|
+
if (!subject || !authConfig?.authorize) {
|
|
130
|
+
return requestedEvents;
|
|
131
|
+
}
|
|
132
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
133
|
+
return allowed.length === 0 ? null : allowed;
|
|
134
|
+
}
|
|
135
|
+
function createConnection(ws) {
|
|
136
|
+
return {
|
|
137
|
+
send: (type, payload) => {
|
|
138
|
+
if (ws.readyState !== 1) return;
|
|
139
|
+
ws.send(JSON.stringify({ type, data: payload }));
|
|
140
|
+
},
|
|
141
|
+
close: (code, reason) => ws.close(code, reason)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function subscribeEvents(ws, router, allowedEvents, subject, authConfig) {
|
|
145
|
+
const unsubscribes = [];
|
|
146
|
+
for (const eventName of allowedEvents) {
|
|
147
|
+
const eventDef = router.events[eventName];
|
|
148
|
+
if (!eventDef) continue;
|
|
149
|
+
const unsubscribe = eventDef.subscribe((payload) => {
|
|
150
|
+
if (ws.readyState !== 1) return;
|
|
151
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
152
|
+
if (!authConfig.filter[eventName](subject, payload)) return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
ws.send(JSON.stringify({ type: eventName, data: payload }));
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
unsubscribes.push(unsubscribe);
|
|
160
|
+
}
|
|
161
|
+
return unsubscribes;
|
|
162
|
+
}
|
|
163
|
+
async function onClientMessage(data, router, connection, subject) {
|
|
164
|
+
let message;
|
|
165
|
+
try {
|
|
166
|
+
message = JSON.parse(data.toString());
|
|
167
|
+
} catch {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const { type, data: payload } = message;
|
|
171
|
+
if (!type) return;
|
|
172
|
+
const handler = router.messages[type];
|
|
173
|
+
if (!handler) return;
|
|
174
|
+
try {
|
|
175
|
+
await handler({ payload, subject, ws: connection });
|
|
176
|
+
} catch (err) {
|
|
177
|
+
wsLogger.error(`WebSocket message handler error: ${type}`, err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function loadWSServer() {
|
|
181
|
+
try {
|
|
182
|
+
const mod = await import('ws');
|
|
183
|
+
const WS = mod.default ?? mod;
|
|
184
|
+
const WSS = WS.WebSocketServer ?? WS.Server;
|
|
185
|
+
if (typeof WSS !== "function") {
|
|
186
|
+
throw new Error(
|
|
187
|
+
"WebSocketServer not found in ws module. Ensure ws@^8 is installed: pnpm add ws"
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return WSS;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (err instanceof Error && err.message.includes("WebSocketServer not found")) {
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
throw new Error(
|
|
196
|
+
'@spfn/core WebSocket support requires the "ws" package.\nInstall it with: pnpm add ws'
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/event/ws/index.ts
|
|
202
|
+
function defineWSRouter(def) {
|
|
203
|
+
return {
|
|
204
|
+
events: def.events,
|
|
205
|
+
eventNames: Object.keys(def.events),
|
|
206
|
+
messages: def.messages ?? {},
|
|
207
|
+
_types: {}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { attachWSHandler, defineWSRouter };
|
|
212
|
+
//# sourceMappingURL=index.js.map
|
|
213
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/event/ws/handler.ts","../../../src/event/ws/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\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// 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 auth: authConfig,\n } = config;\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 });\n\n // Track live connections for graceful shutdown\n const clients = new Set<any>();\n\n wss.on('connection', (ws: any, req: any) =>\n {\n clients.add(ws);\n ws.on('close', () => clients.delete(ws));\n handleConnection(ws, req, router, authConfig, tokenManager, pingInterval)\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\nasync function handleConnection(\n ws: any,\n req: any,\n router: WSRouterDef<any, any>,\n authConfig: WSHandlerAuthConfig | undefined,\n tokenManager: SSETokenManager | undefined,\n pingInterval: number\n): Promise<void>\n{\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 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 return;\n }\n if (subject === null)\n {\n ws.close(4001, 'Invalid or expired token');\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 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 return;\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);\n\n // ── 5. Subscribe to server-push events ──\n connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig);\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 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 ──\n if (pingInterval > 0)\n {\n pingTimer = setInterval(() =>\n {\n if (ws.readyState === 1) 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 return allowed.length === 0 ? null : allowed;\n}\n\nfunction createConnection(ws: any): WSRawConnection\n{\n return {\n send: (type, payload) =>\n {\n if (ws.readyState !== 1) return;\n ws.send(JSON.stringify({ type, data: payload }));\n },\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\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 try\n {\n ws.send(JSON.stringify({ type: eventName, data: payload }));\n }\n catch\n {\n // Socket closed between readyState check and send — ignore\n }\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\nasync function loadWSServer(): Promise<any>\n{\n try\n {\n // ws is a CJS package: module.exports = WebSocket, WebSocket.WebSocketServer is set on it.\n // ESM dynamic import wraps CJS default export under .default\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const mod = await import('ws') as any;\n const WS = mod.default ?? mod;\n const WSS = WS.WebSocketServer ?? WS.Server;\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 catch (err)\n {\n if (err instanceof Error && err.message.includes('WebSocketServer not found'))\n {\n throw err;\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","/**\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/server/index.d.ts
CHANGED
|
@@ -5,8 +5,9 @@ import { serve } from '@hono/node-server';
|
|
|
5
5
|
import { NamedMiddleware, Router } from '@spfn/core/route';
|
|
6
6
|
import { OnErrorContext } from '@spfn/core/middleware';
|
|
7
7
|
import { J as JobRouter, B as BossOptions } from '../boss-Cxqc-Oiw.js';
|
|
8
|
-
import { E as EventRouterDef } from '../
|
|
9
|
-
import { S as SSEHandlerConfig, a as SSEAuthConfig } from '../types-
|
|
8
|
+
import { E as EventRouterDef } from '../token-manager-DSwIDD-_.js';
|
|
9
|
+
import { S as SSEHandlerConfig, a as SSEAuthConfig } from '../types-BOOUBu9l.js';
|
|
10
|
+
import { W as WSRouterDef, a as WSHandlerConfig, b as WSMessageHandlers, c as WSAuthConfig } from '../types-FuJb3yrP.js';
|
|
10
11
|
import '@sinclair/typebox';
|
|
11
12
|
import 'pg-boss';
|
|
12
13
|
|
|
@@ -193,6 +194,30 @@ interface ServerConfig {
|
|
|
193
194
|
*/
|
|
194
195
|
path?: string;
|
|
195
196
|
};
|
|
197
|
+
/**
|
|
198
|
+
* WebSocket router for bidirectional real-time communication
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* ```typescript
|
|
202
|
+
* import { defineWSRouter } from '@spfn/core/event/ws';
|
|
203
|
+
*
|
|
204
|
+
* export default defineServerConfig()
|
|
205
|
+
* .websockets(wsRouter) // → WS /ws
|
|
206
|
+
* .build();
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
websockets?: WSRouterDef<any, any>;
|
|
210
|
+
/**
|
|
211
|
+
* WebSocket configuration options
|
|
212
|
+
* Only used if websockets router is provided
|
|
213
|
+
*/
|
|
214
|
+
websocketsConfig?: WSHandlerConfig & {
|
|
215
|
+
/**
|
|
216
|
+
* WebSocket endpoint path
|
|
217
|
+
* @default '/ws'
|
|
218
|
+
*/
|
|
219
|
+
path?: string;
|
|
220
|
+
};
|
|
196
221
|
/**
|
|
197
222
|
* Enable debug mode (default: NODE_ENV === 'development')
|
|
198
223
|
*/
|
|
@@ -836,6 +861,37 @@ declare class ServerConfigBuilder {
|
|
|
836
861
|
path?: string;
|
|
837
862
|
auth?: SSEAuthConfig<TRouter>;
|
|
838
863
|
}): this;
|
|
864
|
+
/**
|
|
865
|
+
* Register WebSocket router for bidirectional real-time communication
|
|
866
|
+
*
|
|
867
|
+
* Enables type-safe WebSocket connections with:
|
|
868
|
+
* - Server→client event push (via defineEvent + emit)
|
|
869
|
+
* - Client→server message handling (via messages in defineWSRouter)
|
|
870
|
+
*
|
|
871
|
+
* @example
|
|
872
|
+
* ```typescript
|
|
873
|
+
* // src/server/ws.ts
|
|
874
|
+
* export const wsRouter = defineWSRouter({
|
|
875
|
+
* events: { userUpdated, notification },
|
|
876
|
+
* messages: {
|
|
877
|
+
* ping: ({ ws }) => ws.send('pong', {}),
|
|
878
|
+
* },
|
|
879
|
+
* });
|
|
880
|
+
*
|
|
881
|
+
* // server.config.ts
|
|
882
|
+
* export default defineServerConfig()
|
|
883
|
+
* .websockets(wsRouter) // → WS /ws
|
|
884
|
+
* .websockets(wsRouter, {
|
|
885
|
+
* path: '/realtime', // custom path
|
|
886
|
+
* auth: { enabled: true }, // token authentication
|
|
887
|
+
* })
|
|
888
|
+
* .build();
|
|
889
|
+
* ```
|
|
890
|
+
*/
|
|
891
|
+
websockets<TEvents extends Record<string, any>, TMessages extends WSMessageHandlers, TRouter extends WSRouterDef<TEvents, TMessages>>(router: TRouter, config?: Omit<WSHandlerConfig, 'auth'> & {
|
|
892
|
+
path?: string;
|
|
893
|
+
auth?: WSAuthConfig<TRouter>;
|
|
894
|
+
}): this;
|
|
839
895
|
/**
|
|
840
896
|
* Enable/disable debug mode
|
|
841
897
|
*/
|
package/dist/server/index.js
CHANGED
|
@@ -1413,6 +1413,202 @@ async function registerJob(job2) {
|
|
|
1413
1413
|
await queueRunOnceJob(boss, job2);
|
|
1414
1414
|
jobLogger2.debug(`Job registered: ${job2.name}`);
|
|
1415
1415
|
}
|
|
1416
|
+
var wsLogger = logger.child("@spfn/core:ws");
|
|
1417
|
+
async function attachWSHandler(server, router, config = {}, tokenManager) {
|
|
1418
|
+
const WebSocketServer = await loadWSServer();
|
|
1419
|
+
const {
|
|
1420
|
+
pingInterval = 3e4,
|
|
1421
|
+
path = "/ws",
|
|
1422
|
+
auth: authConfig
|
|
1423
|
+
} = config;
|
|
1424
|
+
if (authConfig?.enabled && !tokenManager) {
|
|
1425
|
+
throw new Error(
|
|
1426
|
+
"WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
const wss = new WebSocketServer({ server, path });
|
|
1430
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1431
|
+
wss.on("connection", (ws, req) => {
|
|
1432
|
+
clients.add(ws);
|
|
1433
|
+
ws.on("close", () => clients.delete(ws));
|
|
1434
|
+
handleConnection(ws, req, router, authConfig, tokenManager, pingInterval).catch((err) => {
|
|
1435
|
+
wsLogger.error("WebSocket connection handler error", err);
|
|
1436
|
+
if (ws.readyState === 1) ws.close(1011, "Internal server error");
|
|
1437
|
+
});
|
|
1438
|
+
});
|
|
1439
|
+
wss.on("error", (err) => {
|
|
1440
|
+
wsLogger.error("WebSocket server error", err);
|
|
1441
|
+
});
|
|
1442
|
+
wsLogger.info(`\u2713 WebSocket endpoint registered at ${path}`, {
|
|
1443
|
+
events: router.eventNames,
|
|
1444
|
+
auth: !!authConfig?.enabled
|
|
1445
|
+
});
|
|
1446
|
+
return () => new Promise((resolve2, reject) => {
|
|
1447
|
+
for (const client of clients) {
|
|
1448
|
+
client.close(1001, "Server shutting down");
|
|
1449
|
+
}
|
|
1450
|
+
clients.clear();
|
|
1451
|
+
wss.close((err) => {
|
|
1452
|
+
if (err) reject(err);
|
|
1453
|
+
else resolve2();
|
|
1454
|
+
});
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
async function handleConnection(ws, req, router, authConfig, tokenManager, pingInterval) {
|
|
1458
|
+
let pingTimer;
|
|
1459
|
+
let connectionUnsubscribes = [];
|
|
1460
|
+
let subscribedEvents = [];
|
|
1461
|
+
ws.on("close", () => {
|
|
1462
|
+
clearInterval(pingTimer);
|
|
1463
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
1464
|
+
if (subscribedEvents.length > 0)
|
|
1465
|
+
wsLogger.info("WebSocket connection closed", { events: subscribedEvents });
|
|
1466
|
+
});
|
|
1467
|
+
const url = parseURL(req);
|
|
1468
|
+
if (!url) {
|
|
1469
|
+
ws.close(1002, "Invalid request URL");
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : void 0);
|
|
1473
|
+
if (subject === false) {
|
|
1474
|
+
ws.close(4001, "Missing token");
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (subject === null) {
|
|
1478
|
+
ws.close(4001, "Invalid or expired token");
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const requestedEvents = parseRequestedEvents2(url, router.eventNames);
|
|
1482
|
+
if (requestedEvents.length === 0) {
|
|
1483
|
+
ws.close(4e3, "No valid event names specified");
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);
|
|
1487
|
+
if (allowedEvents === null) {
|
|
1488
|
+
ws.close(4003, "Not authorized for any requested events");
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
subscribedEvents = allowedEvents;
|
|
1492
|
+
wsLogger.info("WebSocket connection established", {
|
|
1493
|
+
events: allowedEvents,
|
|
1494
|
+
subject: subject ?? void 0
|
|
1495
|
+
});
|
|
1496
|
+
const connection = createConnection(ws);
|
|
1497
|
+
connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig);
|
|
1498
|
+
if (ws.readyState !== 1) {
|
|
1499
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
1500
|
+
connectionUnsubscribes = [];
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
ws.on("message", (data) => {
|
|
1504
|
+
onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
|
|
1505
|
+
});
|
|
1506
|
+
if (pingInterval > 0) {
|
|
1507
|
+
pingTimer = setInterval(() => {
|
|
1508
|
+
if (ws.readyState === 1) ws.ping();
|
|
1509
|
+
}, pingInterval);
|
|
1510
|
+
}
|
|
1511
|
+
connection.send("__connected", {
|
|
1512
|
+
subscribedEvents: allowedEvents,
|
|
1513
|
+
timestamp: Date.now()
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
function parseURL(req) {
|
|
1517
|
+
try {
|
|
1518
|
+
return new URL(req.url ?? "/", "ws://localhost");
|
|
1519
|
+
} catch {
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
async function resolveSubject(url, tokenManager) {
|
|
1524
|
+
if (!tokenManager) {
|
|
1525
|
+
return void 0;
|
|
1526
|
+
}
|
|
1527
|
+
const token = url.searchParams.get("token");
|
|
1528
|
+
if (!token) {
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1531
|
+
return await tokenManager.verify(token);
|
|
1532
|
+
}
|
|
1533
|
+
function parseRequestedEvents2(url, validEventNames) {
|
|
1534
|
+
const eventsParam = url.searchParams.get("events");
|
|
1535
|
+
if (!eventsParam) {
|
|
1536
|
+
return [];
|
|
1537
|
+
}
|
|
1538
|
+
return eventsParam.split(",").map((e) => e.trim()).filter((e) => validEventNames.includes(e));
|
|
1539
|
+
}
|
|
1540
|
+
async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
|
|
1541
|
+
if (!subject || !authConfig?.authorize) {
|
|
1542
|
+
return requestedEvents;
|
|
1543
|
+
}
|
|
1544
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
1545
|
+
return allowed.length === 0 ? null : allowed;
|
|
1546
|
+
}
|
|
1547
|
+
function createConnection(ws) {
|
|
1548
|
+
return {
|
|
1549
|
+
send: (type, payload) => {
|
|
1550
|
+
if (ws.readyState !== 1) return;
|
|
1551
|
+
ws.send(JSON.stringify({ type, data: payload }));
|
|
1552
|
+
},
|
|
1553
|
+
close: (code, reason) => ws.close(code, reason)
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
function subscribeEvents(ws, router, allowedEvents, subject, authConfig) {
|
|
1557
|
+
const unsubscribes = [];
|
|
1558
|
+
for (const eventName of allowedEvents) {
|
|
1559
|
+
const eventDef = router.events[eventName];
|
|
1560
|
+
if (!eventDef) continue;
|
|
1561
|
+
const unsubscribe = eventDef.subscribe((payload) => {
|
|
1562
|
+
if (ws.readyState !== 1) return;
|
|
1563
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
1564
|
+
if (!authConfig.filter[eventName](subject, payload)) return;
|
|
1565
|
+
}
|
|
1566
|
+
try {
|
|
1567
|
+
ws.send(JSON.stringify({ type: eventName, data: payload }));
|
|
1568
|
+
} catch {
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
unsubscribes.push(unsubscribe);
|
|
1572
|
+
}
|
|
1573
|
+
return unsubscribes;
|
|
1574
|
+
}
|
|
1575
|
+
async function onClientMessage(data, router, connection, subject) {
|
|
1576
|
+
let message;
|
|
1577
|
+
try {
|
|
1578
|
+
message = JSON.parse(data.toString());
|
|
1579
|
+
} catch {
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const { type, data: payload } = message;
|
|
1583
|
+
if (!type) return;
|
|
1584
|
+
const handler = router.messages[type];
|
|
1585
|
+
if (!handler) return;
|
|
1586
|
+
try {
|
|
1587
|
+
await handler({ payload, subject, ws: connection });
|
|
1588
|
+
} catch (err) {
|
|
1589
|
+
wsLogger.error(`WebSocket message handler error: ${type}`, err);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
async function loadWSServer() {
|
|
1593
|
+
try {
|
|
1594
|
+
const mod = await import('ws');
|
|
1595
|
+
const WS = mod.default ?? mod;
|
|
1596
|
+
const WSS = WS.WebSocketServer ?? WS.Server;
|
|
1597
|
+
if (typeof WSS !== "function") {
|
|
1598
|
+
throw new Error(
|
|
1599
|
+
"WebSocketServer not found in ws module. Ensure ws@^8 is installed: pnpm add ws"
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
return WSS;
|
|
1603
|
+
} catch (err) {
|
|
1604
|
+
if (err instanceof Error && err.message.includes("WebSocketServer not found")) {
|
|
1605
|
+
throw err;
|
|
1606
|
+
}
|
|
1607
|
+
throw new Error(
|
|
1608
|
+
'@spfn/core WebSocket support requires the "ws" package.\nInstall it with: pnpm add ws'
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1416
1612
|
function getNetworkAddress() {
|
|
1417
1613
|
const nets = networkInterfaces();
|
|
1418
1614
|
for (const name of Object.keys(nets)) {
|
|
@@ -1519,6 +1715,10 @@ async function startServer(config) {
|
|
|
1519
1715
|
await initializeInfrastructure(finalConfig);
|
|
1520
1716
|
const app = await createServer(finalConfig);
|
|
1521
1717
|
const server = startHttpServer(app, host, port);
|
|
1718
|
+
let wsCleanup;
|
|
1719
|
+
if (finalConfig.websockets) {
|
|
1720
|
+
wsCleanup = await initializeWebSocket(server, app, finalConfig);
|
|
1721
|
+
}
|
|
1522
1722
|
const timeouts = getTimeoutConfig(finalConfig.timeout);
|
|
1523
1723
|
applyServerTimeouts(server, timeouts);
|
|
1524
1724
|
const fetchTimeouts = getFetchTimeoutConfig(finalConfig.fetchTimeout);
|
|
@@ -1530,7 +1730,7 @@ async function startServer(config) {
|
|
|
1530
1730
|
port
|
|
1531
1731
|
});
|
|
1532
1732
|
logServerStarted(debug, host, port, finalConfig, timeouts);
|
|
1533
|
-
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState);
|
|
1733
|
+
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState, wsCleanup);
|
|
1534
1734
|
const shutdown = createGracefulShutdown(shutdownServer, finalConfig, shutdownState);
|
|
1535
1735
|
registerProcessHandlers(shutdown);
|
|
1536
1736
|
const serverInstance = {
|
|
@@ -1653,6 +1853,46 @@ function startHttpServer(app, host, port) {
|
|
|
1653
1853
|
hostname: host
|
|
1654
1854
|
});
|
|
1655
1855
|
}
|
|
1856
|
+
async function initializeWebSocket(server, app, config) {
|
|
1857
|
+
const wsRouter = config.websockets;
|
|
1858
|
+
const wsConfig = config.websocketsConfig ?? {};
|
|
1859
|
+
const authConfig = wsConfig.auth;
|
|
1860
|
+
const wsPath = wsConfig.path ?? "/ws";
|
|
1861
|
+
const debug = config.debug ?? process.env.NODE_ENV === "development";
|
|
1862
|
+
let tokenManager;
|
|
1863
|
+
if (authConfig?.enabled) {
|
|
1864
|
+
let store = authConfig.store;
|
|
1865
|
+
if (!store) {
|
|
1866
|
+
try {
|
|
1867
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
1868
|
+
const cache = getCache2();
|
|
1869
|
+
if (cache) {
|
|
1870
|
+
store = new CacheTokenStore(cache);
|
|
1871
|
+
if (debug) serverLogger.info("WS token store: cache (Redis/Valkey)");
|
|
1872
|
+
}
|
|
1873
|
+
} catch {
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
1877
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
1878
|
+
ttl: authConfig.tokenTtl,
|
|
1879
|
+
store
|
|
1880
|
+
});
|
|
1881
|
+
const tokenPath = wsPath.replace(/\/[^/]+$/, "/token");
|
|
1882
|
+
const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
|
|
1883
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
1884
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
1885
|
+
const subject = getSubject(c);
|
|
1886
|
+
if (!subject) {
|
|
1887
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
1888
|
+
}
|
|
1889
|
+
const token = await tokenManager.issue(subject);
|
|
1890
|
+
return c.json({ token });
|
|
1891
|
+
});
|
|
1892
|
+
if (debug) serverLogger.info(`\u2713 WS token endpoint registered at POST ${tokenPath}`);
|
|
1893
|
+
}
|
|
1894
|
+
return await attachWSHandler(server, wsRouter, wsConfig, tokenManager);
|
|
1895
|
+
}
|
|
1656
1896
|
function logMiddlewareOrder(config) {
|
|
1657
1897
|
const middlewareOrder = buildMiddlewareOrder(config);
|
|
1658
1898
|
serverLogger.debug("Middleware execution order", {
|
|
@@ -1675,7 +1915,7 @@ function logServerStarted(debug, host, port, config, timeouts) {
|
|
|
1675
1915
|
config: startupConfig
|
|
1676
1916
|
});
|
|
1677
1917
|
}
|
|
1678
|
-
function createShutdownHandler(server, config, shutdownState) {
|
|
1918
|
+
function createShutdownHandler(server, config, shutdownState, wsCleanup) {
|
|
1679
1919
|
return async () => {
|
|
1680
1920
|
if (shutdownState.isShuttingDown) {
|
|
1681
1921
|
serverLogger.debug("Shutdown already in progress for this instance, skipping");
|
|
@@ -1687,6 +1927,15 @@ function createShutdownHandler(server, config, shutdownState) {
|
|
|
1687
1927
|
shutdownManager.beginShutdown();
|
|
1688
1928
|
serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
|
|
1689
1929
|
await closeHttpServer(server);
|
|
1930
|
+
if (wsCleanup) {
|
|
1931
|
+
serverLogger.info("Phase 1.5: Closing WebSocket server...");
|
|
1932
|
+
try {
|
|
1933
|
+
await wsCleanup();
|
|
1934
|
+
serverLogger.info("WebSocket server closed");
|
|
1935
|
+
} catch (error) {
|
|
1936
|
+
serverLogger.error("WebSocket server close failed", error);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1690
1939
|
if (config.jobs) {
|
|
1691
1940
|
serverLogger.info("Phase 2: Stopping pg-boss...");
|
|
1692
1941
|
try {
|
|
@@ -2033,6 +2282,40 @@ var ServerConfigBuilder = class {
|
|
|
2033
2282
|
}
|
|
2034
2283
|
return this;
|
|
2035
2284
|
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Register WebSocket router for bidirectional real-time communication
|
|
2287
|
+
*
|
|
2288
|
+
* Enables type-safe WebSocket connections with:
|
|
2289
|
+
* - Server→client event push (via defineEvent + emit)
|
|
2290
|
+
* - Client→server message handling (via messages in defineWSRouter)
|
|
2291
|
+
*
|
|
2292
|
+
* @example
|
|
2293
|
+
* ```typescript
|
|
2294
|
+
* // src/server/ws.ts
|
|
2295
|
+
* export const wsRouter = defineWSRouter({
|
|
2296
|
+
* events: { userUpdated, notification },
|
|
2297
|
+
* messages: {
|
|
2298
|
+
* ping: ({ ws }) => ws.send('pong', {}),
|
|
2299
|
+
* },
|
|
2300
|
+
* });
|
|
2301
|
+
*
|
|
2302
|
+
* // server.config.ts
|
|
2303
|
+
* export default defineServerConfig()
|
|
2304
|
+
* .websockets(wsRouter) // → WS /ws
|
|
2305
|
+
* .websockets(wsRouter, {
|
|
2306
|
+
* path: '/realtime', // custom path
|
|
2307
|
+
* auth: { enabled: true }, // token authentication
|
|
2308
|
+
* })
|
|
2309
|
+
* .build();
|
|
2310
|
+
* ```
|
|
2311
|
+
*/
|
|
2312
|
+
websockets(router, config) {
|
|
2313
|
+
this.config.websockets = router;
|
|
2314
|
+
if (config) {
|
|
2315
|
+
this.config.websocketsConfig = config;
|
|
2316
|
+
}
|
|
2317
|
+
return this;
|
|
2318
|
+
}
|
|
2036
2319
|
/**
|
|
2037
2320
|
* Enable/disable debug mode
|
|
2038
2321
|
*/
|