@wooksjs/event-ws 0.7.0
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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/index.cjs +725 -0
- package/dist/index.d.ts +335 -0
- package/dist/index.mjs +677 -0
- package/package.json +64 -0
- package/scripts/setup-skills.js +78 -0
- package/skills/wooksjs-event-ws/SKILL.md +47 -0
- package/skills/wooksjs-event-ws/composables.md +157 -0
- package/skills/wooksjs-event-ws/core.md +229 -0
- package/skills/wooksjs-event-ws/rooms.md +139 -0
- package/skills/wooksjs-event-ws/testing.md +150 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import { EventContext, current, defineEventKind, defineWook, key, routeParamsKey, run, slot, useLogger, useRouteParams, useRouteParams as useRouteParams$1 } from "@wooksjs/event-core";
|
|
2
|
+
import http from "http";
|
|
3
|
+
import { WooksAdapterBase } from "wooks";
|
|
4
|
+
import { WebSocketServer } from "ws";
|
|
5
|
+
|
|
6
|
+
//#region packages/event-ws/src/ws-kind.ts
|
|
7
|
+
/** Event kind for WebSocket connections (long-lived, one per client). */
|
|
8
|
+
const wsConnectionKind = defineEventKind("ws:connection", {
|
|
9
|
+
id: slot(),
|
|
10
|
+
ws: slot()
|
|
11
|
+
});
|
|
12
|
+
/** Event kind for WebSocket messages (short-lived, one per incoming message). */
|
|
13
|
+
const wsMessageKind = defineEventKind("ws:message", {
|
|
14
|
+
data: slot(),
|
|
15
|
+
rawMessage: slot(),
|
|
16
|
+
messageId: slot(),
|
|
17
|
+
messagePath: slot(),
|
|
18
|
+
messageEvent: slot()
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region packages/event-ws/src/composables/state.ts
|
|
23
|
+
let state;
|
|
24
|
+
/** Called by the WooksWs adapter to expose state to composables. */
|
|
25
|
+
function setAdapterState(s) {
|
|
26
|
+
state = s;
|
|
27
|
+
}
|
|
28
|
+
/** Called by composables to access adapter state. Throws if adapter not initialized. */
|
|
29
|
+
function getAdapterState() {
|
|
30
|
+
if (!state) throw new Error("[event-ws] No active WooksWs adapter");
|
|
31
|
+
return state;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region packages/event-ws/src/composables/connection.ts
|
|
36
|
+
/**
|
|
37
|
+
* Returns the connection `EventContext` from either connection-level or message-level handlers.
|
|
38
|
+
* Mirrors `current()` from `@wooksjs/event-core`.
|
|
39
|
+
*
|
|
40
|
+
* - In `onConnect` / `onDisconnect`: returns `current()` directly.
|
|
41
|
+
* - In `onMessage`: returns `current().parent` (the connection context).
|
|
42
|
+
*/
|
|
43
|
+
function currentConnection(ctx) {
|
|
44
|
+
const c = ctx ?? current();
|
|
45
|
+
return c.parent ?? c;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Provides access to the current WebSocket connection (id, send, close).
|
|
49
|
+
* Works in both connection and message contexts via parent chain traversal.
|
|
50
|
+
*/
|
|
51
|
+
const useWsConnection = defineWook((ctx) => {
|
|
52
|
+
const id = ctx.get(wsConnectionKind.keys.id);
|
|
53
|
+
const ws = ctx.get(wsConnectionKind.keys.ws);
|
|
54
|
+
const state$1 = getAdapterState();
|
|
55
|
+
const connection = state$1.connections.get(id);
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
send(event, path, data, params) {
|
|
59
|
+
connection.send(event, path, data, params);
|
|
60
|
+
},
|
|
61
|
+
close(code, reason) {
|
|
62
|
+
ws.close(code, reason);
|
|
63
|
+
},
|
|
64
|
+
context: currentConnection(ctx)
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region packages/event-ws/src/composables/message.ts
|
|
70
|
+
/**
|
|
71
|
+
* Provides access to the current WebSocket message data.
|
|
72
|
+
* Only available in message context (inside `onMessage` handlers).
|
|
73
|
+
*/
|
|
74
|
+
function useWsMessage(ctx) {
|
|
75
|
+
return useWsMessageInternal(ctx);
|
|
76
|
+
}
|
|
77
|
+
const useWsMessageInternal = defineWook((ctx) => {
|
|
78
|
+
return {
|
|
79
|
+
data: ctx.get(wsMessageKind.keys.data),
|
|
80
|
+
raw: ctx.get(wsMessageKind.keys.rawMessage),
|
|
81
|
+
id: ctx.get(wsMessageKind.keys.messageId),
|
|
82
|
+
path: ctx.get(wsMessageKind.keys.messagePath),
|
|
83
|
+
event: ctx.get(wsMessageKind.keys.messageEvent)
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region packages/event-ws/src/composables/rooms.ts
|
|
89
|
+
/**
|
|
90
|
+
* Provides room management for the current connection.
|
|
91
|
+
* All methods default to the current message path as the room.
|
|
92
|
+
* Only available in message context (inside `onMessage` handlers).
|
|
93
|
+
*/
|
|
94
|
+
const useWsRooms = defineWook((ctx) => {
|
|
95
|
+
const messagePath = ctx.get(wsMessageKind.keys.messagePath);
|
|
96
|
+
const connectionId = ctx.get(wsConnectionKind.keys.id);
|
|
97
|
+
const state$1 = getAdapterState();
|
|
98
|
+
const connection = state$1.connections.get(connectionId);
|
|
99
|
+
return {
|
|
100
|
+
join(room) {
|
|
101
|
+
state$1.roomManager.join(connection, room ?? messagePath);
|
|
102
|
+
},
|
|
103
|
+
leave(room) {
|
|
104
|
+
state$1.roomManager.leave(connection, room ?? messagePath);
|
|
105
|
+
},
|
|
106
|
+
broadcast(event, data, options) {
|
|
107
|
+
const room = options?.room ?? messagePath;
|
|
108
|
+
const excludeSelf = options?.excludeSelf ?? true;
|
|
109
|
+
let params;
|
|
110
|
+
try {
|
|
111
|
+
const rp = useRouteParams$1(ctx);
|
|
112
|
+
const p = rp.params;
|
|
113
|
+
if (Object.keys(p).length > 0) params = p;
|
|
114
|
+
} catch {}
|
|
115
|
+
state$1.roomManager.broadcast(room, event, room, data, params, excludeSelf ? connection : void 0);
|
|
116
|
+
},
|
|
117
|
+
rooms() {
|
|
118
|
+
return [...connection.rooms];
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region packages/event-ws/src/composables/server.ts
|
|
125
|
+
/**
|
|
126
|
+
* Provides server-wide operations. Available in any context.
|
|
127
|
+
* Not a `defineWook` — reads directly from the adapter state.
|
|
128
|
+
*/
|
|
129
|
+
function useWsServer() {
|
|
130
|
+
const state$1 = getAdapterState();
|
|
131
|
+
return {
|
|
132
|
+
connections() {
|
|
133
|
+
return state$1.connections;
|
|
134
|
+
},
|
|
135
|
+
broadcast(event, path, data, params) {
|
|
136
|
+
for (const conn of state$1.connections.values()) conn.send(event, path, data, params);
|
|
137
|
+
},
|
|
138
|
+
getConnection(id) {
|
|
139
|
+
return state$1.connections.get(id);
|
|
140
|
+
},
|
|
141
|
+
roomConnections(room) {
|
|
142
|
+
return state$1.roomManager.connections(room);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region packages/event-ws/src/adapters/ws-adapter-default.ts
|
|
149
|
+
/** Creates the default WsServerAdapter wrapping the `ws` package (peer dependency). */
|
|
150
|
+
function createDefaultWsServerAdapter() {
|
|
151
|
+
return { create() {
|
|
152
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
153
|
+
return {
|
|
154
|
+
handleUpgrade: (req, socket, head, cb) => {
|
|
155
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
156
|
+
cb(ws);
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
close: () => {
|
|
160
|
+
wss.close();
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
} };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region packages/event-ws/src/ws-connection.ts
|
|
168
|
+
/** Internal class representing a connected WebSocket client. */
|
|
169
|
+
var WsConnection = class {
|
|
170
|
+
rooms = /* @__PURE__ */ new Set();
|
|
171
|
+
alive = true;
|
|
172
|
+
constructor(id, ws, ctx, serializer) {
|
|
173
|
+
this.id = id;
|
|
174
|
+
this.ws = ws;
|
|
175
|
+
this.ctx = ctx;
|
|
176
|
+
this.serializer = serializer;
|
|
177
|
+
}
|
|
178
|
+
/** Send a push message to this connection. */
|
|
179
|
+
send(event, path, data, params) {
|
|
180
|
+
if (this.ws.readyState !== 1) return;
|
|
181
|
+
const msg = {
|
|
182
|
+
event,
|
|
183
|
+
path
|
|
184
|
+
};
|
|
185
|
+
if (params) msg.params = params;
|
|
186
|
+
if (data !== void 0) msg.data = data;
|
|
187
|
+
this.ws.send(this.serializer(msg));
|
|
188
|
+
}
|
|
189
|
+
/** Send a reply to a client request. */
|
|
190
|
+
reply(id, data) {
|
|
191
|
+
if (this.ws.readyState !== 1) return;
|
|
192
|
+
const msg = { id };
|
|
193
|
+
if (data !== void 0) msg.data = data;
|
|
194
|
+
this.ws.send(this.serializer(msg));
|
|
195
|
+
}
|
|
196
|
+
/** Send an error reply to a client request. */
|
|
197
|
+
replyError(id, code, message) {
|
|
198
|
+
if (this.ws.readyState !== 1) return;
|
|
199
|
+
const msg = {
|
|
200
|
+
id,
|
|
201
|
+
error: {
|
|
202
|
+
code,
|
|
203
|
+
message
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
this.ws.send(this.serializer(msg));
|
|
207
|
+
}
|
|
208
|
+
/** Close the connection. */
|
|
209
|
+
close(code, reason) {
|
|
210
|
+
this.ws.close(code, reason);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region packages/event-ws/src/ws-error.ts
|
|
216
|
+
/** WebSocket error with a numeric code following HTTP conventions (401, 403, 404, 500, etc.). */
|
|
217
|
+
var WsError = class extends Error {
|
|
218
|
+
constructor(code, message) {
|
|
219
|
+
super(message ?? `WsError ${code}`);
|
|
220
|
+
this.code = code;
|
|
221
|
+
this.name = "WsError";
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region packages/event-ws/src/ws-room-manager.ts
|
|
227
|
+
/** Manages room → connections mapping with optional distributed broadcast transport. */
|
|
228
|
+
var WsRoomManager = class {
|
|
229
|
+
rooms = /* @__PURE__ */ new Map();
|
|
230
|
+
constructor(transport) {
|
|
231
|
+
this.transport = transport;
|
|
232
|
+
if (transport) {}
|
|
233
|
+
}
|
|
234
|
+
/** Add a connection to a room. */
|
|
235
|
+
join(connection, room) {
|
|
236
|
+
connection.rooms.add(room);
|
|
237
|
+
let set = this.rooms.get(room);
|
|
238
|
+
if (!set) {
|
|
239
|
+
set = /* @__PURE__ */ new Set();
|
|
240
|
+
this.rooms.set(room, set);
|
|
241
|
+
if (this.transport) this.transport.subscribe(`ws:room:${room}`, (payload) => {
|
|
242
|
+
this.onTransportMessage(room, payload);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
set.add(connection);
|
|
246
|
+
}
|
|
247
|
+
/** Remove a connection from a room. */
|
|
248
|
+
leave(connection, room) {
|
|
249
|
+
connection.rooms.delete(room);
|
|
250
|
+
const set = this.rooms.get(room);
|
|
251
|
+
if (!set) return;
|
|
252
|
+
set.delete(connection);
|
|
253
|
+
if (set.size === 0) {
|
|
254
|
+
this.rooms.delete(room);
|
|
255
|
+
if (this.transport) this.transport.unsubscribe(`ws:room:${room}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Remove a connection from ALL rooms (called on disconnect). */
|
|
259
|
+
leaveAll(connection) {
|
|
260
|
+
for (const room of connection.rooms) {
|
|
261
|
+
const set = this.rooms.get(room);
|
|
262
|
+
if (set) {
|
|
263
|
+
set.delete(connection);
|
|
264
|
+
if (set.size === 0) {
|
|
265
|
+
this.rooms.delete(room);
|
|
266
|
+
if (this.transport) this.transport.unsubscribe(`ws:room:${room}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
connection.rooms.clear();
|
|
271
|
+
}
|
|
272
|
+
/** Get all connections in a room. */
|
|
273
|
+
connections(room) {
|
|
274
|
+
return this.rooms.get(room) ?? /* @__PURE__ */ new Set();
|
|
275
|
+
}
|
|
276
|
+
/** Broadcast to all connections in a room. */
|
|
277
|
+
broadcast(room, event, path, data, params, exclude) {
|
|
278
|
+
const set = this.rooms.get(room);
|
|
279
|
+
if (set) {
|
|
280
|
+
for (const conn of set) if (conn !== exclude) conn.send(event, path, data, params);
|
|
281
|
+
}
|
|
282
|
+
if (this.transport) {
|
|
283
|
+
const payload = JSON.stringify({
|
|
284
|
+
event,
|
|
285
|
+
path,
|
|
286
|
+
data,
|
|
287
|
+
params,
|
|
288
|
+
excludeId: exclude?.id
|
|
289
|
+
});
|
|
290
|
+
this.transport.publish(`ws:room:${room}`, payload);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Handle inbound message from broadcast transport (other instances). */
|
|
294
|
+
onTransportMessage(room, payload) {
|
|
295
|
+
const set = this.rooms.get(room);
|
|
296
|
+
if (!set || set.size === 0) return;
|
|
297
|
+
try {
|
|
298
|
+
const { event, path, data, params, excludeId } = JSON.parse(payload);
|
|
299
|
+
for (const conn of set) if (conn.id !== excludeId) conn.send(event, path, data, params);
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region packages/event-ws/src/ws-adapter.ts
|
|
306
|
+
const DEFAULT_HEARTBEAT_INTERVAL = 3e4;
|
|
307
|
+
const DEFAULT_MAX_MESSAGE_SIZE = 1024 * 1024;
|
|
308
|
+
const upgradeReqKey = key("ws:upgrade-req");
|
|
309
|
+
const upgradeSocketKey = key("ws:upgrade-socket");
|
|
310
|
+
const upgradeHeadKey = key("ws:upgrade-head");
|
|
311
|
+
/** WebSocket adapter for Wooks. Implements WooksUpgradeHandler for HTTP integration. */
|
|
312
|
+
var WooksWs = class extends WooksAdapterBase {
|
|
313
|
+
logger;
|
|
314
|
+
eventContextOptions;
|
|
315
|
+
connections = /* @__PURE__ */ new Map();
|
|
316
|
+
roomManager;
|
|
317
|
+
wsServer;
|
|
318
|
+
opts;
|
|
319
|
+
serializer;
|
|
320
|
+
parser;
|
|
321
|
+
onConnectHandler;
|
|
322
|
+
onDisconnectHandler;
|
|
323
|
+
heartbeatTimer;
|
|
324
|
+
server;
|
|
325
|
+
reqKey = upgradeReqKey;
|
|
326
|
+
socketKey = upgradeSocketKey;
|
|
327
|
+
headKey = upgradeHeadKey;
|
|
328
|
+
constructor(wooksOrOpts, opts) {
|
|
329
|
+
const isWooks = wooksOrOpts instanceof WooksAdapterBase || wooksOrOpts && "getRouter" in wooksOrOpts;
|
|
330
|
+
const wooks = isWooks ? wooksOrOpts : void 0;
|
|
331
|
+
const resolvedOpts = isWooks ? opts ?? {} : wooksOrOpts ?? {};
|
|
332
|
+
super(wooks, resolvedOpts.logger, void 0);
|
|
333
|
+
if (wooksOrOpts && typeof wooksOrOpts.ws === "function") wooksOrOpts.ws(this);
|
|
334
|
+
this.opts = resolvedOpts;
|
|
335
|
+
this.logger = resolvedOpts.logger || this.getLogger(`[96m[wooks-ws]`);
|
|
336
|
+
this.eventContextOptions = this.getEventContextOptions();
|
|
337
|
+
this.serializer = resolvedOpts.messageSerializer ?? JSON.stringify;
|
|
338
|
+
this.parser = resolvedOpts.messageParser ?? defaultParser;
|
|
339
|
+
this.roomManager = new WsRoomManager(resolvedOpts.broadcastTransport);
|
|
340
|
+
const adapter = resolvedOpts.wsServerAdapter ?? createDefaultWsServerAdapter();
|
|
341
|
+
this.wsServer = adapter.create();
|
|
342
|
+
setAdapterState({
|
|
343
|
+
connections: this.connections,
|
|
344
|
+
roomManager: this.roomManager,
|
|
345
|
+
serializer: this.serializer,
|
|
346
|
+
wooks: this.wooks
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/** Register a handler that runs when a new WebSocket connection is established. */
|
|
350
|
+
onConnect(handler) {
|
|
351
|
+
this.onConnectHandler = handler;
|
|
352
|
+
}
|
|
353
|
+
/** Register a handler that runs when a WebSocket connection closes. */
|
|
354
|
+
onDisconnect(handler) {
|
|
355
|
+
this.onDisconnectHandler = handler;
|
|
356
|
+
}
|
|
357
|
+
/** Register a routed message handler. Uses the standard Wooks router internally. */
|
|
358
|
+
onMessage(event, path, handler) {
|
|
359
|
+
return this.on(event, path, handler);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Complete the WebSocket handshake from inside an UPGRADE route handler.
|
|
363
|
+
* Reads req/socket/head from the current HTTP context (set by the HTTP adapter).
|
|
364
|
+
* The HTTP context becomes the parent of the WS connection context.
|
|
365
|
+
*/
|
|
366
|
+
upgrade() {
|
|
367
|
+
const httpCtx = current();
|
|
368
|
+
const req = httpCtx.get(upgradeReqKey);
|
|
369
|
+
const socket = httpCtx.get(upgradeSocketKey);
|
|
370
|
+
const head = httpCtx.get(upgradeHeadKey);
|
|
371
|
+
this.doUpgrade(req, socket, head, httpCtx);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Fallback: called by the HTTP adapter when no UPGRADE route matches.
|
|
375
|
+
* Also used internally for standalone mode.
|
|
376
|
+
*/
|
|
377
|
+
handleUpgrade(req, socket, head) {
|
|
378
|
+
this.doUpgrade(req, socket, head);
|
|
379
|
+
}
|
|
380
|
+
/** Start a standalone server (without event-http). */
|
|
381
|
+
async listen(port, hostname) {
|
|
382
|
+
const server = this.server = http.createServer();
|
|
383
|
+
server.on("upgrade", (req, socket, head) => {
|
|
384
|
+
this.doUpgrade(req, socket, head);
|
|
385
|
+
});
|
|
386
|
+
this.startHeartbeat();
|
|
387
|
+
return new Promise((resolve, reject) => {
|
|
388
|
+
server.once("listening", resolve);
|
|
389
|
+
server.once("error", reject);
|
|
390
|
+
if (hostname) server.listen(port, hostname);
|
|
391
|
+
else server.listen(port);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/** Stop the server and clean up. */
|
|
395
|
+
close() {
|
|
396
|
+
this.stopHeartbeat();
|
|
397
|
+
this.wsServer.close();
|
|
398
|
+
for (const conn of this.connections.values()) conn.close(1001, "Server shutting down");
|
|
399
|
+
this.connections.clear();
|
|
400
|
+
}
|
|
401
|
+
/** Returns the underlying HTTP server (if any). */
|
|
402
|
+
getServer() {
|
|
403
|
+
return this.server;
|
|
404
|
+
}
|
|
405
|
+
doUpgrade(req, socket, head, parentCtx) {
|
|
406
|
+
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
407
|
+
const id = crypto.randomUUID();
|
|
408
|
+
const ctxOptions = {
|
|
409
|
+
...this.eventContextOptions,
|
|
410
|
+
...parentCtx ? { parent: parentCtx } : {}
|
|
411
|
+
};
|
|
412
|
+
const connectionCtx = new EventContext(ctxOptions);
|
|
413
|
+
const connection = new WsConnection(id, ws, connectionCtx, this.serializer);
|
|
414
|
+
connectionCtx.seed(wsConnectionKind, {
|
|
415
|
+
id,
|
|
416
|
+
ws
|
|
417
|
+
});
|
|
418
|
+
try {
|
|
419
|
+
if (this.onConnectHandler) {
|
|
420
|
+
const result = run(connectionCtx, this.onConnectHandler);
|
|
421
|
+
if (result !== null && result !== void 0 && typeof result.then === "function") {
|
|
422
|
+
result.then(() => this.acceptConnection(connection)).catch((error) => this.rejectConnection(connection, error));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
this.acceptConnection(connection);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
this.rejectConnection(connection, error);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
acceptConnection(connection) {
|
|
433
|
+
this.connections.set(connection.id, connection);
|
|
434
|
+
this.logger.debug(`WS connected: ${connection.id}`);
|
|
435
|
+
const ws = connection.ws;
|
|
436
|
+
ws.on("message", (raw) => {
|
|
437
|
+
this.handleMessage(connection, raw);
|
|
438
|
+
});
|
|
439
|
+
ws.on("close", () => {
|
|
440
|
+
this.handleClose(connection);
|
|
441
|
+
});
|
|
442
|
+
ws.on("error", (err) => {
|
|
443
|
+
this.logger.error(`WS error [${connection.id}]:`, err);
|
|
444
|
+
});
|
|
445
|
+
ws.on("pong", () => {
|
|
446
|
+
connection.alive = true;
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
rejectConnection(connection, error) {
|
|
450
|
+
const code = error instanceof WsError ? error.code : 500;
|
|
451
|
+
const message = error instanceof Error ? error.message : "Connection rejected";
|
|
452
|
+
this.logger.debug(`WS rejected: ${message}`);
|
|
453
|
+
const wsCloseCode = code === 401 || code === 403 ? 1008 : 1011;
|
|
454
|
+
connection.close(wsCloseCode, message);
|
|
455
|
+
}
|
|
456
|
+
handleMessage(connection, raw) {
|
|
457
|
+
const maxSize = this.opts.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE;
|
|
458
|
+
const size = typeof raw === "string" ? Buffer.byteLength(raw) : raw.length;
|
|
459
|
+
if (size > maxSize) {
|
|
460
|
+
this.logger.debug(`WS message too large (${size} > ${maxSize}), dropping`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
let msg;
|
|
464
|
+
try {
|
|
465
|
+
msg = this.parser(raw);
|
|
466
|
+
} catch {
|
|
467
|
+
this.logger.debug("WS message parse failed, dropping");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const { event, path, data, id: messageId } = msg;
|
|
471
|
+
const msgCtx = new EventContext({
|
|
472
|
+
...this.eventContextOptions,
|
|
473
|
+
parent: connection.ctx
|
|
474
|
+
});
|
|
475
|
+
msgCtx.seed(wsMessageKind, {
|
|
476
|
+
data,
|
|
477
|
+
rawMessage: raw,
|
|
478
|
+
messageId,
|
|
479
|
+
messagePath: path,
|
|
480
|
+
messageEvent: event
|
|
481
|
+
});
|
|
482
|
+
run(msgCtx, () => {
|
|
483
|
+
const handlers = this.wooks.lookupHandlers(event, path, msgCtx);
|
|
484
|
+
if (!handlers) {
|
|
485
|
+
if (messageId !== void 0) connection.replyError(messageId, 404, "Not found");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const result = this.processHandlers(handlers, connection, messageId);
|
|
489
|
+
if (result !== null && result !== void 0 && typeof result.then === "function") result.catch((error) => {
|
|
490
|
+
this.handleHandlerError(error, connection, messageId);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
processHandlers(handlers, connection, messageId) {
|
|
495
|
+
for (let i = 0; i < handlers.length; i++) {
|
|
496
|
+
const handler = handlers[i];
|
|
497
|
+
const isLast = i === handlers.length - 1;
|
|
498
|
+
try {
|
|
499
|
+
const result = handler();
|
|
500
|
+
if (result !== null && result !== void 0 && typeof result.then === "function") return this.processAsyncResult(result, handlers, i, connection, messageId);
|
|
501
|
+
this.sendReply(connection, messageId, result);
|
|
502
|
+
return;
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (isLast) {
|
|
505
|
+
this.handleHandlerError(error, connection, messageId);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async processAsyncResult(promise, handlers, startIndex, connection, messageId) {
|
|
512
|
+
try {
|
|
513
|
+
const result = await promise;
|
|
514
|
+
this.sendReply(connection, messageId, result);
|
|
515
|
+
return;
|
|
516
|
+
} catch (error) {
|
|
517
|
+
if (startIndex === handlers.length - 1) {
|
|
518
|
+
this.handleHandlerError(error, connection, messageId);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
for (let i = startIndex + 1; i < handlers.length; i++) {
|
|
523
|
+
const isLast = i === handlers.length - 1;
|
|
524
|
+
try {
|
|
525
|
+
const result = await handlers[i]();
|
|
526
|
+
this.sendReply(connection, messageId, result);
|
|
527
|
+
return;
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (isLast) {
|
|
530
|
+
this.handleHandlerError(error, connection, messageId);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
sendReply(connection, messageId, data) {
|
|
537
|
+
if (messageId === void 0) return;
|
|
538
|
+
connection.reply(messageId, data ?? null);
|
|
539
|
+
}
|
|
540
|
+
handleHandlerError(error, connection, messageId) {
|
|
541
|
+
const code = error instanceof WsError ? error.code : 500;
|
|
542
|
+
const message = error instanceof WsError ? error.message : "Internal Error";
|
|
543
|
+
if (messageId !== void 0) connection.replyError(messageId, code, message);
|
|
544
|
+
if (!(error instanceof WsError)) this.logger.error("Uncaught WS handler error:", error);
|
|
545
|
+
}
|
|
546
|
+
handleClose(connection) {
|
|
547
|
+
this.logger.debug(`WS disconnected: ${connection.id}`);
|
|
548
|
+
if (this.onDisconnectHandler) try {
|
|
549
|
+
const result = run(connection.ctx, this.onDisconnectHandler);
|
|
550
|
+
if (result !== null && result !== void 0 && typeof result.then === "function") result.catch((error) => {
|
|
551
|
+
this.logger.error("onDisconnect error:", error);
|
|
552
|
+
});
|
|
553
|
+
} catch (error) {
|
|
554
|
+
this.logger.error("onDisconnect error:", error);
|
|
555
|
+
}
|
|
556
|
+
this.roomManager.leaveAll(connection);
|
|
557
|
+
this.connections.delete(connection.id);
|
|
558
|
+
}
|
|
559
|
+
startHeartbeat() {
|
|
560
|
+
const interval = this.opts.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
|
|
561
|
+
if (interval === 0) return;
|
|
562
|
+
this.heartbeatTimer = setInterval(() => {
|
|
563
|
+
for (const conn of this.connections.values()) {
|
|
564
|
+
if (!conn.alive) {
|
|
565
|
+
this.logger.debug(`WS heartbeat timeout: ${conn.id}`);
|
|
566
|
+
conn.ws.close(1001, "Heartbeat timeout");
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
conn.alive = false;
|
|
570
|
+
conn.ws.ping();
|
|
571
|
+
}
|
|
572
|
+
}, interval);
|
|
573
|
+
if (this.heartbeatTimer.unref) this.heartbeatTimer.unref();
|
|
574
|
+
}
|
|
575
|
+
stopHeartbeat() {
|
|
576
|
+
if (this.heartbeatTimer) {
|
|
577
|
+
clearInterval(this.heartbeatTimer);
|
|
578
|
+
this.heartbeatTimer = void 0;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
function defaultParser(raw) {
|
|
583
|
+
const str = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
584
|
+
const parsed = JSON.parse(str);
|
|
585
|
+
if (typeof parsed.event !== "string" || typeof parsed.path !== "string") throw new TypeError("Invalid WS message: missing event or path");
|
|
586
|
+
return parsed;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Creates a new WooksWs WebSocket adapter.
|
|
590
|
+
*
|
|
591
|
+
* @example Integrated with HTTP (recommended):
|
|
592
|
+
* ```ts
|
|
593
|
+
* const http = createHttpApp()
|
|
594
|
+
* const ws = createWsApp(http) // auto-registers upgrade contract
|
|
595
|
+
* http.upgrade('/ws', () => ws.upgrade())
|
|
596
|
+
* http.listen(3000)
|
|
597
|
+
* ```
|
|
598
|
+
*
|
|
599
|
+
* @example Standalone:
|
|
600
|
+
* ```ts
|
|
601
|
+
* const ws = createWsApp({ heartbeatInterval: 30_000 })
|
|
602
|
+
* ws.listen(3000)
|
|
603
|
+
* ```
|
|
604
|
+
*/
|
|
605
|
+
function createWsApp(wooksOrOpts, opts) {
|
|
606
|
+
return new WooksWs(wooksOrOpts, opts);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
//#endregion
|
|
610
|
+
//#region packages/event-ws/src/testing.ts
|
|
611
|
+
/** Creates a mock WsSocket for testing. */
|
|
612
|
+
function createMockWsSocket() {
|
|
613
|
+
return {
|
|
614
|
+
send: () => {},
|
|
615
|
+
close: () => {},
|
|
616
|
+
on: () => {},
|
|
617
|
+
ping: () => {},
|
|
618
|
+
readyState: 1
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Creates a fully initialized WS connection context for testing.
|
|
623
|
+
* Returns a runner function that executes callbacks inside the context scope.
|
|
624
|
+
*/
|
|
625
|
+
function prepareTestWsConnectionContext(options) {
|
|
626
|
+
const id = options?.id ?? "test-conn-id";
|
|
627
|
+
const ws = createMockWsSocket();
|
|
628
|
+
const ctx = new EventContext({
|
|
629
|
+
logger: console,
|
|
630
|
+
...options?.parentCtx ? { parent: options.parentCtx } : {}
|
|
631
|
+
});
|
|
632
|
+
ctx.seed(wsConnectionKind, {
|
|
633
|
+
id,
|
|
634
|
+
ws
|
|
635
|
+
});
|
|
636
|
+
if (options?.params) ctx.set(routeParamsKey, options.params);
|
|
637
|
+
return (cb) => run(ctx, cb);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Creates a fully initialized WS message context for testing.
|
|
641
|
+
* Sets up both connection context (parent) and message context (child).
|
|
642
|
+
* Returns a runner function that executes callbacks inside the message context scope.
|
|
643
|
+
*/
|
|
644
|
+
function prepareTestWsMessageContext(options) {
|
|
645
|
+
const id = options.id ?? "test-conn-id";
|
|
646
|
+
const ws = createMockWsSocket();
|
|
647
|
+
const connectionCtx = new EventContext({
|
|
648
|
+
logger: console,
|
|
649
|
+
...options.parentCtx ? { parent: options.parentCtx } : {}
|
|
650
|
+
});
|
|
651
|
+
connectionCtx.seed(wsConnectionKind, {
|
|
652
|
+
id,
|
|
653
|
+
ws
|
|
654
|
+
});
|
|
655
|
+
const messageCtx = new EventContext({
|
|
656
|
+
logger: console,
|
|
657
|
+
parent: connectionCtx
|
|
658
|
+
});
|
|
659
|
+
const rawMessage = options.rawMessage ?? JSON.stringify({
|
|
660
|
+
event: options.event,
|
|
661
|
+
path: options.path,
|
|
662
|
+
data: options.data,
|
|
663
|
+
id: options.messageId
|
|
664
|
+
});
|
|
665
|
+
messageCtx.seed(wsMessageKind, {
|
|
666
|
+
data: options.data,
|
|
667
|
+
rawMessage,
|
|
668
|
+
messageId: options.messageId,
|
|
669
|
+
messagePath: options.path,
|
|
670
|
+
messageEvent: options.event
|
|
671
|
+
});
|
|
672
|
+
if (options.params) messageCtx.set(routeParamsKey, options.params);
|
|
673
|
+
return (cb) => run(messageCtx, cb);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
//#endregion
|
|
677
|
+
export { WooksWs, WsConnection, WsError, WsRoomManager, createWsApp, currentConnection, prepareTestWsConnectionContext, prepareTestWsMessageContext, useLogger, useRouteParams, useWsConnection, useWsMessage, useWsRooms, useWsServer, wsConnectionKind, wsMessageKind };
|