@jgengine/ws 0.6.0 → 0.8.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.
@@ -0,0 +1,493 @@
1
+ import { decidePoseSync } from "@jgengine/core/multiplayer/presenceModel";
2
+ import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
3
+ import { createChatRateLimiter, DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, } from "@jgengine/core/game/chat";
4
+ import { decodeWsClientMessage, encodeWsMessage, subscriptionKey, } from "./protocol.js";
5
+ export const DEFAULT_POSE_RULES = {
6
+ maxSpeed: 12,
7
+ maxVerticalOffset: 3,
8
+ minElapsedSec: 0.05,
9
+ maxElapsedSec: 0.5,
10
+ keepAliveRefreshMs: 10_000,
11
+ };
12
+ function appearanceEqual(a, b) {
13
+ if (a === b)
14
+ return true;
15
+ if (a === undefined || b === undefined)
16
+ return false;
17
+ const aKeys = Object.keys(a);
18
+ const bKeys = Object.keys(b);
19
+ if (aKeys.length !== bKeys.length)
20
+ return false;
21
+ return aKeys.every((key) => a[key] === b[key]);
22
+ }
23
+ export function createHostRouter(options) {
24
+ const host = options.host;
25
+ const now = options.now ?? Date.now;
26
+ const poseRules = options.poseRules ?? DEFAULT_POSE_RULES;
27
+ const authenticate = options.authenticate ?? (({ userId }) => userId);
28
+ const connections = new Set();
29
+ const presence = new Map();
30
+ const positionHistoryMs = options.positionHistoryMs ?? 1_000;
31
+ const histories = new Map();
32
+ const historyFor = (serverId) => {
33
+ let history = histories.get(serverId);
34
+ if (history === undefined) {
35
+ history = createPositionHistory({ historyMs: positionHistoryMs });
36
+ histories.set(serverId, history);
37
+ }
38
+ return history;
39
+ };
40
+ const send = (connection, message) => {
41
+ connection.transport.send(encodeWsMessage(message));
42
+ };
43
+ const reply = (connection, id, result) => {
44
+ send(connection, { v: 1, t: "reply", id, ok: true, result });
45
+ };
46
+ const replyError = (connection, id, reason) => {
47
+ send(connection, { v: 1, t: "reply", id, ok: false, reason });
48
+ };
49
+ const presenceRows = (serverId) => {
50
+ const rows = presence.get(serverId);
51
+ if (rows === undefined)
52
+ return [];
53
+ return [...rows.entries()].map(([userId, pose]) => ({
54
+ userId,
55
+ position: pose.position,
56
+ rotationY: pose.rotationY,
57
+ rotationPitch: pose.rotationPitch ?? 0,
58
+ lastSeenAt: pose.lastSeenAtMs ?? 0,
59
+ appearance: pose.appearance,
60
+ }));
61
+ };
62
+ const broadcastPresence = (serverId) => {
63
+ const key = subscriptionKey("presence", serverId);
64
+ const data = presenceRows(serverId);
65
+ for (const connection of connections) {
66
+ if (!connection.subscriptions.has(key))
67
+ continue;
68
+ send(connection, { v: 1, t: "update", channel: "presence", serverId, data });
69
+ }
70
+ };
71
+ const chatRings = new Map();
72
+ const chatLimiter = createChatRateLimiter(options.chatRateLimit ?? DEFAULT_CHAT_RATE_LIMIT);
73
+ const chatHistoryLimit = options.chatHistoryLimit ?? DEFAULT_CHAT_HISTORY_LIMIT;
74
+ const chatMaxBodyLength = options.chatMaxBodyLength ?? DEFAULT_CHAT_BODY_LENGTH;
75
+ let chatCounter = 0;
76
+ const chatRing = (serverId, channelId) => {
77
+ const key = `${serverId}|${channelId}`;
78
+ let ring = chatRings.get(key);
79
+ if (ring === undefined) {
80
+ ring = [];
81
+ chatRings.set(key, ring);
82
+ }
83
+ return ring;
84
+ };
85
+ const broadcastChat = (serverId, channelId) => {
86
+ const key = subscriptionKey("chat", serverId, channelId);
87
+ const data = chatRing(serverId, channelId).slice();
88
+ for (const connection of connections) {
89
+ if (!connection.subscriptions.has(key))
90
+ continue;
91
+ send(connection, { v: 1, t: "update", channel: "chat", serverId, action: channelId, data });
92
+ }
93
+ };
94
+ const voiceRosters = new Map();
95
+ const voiceRoster = (serverId, channelId) => {
96
+ const key = `${serverId}|${channelId}`;
97
+ let roster = voiceRosters.get(key);
98
+ if (roster === undefined) {
99
+ roster = new Map();
100
+ voiceRosters.set(key, roster);
101
+ }
102
+ return roster;
103
+ };
104
+ const voiceParticipants = (serverId, channelId) => [...voiceRoster(serverId, channelId).values()].map((participant) => ({ ...participant }));
105
+ const broadcastVoice = (serverId, channelId) => {
106
+ const key = subscriptionKey("voice", serverId, channelId);
107
+ const data = voiceParticipants(serverId, channelId);
108
+ for (const connection of connections) {
109
+ if (!connection.subscriptions.has(key))
110
+ continue;
111
+ send(connection, { v: 1, t: "update", channel: "voice", serverId, action: channelId, data });
112
+ }
113
+ };
114
+ const dropVoice = (connection) => {
115
+ if (connection.userId === null)
116
+ return;
117
+ for (const [key, roster] of voiceRosters) {
118
+ if (!roster.delete(connection.userId))
119
+ continue;
120
+ if (roster.size === 0)
121
+ voiceRosters.delete(key);
122
+ const [serverId, channelId] = key.split("|");
123
+ broadcastVoice(serverId, channelId);
124
+ }
125
+ };
126
+ const dropVoiceForServer = (userId, serverId) => {
127
+ const prefix = `${serverId}|`;
128
+ for (const [key, roster] of voiceRosters) {
129
+ if (!key.startsWith(prefix))
130
+ continue;
131
+ if (!roster.delete(userId))
132
+ continue;
133
+ if (roster.size === 0)
134
+ voiceRosters.delete(key);
135
+ broadcastVoice(serverId, key.slice(prefix.length));
136
+ }
137
+ };
138
+ const handleChatSend = (connection, message) => {
139
+ const userId = connection.userId;
140
+ if (userId === null) {
141
+ replyError(connection, message.id, "Not authenticated");
142
+ return;
143
+ }
144
+ const body = message.body.trim();
145
+ if (body.length === 0) {
146
+ replyError(connection, message.id, "empty message");
147
+ return;
148
+ }
149
+ if (body.length > chatMaxBodyLength) {
150
+ replyError(connection, message.id, "message too long");
151
+ return;
152
+ }
153
+ const timestamp = now();
154
+ if (!chatLimiter.allow(`${userId}|${message.serverId}`, timestamp)) {
155
+ replyError(connection, message.id, "rate limited");
156
+ return;
157
+ }
158
+ chatCounter += 1;
159
+ const entry = {
160
+ id: `msg_${chatCounter}`,
161
+ channelId: message.channelId,
162
+ fromUserId: userId,
163
+ body,
164
+ at: timestamp,
165
+ };
166
+ const ring = chatRing(message.serverId, message.channelId);
167
+ ring.push(entry);
168
+ if (ring.length > chatHistoryLimit)
169
+ ring.splice(0, ring.length - chatHistoryLimit);
170
+ reply(connection, message.id, null);
171
+ broadcastChat(message.serverId, message.channelId);
172
+ };
173
+ const pushSubscription = async (connection, channel, serverId, action) => {
174
+ if (connection.userId === null)
175
+ return;
176
+ if (channel === "server") {
177
+ const data = await host.getServerView({ userId: connection.userId, serverId });
178
+ send(connection, { v: 1, t: "update", channel, serverId, data });
179
+ }
180
+ else if (channel === "player") {
181
+ const data = await host.getPlayerView({ userId: connection.userId, serverId });
182
+ send(connection, { v: 1, t: "update", channel, serverId, data });
183
+ }
184
+ else if (channel === "feed") {
185
+ const data = await host.getFeed({ userId: connection.userId, serverId, action: action ?? "" });
186
+ send(connection, { v: 1, t: "update", channel, serverId, action: action ?? "", data });
187
+ }
188
+ else if (channel === "chat") {
189
+ const channelId = action ?? "";
190
+ send(connection, {
191
+ v: 1,
192
+ t: "update",
193
+ channel,
194
+ serverId,
195
+ action: channelId,
196
+ data: chatRing(serverId, channelId).slice(),
197
+ });
198
+ }
199
+ else if (channel === "voice") {
200
+ const channelId = action ?? "";
201
+ send(connection, {
202
+ v: 1,
203
+ t: "update",
204
+ channel,
205
+ serverId,
206
+ action: channelId,
207
+ data: voiceParticipants(serverId, channelId),
208
+ });
209
+ }
210
+ else {
211
+ send(connection, { v: 1, t: "update", channel, serverId, data: presenceRows(serverId) });
212
+ }
213
+ };
214
+ const onHostEvent = (event) => {
215
+ for (const connection of connections) {
216
+ if (connection.userId === null)
217
+ continue;
218
+ if (event.type === "server") {
219
+ if (connection.subscriptions.has(subscriptionKey("server", event.serverId))) {
220
+ void pushSubscription(connection, "server", event.serverId);
221
+ }
222
+ }
223
+ else if (event.type === "player") {
224
+ if (connection.userId === event.userId &&
225
+ connection.subscriptions.has(subscriptionKey("player", event.serverId))) {
226
+ void pushSubscription(connection, "player", event.serverId);
227
+ }
228
+ }
229
+ else {
230
+ if (connection.subscriptions.has(subscriptionKey("feed", event.serverId, event.action))) {
231
+ void pushSubscription(connection, "feed", event.serverId, event.action);
232
+ }
233
+ }
234
+ }
235
+ };
236
+ const unsubscribeHost = host.subscribe(onHostEvent);
237
+ const handlePose = (connection, serverId, pose) => {
238
+ if (connection.userId === null)
239
+ return;
240
+ const rows = presence.get(serverId) ?? new Map();
241
+ presence.set(serverId, rows);
242
+ const timestamp = now();
243
+ const current = rows.get(connection.userId);
244
+ if (current === undefined) {
245
+ rows.set(connection.userId, {
246
+ position: { x: pose.x, y: pose.y, z: pose.z },
247
+ rotationY: pose.rotationY,
248
+ rotationPitch: pose.rotationPitch,
249
+ lastSeenAtMs: timestamp,
250
+ appearance: pose.appearance,
251
+ });
252
+ historyFor(serverId).record(connection.userId, timestamp, { x: pose.x, y: pose.y, z: pose.z });
253
+ broadcastPresence(serverId);
254
+ return;
255
+ }
256
+ const decision = decidePoseSync(current, { position: { x: pose.x, y: pose.y, z: pose.z }, rotationY: pose.rotationY, rotationPitch: pose.rotationPitch }, poseRules, timestamp);
257
+ const appearanceChanged = pose.appearance !== undefined && !appearanceEqual(pose.appearance, current.appearance);
258
+ if (decision.changed || decision.refreshKeepAlive || appearanceChanged) {
259
+ rows.set(connection.userId, {
260
+ position: decision.position,
261
+ rotationY: decision.rotationY,
262
+ rotationPitch: decision.rotationPitch,
263
+ lastSeenAtMs: timestamp,
264
+ appearance: pose.appearance ?? current.appearance,
265
+ });
266
+ historyFor(serverId).record(connection.userId, timestamp, decision.position);
267
+ }
268
+ if (decision.changed || appearanceChanged)
269
+ broadcastPresence(serverId);
270
+ };
271
+ const dropPresence = (connection) => {
272
+ if (connection.userId === null)
273
+ return;
274
+ for (const [serverId, rows] of presence) {
275
+ if (rows.delete(connection.userId)) {
276
+ if (rows.size === 0)
277
+ presence.delete(serverId);
278
+ broadcastPresence(serverId);
279
+ }
280
+ }
281
+ };
282
+ const dropPresenceForServer = (userId, serverId) => {
283
+ const rows = presence.get(serverId);
284
+ if (rows === undefined || !rows.delete(userId))
285
+ return;
286
+ if (rows.size === 0)
287
+ presence.delete(serverId);
288
+ broadcastPresence(serverId);
289
+ };
290
+ const handleMessage = async (connection, message) => {
291
+ if (message.t === "hello") {
292
+ const userId = await authenticate({ userId: message.userId, token: message.token });
293
+ if (userId === null) {
294
+ replyError(connection, message.id, "Not authenticated");
295
+ connection.transport.close();
296
+ return;
297
+ }
298
+ connection.userId = userId;
299
+ reply(connection, message.id, { userId });
300
+ return;
301
+ }
302
+ if (message.t === "pose") {
303
+ handlePose(connection, message.serverId, message.pose);
304
+ return;
305
+ }
306
+ if (connection.userId === null) {
307
+ replyError(connection, message.id, "Not authenticated");
308
+ return;
309
+ }
310
+ const userId = connection.userId;
311
+ try {
312
+ switch (message.t) {
313
+ case "join": {
314
+ const result = await host.joinServer({
315
+ userId,
316
+ gameId: message.gameId,
317
+ serverId: message.serverId,
318
+ attributes: message.attributes,
319
+ });
320
+ reply(connection, message.id, result);
321
+ return;
322
+ }
323
+ case "joinByCode": {
324
+ const result = await host.joinByCode({
325
+ userId,
326
+ gameId: message.gameId,
327
+ code: message.code,
328
+ });
329
+ reply(connection, message.id, result);
330
+ return;
331
+ }
332
+ case "browse": {
333
+ const result = await host.browseServers({
334
+ gameId: message.gameId,
335
+ filter: message.filter,
336
+ limit: message.limit,
337
+ });
338
+ reply(connection, message.id, result);
339
+ return;
340
+ }
341
+ case "leave": {
342
+ await host.leaveServer({ userId, serverId: message.serverId });
343
+ dropPresenceForServer(userId, message.serverId);
344
+ dropVoiceForServer(userId, message.serverId);
345
+ reply(connection, message.id, null);
346
+ return;
347
+ }
348
+ case "runCommand": {
349
+ const result = await host.runCommand({
350
+ userId,
351
+ serverId: message.serverId,
352
+ command: message.command,
353
+ input: message.input,
354
+ });
355
+ reply(connection, message.id, result);
356
+ return;
357
+ }
358
+ case "pushFeed": {
359
+ await host.pushFeedEntry({
360
+ userId,
361
+ serverId: message.serverId,
362
+ action: message.action,
363
+ entry: message.entry,
364
+ });
365
+ reply(connection, message.id, null);
366
+ return;
367
+ }
368
+ case "chatSend": {
369
+ handleChatSend(connection, message);
370
+ return;
371
+ }
372
+ case "voiceJoin": {
373
+ const participant = { userId };
374
+ if (message.streamId !== undefined)
375
+ participant.streamId = message.streamId;
376
+ voiceRoster(message.serverId, message.channelId).set(userId, participant);
377
+ reply(connection, message.id, null);
378
+ broadcastVoice(message.serverId, message.channelId);
379
+ return;
380
+ }
381
+ case "voiceLeave": {
382
+ const roster = voiceRoster(message.serverId, message.channelId);
383
+ const removed = roster.delete(userId);
384
+ reply(connection, message.id, null);
385
+ if (removed)
386
+ broadcastVoice(message.serverId, message.channelId);
387
+ return;
388
+ }
389
+ case "voicePublish": {
390
+ const participant = voiceRoster(message.serverId, message.channelId).get(userId);
391
+ if (participant === undefined) {
392
+ replyError(connection, message.id, "not in this voice channel");
393
+ return;
394
+ }
395
+ participant.streamId = message.streamId;
396
+ reply(connection, message.id, null);
397
+ broadcastVoice(message.serverId, message.channelId);
398
+ return;
399
+ }
400
+ case "subscribe": {
401
+ connection.subscriptions.add(subscriptionKey(message.channel, message.serverId, message.action));
402
+ reply(connection, message.id, null);
403
+ await pushSubscription(connection, message.channel, message.serverId, message.action);
404
+ return;
405
+ }
406
+ case "unsubscribe": {
407
+ connection.subscriptions.delete(subscriptionKey(message.channel, message.serverId, message.action));
408
+ reply(connection, message.id, null);
409
+ return;
410
+ }
411
+ }
412
+ }
413
+ catch (error) {
414
+ replyError(connection, message.id, error instanceof Error ? error.message : "Internal error");
415
+ }
416
+ };
417
+ return {
418
+ connect: (transport) => {
419
+ const connection = { transport, userId: null, subscriptions: new Set() };
420
+ connections.add(connection);
421
+ return {
422
+ handleRaw: (raw) => {
423
+ const message = decodeWsClientMessage(typeof raw === "string" ? raw : String(raw));
424
+ if (message === null)
425
+ return;
426
+ void handleMessage(connection, message);
427
+ },
428
+ close: () => {
429
+ connections.delete(connection);
430
+ dropPresence(connection);
431
+ dropVoice(connection);
432
+ },
433
+ };
434
+ },
435
+ rewind: ({ serverId, atMs }) => {
436
+ const history = histories.get(serverId);
437
+ if (history === undefined)
438
+ return [];
439
+ const positions = [];
440
+ for (const userId of history.entities()) {
441
+ const sample = history.sampleAt(userId, atMs);
442
+ if (sample !== null) {
443
+ positions.push({ userId, x: sample.x, y: sample.y, z: sample.z });
444
+ }
445
+ }
446
+ return positions;
447
+ },
448
+ close: () => {
449
+ unsubscribeHost();
450
+ connections.clear();
451
+ },
452
+ };
453
+ }
454
+ export function loopbackPipe(router) {
455
+ return (handlers) => {
456
+ let open = true;
457
+ const connection = router.connect({
458
+ send: (data) => {
459
+ if (!open)
460
+ return;
461
+ queueMicrotask(() => {
462
+ if (open)
463
+ handlers.onMessage(data);
464
+ });
465
+ },
466
+ close: () => {
467
+ if (!open)
468
+ return;
469
+ open = false;
470
+ connection.close();
471
+ queueMicrotask(() => handlers.onClose());
472
+ },
473
+ });
474
+ queueMicrotask(() => {
475
+ if (open)
476
+ handlers.onOpen();
477
+ });
478
+ return {
479
+ send: (data) => {
480
+ if (!open)
481
+ return;
482
+ queueMicrotask(() => connection.handleRaw(data));
483
+ },
484
+ close: () => {
485
+ if (!open)
486
+ return;
487
+ open = false;
488
+ connection.close();
489
+ queueMicrotask(() => handlers.onClose());
490
+ },
491
+ };
492
+ };
493
+ }
package/dist/peer.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
+ import type { HostPersistence } from "@jgengine/core/runtime/hostPersistence";
3
+ import { type GameHost } from "./host.js";
4
+ import { type HostRouter, type HostRouterOptions } from "./hostRouter.js";
5
+ import { type WsBackend } from "./createWsBackend.js";
6
+ export type PeerSignalPayload = {
7
+ type: "offer" | "answer";
8
+ sdp: string;
9
+ };
10
+ export declare function encodePeerSignal(payload: PeerSignalPayload): string;
11
+ export declare function decodePeerSignal(code: string): PeerSignalPayload | null;
12
+ export type PeerRtcOptions = {
13
+ configuration?: RTCConfiguration;
14
+ iceGatherTimeoutMs?: number;
15
+ };
16
+ export type PeerHostOptions = {
17
+ userId: string;
18
+ token?: string;
19
+ host?: GameHost;
20
+ runtimes?: GameRuntime[];
21
+ persistence?: HostPersistence;
22
+ tickMs?: number;
23
+ router?: Omit<HostRouterOptions, "host">;
24
+ rtc?: PeerRtcOptions;
25
+ };
26
+ export type PeerHost = {
27
+ backend: WsBackend;
28
+ host: GameHost;
29
+ router: HostRouter;
30
+ accept: (offerCode: string) => Promise<string>;
31
+ close: () => void;
32
+ };
33
+ export declare function createPeerHost(options: PeerHostOptions): PeerHost;
34
+ export type PeerGuestOptions = {
35
+ userId: string;
36
+ token?: string;
37
+ rtc?: PeerRtcOptions;
38
+ };
39
+ export type PeerGuest = {
40
+ backend: WsBackend;
41
+ offer: () => Promise<string>;
42
+ connect: (answerCode: string) => Promise<void>;
43
+ close: () => void;
44
+ };
45
+ export declare function createPeerGuest(options: PeerGuestOptions): PeerGuest;
46
+ export type PeerSignaling = {
47
+ publishOffer: (offerCode: string) => Promise<string>;
48
+ onOffer: (answer: (offerCode: string) => Promise<string>) => () => void;
49
+ close: () => void;
50
+ };
51
+ export declare function broadcastChannelSignaling(room: string): PeerSignaling;
52
+ export declare function announcePeerHost(host: PeerHost, signaling: PeerSignaling): () => void;
53
+ export declare function joinPeerSession(guest: PeerGuest, signaling: PeerSignaling): Promise<WsBackend>;