@jgengine/ws 0.10.0 → 0.12.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/CHANGELOG.md +39 -2
- package/LICENSE +203 -661
- package/README.md +1 -1
- package/dist/commandMiddleware.d.ts +62 -0
- package/dist/commandMiddleware.js +66 -0
- package/dist/createWsBackend.js +13 -1
- package/dist/host.d.ts +3 -0
- package/dist/host.js +173 -101
- package/dist/hostRouter.d.ts +9 -0
- package/dist/hostRouter.js +178 -116
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pipe.js +2 -1
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.js +51 -5
- package/dist/readsHandler.js +2 -1
- package/dist/worldHost.js +2 -2
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +54 -0
- package/skills/jgengine/api.md +527 -0
- package/skills/jgengine/capabilities.md +131 -0
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-multiplayer/SKILL.md +47 -0
- package/skills/jgengine-multiplayer/api.md +620 -0
- package/skills/jgengine-multiplayer/capabilities.md +17 -0
- package/skills/jgengine-multiplayer/reference.md +71 -0
- package/llms.txt +0 -1061
package/dist/hostRouter.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import { decidePoseSync, spawnPresenceState } from "@jgengine/core/multiplayer/presenceModel";
|
|
1
|
+
import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
2
2
|
import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
|
|
3
3
|
import { createChatRateLimiter, DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, } from "@jgengine/core/game/chat";
|
|
4
|
+
import { createCommandMiddleware, } from "./commandMiddleware.js";
|
|
4
5
|
import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
|
|
5
|
-
export const DEFAULT_POSE_RULES =
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
minElapsedSec: 0.05,
|
|
9
|
-
maxElapsedSec: 0.5,
|
|
10
|
-
keepAliveRefreshMs: 10_000,
|
|
11
|
-
};
|
|
6
|
+
export const DEFAULT_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
|
|
7
|
+
/** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
|
|
8
|
+
export const MAX_QUEUED_MESSAGES = 64;
|
|
12
9
|
function appearanceEqual(a, b) {
|
|
13
10
|
if (a === b)
|
|
14
11
|
return true;
|
|
@@ -20,6 +17,29 @@ function appearanceEqual(a, b) {
|
|
|
20
17
|
return false;
|
|
21
18
|
return aKeys.every((key) => a[key] === b[key]);
|
|
22
19
|
}
|
|
20
|
+
function getOrCreate(map, key, factory) {
|
|
21
|
+
let value = map.get(key);
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
value = factory();
|
|
24
|
+
map.set(key, value);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Remove `userId` from every visited `server|scope` bucket, drop buckets that empty out, and notify
|
|
30
|
+
* per changed key. Backs both the disconnect-wide and per-server drops for presence and voice.
|
|
31
|
+
*/
|
|
32
|
+
function dropUserFrom(store, userId, shouldVisit, onChanged) {
|
|
33
|
+
for (const [key, inner] of store) {
|
|
34
|
+
if (!shouldVisit(key))
|
|
35
|
+
continue;
|
|
36
|
+
if (!inner.delete(userId))
|
|
37
|
+
continue;
|
|
38
|
+
if (inner.size === 0)
|
|
39
|
+
store.delete(key);
|
|
40
|
+
onChanged(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
23
43
|
export function createHostRouter(options) {
|
|
24
44
|
const host = options.host;
|
|
25
45
|
const now = options.now ?? Date.now;
|
|
@@ -30,17 +50,33 @@ export function createHostRouter(options) {
|
|
|
30
50
|
(allowAnonymous ? ({ userId }) => userId : null);
|
|
31
51
|
const connections = new Set();
|
|
32
52
|
const sessionsByUserId = new Map();
|
|
53
|
+
const subscribers = new Map();
|
|
54
|
+
const subscribersOf = (key) => {
|
|
55
|
+
const set = subscribers.get(key);
|
|
56
|
+
return set === undefined ? [] : [...set];
|
|
57
|
+
};
|
|
58
|
+
const addSubscription = (connection, key) => {
|
|
59
|
+
connection.subscriptions.add(key);
|
|
60
|
+
let set = subscribers.get(key);
|
|
61
|
+
if (set === undefined) {
|
|
62
|
+
set = new Set();
|
|
63
|
+
subscribers.set(key, set);
|
|
64
|
+
}
|
|
65
|
+
set.add(connection);
|
|
66
|
+
};
|
|
67
|
+
const removeSubscription = (connection, key) => {
|
|
68
|
+
connection.subscriptions.delete(key);
|
|
69
|
+
const set = subscribers.get(key);
|
|
70
|
+
if (set === undefined)
|
|
71
|
+
return;
|
|
72
|
+
set.delete(connection);
|
|
73
|
+
if (set.size === 0)
|
|
74
|
+
subscribers.delete(key);
|
|
75
|
+
};
|
|
33
76
|
const presence = new Map();
|
|
34
77
|
const positionHistoryMs = options.positionHistoryMs ?? 1_000;
|
|
35
78
|
const histories = new Map();
|
|
36
|
-
const historyFor = (serverId) => {
|
|
37
|
-
let history = histories.get(serverId);
|
|
38
|
-
if (history === undefined) {
|
|
39
|
-
history = createPositionHistory({ historyMs: positionHistoryMs });
|
|
40
|
-
histories.set(serverId, history);
|
|
41
|
-
}
|
|
42
|
-
return history;
|
|
43
|
-
};
|
|
79
|
+
const historyFor = (serverId) => getOrCreate(histories, serverId, () => createPositionHistory({ historyMs: positionHistoryMs }));
|
|
44
80
|
const send = (connection, message) => {
|
|
45
81
|
connection.transport.send(encodeWsMessage(message));
|
|
46
82
|
};
|
|
@@ -63,81 +99,78 @@ export function createHostRouter(options) {
|
|
|
63
99
|
appearance: pose.appearance,
|
|
64
100
|
}));
|
|
65
101
|
};
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
send(connection,
|
|
102
|
+
const broadcast = (key, build) => {
|
|
103
|
+
const targets = subscribersOf(key);
|
|
104
|
+
if (targets.length === 0)
|
|
105
|
+
return;
|
|
106
|
+
const message = build();
|
|
107
|
+
for (const connection of targets)
|
|
108
|
+
send(connection, message);
|
|
109
|
+
};
|
|
110
|
+
const broadcastPresence = (serverId) => broadcast(subscriptionKey("presence", serverId), () => ({
|
|
111
|
+
v: 1,
|
|
112
|
+
t: "update",
|
|
113
|
+
channel: "presence",
|
|
114
|
+
serverId,
|
|
115
|
+
data: presenceRows(serverId),
|
|
116
|
+
}));
|
|
117
|
+
const commandMiddleware = createCommandMiddleware({
|
|
118
|
+
limits: options.limits,
|
|
119
|
+
authorize: options.authorize,
|
|
120
|
+
validate: options.validate,
|
|
121
|
+
});
|
|
122
|
+
const gate = async (connection, id, op, args = {}) => {
|
|
123
|
+
if (connection.userId === null)
|
|
124
|
+
return true;
|
|
125
|
+
const decision = await commandMiddleware.check({
|
|
126
|
+
connection,
|
|
127
|
+
userId: connection.userId,
|
|
128
|
+
op,
|
|
129
|
+
atMs: now(),
|
|
130
|
+
...args,
|
|
131
|
+
});
|
|
132
|
+
if (!decision.allow) {
|
|
133
|
+
replyError(connection, id, decision.reason);
|
|
134
|
+
return false;
|
|
73
135
|
}
|
|
136
|
+
return true;
|
|
74
137
|
};
|
|
75
138
|
const chatRings = new Map();
|
|
76
139
|
const chatLimiter = createChatRateLimiter(options.chatRateLimit ?? DEFAULT_CHAT_RATE_LIMIT);
|
|
77
140
|
const chatHistoryLimit = options.chatHistoryLimit ?? DEFAULT_CHAT_HISTORY_LIMIT;
|
|
78
141
|
const chatMaxBodyLength = options.chatMaxBodyLength ?? DEFAULT_CHAT_BODY_LENGTH;
|
|
79
142
|
let chatCounter = 0;
|
|
80
|
-
const chatRing = (serverId, channelId) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
};
|
|
89
|
-
const broadcastChat = (serverId, channelId) => {
|
|
90
|
-
const key = subscriptionKey("chat", serverId, channelId);
|
|
91
|
-
const data = chatRing(serverId, channelId).slice();
|
|
92
|
-
for (const connection of connections) {
|
|
93
|
-
if (!connection.subscriptions.has(key))
|
|
94
|
-
continue;
|
|
95
|
-
send(connection, { v: 1, t: "update", channel: "chat", serverId, action: channelId, data });
|
|
96
|
-
}
|
|
97
|
-
};
|
|
143
|
+
const chatRing = (serverId, channelId) => getOrCreate(chatRings, `${serverId}|${channelId}`, () => []);
|
|
144
|
+
const broadcastChat = (serverId, channelId) => broadcast(subscriptionKey("chat", serverId, channelId), () => ({
|
|
145
|
+
v: 1,
|
|
146
|
+
t: "update",
|
|
147
|
+
channel: "chat",
|
|
148
|
+
serverId,
|
|
149
|
+
action: channelId,
|
|
150
|
+
data: chatRing(serverId, channelId).slice(),
|
|
151
|
+
}));
|
|
98
152
|
const voiceRosters = new Map();
|
|
99
|
-
const voiceRoster = (serverId, channelId) => {
|
|
100
|
-
const key = `${serverId}|${channelId}`;
|
|
101
|
-
let roster = voiceRosters.get(key);
|
|
102
|
-
if (roster === undefined) {
|
|
103
|
-
roster = new Map();
|
|
104
|
-
voiceRosters.set(key, roster);
|
|
105
|
-
}
|
|
106
|
-
return roster;
|
|
107
|
-
};
|
|
153
|
+
const voiceRoster = (serverId, channelId) => getOrCreate(voiceRosters, `${serverId}|${channelId}`, () => new Map());
|
|
108
154
|
const voiceParticipants = (serverId, channelId) => [...voiceRoster(serverId, channelId).values()].map((participant) => ({ ...participant }));
|
|
109
|
-
const broadcastVoice = (serverId, channelId) => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
};
|
|
155
|
+
const broadcastVoice = (serverId, channelId) => broadcast(subscriptionKey("voice", serverId, channelId), () => ({
|
|
156
|
+
v: 1,
|
|
157
|
+
t: "update",
|
|
158
|
+
channel: "voice",
|
|
159
|
+
serverId,
|
|
160
|
+
action: channelId,
|
|
161
|
+
data: voiceParticipants(serverId, channelId),
|
|
162
|
+
}));
|
|
118
163
|
const dropVoice = (connection) => {
|
|
119
164
|
if (connection.userId === null)
|
|
120
165
|
return;
|
|
121
|
-
|
|
122
|
-
if (!roster.delete(connection.userId))
|
|
123
|
-
continue;
|
|
124
|
-
if (roster.size === 0)
|
|
125
|
-
voiceRosters.delete(key);
|
|
166
|
+
dropUserFrom(voiceRosters, connection.userId, () => true, (key) => {
|
|
126
167
|
const [serverId, channelId] = key.split("|");
|
|
127
168
|
broadcastVoice(serverId, channelId);
|
|
128
|
-
}
|
|
169
|
+
});
|
|
129
170
|
};
|
|
130
171
|
const dropVoiceForServer = (userId, serverId) => {
|
|
131
172
|
const prefix = `${serverId}|`;
|
|
132
|
-
|
|
133
|
-
if (!key.startsWith(prefix))
|
|
134
|
-
continue;
|
|
135
|
-
if (!roster.delete(userId))
|
|
136
|
-
continue;
|
|
137
|
-
if (roster.size === 0)
|
|
138
|
-
voiceRosters.delete(key);
|
|
139
|
-
broadcastVoice(serverId, key.slice(prefix.length));
|
|
140
|
-
}
|
|
173
|
+
dropUserFrom(voiceRosters, userId, (key) => key.startsWith(prefix), (key) => broadcastVoice(serverId, key.slice(prefix.length)));
|
|
141
174
|
};
|
|
142
175
|
const requireMembership = async (connection, id, serverId) => {
|
|
143
176
|
const userId = connection.userId;
|
|
@@ -226,24 +259,25 @@ export function createHostRouter(options) {
|
|
|
226
259
|
}
|
|
227
260
|
};
|
|
228
261
|
const onHostEvent = (event) => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
void pushSubscription(connection, "server", event.serverId);
|
|
235
|
-
}
|
|
262
|
+
if (event.type === "server") {
|
|
263
|
+
for (const connection of subscribersOf(subscriptionKey("server", event.serverId))) {
|
|
264
|
+
if (connection.userId === null)
|
|
265
|
+
continue;
|
|
266
|
+
void pushSubscription(connection, "server", event.serverId);
|
|
236
267
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
268
|
+
}
|
|
269
|
+
else if (event.type === "player") {
|
|
270
|
+
for (const connection of subscribersOf(subscriptionKey("player", event.serverId))) {
|
|
271
|
+
if (connection.userId === event.userId) {
|
|
240
272
|
void pushSubscription(connection, "player", event.serverId);
|
|
241
273
|
}
|
|
242
274
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
for (const connection of subscribersOf(subscriptionKey("feed", event.serverId, event.action))) {
|
|
278
|
+
if (connection.userId === null)
|
|
279
|
+
continue;
|
|
280
|
+
void pushSubscription(connection, "feed", event.serverId, event.action);
|
|
247
281
|
}
|
|
248
282
|
}
|
|
249
283
|
};
|
|
@@ -251,6 +285,15 @@ export function createHostRouter(options) {
|
|
|
251
285
|
const handlePose = async (connection, serverId, pose) => {
|
|
252
286
|
if (connection.userId === null)
|
|
253
287
|
return;
|
|
288
|
+
const gateDecision = await commandMiddleware.check({
|
|
289
|
+
connection,
|
|
290
|
+
userId: connection.userId,
|
|
291
|
+
op: "pose",
|
|
292
|
+
atMs: now(),
|
|
293
|
+
serverId,
|
|
294
|
+
});
|
|
295
|
+
if (!gateDecision.allow)
|
|
296
|
+
return;
|
|
254
297
|
if (!(await host.isMember({ userId: connection.userId, serverId })))
|
|
255
298
|
return;
|
|
256
299
|
const rows = presence.get(serverId) ?? new Map();
|
|
@@ -277,21 +320,10 @@ export function createHostRouter(options) {
|
|
|
277
320
|
const dropPresence = (connection) => {
|
|
278
321
|
if (connection.userId === null)
|
|
279
322
|
return;
|
|
280
|
-
|
|
281
|
-
if (rows.delete(connection.userId)) {
|
|
282
|
-
if (rows.size === 0)
|
|
283
|
-
presence.delete(serverId);
|
|
284
|
-
broadcastPresence(serverId);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
323
|
+
dropUserFrom(presence, connection.userId, () => true, (serverId) => broadcastPresence(serverId));
|
|
287
324
|
};
|
|
288
325
|
const dropPresenceForServer = (userId, serverId) => {
|
|
289
|
-
|
|
290
|
-
if (rows === undefined || !rows.delete(userId))
|
|
291
|
-
return;
|
|
292
|
-
if (rows.size === 0)
|
|
293
|
-
presence.delete(serverId);
|
|
294
|
-
broadcastPresence(serverId);
|
|
326
|
+
dropUserFrom(presence, userId, (key) => key === serverId, () => broadcastPresence(serverId));
|
|
295
327
|
};
|
|
296
328
|
const handleMessage = async (connection, message) => {
|
|
297
329
|
if (message.t === "hello") {
|
|
@@ -335,17 +367,22 @@ export function createHostRouter(options) {
|
|
|
335
367
|
try {
|
|
336
368
|
switch (message.t) {
|
|
337
369
|
case "join": {
|
|
370
|
+
if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
|
|
371
|
+
return;
|
|
338
372
|
const result = await host.joinServer({
|
|
339
373
|
userId,
|
|
340
374
|
gameId: message.gameId,
|
|
341
375
|
serverId: message.serverId,
|
|
342
376
|
attributes: message.attributes,
|
|
377
|
+
code: message.code,
|
|
343
378
|
});
|
|
344
379
|
connection.joinedServers.add(result.serverId);
|
|
345
380
|
reply(connection, message.id, result);
|
|
346
381
|
return;
|
|
347
382
|
}
|
|
348
383
|
case "joinByCode": {
|
|
384
|
+
if (!(await gate(connection, message.id, "join", {})))
|
|
385
|
+
return;
|
|
349
386
|
const result = await host.joinByCode({
|
|
350
387
|
userId,
|
|
351
388
|
gameId: message.gameId,
|
|
@@ -357,6 +394,8 @@ export function createHostRouter(options) {
|
|
|
357
394
|
return;
|
|
358
395
|
}
|
|
359
396
|
case "browse": {
|
|
397
|
+
if (!(await gate(connection, message.id, "browse", {})))
|
|
398
|
+
return;
|
|
360
399
|
const result = await host.browseServers({
|
|
361
400
|
gameId: message.gameId,
|
|
362
401
|
filter: message.filter,
|
|
@@ -374,6 +413,12 @@ export function createHostRouter(options) {
|
|
|
374
413
|
return;
|
|
375
414
|
}
|
|
376
415
|
case "runCommand": {
|
|
416
|
+
if (!(await gate(connection, message.id, "runCommand", {
|
|
417
|
+
serverId: message.serverId,
|
|
418
|
+
command: message.command,
|
|
419
|
+
input: message.input,
|
|
420
|
+
})))
|
|
421
|
+
return;
|
|
377
422
|
const result = await host.runCommand({
|
|
378
423
|
userId,
|
|
379
424
|
serverId: message.serverId,
|
|
@@ -398,6 +443,8 @@ export function createHostRouter(options) {
|
|
|
398
443
|
return;
|
|
399
444
|
}
|
|
400
445
|
case "voiceJoin": {
|
|
446
|
+
if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
|
|
447
|
+
return;
|
|
401
448
|
if ((await requireMembership(connection, message.id, message.serverId)) === null)
|
|
402
449
|
return;
|
|
403
450
|
const participant = { userId };
|
|
@@ -409,6 +456,8 @@ export function createHostRouter(options) {
|
|
|
409
456
|
return;
|
|
410
457
|
}
|
|
411
458
|
case "voiceLeave": {
|
|
459
|
+
if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
|
|
460
|
+
return;
|
|
412
461
|
if ((await requireMembership(connection, message.id, message.serverId)) === null)
|
|
413
462
|
return;
|
|
414
463
|
const roster = voiceRoster(message.serverId, message.channelId);
|
|
@@ -419,6 +468,8 @@ export function createHostRouter(options) {
|
|
|
419
468
|
return;
|
|
420
469
|
}
|
|
421
470
|
case "voicePublish": {
|
|
471
|
+
if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
|
|
472
|
+
return;
|
|
422
473
|
if ((await requireMembership(connection, message.id, message.serverId)) === null)
|
|
423
474
|
return;
|
|
424
475
|
const participant = voiceRoster(message.serverId, message.channelId).get(userId);
|
|
@@ -439,13 +490,13 @@ export function createHostRouter(options) {
|
|
|
439
490
|
replyError(connection, message.id, "Not a member of this server");
|
|
440
491
|
return;
|
|
441
492
|
}
|
|
442
|
-
connection
|
|
493
|
+
addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
|
|
443
494
|
reply(connection, message.id, null);
|
|
444
495
|
await pushSubscription(connection, message.channel, message.serverId, message.action);
|
|
445
496
|
return;
|
|
446
497
|
}
|
|
447
498
|
case "unsubscribe": {
|
|
448
|
-
connection
|
|
499
|
+
removeSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
|
|
449
500
|
reply(connection, message.id, null);
|
|
450
501
|
return;
|
|
451
502
|
}
|
|
@@ -473,30 +524,40 @@ export function createHostRouter(options) {
|
|
|
473
524
|
subscriptions: new Set(),
|
|
474
525
|
joinedServers: new Set(),
|
|
475
526
|
queue: Promise.resolve(),
|
|
527
|
+
queuedMessages: 0,
|
|
476
528
|
};
|
|
477
529
|
connections.add(connection);
|
|
478
530
|
return {
|
|
479
531
|
handleRaw: (raw) => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
if (
|
|
485
|
-
|
|
486
|
-
if (failure.id !== undefined) {
|
|
487
|
-
replyError(connection, failure.id, failure.reason);
|
|
488
|
-
}
|
|
489
|
-
return;
|
|
532
|
+
const text = typeof raw === "string" ? raw : String(raw);
|
|
533
|
+
const message = decodeWsClientMessage(text);
|
|
534
|
+
if (message === null) {
|
|
535
|
+
const failure = inspectWsDecodeFailure(text);
|
|
536
|
+
if (failure.id !== undefined) {
|
|
537
|
+
replyError(connection, failure.id, failure.reason);
|
|
490
538
|
}
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (connection.queuedMessages >= MAX_QUEUED_MESSAGES) {
|
|
542
|
+
if ("id" in message)
|
|
543
|
+
replyError(connection, message.id, "Too many pending requests");
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
connection.queuedMessages += 1;
|
|
547
|
+
connection.queue = connection.queue
|
|
548
|
+
.then(() => handleMessage(connection, message))
|
|
549
|
+
.catch(() => undefined)
|
|
550
|
+
.finally(() => {
|
|
551
|
+
connection.queuedMessages -= 1;
|
|
552
|
+
});
|
|
494
553
|
},
|
|
495
554
|
close: () => {
|
|
496
555
|
if (connection.userId !== null && sessionsByUserId.get(connection.userId) === connection) {
|
|
497
556
|
sessionsByUserId.delete(connection.userId);
|
|
498
557
|
}
|
|
499
558
|
connections.delete(connection);
|
|
559
|
+
for (const key of [...connection.subscriptions])
|
|
560
|
+
removeSubscription(connection, key);
|
|
500
561
|
leaveJoinedServers(connection);
|
|
501
562
|
dropPresence(connection);
|
|
502
563
|
dropVoice(connection);
|
|
@@ -519,6 +580,7 @@ export function createHostRouter(options) {
|
|
|
519
580
|
close: () => {
|
|
520
581
|
unsubscribeHost();
|
|
521
582
|
connections.clear();
|
|
583
|
+
subscribers.clear();
|
|
522
584
|
},
|
|
523
585
|
};
|
|
524
586
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/pipe.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const OPEN = 1;
|
|
1
2
|
export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(target)) {
|
|
2
3
|
return (handlers) => {
|
|
3
4
|
const socket = webSocketFactory(url);
|
|
@@ -6,7 +7,7 @@ export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(
|
|
|
6
7
|
socket.onclose = () => handlers.onClose();
|
|
7
8
|
return {
|
|
8
9
|
send: (data) => {
|
|
9
|
-
if (socket.readyState !==
|
|
10
|
+
if (socket.readyState !== OPEN)
|
|
10
11
|
return;
|
|
11
12
|
socket.send(data);
|
|
12
13
|
},
|
package/dist/protocol.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ import type { PlayerPose } from "@jgengine/core/multiplayer/poseSyncGate";
|
|
|
3
3
|
import type { SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
|
|
4
4
|
import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
|
|
5
5
|
export declare const WS_PROTOCOL_VERSION = 1;
|
|
6
|
+
/** Max length of a `runCommand` command name, in UTF-16 code units. */
|
|
7
|
+
export declare const MAX_COMMAND_LENGTH = 4096;
|
|
8
|
+
/** Max length of a `pushFeed` action name, in UTF-16 code units. */
|
|
9
|
+
export declare const MAX_FEED_ACTION_LENGTH = 256;
|
|
10
|
+
/** Max serialized size of a `pushFeed` entry payload, in bytes. */
|
|
11
|
+
export declare const MAX_FEED_ENTRY_BYTES = 65536;
|
|
12
|
+
/** Max number of keys in a pose `appearance` tag map. */
|
|
13
|
+
export declare const MAX_APPEARANCE_ENTRIES = 32;
|
|
14
|
+
/** Max length of a single `appearance` tag string value, in UTF-16 code units. */
|
|
15
|
+
export declare const MAX_APPEARANCE_VALUE_LENGTH = 256;
|
|
6
16
|
export type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice";
|
|
7
17
|
/** Client-set cosmetic/state tags carried alongside a pose (skin, mount, emote, ...). Primitive values only. */
|
|
8
18
|
export type WsAppearance = Record<string, string | number | boolean>;
|
|
@@ -36,6 +46,7 @@ export type WsClientMessage = {
|
|
|
36
46
|
gameId: string;
|
|
37
47
|
serverId?: string;
|
|
38
48
|
attributes?: SessionAttributes;
|
|
49
|
+
code?: string;
|
|
39
50
|
} | {
|
|
40
51
|
v: 1;
|
|
41
52
|
t: "joinByCode";
|
|
@@ -172,12 +183,17 @@ export type WsJoinResult = JoinServerResult;
|
|
|
172
183
|
export type WsRunCommandResult = TransportRunCommandResult;
|
|
173
184
|
export type WsBrowseResult = SessionListing[];
|
|
174
185
|
export type WsJoinByCodeResult = JoinServerResult | null;
|
|
186
|
+
/** @internal */
|
|
175
187
|
export declare function encodeWsMessage(message: WsClientMessage | WsServerMessage): string;
|
|
176
188
|
export type WsDecodeFailure = {
|
|
177
189
|
reason: string;
|
|
178
190
|
id?: number;
|
|
179
191
|
};
|
|
192
|
+
/** @internal */
|
|
180
193
|
export declare function inspectWsDecodeFailure(raw: unknown): WsDecodeFailure;
|
|
194
|
+
/** @internal */
|
|
181
195
|
export declare function decodeWsClientMessage(raw: unknown): WsClientMessage | null;
|
|
196
|
+
/** @internal */
|
|
182
197
|
export declare function decodeWsServerMessage(raw: unknown): WsServerMessage | null;
|
|
198
|
+
/** @internal */
|
|
183
199
|
export declare function subscriptionKey(channel: WsChannel, serverId: string, action?: string): string;
|
package/dist/protocol.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
export const WS_PROTOCOL_VERSION = 1;
|
|
2
|
+
/** Max length of a `runCommand` command name, in UTF-16 code units. */
|
|
3
|
+
export const MAX_COMMAND_LENGTH = 4_096;
|
|
4
|
+
/** Max length of a `pushFeed` action name, in UTF-16 code units. */
|
|
5
|
+
export const MAX_FEED_ACTION_LENGTH = 256;
|
|
6
|
+
/** Max serialized size of a `pushFeed` entry payload, in bytes. */
|
|
7
|
+
export const MAX_FEED_ENTRY_BYTES = 65_536;
|
|
8
|
+
/** Max number of keys in a pose `appearance` tag map. */
|
|
9
|
+
export const MAX_APPEARANCE_ENTRIES = 32;
|
|
10
|
+
/** Max length of a single `appearance` tag string value, in UTF-16 code units. */
|
|
11
|
+
export const MAX_APPEARANCE_VALUE_LENGTH = 256;
|
|
12
|
+
/** @internal */
|
|
2
13
|
export function encodeWsMessage(message) {
|
|
3
14
|
return JSON.stringify(message);
|
|
4
15
|
}
|
|
@@ -32,8 +43,17 @@ function isWsChannel(value) {
|
|
|
32
43
|
value === "voice");
|
|
33
44
|
}
|
|
34
45
|
function isAppearance(value) {
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
if (!isRecord(value))
|
|
47
|
+
return false;
|
|
48
|
+
const entries = Object.values(value);
|
|
49
|
+
if (entries.length > MAX_APPEARANCE_ENTRIES)
|
|
50
|
+
return false;
|
|
51
|
+
return entries.every((v) => (typeof v === "string" && v.length <= MAX_APPEARANCE_VALUE_LENGTH) ||
|
|
52
|
+
typeof v === "number" ||
|
|
53
|
+
typeof v === "boolean");
|
|
54
|
+
}
|
|
55
|
+
function withinFeedEntryBudget(entry) {
|
|
56
|
+
return new TextEncoder().encode(JSON.stringify(entry)).length <= MAX_FEED_ENTRY_BYTES;
|
|
37
57
|
}
|
|
38
58
|
function isFiniteNumber(value) {
|
|
39
59
|
return typeof value === "number" && Number.isFinite(value);
|
|
@@ -47,6 +67,7 @@ function isPose(value) {
|
|
|
47
67
|
isFiniteNumber(value.rotationPitch) &&
|
|
48
68
|
(value.appearance === undefined || isAppearance(value.appearance)));
|
|
49
69
|
}
|
|
70
|
+
/** @internal */
|
|
50
71
|
export function inspectWsDecodeFailure(raw) {
|
|
51
72
|
if (typeof raw !== "string")
|
|
52
73
|
return { reason: "Invalid message framing" };
|
|
@@ -66,8 +87,27 @@ export function inspectWsDecodeFailure(raw) {
|
|
|
66
87
|
if (typeof parsed.t !== "string") {
|
|
67
88
|
return { reason: "Invalid message type", id };
|
|
68
89
|
}
|
|
90
|
+
if (parsed.t === "runCommand" && typeof parsed.command === "string" && parsed.command.length > MAX_COMMAND_LENGTH) {
|
|
91
|
+
return { reason: "Command exceeds max length", id };
|
|
92
|
+
}
|
|
93
|
+
if (parsed.t === "pushFeed" && typeof parsed.action === "string" && parsed.action.length > MAX_FEED_ACTION_LENGTH) {
|
|
94
|
+
return { reason: "Feed action exceeds max length", id };
|
|
95
|
+
}
|
|
96
|
+
if (parsed.t === "pushFeed" && "entry" in parsed && !withinFeedEntryBudget(parsed.entry)) {
|
|
97
|
+
return { reason: "Feed entry exceeds max size", id };
|
|
98
|
+
}
|
|
99
|
+
if (parsed.t === "pose" && isRecord(parsed.pose) && isRecord(parsed.pose.appearance)) {
|
|
100
|
+
const values = Object.values(parsed.pose.appearance);
|
|
101
|
+
if (values.length > MAX_APPEARANCE_ENTRIES) {
|
|
102
|
+
return { reason: "Appearance exceeds max entries", id };
|
|
103
|
+
}
|
|
104
|
+
if (values.some((v) => typeof v === "string" && v.length > MAX_APPEARANCE_VALUE_LENGTH)) {
|
|
105
|
+
return { reason: "Appearance value exceeds max length", id };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
69
108
|
return { reason: "Malformed message", id };
|
|
70
109
|
}
|
|
110
|
+
/** @internal */
|
|
71
111
|
export function decodeWsClientMessage(raw) {
|
|
72
112
|
const message = parseVersioned(raw);
|
|
73
113
|
if (message === null)
|
|
@@ -82,7 +122,8 @@ export function decodeWsClientMessage(raw) {
|
|
|
82
122
|
case "join":
|
|
83
123
|
return typeof message.id === "number" &&
|
|
84
124
|
typeof message.gameId === "string" &&
|
|
85
|
-
(message.serverId === undefined || typeof message.serverId === "string")
|
|
125
|
+
(message.serverId === undefined || typeof message.serverId === "string") &&
|
|
126
|
+
(message.code === undefined || typeof message.code === "string")
|
|
86
127
|
? message
|
|
87
128
|
: null;
|
|
88
129
|
case "joinByCode":
|
|
@@ -102,13 +143,16 @@ export function decodeWsClientMessage(raw) {
|
|
|
102
143
|
case "runCommand":
|
|
103
144
|
return typeof message.id === "number" &&
|
|
104
145
|
typeof message.serverId === "string" &&
|
|
105
|
-
typeof message.command === "string"
|
|
146
|
+
typeof message.command === "string" &&
|
|
147
|
+
message.command.length <= MAX_COMMAND_LENGTH
|
|
106
148
|
? message
|
|
107
149
|
: null;
|
|
108
150
|
case "pushFeed":
|
|
109
151
|
return typeof message.id === "number" &&
|
|
110
152
|
typeof message.serverId === "string" &&
|
|
111
|
-
typeof message.action === "string"
|
|
153
|
+
typeof message.action === "string" &&
|
|
154
|
+
message.action.length <= MAX_FEED_ACTION_LENGTH &&
|
|
155
|
+
withinFeedEntryBudget(message.entry)
|
|
112
156
|
? message
|
|
113
157
|
: null;
|
|
114
158
|
case "subscribe":
|
|
@@ -154,6 +198,7 @@ export function decodeWsClientMessage(raw) {
|
|
|
154
198
|
return null;
|
|
155
199
|
}
|
|
156
200
|
}
|
|
201
|
+
/** @internal */
|
|
157
202
|
export function decodeWsServerMessage(raw) {
|
|
158
203
|
const message = parseVersioned(raw);
|
|
159
204
|
if (message === null)
|
|
@@ -176,6 +221,7 @@ export function decodeWsServerMessage(raw) {
|
|
|
176
221
|
return null;
|
|
177
222
|
}
|
|
178
223
|
}
|
|
224
|
+
/** @internal */
|
|
179
225
|
export function subscriptionKey(channel, serverId, action) {
|
|
180
226
|
return `${channel}|${serverId}|${action ?? ""}`;
|
|
181
227
|
}
|
package/dist/readsHandler.js
CHANGED
|
@@ -41,7 +41,8 @@ export function createReadsHandler(options) {
|
|
|
41
41
|
return json(await options.listOpenServers({ gameId, limit }));
|
|
42
42
|
}
|
|
43
43
|
const records = await (await getPersistence()).listServers(gameId);
|
|
44
|
-
|
|
44
|
+
const publicRecords = records.filter((record) => (record.visibility ?? "public") !== "private");
|
|
45
|
+
return json(toOpenServerListings(publicRecords.map((record) => toServerListing(record)), limit));
|
|
45
46
|
}
|
|
46
47
|
if (head === "leaderboard" && tail !== undefined && segments.length === 2) {
|
|
47
48
|
const scope = url.searchParams.get("scope") ?? "global";
|
package/dist/worldHost.js
CHANGED
|
@@ -72,7 +72,7 @@ export function createWorldGameHost(options) {
|
|
|
72
72
|
async isMember({ userId, serverId }) {
|
|
73
73
|
return live.get(serverId)?.session.members().includes(userId) ?? false;
|
|
74
74
|
},
|
|
75
|
-
async getServerView({ serverId }) {
|
|
75
|
+
async getServerView({ userId, serverId }) {
|
|
76
76
|
const entry = live.get(serverId);
|
|
77
77
|
if (entry === undefined)
|
|
78
78
|
return null;
|
|
@@ -81,7 +81,7 @@ export function createWorldGameHost(options) {
|
|
|
81
81
|
gameId: entry.gameId,
|
|
82
82
|
revision: entry.session.revision(),
|
|
83
83
|
memberUserIds: [...entry.session.members()],
|
|
84
|
-
serverState: entry.session.
|
|
84
|
+
serverState: entry.session.snapshotFor({ userId }),
|
|
85
85
|
updatedAt: now(),
|
|
86
86
|
};
|
|
87
87
|
},
|