@jgengine/ws 0.18.1 → 0.21.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 +504 -7
- package/dist/createWsBackend.js +23 -3
- package/dist/host.d.ts +2 -0
- package/dist/host.js +5 -11
- package/dist/hostRouter.d.ts +2 -0
- package/dist/hostRouter.js +179 -33
- package/dist/peer.js +45 -12
- package/dist/protocol.d.ts +5 -0
- package/dist/protocol.js +8 -2
- package/dist/worldHost.d.ts +13 -2
- package/dist/worldHost.js +132 -47
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +28 -6
- package/skills/jgengine/api.md +139 -99
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +10 -2
- package/skills/jgengine-multiplayer/SKILL.md +3 -3
- package/skills/jgengine-multiplayer/api.md +91 -83
- package/skills/jgengine-multiplayer/reference.md +27 -3
- package/skills/jgengine-multiplayer/references/peer-worlds.md +62 -0
package/dist/createWsBackend.js
CHANGED
|
@@ -42,6 +42,7 @@ export function createWsBackend(options) {
|
|
|
42
42
|
let wantConnection = false;
|
|
43
43
|
let nextId = 1;
|
|
44
44
|
let nextOpId = 1;
|
|
45
|
+
const operationSession = crypto.randomUUID();
|
|
45
46
|
let reconnectAttempt = 0;
|
|
46
47
|
let reconnectTimer = null;
|
|
47
48
|
const pending = new Map();
|
|
@@ -49,6 +50,7 @@ export function createWsBackend(options) {
|
|
|
49
50
|
const resumeTickets = new Map();
|
|
50
51
|
const joinedGames = new Map();
|
|
51
52
|
const joinedRoles = new Map();
|
|
53
|
+
const joinedSessions = new Map();
|
|
52
54
|
const rtt = { sampleMs: 0, smoothedMs: 0 };
|
|
53
55
|
let pingTimer = null;
|
|
54
56
|
const schedulePing = () => {
|
|
@@ -119,7 +121,10 @@ export function createWsBackend(options) {
|
|
|
119
121
|
};
|
|
120
122
|
const rejoinServers = () => {
|
|
121
123
|
for (const [serverId, gameId] of joinedGames) {
|
|
122
|
-
|
|
124
|
+
const sessions = [...(joinedSessions.get(serverId) ?? [])];
|
|
125
|
+
for (const sessionId of sessions.length > 0 ? sessions : [undefined]) {
|
|
126
|
+
rawSend({ v: 1, t: "join", id: nextId++, gameId, serverId, role: joinedRoles.get(serverId), ...(sessionId === undefined ? {} : { sessionId }) });
|
|
127
|
+
}
|
|
123
128
|
}
|
|
124
129
|
};
|
|
125
130
|
const helloToken = () => resumeTickets.values().next().value?.token ?? options.token;
|
|
@@ -315,10 +320,16 @@ export function createWsBackend(options) {
|
|
|
315
320
|
gameId: args.gameId,
|
|
316
321
|
serverId: args.serverId,
|
|
317
322
|
role: args.role,
|
|
323
|
+
...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }),
|
|
318
324
|
}));
|
|
319
325
|
const joined = result;
|
|
320
326
|
joinedGames.set(joined.serverId, args.gameId);
|
|
321
327
|
joinedRoles.set(joined.serverId, args.role ?? "player");
|
|
328
|
+
if (args.sessionId !== undefined) {
|
|
329
|
+
const sessions = joinedSessions.get(joined.serverId) ?? new Set();
|
|
330
|
+
sessions.add(args.sessionId);
|
|
331
|
+
joinedSessions.set(joined.serverId, sessions);
|
|
332
|
+
}
|
|
322
333
|
if (joined.resumeTicket !== undefined)
|
|
323
334
|
resumeTickets.set(joined.serverId, joined.resumeTicket);
|
|
324
335
|
return { ...joined, ok: true };
|
|
@@ -335,14 +346,23 @@ export function createWsBackend(options) {
|
|
|
335
346
|
}
|
|
336
347
|
},
|
|
337
348
|
async leaveServer(args) {
|
|
349
|
+
const sessions = joinedSessions.get(args.serverId);
|
|
350
|
+
const partial = args.sessionId !== undefined && sessions !== undefined && sessions.size > 0 &&
|
|
351
|
+
(!sessions.has(args.sessionId) || sessions.size > 1);
|
|
352
|
+
if (partial) {
|
|
353
|
+
await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId, sessionId: args.sessionId }));
|
|
354
|
+
sessions.delete(args.sessionId);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
338
357
|
poseGates.delete(args.serverId);
|
|
339
|
-
await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId }));
|
|
358
|
+
await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId, ...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }) }));
|
|
359
|
+
joinedSessions.delete(args.serverId);
|
|
340
360
|
joinedGames.delete(args.serverId);
|
|
341
361
|
joinedRoles.delete(args.serverId);
|
|
342
362
|
resumeTickets.delete(args.serverId);
|
|
343
363
|
},
|
|
344
364
|
async runCommand(args) {
|
|
345
|
-
const opId =
|
|
365
|
+
const opId = `${operationSession}:${nextOpId++}`;
|
|
346
366
|
try {
|
|
347
367
|
const result = await request((id) => ({
|
|
348
368
|
v: 1,
|
package/dist/host.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export type GameHost = {
|
|
|
55
55
|
serverId: string;
|
|
56
56
|
command: string;
|
|
57
57
|
input: unknown;
|
|
58
|
+
/** Set only from host code: runs commands marked `access: "server"`, which client messages cannot. */
|
|
59
|
+
trusted?: boolean;
|
|
58
60
|
}) => Promise<TransportRunCommandResult>;
|
|
59
61
|
isMember: (args: {
|
|
60
62
|
userId: string;
|
package/dist/host.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
2
|
+
import { SERVER_ONLY_COMMAND_REASON } from "@jgengine/core/runtime/commandRunner";
|
|
2
3
|
import { applyLeaderboardRows, buildHydratePlayers, planServerPersist, profileLeaderboardStats, shouldAutoSave, topLeaderboardRows, toOpenServerListings, toServerListing, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
|
|
3
4
|
import { isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
|
|
4
5
|
import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
|
|
5
6
|
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
6
7
|
import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
|
|
8
|
+
import { unwrapOpEnvelope } from "@jgengine/core/runtime/hostedWorldSession";
|
|
7
9
|
const builtinCommands = {
|
|
8
10
|
"engine.ping": {
|
|
9
11
|
validate: () => null,
|
|
@@ -12,17 +14,6 @@ const builtinCommands = {
|
|
|
12
14
|
};
|
|
13
15
|
/** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
|
|
14
16
|
export const OP_LEDGER_LIMIT = 64;
|
|
15
|
-
const WS_OP_ID_FIELD = "__jgWsOpId";
|
|
16
|
-
function unwrapOpEnvelope(input) {
|
|
17
|
-
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
18
|
-
return null;
|
|
19
|
-
const record = input;
|
|
20
|
-
const opId = record[WS_OP_ID_FIELD];
|
|
21
|
-
if (typeof opId !== "string")
|
|
22
|
-
return null;
|
|
23
|
-
const { [WS_OP_ID_FIELD]: _opId, ...rest } = record;
|
|
24
|
-
return { opId, input: rest };
|
|
25
|
-
}
|
|
26
17
|
function opLedgerKey(serverId, userId) {
|
|
27
18
|
return `${serverId}|${userId}`;
|
|
28
19
|
}
|
|
@@ -330,6 +321,9 @@ export function createGameHost(options) {
|
|
|
330
321
|
}
|
|
331
322
|
const commandInput = envelope !== null ? envelope.input : args.input;
|
|
332
323
|
const runtime = resolveRuntime(entry.record.gameId);
|
|
324
|
+
if (args.trusted !== true && runtime.commandAccess(args.command) === "server") {
|
|
325
|
+
return { ok: false, reason: SERVER_ONLY_COMMAND_REASON };
|
|
326
|
+
}
|
|
333
327
|
const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput, now());
|
|
334
328
|
const outcome = result.ok
|
|
335
329
|
? { ok: true }
|
package/dist/hostRouter.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type HostRouter = {
|
|
|
49
49
|
atMs: number;
|
|
50
50
|
}) => RewoundPosition[];
|
|
51
51
|
close: () => void;
|
|
52
|
+
/** Wait for accepted message handlers and subscription reads to settle. Close first to prevent new work. */
|
|
53
|
+
drain: () => Promise<void>;
|
|
52
54
|
};
|
|
53
55
|
export declare const DEFAULT_POSE_RULES: PoseSyncRules;
|
|
54
56
|
/** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
|
package/dist/hostRouter.js
CHANGED
|
@@ -53,6 +53,13 @@ export function createHostRouter(options) {
|
|
|
53
53
|
const authenticate = options.authenticate ??
|
|
54
54
|
(allowAnonymous ? ({ userId }) => userId : null);
|
|
55
55
|
const connections = new Set();
|
|
56
|
+
let closed = false;
|
|
57
|
+
const work = new Set();
|
|
58
|
+
const track = (promise) => {
|
|
59
|
+
work.add(promise);
|
|
60
|
+
void promise.finally(() => work.delete(promise)).catch(() => { });
|
|
61
|
+
return promise;
|
|
62
|
+
};
|
|
56
63
|
const sessionsByUserId = new Map();
|
|
57
64
|
const resumeTokens = new Map();
|
|
58
65
|
const pendingLeaves = new Map();
|
|
@@ -70,6 +77,52 @@ export function createHostRouter(options) {
|
|
|
70
77
|
clearTimeout(timer);
|
|
71
78
|
pendingLeaves.delete(key);
|
|
72
79
|
};
|
|
80
|
+
const heldByOtherConnection = (self, userId, serverId) => {
|
|
81
|
+
for (const other of connections) {
|
|
82
|
+
if (other !== self && other.userId === userId && other.joinedServers.has(serverId))
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
};
|
|
87
|
+
const scheduleLeave = (connection, userId, serverId) => {
|
|
88
|
+
clearPendingLeave(userId, serverId);
|
|
89
|
+
const key = ticketKey(userId, serverId);
|
|
90
|
+
pendingLeaves.set(key, setTimeout(() => {
|
|
91
|
+
pendingLeaves.delete(key);
|
|
92
|
+
void track((async () => {
|
|
93
|
+
const admissions = [...connections]
|
|
94
|
+
.filter(other => other !== connection && other.userId === userId)
|
|
95
|
+
.flatMap(other => other.admission === null || (other.admission.serverId !== undefined && other.admission.serverId !== serverId)
|
|
96
|
+
? [] : [other.admission.result]);
|
|
97
|
+
await Promise.allSettled(admissions);
|
|
98
|
+
if (closed || heldByOtherConnection(connection, userId, serverId))
|
|
99
|
+
return;
|
|
100
|
+
await host.leaveServer({ userId, serverId });
|
|
101
|
+
})()).catch(() => { });
|
|
102
|
+
}, graceMs));
|
|
103
|
+
};
|
|
104
|
+
const admit = async (connection, userId, join, role, sessionId, serverId) => {
|
|
105
|
+
const admission = join().then(result => {
|
|
106
|
+
if (result === null || closed)
|
|
107
|
+
return result;
|
|
108
|
+
if (connection.closed || connection.userId !== userId) {
|
|
109
|
+
scheduleLeave(connection, userId, result.serverId);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
connection.joinedServers.add(result.serverId);
|
|
113
|
+
connection.roles.set(result.serverId, role ?? "player");
|
|
114
|
+
if (sessionId !== undefined)
|
|
115
|
+
getOrCreate(connection.sessions, result.serverId, () => new Set()).add(sessionId);
|
|
116
|
+
return result;
|
|
117
|
+
});
|
|
118
|
+
connection.admission = { serverId, result: admission };
|
|
119
|
+
try {
|
|
120
|
+
return await admission;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
connection.admission = null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
73
126
|
const subscribers = new Map();
|
|
74
127
|
const subscribersOf = (key) => {
|
|
75
128
|
const set = subscribers.get(key);
|
|
@@ -77,6 +130,13 @@ export function createHostRouter(options) {
|
|
|
77
130
|
};
|
|
78
131
|
const addSubscription = (connection, key) => {
|
|
79
132
|
connection.subscriptions.add(key);
|
|
133
|
+
const push = connection.subscriptionPushes.get(key);
|
|
134
|
+
if (push === undefined) {
|
|
135
|
+
connection.subscriptionPushes.set(key, { generation: 0, pending: false });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
push.generation += 1;
|
|
139
|
+
}
|
|
80
140
|
let set = subscribers.get(key);
|
|
81
141
|
if (set === undefined) {
|
|
82
142
|
set = new Set();
|
|
@@ -86,6 +146,13 @@ export function createHostRouter(options) {
|
|
|
86
146
|
};
|
|
87
147
|
const removeSubscription = (connection, key) => {
|
|
88
148
|
connection.subscriptions.delete(key);
|
|
149
|
+
const push = connection.subscriptionPushes.get(key);
|
|
150
|
+
if (push !== undefined) {
|
|
151
|
+
push.generation += 1;
|
|
152
|
+
push.pending = false;
|
|
153
|
+
if (push.run === undefined)
|
|
154
|
+
connection.subscriptionPushes.delete(key);
|
|
155
|
+
}
|
|
89
156
|
const set = subscribers.get(key);
|
|
90
157
|
if (set === undefined)
|
|
91
158
|
return;
|
|
@@ -98,6 +165,8 @@ export function createHostRouter(options) {
|
|
|
98
165
|
const histories = new Map();
|
|
99
166
|
const historyFor = (serverId) => getOrCreate(histories, serverId, () => createPositionHistory({ historyMs: positionHistoryMs }));
|
|
100
167
|
const send = (connection, message) => {
|
|
168
|
+
if (closed || connection.closed)
|
|
169
|
+
return;
|
|
101
170
|
connection.transport.send(encodeWsMessage(message));
|
|
102
171
|
};
|
|
103
172
|
const reply = (connection, id, result) => {
|
|
@@ -149,6 +218,8 @@ export function createHostRouter(options) {
|
|
|
149
218
|
atMs: now(),
|
|
150
219
|
...args,
|
|
151
220
|
});
|
|
221
|
+
if (closed || connection.closed)
|
|
222
|
+
return false;
|
|
152
223
|
if (!decision.allow) {
|
|
153
224
|
replyError(connection, id, decision.reason);
|
|
154
225
|
return false;
|
|
@@ -237,32 +308,42 @@ export function createHostRouter(options) {
|
|
|
237
308
|
reply(connection, message.id, null);
|
|
238
309
|
broadcastChat(message.serverId, message.channelId);
|
|
239
310
|
};
|
|
240
|
-
const pushSubscription = async (connection, channel, serverId, action) => {
|
|
241
|
-
if (connection.userId === null)
|
|
311
|
+
const pushSubscription = async (connection, channel, serverId, action, isCurrent) => {
|
|
312
|
+
if (connection.userId === null || !isCurrent())
|
|
242
313
|
return;
|
|
314
|
+
if (host.pullWorld !== undefined) {
|
|
315
|
+
if (!(await host.isMember({ userId: connection.userId, serverId })) || !isCurrent())
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
243
318
|
if (channel === "server") {
|
|
244
319
|
if (host.pullWorld !== undefined) {
|
|
245
320
|
const sinceRevision = connection.worldRevisions.get(serverId) ?? null;
|
|
246
321
|
const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision, role: connection.roles.get(serverId) });
|
|
322
|
+
if (!isCurrent())
|
|
323
|
+
return;
|
|
247
324
|
if (world !== null) {
|
|
248
|
-
connection.worldRevisions.set(serverId, world.revision);
|
|
249
325
|
const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
|
|
250
|
-
if (data !== null) {
|
|
326
|
+
if (data !== null && isCurrent()) {
|
|
251
327
|
send(connection, { v: 1, t: "update", channel, serverId, data: { ...data, serverState: world } });
|
|
328
|
+
connection.worldRevisions.set(serverId, world.revision);
|
|
252
329
|
}
|
|
253
330
|
return;
|
|
254
331
|
}
|
|
332
|
+
return;
|
|
255
333
|
}
|
|
256
334
|
const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
|
|
257
|
-
|
|
335
|
+
if (isCurrent())
|
|
336
|
+
send(connection, { v: 1, t: "update", channel, serverId, data });
|
|
258
337
|
}
|
|
259
338
|
else if (channel === "player") {
|
|
260
339
|
const data = await host.getPlayerView({ userId: connection.userId, serverId });
|
|
261
|
-
|
|
340
|
+
if (isCurrent())
|
|
341
|
+
send(connection, { v: 1, t: "update", channel, serverId, data });
|
|
262
342
|
}
|
|
263
343
|
else if (channel === "feed") {
|
|
264
344
|
const data = await host.getFeed({ userId: connection.userId, serverId, action: action ?? "" });
|
|
265
|
-
|
|
345
|
+
if (isCurrent())
|
|
346
|
+
send(connection, { v: 1, t: "update", channel, serverId, action: action ?? "", data });
|
|
266
347
|
}
|
|
267
348
|
else if (channel === "chat") {
|
|
268
349
|
const channelId = action ?? "";
|
|
@@ -290,18 +371,50 @@ export function createHostRouter(options) {
|
|
|
290
371
|
send(connection, { v: 1, t: "update", channel, serverId, data: presenceRows(serverId) });
|
|
291
372
|
}
|
|
292
373
|
};
|
|
374
|
+
const requestSubscriptionPush = (connection, channel, serverId, action) => {
|
|
375
|
+
const key = subscriptionKey(channel, serverId, action);
|
|
376
|
+
const push = connection.subscriptionPushes.get(key);
|
|
377
|
+
if (push === undefined)
|
|
378
|
+
return Promise.resolve();
|
|
379
|
+
push.pending = true;
|
|
380
|
+
if (push.run !== undefined)
|
|
381
|
+
return push.run;
|
|
382
|
+
const isActive = () => connections.has(connection) && connection.userId !== null && connection.subscriptions.has(key) && connection.subscriptionPushes.get(key) === push;
|
|
383
|
+
push.run = track(Promise.resolve().then(async () => {
|
|
384
|
+
while (push.pending && isActive()) {
|
|
385
|
+
push.pending = false;
|
|
386
|
+
const generation = push.generation;
|
|
387
|
+
try {
|
|
388
|
+
await pushSubscription(connection, channel, serverId, action, () => isActive() && push.generation === generation);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// A failed read leaves the sent revision intact; a later notification retries it.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}).finally(() => {
|
|
395
|
+
push.run = undefined;
|
|
396
|
+
if (!isActive()) {
|
|
397
|
+
if (connection.subscriptionPushes.get(key) === push)
|
|
398
|
+
connection.subscriptionPushes.delete(key);
|
|
399
|
+
}
|
|
400
|
+
else if (push.pending) {
|
|
401
|
+
void requestSubscriptionPush(connection, channel, serverId, action);
|
|
402
|
+
}
|
|
403
|
+
}));
|
|
404
|
+
return push.run;
|
|
405
|
+
};
|
|
293
406
|
const onHostEvent = (event) => {
|
|
294
407
|
if (event.type === "server") {
|
|
295
408
|
for (const connection of subscribersOf(subscriptionKey("server", event.serverId))) {
|
|
296
409
|
if (connection.userId === null)
|
|
297
410
|
continue;
|
|
298
|
-
void
|
|
411
|
+
void requestSubscriptionPush(connection, "server", event.serverId);
|
|
299
412
|
}
|
|
300
413
|
}
|
|
301
414
|
else if (event.type === "player") {
|
|
302
415
|
for (const connection of subscribersOf(subscriptionKey("player", event.serverId))) {
|
|
303
416
|
if (connection.userId === event.userId) {
|
|
304
|
-
void
|
|
417
|
+
void requestSubscriptionPush(connection, "player", event.serverId);
|
|
305
418
|
}
|
|
306
419
|
}
|
|
307
420
|
}
|
|
@@ -309,7 +422,7 @@ export function createHostRouter(options) {
|
|
|
309
422
|
for (const connection of subscribersOf(subscriptionKey("feed", event.serverId, event.action))) {
|
|
310
423
|
if (connection.userId === null)
|
|
311
424
|
continue;
|
|
312
|
-
void
|
|
425
|
+
void requestSubscriptionPush(connection, "feed", event.serverId, event.action);
|
|
313
426
|
}
|
|
314
427
|
}
|
|
315
428
|
};
|
|
@@ -358,6 +471,8 @@ export function createHostRouter(options) {
|
|
|
358
471
|
dropUserFrom(presence, userId, (key) => key === serverId, () => broadcastPresence(serverId));
|
|
359
472
|
};
|
|
360
473
|
const handleMessage = async (connection, message) => {
|
|
474
|
+
if (closed || connection.closed)
|
|
475
|
+
return;
|
|
361
476
|
if (message.t === "hello") {
|
|
362
477
|
if (connection.userId !== null) {
|
|
363
478
|
replyError(connection, message.id, "Already authenticated");
|
|
@@ -372,6 +487,8 @@ export function createHostRouter(options) {
|
|
|
372
487
|
const userId = resumeToken !== undefined && message.token === resumeToken
|
|
373
488
|
? message.userId
|
|
374
489
|
: await authenticate({ userId: message.userId, token: message.token });
|
|
490
|
+
if (closed || connection.closed)
|
|
491
|
+
return;
|
|
375
492
|
if (userId === null) {
|
|
376
493
|
replyError(connection, message.id, "Not authenticated");
|
|
377
494
|
connection.transport.close();
|
|
@@ -417,16 +534,18 @@ export function createHostRouter(options) {
|
|
|
417
534
|
case "join": {
|
|
418
535
|
if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
|
|
419
536
|
return;
|
|
420
|
-
const result = await host.joinServer({
|
|
537
|
+
const result = await admit(connection, userId, () => host.joinServer({
|
|
421
538
|
userId,
|
|
422
539
|
gameId: message.gameId,
|
|
423
540
|
serverId: message.serverId,
|
|
424
541
|
attributes: message.attributes,
|
|
425
542
|
code: message.code,
|
|
426
543
|
role: message.role,
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
|
|
544
|
+
}), message.role, message.sessionId, message.serverId);
|
|
545
|
+
if (result === null)
|
|
546
|
+
return;
|
|
547
|
+
if (closed || connection.closed || connection.userId !== userId)
|
|
548
|
+
return;
|
|
430
549
|
reply(connection, message.id, {
|
|
431
550
|
...result,
|
|
432
551
|
resumeTicket: ticketFor(userId, result.serverId),
|
|
@@ -436,14 +555,14 @@ export function createHostRouter(options) {
|
|
|
436
555
|
case "joinByCode": {
|
|
437
556
|
if (!(await gate(connection, message.id, "join", {})))
|
|
438
557
|
return;
|
|
439
|
-
const result = await host.joinByCode({
|
|
558
|
+
const result = await admit(connection, userId, () => host.joinByCode({
|
|
440
559
|
userId,
|
|
441
560
|
gameId: message.gameId,
|
|
442
561
|
code: message.code,
|
|
443
|
-
});
|
|
562
|
+
}), message.role);
|
|
444
563
|
if (result !== null) {
|
|
445
|
-
connection.
|
|
446
|
-
|
|
564
|
+
if (closed || connection.closed || connection.userId !== userId)
|
|
565
|
+
return;
|
|
447
566
|
}
|
|
448
567
|
reply(connection, message.id, result === null
|
|
449
568
|
? null
|
|
@@ -462,12 +581,23 @@ export function createHostRouter(options) {
|
|
|
462
581
|
return;
|
|
463
582
|
}
|
|
464
583
|
case "leave": {
|
|
465
|
-
|
|
466
|
-
|
|
584
|
+
const sessions = connection.sessions.get(message.serverId);
|
|
585
|
+
// A leave for a session that is not live (a disposed join) must not end the sessions that are.
|
|
586
|
+
if (message.sessionId !== undefined && sessions !== undefined && sessions.size > 0) {
|
|
587
|
+
if (!sessions.delete(message.sessionId) || sessions.size > 0) {
|
|
588
|
+
reply(connection, message.id, null);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
connection.sessions.delete(message.serverId);
|
|
467
593
|
connection.joinedServers.delete(message.serverId);
|
|
468
594
|
connection.roles.delete(message.serverId);
|
|
469
|
-
|
|
470
|
-
|
|
595
|
+
if (!heldByOtherConnection(connection, userId, message.serverId)) {
|
|
596
|
+
await host.leaveServer({ userId, serverId: message.serverId });
|
|
597
|
+
clearPendingLeave(userId, message.serverId);
|
|
598
|
+
dropPresenceForServer(userId, message.serverId);
|
|
599
|
+
dropVoiceForServer(userId, message.serverId);
|
|
600
|
+
}
|
|
471
601
|
reply(connection, message.id, null);
|
|
472
602
|
return;
|
|
473
603
|
}
|
|
@@ -546,18 +676,20 @@ export function createHostRouter(options) {
|
|
|
546
676
|
return;
|
|
547
677
|
}
|
|
548
678
|
case "subscribe": {
|
|
549
|
-
if ((message.channel === "chat" ||
|
|
679
|
+
if ((host.pullWorld !== undefined || message.channel === "chat" ||
|
|
550
680
|
message.channel === "voice" ||
|
|
551
681
|
message.channel === "presence") &&
|
|
552
682
|
!(await host.isMember({ userId, serverId: message.serverId }))) {
|
|
553
683
|
replyError(connection, message.id, "Not a member of this server");
|
|
554
684
|
return;
|
|
555
685
|
}
|
|
686
|
+
if (closed || connection.closed)
|
|
687
|
+
return;
|
|
556
688
|
if (message.channel === "server")
|
|
557
689
|
connection.worldRevisions.set(message.serverId, null);
|
|
558
690
|
addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
|
|
559
691
|
reply(connection, message.id, null);
|
|
560
|
-
|
|
692
|
+
void requestSubscriptionPush(connection, message.channel, message.serverId, message.action);
|
|
561
693
|
return;
|
|
562
694
|
}
|
|
563
695
|
case "unsubscribe": {
|
|
@@ -572,35 +704,39 @@ export function createHostRouter(options) {
|
|
|
572
704
|
}
|
|
573
705
|
};
|
|
574
706
|
const leaveJoinedServers = (connection) => {
|
|
575
|
-
if (connection.userId === null)
|
|
707
|
+
if (closed || connection.userId === null)
|
|
576
708
|
return;
|
|
577
709
|
const userId = connection.userId;
|
|
578
710
|
const servers = [...connection.joinedServers];
|
|
579
711
|
connection.joinedServers.clear();
|
|
712
|
+
connection.sessions.clear();
|
|
580
713
|
for (const serverId of servers) {
|
|
581
|
-
|
|
582
|
-
clearPendingLeave(userId, serverId);
|
|
583
|
-
pendingLeaves.set(key, setTimeout(() => {
|
|
584
|
-
pendingLeaves.delete(key);
|
|
585
|
-
void host.leaveServer({ userId, serverId }).catch(() => undefined);
|
|
586
|
-
}, graceMs));
|
|
714
|
+
scheduleLeave(connection, userId, serverId);
|
|
587
715
|
}
|
|
588
716
|
};
|
|
589
717
|
return {
|
|
590
718
|
connect: (transport) => {
|
|
719
|
+
if (closed)
|
|
720
|
+
throw new Error("Host router is closed");
|
|
591
721
|
const connection = {
|
|
722
|
+
closed: false,
|
|
592
723
|
transport,
|
|
593
724
|
userId: null,
|
|
594
725
|
subscriptions: new Set(),
|
|
595
726
|
joinedServers: new Set(),
|
|
727
|
+
sessions: new Map(),
|
|
596
728
|
queue: Promise.resolve(),
|
|
597
729
|
queuedMessages: 0,
|
|
598
730
|
worldRevisions: new Map(),
|
|
599
731
|
roles: new Map(),
|
|
732
|
+
subscriptionPushes: new Map(),
|
|
733
|
+
admission: null,
|
|
600
734
|
};
|
|
601
735
|
connections.add(connection);
|
|
602
736
|
return {
|
|
603
737
|
handleRaw: (raw) => {
|
|
738
|
+
if (closed || connection.closed)
|
|
739
|
+
return;
|
|
604
740
|
const text = typeof raw === "string" ? raw : String(raw);
|
|
605
741
|
const message = decodeWsClientMessage(text);
|
|
606
742
|
if (message === null) {
|
|
@@ -616,14 +752,17 @@ export function createHostRouter(options) {
|
|
|
616
752
|
return;
|
|
617
753
|
}
|
|
618
754
|
connection.queuedMessages += 1;
|
|
619
|
-
connection.queue = connection.queue
|
|
755
|
+
connection.queue = track(connection.queue
|
|
620
756
|
.then(() => handleMessage(connection, message))
|
|
621
757
|
.catch(() => undefined)
|
|
622
758
|
.finally(() => {
|
|
623
759
|
connection.queuedMessages -= 1;
|
|
624
|
-
});
|
|
760
|
+
}));
|
|
625
761
|
},
|
|
626
762
|
close: () => {
|
|
763
|
+
if (connection.closed)
|
|
764
|
+
return;
|
|
765
|
+
connection.closed = true;
|
|
627
766
|
if (connection.userId !== null && sessionsByUserId.get(connection.userId) === connection) {
|
|
628
767
|
sessionsByUserId.delete(connection.userId);
|
|
629
768
|
}
|
|
@@ -650,6 +789,9 @@ export function createHostRouter(options) {
|
|
|
650
789
|
return positions;
|
|
651
790
|
},
|
|
652
791
|
close: () => {
|
|
792
|
+
if (closed)
|
|
793
|
+
return;
|
|
794
|
+
closed = true;
|
|
653
795
|
unsubscribeHost();
|
|
654
796
|
for (const timer of pendingLeaves.values())
|
|
655
797
|
clearTimeout(timer);
|
|
@@ -657,6 +799,10 @@ export function createHostRouter(options) {
|
|
|
657
799
|
connections.clear();
|
|
658
800
|
subscribers.clear();
|
|
659
801
|
},
|
|
802
|
+
async drain() {
|
|
803
|
+
while (work.size > 0)
|
|
804
|
+
await Promise.allSettled([...work]);
|
|
805
|
+
},
|
|
660
806
|
};
|
|
661
807
|
}
|
|
662
808
|
export function loopbackPipe(router) {
|
package/dist/peer.js
CHANGED
|
@@ -126,12 +126,19 @@ export function createPeerHost(options) {
|
|
|
126
126
|
};
|
|
127
127
|
channel.onclose = () => connection.close();
|
|
128
128
|
};
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
try {
|
|
130
|
+
await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
|
|
131
|
+
const answer = await pc.createAnswer();
|
|
132
|
+
await pc.setLocalDescription(answer);
|
|
133
|
+
await waitForIceGathering(pc, iceGatherTimeoutMs);
|
|
134
|
+
const description = localDescriptionOrThrow(pc);
|
|
135
|
+
return encodePeerSignal({ type: "answer", sdp: description.sdp });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
peerConnections.delete(pc);
|
|
139
|
+
pc.close();
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
135
142
|
};
|
|
136
143
|
return {
|
|
137
144
|
backend,
|
|
@@ -190,7 +197,10 @@ export function broadcastChannelSignaling(room) {
|
|
|
190
197
|
const channel = new BroadcastChannel(room);
|
|
191
198
|
const pendingAnswers = new Map();
|
|
192
199
|
let currentAnswer = null;
|
|
200
|
+
let closed = false;
|
|
193
201
|
channel.onmessage = (event) => {
|
|
202
|
+
if (closed)
|
|
203
|
+
return;
|
|
194
204
|
const envelope = event.data;
|
|
195
205
|
if (!isSignalEnvelope(envelope))
|
|
196
206
|
return;
|
|
@@ -199,25 +209,40 @@ export function broadcastChannelSignaling(room) {
|
|
|
199
209
|
if (answer === null)
|
|
200
210
|
return;
|
|
201
211
|
void answer(envelope.code).then((answerCode) => {
|
|
212
|
+
if (closed)
|
|
213
|
+
return;
|
|
202
214
|
channel.postMessage({ t: "answer", id: envelope.id, code: answerCode });
|
|
215
|
+
}, (error) => {
|
|
216
|
+
if (!closed)
|
|
217
|
+
throw error;
|
|
203
218
|
});
|
|
204
219
|
return;
|
|
205
220
|
}
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
221
|
+
const pending = pendingAnswers.get(envelope.id);
|
|
222
|
+
if (pending === undefined)
|
|
208
223
|
return;
|
|
209
224
|
pendingAnswers.delete(envelope.id);
|
|
210
|
-
resolve(envelope.code);
|
|
225
|
+
pending.resolve(envelope.code);
|
|
211
226
|
};
|
|
212
227
|
return {
|
|
213
228
|
publishOffer: (offerCode) => {
|
|
229
|
+
if (closed)
|
|
230
|
+
return Promise.reject(new Error("Peer signaling is closed"));
|
|
214
231
|
const id = crypto.randomUUID();
|
|
215
|
-
return new Promise((resolve) => {
|
|
216
|
-
pendingAnswers.set(id, resolve);
|
|
217
|
-
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
pendingAnswers.set(id, { resolve, reject });
|
|
234
|
+
try {
|
|
235
|
+
channel.postMessage({ t: "offer", id, code: offerCode });
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
pendingAnswers.delete(id);
|
|
239
|
+
reject(error);
|
|
240
|
+
}
|
|
218
241
|
});
|
|
219
242
|
},
|
|
220
243
|
onOffer: (answer) => {
|
|
244
|
+
if (closed)
|
|
245
|
+
throw new Error("Peer signaling is closed");
|
|
221
246
|
currentAnswer = answer;
|
|
222
247
|
return () => {
|
|
223
248
|
if (currentAnswer === answer)
|
|
@@ -225,6 +250,14 @@ export function broadcastChannelSignaling(room) {
|
|
|
225
250
|
};
|
|
226
251
|
},
|
|
227
252
|
close: () => {
|
|
253
|
+
if (closed)
|
|
254
|
+
return;
|
|
255
|
+
closed = true;
|
|
256
|
+
currentAnswer = null;
|
|
257
|
+
channel.onmessage = null;
|
|
258
|
+
for (const pending of pendingAnswers.values()) {
|
|
259
|
+
pending.reject(new Error("Peer signaling is closed"));
|
|
260
|
+
}
|
|
228
261
|
pendingAnswers.clear();
|
|
229
262
|
channel.close();
|
|
230
263
|
},
|
package/dist/protocol.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/mat
|
|
|
5
5
|
export declare const WS_PROTOCOL_VERSION = 1;
|
|
6
6
|
/** Max length of a `runCommand` command name, in UTF-16 code units. */
|
|
7
7
|
export declare const MAX_COMMAND_LENGTH = 4096;
|
|
8
|
+
/** Max length of a join/leave `sessionId`, in UTF-16 code units. */
|
|
9
|
+
export declare const MAX_SESSION_ID_LENGTH = 128;
|
|
8
10
|
/** Max length of a `pushFeed` action name, in UTF-16 code units. */
|
|
9
11
|
export declare const MAX_FEED_ACTION_LENGTH = 256;
|
|
10
12
|
/** Max serialized size of a `pushFeed` entry payload, in bytes. */
|
|
@@ -53,6 +55,8 @@ export type WsClientMessage = {
|
|
|
53
55
|
attributes?: SessionAttributes;
|
|
54
56
|
code?: string;
|
|
55
57
|
role?: "player" | "spectator";
|
|
58
|
+
/** Client session holding this join; see `GameRuntimeTransport.joinServer`. */
|
|
59
|
+
sessionId?: string;
|
|
56
60
|
} | {
|
|
57
61
|
v: 1;
|
|
58
62
|
t: "joinByCode";
|
|
@@ -72,6 +76,7 @@ export type WsClientMessage = {
|
|
|
72
76
|
t: "leave";
|
|
73
77
|
id: number;
|
|
74
78
|
serverId: string;
|
|
79
|
+
sessionId?: string;
|
|
75
80
|
} | {
|
|
76
81
|
v: 1;
|
|
77
82
|
t: "runCommand";
|