@hadoku/rooms-server 0.0.0-stage → 0.43.15
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/dist/emit.d.ts +85 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/emit.js +128 -0
- package/dist/gc.d.ts +23 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/gc.js +22 -0
- package/dist/identity.d.ts +45 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +87 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/rateLimiter.d.ts +26 -0
- package/dist/rateLimiter.d.ts.map +1 -0
- package/dist/rateLimiter.js +95 -0
- package/dist/state.d.ts +186 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +221 -0
- package/package.json +28 -4
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Matthaeus Wolff
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/emit.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Server as SocketIOServer, Socket } from 'socket.io';
|
|
2
|
+
import { type SeatRole } from '@wolffm/rooms-protocol';
|
|
3
|
+
import type { CoreRoomState, ClientMeta } from './state.js';
|
|
4
|
+
/**
|
|
5
|
+
* Backpressure-aware emit.
|
|
6
|
+
*
|
|
7
|
+
* A socket whose write buffer is already deep cannot be helped by adding to
|
|
8
|
+
* it, so above a threshold the low-value events are dropped rather than
|
|
9
|
+
* queued. Which events those are is the HOSTING APP's call: this was extracted
|
|
10
|
+
* with `control_broadcast`, `chat_broadcast` and `time_pong` hardcoded, and
|
|
11
|
+
* shipping that list would have silently dropped a card game's state deltas as
|
|
12
|
+
* "low priority".
|
|
13
|
+
*/
|
|
14
|
+
export interface EmitPolicy {
|
|
15
|
+
/** Above this many buffered bytes, droppable events are dropped. */
|
|
16
|
+
thresholdBytes: number;
|
|
17
|
+
/** Never dropped, whatever the buffer depth. */
|
|
18
|
+
highPriority: ReadonlySet<string>;
|
|
19
|
+
/** Droppable, EARLIEST FIRST. An event in neither list is never dropped. */
|
|
20
|
+
lowPriorityOrder: readonly string[];
|
|
21
|
+
/** Called when an event is dropped (metrics, throttled logging). */
|
|
22
|
+
onDrop?: (info: {
|
|
23
|
+
event: string;
|
|
24
|
+
bufferedBytes: number;
|
|
25
|
+
socketId: string;
|
|
26
|
+
}) => void;
|
|
27
|
+
/** Called when the emit itself threw, after a retry. */
|
|
28
|
+
onError?: (info: {
|
|
29
|
+
event: string;
|
|
30
|
+
socketId: string;
|
|
31
|
+
err: unknown;
|
|
32
|
+
}) => void;
|
|
33
|
+
}
|
|
34
|
+
export declare function safeEmit(socket: Socket, event: string, payload: unknown, policy: EmitPolicy): void;
|
|
35
|
+
/**
|
|
36
|
+
* Returned from an `emitPerRecipient` builder to send that recipient nothing.
|
|
37
|
+
*
|
|
38
|
+
* A sentinel rather than `null`, because `null` is a legal payload — and this
|
|
39
|
+
* codebase has already paid for that ambiguity once: `applySnapshot` has to
|
|
40
|
+
* use `!== undefined` rather than `??` precisely because `null` means "no
|
|
41
|
+
* media" there rather than "absent".
|
|
42
|
+
*/
|
|
43
|
+
export declare const SKIP: unique symbol;
|
|
44
|
+
/**
|
|
45
|
+
* Emit to every client in a room. Returns the recipient count.
|
|
46
|
+
*
|
|
47
|
+
* Uses socket.io's own room set when the sockets have joined it, falling back
|
|
48
|
+
* to the room's client map otherwise — and cross-checks membership either way,
|
|
49
|
+
* so a socket that is in the adapter's room but not ours is not served.
|
|
50
|
+
*/
|
|
51
|
+
export declare function broadcastToRoom<TApp, TClient>(io: SocketIOServer, room: CoreRoomState<TApp, TClient>, event: string, payload: unknown, policy?: EmitPolicy): number;
|
|
52
|
+
/**
|
|
53
|
+
* Emit a DIFFERENT payload to each recipient. Returns the number sent.
|
|
54
|
+
*
|
|
55
|
+
* The reason this exists: broadcasting one payload to the whole room is
|
|
56
|
+
* structurally wrong for anything with hidden state. A card game's opponent
|
|
57
|
+
* must not receive your hand *at all* — not receive it and decline to render
|
|
58
|
+
* it. Filtering at the client is not privacy.
|
|
59
|
+
*
|
|
60
|
+
* `build` returns the payload for that recipient, or `SKIP` to send nothing.
|
|
61
|
+
* It runs once per connected client, so keep anything expensive outside it.
|
|
62
|
+
*
|
|
63
|
+
* ## Why `TPayload` is generic and constrained
|
|
64
|
+
*
|
|
65
|
+
* This took `unknown | typeof SKIP` in 0.38.110, which **constrains nothing**:
|
|
66
|
+
* `unknown` absorbs every union member, so the whole thing collapses to plain
|
|
67
|
+
* `unknown` and the `SKIP` arm was decoration. A `build` that fell off the end
|
|
68
|
+
* and returned `undefined` type-checked cleanly. (Found by the first outside
|
|
69
|
+
* consumer reading the published `.d.ts`.)
|
|
70
|
+
*
|
|
71
|
+
* The bound is `{} | null` rather than a bare type parameter because a bare one
|
|
72
|
+
* would simply infer `TPayload = undefined` and compile again. `{}` excludes
|
|
73
|
+
* both `null` and `undefined`, so the union puts `null` back deliberately —
|
|
74
|
+
* `null` IS a legal payload here, which is the entire reason `SKIP` is a symbol.
|
|
75
|
+
*/
|
|
76
|
+
export declare function emitPerRecipient<TApp, TClient, TPayload extends {} | null>(io: SocketIOServer, room: CoreRoomState<TApp, TClient>, event: string, build: (client: ClientMeta<TClient>) => TPayload | typeof SKIP, policy?: EmitPolicy): number;
|
|
77
|
+
/**
|
|
78
|
+
* Emit one payload to everyone holding a given seat role. Returns the count.
|
|
79
|
+
*
|
|
80
|
+
* Role is DERIVED from the seat table (`roleOf`), never stored — see
|
|
81
|
+
* rooms-protocol's seats.ts for why a stored role and the seating can
|
|
82
|
+
* disagree, and what that costs.
|
|
83
|
+
*/
|
|
84
|
+
export declare function emitToRole<TApp, TClient, TPayload extends {} | null>(io: SocketIOServer, room: CoreRoomState<TApp, TClient>, role: SeatRole, event: string, payload: TPayload, policy?: EmitPolicy): number;
|
|
85
|
+
//# sourceMappingURL=emit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"emit.d.ts","sourceRoot":"","sources":["../src/emit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,IAAI,cAAc,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAClE,OAAO,EAAU,KAAK,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE5D;;;;;;;;;GASG;AACH,MAAM,WAAW,UAAU;IACzB,oEAAoE;IACpE,cAAc,EAAE,MAAM,CAAC;IACvB,gDAAgD;IAChD,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,4EAA4E;IAC5E,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,oEAAoE;IACpE,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACpF,wDAAwD;IACxD,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;CAC7E;AAyBD,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI,CAmBlG;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,IAAI,EAAE,OAAO,MAA6B,CAAC;AAExD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,OAAO,EAC3C,EAAE,EAAE,cAAc,EAClB,IAAI,EAAE,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,EAClC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,EAChB,MAAM,CAAC,EAAE,UAAU,GAClB,MAAM,CAaR;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,SAAS,EAAE,GAAG,IAAI,EACxE,EAAE,EAAE,cAAc,EAClB,IAAI,EAAE,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,EAClC,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,CAAC,MAAM,EAAE,UAAU,CAAC,OAAO,CAAC,KAAK,QAAQ,GAAG,OAAO,IAAI,EAC9D,MAAM,CAAC,EAAE,UAAU,GAClB,MAAM,CAYR;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,SAAS,EAAE,GAAG,IAAI,EAClE,EAAE,EAAE,cAAc,EAClB,IAAI,EAAE,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,EAClC,IAAI,EAAE,QAAQ,EACd,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,QAAQ,EACjB,MAAM,CAAC,EAAE,UAAU,GAClB,MAAM,CAQR"}
|
package/dist/emit.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { roleOf } from '@wolffm/rooms-protocol';
|
|
2
|
+
/**
|
|
3
|
+
* An event in NEITHER list is never dropped.
|
|
4
|
+
*
|
|
5
|
+
* That default is deliberate: a new event type added by an app that forgot to
|
|
6
|
+
* classify it will be delivered, not silently discarded. The failure mode of
|
|
7
|
+
* over-delivery is a slow client; the failure mode of over-dropping is a
|
|
8
|
+
* feature that mysteriously does not work under load.
|
|
9
|
+
*/
|
|
10
|
+
function bufferedBytes(socket) {
|
|
11
|
+
// engine.io's write buffer. Approximate by design — the exact wire size is
|
|
12
|
+
// not knowable here, and the decision only needs an order of magnitude.
|
|
13
|
+
const engine = socket.conn;
|
|
14
|
+
const writeBuffer = engine?.writeBuffer ?? [];
|
|
15
|
+
let total = 0;
|
|
16
|
+
for (const pkt of writeBuffer) {
|
|
17
|
+
if (pkt && pkt.data != null) {
|
|
18
|
+
const str = typeof pkt.data === 'string' ? pkt.data : JSON.stringify(pkt.data);
|
|
19
|
+
total += Buffer.byteLength(str);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return total;
|
|
23
|
+
}
|
|
24
|
+
export function safeEmit(socket, event, payload, policy) {
|
|
25
|
+
try {
|
|
26
|
+
const buffered = bufferedBytes(socket);
|
|
27
|
+
if (buffered > policy.thresholdBytes && !policy.highPriority.has(event)) {
|
|
28
|
+
if (policy.lowPriorityOrder.includes(event)) {
|
|
29
|
+
policy.onDrop?.({ event, bufferedBytes: buffered, socketId: socket.id });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
socket.emit(event, payload);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Buffer estimation failed (an engine.io internal changed shape). Still
|
|
37
|
+
// attempt the emit: a failed measurement must not cost delivery.
|
|
38
|
+
try {
|
|
39
|
+
socket.emit(event, payload);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
policy.onError?.({ event, socketId: socket.id, err });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Returned from an `emitPerRecipient` builder to send that recipient nothing.
|
|
48
|
+
*
|
|
49
|
+
* A sentinel rather than `null`, because `null` is a legal payload — and this
|
|
50
|
+
* codebase has already paid for that ambiguity once: `applySnapshot` has to
|
|
51
|
+
* use `!== undefined` rather than `??` precisely because `null` means "no
|
|
52
|
+
* media" there rather than "absent".
|
|
53
|
+
*/
|
|
54
|
+
export const SKIP = Symbol('rooms.skip');
|
|
55
|
+
/**
|
|
56
|
+
* Emit to every client in a room. Returns the recipient count.
|
|
57
|
+
*
|
|
58
|
+
* Uses socket.io's own room set when the sockets have joined it, falling back
|
|
59
|
+
* to the room's client map otherwise — and cross-checks membership either way,
|
|
60
|
+
* so a socket that is in the adapter's room but not ours is not served.
|
|
61
|
+
*/
|
|
62
|
+
export function broadcastToRoom(io, room, event, payload, policy) {
|
|
63
|
+
let sent = 0;
|
|
64
|
+
const roomSockets = io.sockets.adapter.rooms.get(room.roomId);
|
|
65
|
+
const socketIds = roomSockets ?? room.clients.keys();
|
|
66
|
+
for (const socketId of socketIds) {
|
|
67
|
+
const socket = io.sockets.sockets.get(socketId);
|
|
68
|
+
if (socket && room.clients.has(socketId)) {
|
|
69
|
+
if (policy)
|
|
70
|
+
safeEmit(socket, event, payload, policy);
|
|
71
|
+
else
|
|
72
|
+
socket.emit(event, payload);
|
|
73
|
+
sent++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return sent;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Emit a DIFFERENT payload to each recipient. Returns the number sent.
|
|
80
|
+
*
|
|
81
|
+
* The reason this exists: broadcasting one payload to the whole room is
|
|
82
|
+
* structurally wrong for anything with hidden state. A card game's opponent
|
|
83
|
+
* must not receive your hand *at all* — not receive it and decline to render
|
|
84
|
+
* it. Filtering at the client is not privacy.
|
|
85
|
+
*
|
|
86
|
+
* `build` returns the payload for that recipient, or `SKIP` to send nothing.
|
|
87
|
+
* It runs once per connected client, so keep anything expensive outside it.
|
|
88
|
+
*
|
|
89
|
+
* ## Why `TPayload` is generic and constrained
|
|
90
|
+
*
|
|
91
|
+
* This took `unknown | typeof SKIP` in 0.38.110, which **constrains nothing**:
|
|
92
|
+
* `unknown` absorbs every union member, so the whole thing collapses to plain
|
|
93
|
+
* `unknown` and the `SKIP` arm was decoration. A `build` that fell off the end
|
|
94
|
+
* and returned `undefined` type-checked cleanly. (Found by the first outside
|
|
95
|
+
* consumer reading the published `.d.ts`.)
|
|
96
|
+
*
|
|
97
|
+
* The bound is `{} | null` rather than a bare type parameter because a bare one
|
|
98
|
+
* would simply infer `TPayload = undefined` and compile again. `{}` excludes
|
|
99
|
+
* both `null` and `undefined`, so the union puts `null` back deliberately —
|
|
100
|
+
* `null` IS a legal payload here, which is the entire reason `SKIP` is a symbol.
|
|
101
|
+
*/
|
|
102
|
+
export function emitPerRecipient(io, room, event, build, policy) {
|
|
103
|
+
let sent = 0;
|
|
104
|
+
for (const [clientId, meta] of room.clients) {
|
|
105
|
+
const socket = io.sockets.sockets.get(clientId);
|
|
106
|
+
if (!socket)
|
|
107
|
+
continue;
|
|
108
|
+
const payload = build(meta);
|
|
109
|
+
if (payload === SKIP)
|
|
110
|
+
continue;
|
|
111
|
+
if (policy)
|
|
112
|
+
safeEmit(socket, event, payload, policy);
|
|
113
|
+
else
|
|
114
|
+
socket.emit(event, payload);
|
|
115
|
+
sent++;
|
|
116
|
+
}
|
|
117
|
+
return sent;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Emit one payload to everyone holding a given seat role. Returns the count.
|
|
121
|
+
*
|
|
122
|
+
* Role is DERIVED from the seat table (`roleOf`), never stored — see
|
|
123
|
+
* rooms-protocol's seats.ts for why a stored role and the seating can
|
|
124
|
+
* disagree, and what that costs.
|
|
125
|
+
*/
|
|
126
|
+
export function emitToRole(io, room, role, event, payload, policy) {
|
|
127
|
+
return emitPerRecipient(io, room, event, (client) => (roleOf(room.seats, client.participantId) === role ? payload : SKIP), policy);
|
|
128
|
+
}
|
package/dist/gc.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CoreRoomState } from './state.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reaping idle and empty rooms.
|
|
4
|
+
*
|
|
5
|
+
* `evaluateRoom` is pure so the policy can be tested without a registry.
|
|
6
|
+
*/
|
|
7
|
+
export interface GCEvalResult {
|
|
8
|
+
idle: boolean;
|
|
9
|
+
empty: boolean;
|
|
10
|
+
shouldDestroy: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface GCTimings {
|
|
13
|
+
/** Threshold to consider a room idle. */
|
|
14
|
+
idleMs: number;
|
|
15
|
+
/** Grace period after the last client leaves. */
|
|
16
|
+
emptyGraceMs: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function evaluateRoom<TApp, TClient>(room: CoreRoomState<TApp, TClient>, timings: GCTimings, now?: number): GCEvalResult;
|
|
19
|
+
/** Rooms that should be destroyed now. */
|
|
20
|
+
export declare function collectGC<TApp, TClient>(rooms: Array<CoreRoomState<TApp, TClient>>, timings: GCTimings, now?: number): {
|
|
21
|
+
destroy: Array<CoreRoomState<TApp, TClient>>;
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=gc.d.ts.map
|
package/dist/gc.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gc.d.ts","sourceRoot":"","sources":["../src/gc.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;GAIG;AAEH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,yCAAyC;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EACxC,IAAI,EAAE,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,EAClC,OAAO,EAAE,SAAS,EAClB,GAAG,GAAE,MAAmB,GACvB,YAAY,CAWd;AAED,0CAA0C;AAC1C,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,EACrC,KAAK,EAAE,KAAK,CAAC,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,EAC1C,OAAO,EAAE,SAAS,EAClB,GAAG,GAAE,MAAmB,GACvB;IAAE,OAAO,EAAE,KAAK,CAAC,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAA;CAAE,CAMlD"}
|
package/dist/gc.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function evaluateRoom(room, timings, now = Date.now()) {
|
|
2
|
+
const idle = (now - room.updatedAt) >= timings.idleMs;
|
|
3
|
+
const empty = room.clients.size === 0;
|
|
4
|
+
let shouldDestroy = false;
|
|
5
|
+
if (empty) {
|
|
6
|
+
// `updatedAt` stands in for "last emptied". Coarse but conservative: any
|
|
7
|
+
// activity at all pushes the destroy decision out.
|
|
8
|
+
const emptyDuration = now - room.updatedAt;
|
|
9
|
+
if (emptyDuration >= timings.emptyGraceMs)
|
|
10
|
+
shouldDestroy = true;
|
|
11
|
+
}
|
|
12
|
+
return { idle, empty, shouldDestroy };
|
|
13
|
+
}
|
|
14
|
+
/** Rooms that should be destroyed now. */
|
|
15
|
+
export function collectGC(rooms, timings, now = Date.now()) {
|
|
16
|
+
const destroy = [];
|
|
17
|
+
for (const r of rooms) {
|
|
18
|
+
if (evaluateRoom(r, timings, now).shouldDestroy)
|
|
19
|
+
destroy.push(r);
|
|
20
|
+
}
|
|
21
|
+
return { destroy };
|
|
22
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ParticipantId, type ParticipantIdTier } from '@wolffm/rooms-protocol';
|
|
2
|
+
export interface DeriveParticipantInput {
|
|
3
|
+
/**
|
|
4
|
+
* The platform account id — the key registry's `userId`, resolved
|
|
5
|
+
* SERVER-SIDE from the caller's credential through whoami. The `account`
|
|
6
|
+
* tier's preferred material, because it survives key rotation. Never take
|
|
7
|
+
* it from a client payload or from the `X-User-Id` header a request that
|
|
8
|
+
* bypassed the edge could set.
|
|
9
|
+
*/
|
|
10
|
+
accountId?: string | null;
|
|
11
|
+
/**
|
|
12
|
+
* DEPRECATED account material — the raw `X-User-Key` credential. Used only
|
|
13
|
+
* when no `accountId` is given. An id derived from it changes whenever the
|
|
14
|
+
* key rotates, so a consumer still passing it should resolve the userId and
|
|
15
|
+
* pass that instead.
|
|
16
|
+
*/
|
|
17
|
+
friendKey?: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* A stable-but-unproven browser id. CLIENT-ASSERTED, so `device` is NOT an
|
|
20
|
+
* authorization boundary — gate power on `isAuthenticatedTier`, never on
|
|
21
|
+
* `isDurableParticipantId`.
|
|
22
|
+
*/
|
|
23
|
+
deviceId?: string | null;
|
|
24
|
+
/** Fallback for a connection nobody can name. Dies with the transport. */
|
|
25
|
+
socketId: string;
|
|
26
|
+
}
|
|
27
|
+
export interface DerivedParticipant {
|
|
28
|
+
id: ParticipantId;
|
|
29
|
+
tier: ParticipantIdTier;
|
|
30
|
+
}
|
|
31
|
+
export type ParticipantIdDeriver = (input: DeriveParticipantInput) => DerivedParticipant;
|
|
32
|
+
/**
|
|
33
|
+
* Bind a derivation to one app's domain.
|
|
34
|
+
*
|
|
35
|
+
* A FACTORY rather than a `(input, domain)` function so the domain is chosen
|
|
36
|
+
* once, at module scope, and no call site can mint a second identity space for
|
|
37
|
+
* the same person by passing a different string. The highest available tier
|
|
38
|
+
* wins: an account id (or, deprecated, a friend key) beats a device id beats
|
|
39
|
+
* the socket id; an account id beats a friend key. Account material present
|
|
40
|
+
* alongside a device id ignores the device id entirely —
|
|
41
|
+
* preferring the client-controlled input while authenticated is the one
|
|
42
|
+
* combination that lets somebody hold two identities at once.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createParticipantIdDeriver(domain: string): ParticipantIdDeriver;
|
|
45
|
+
//# sourceMappingURL=identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,iBAAiB,EAKvB,MAAM,wBAAwB,CAAC;AA6ChC,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,aAAa,CAAC;IAClB,IAAI,EAAE,iBAAiB,CAAC;CACzB;AAED,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,sBAAsB,KAAK,kBAAkB,CAAC;AASzF;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,MAAM,GAAG,oBAAoB,CAwB/E"}
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { formatParticipantId, isUsableAccountId, resolveParticipantTier, PARTICIPANT_ID_DIGEST_LENGTH, } from '@wolffm/rooms-protocol';
|
|
3
|
+
/**
|
|
4
|
+
* Participant identity — the DERIVATION half.
|
|
5
|
+
*
|
|
6
|
+
* `@wolffm/rooms-protocol` owns the FORMAT (the prefixes, the tier gates, the
|
|
7
|
+
* digest length) and stops there, because it is zero-dependency and isomorphic:
|
|
8
|
+
* hashing needs a synchronous primitive, `node:crypto` is absent in a browser
|
|
9
|
+
* bundle, and Web Crypto's digest is async. This package is server-only, so the
|
|
10
|
+
* hash lives here.
|
|
11
|
+
*
|
|
12
|
+
* It lives here rather than in each app because it had already been written
|
|
13
|
+
* twice — watchparty's `rooms/participantId.ts` and hadoku_site's
|
|
14
|
+
* `templates/game` `identity.ts` — with two different separation schemes for
|
|
15
|
+
* one security property. The second copy is where the property quietly stops
|
|
16
|
+
* holding.
|
|
17
|
+
*
|
|
18
|
+
* ## The DOMAIN is the app's, and it is required
|
|
19
|
+
*
|
|
20
|
+
* Ids minted under one domain agree only with ids minted under that domain, so
|
|
21
|
+
* two hadoku apps hashing the same friend key produce unrelated participants.
|
|
22
|
+
* That is deliberate: a room in one app must not be able to assert a person's
|
|
23
|
+
* identity in another, and an app that leaks its ids does not leak everyone
|
|
24
|
+
* else's. VERSION the string (`hadoku.<app>.participant.v1`) so changing the
|
|
25
|
+
* scheme is a visible re-identification rather than a silent one.
|
|
26
|
+
*
|
|
27
|
+
* ## Per-TIER separation is enforced here, not by the caller
|
|
28
|
+
*
|
|
29
|
+
* The tier is hashed in alongside the domain. Without it, a client that learned
|
|
30
|
+
* any friend key could present it as its DEVICE id and land on that
|
|
31
|
+
* authenticated participant's identity — collapsing the tier distinction the
|
|
32
|
+
* whole scheme rests on, silently. An earlier version left this to the caller
|
|
33
|
+
* by asking for two domain strings; a caller that passed the same string twice
|
|
34
|
+
* got a working system with the property removed, and nothing said so.
|
|
35
|
+
*
|
|
36
|
+
* ## What comes out is safe to log, ship and show
|
|
37
|
+
*
|
|
38
|
+
* The friend key IS the credential and never leaves this module. The body of an
|
|
39
|
+
* `fk:`/`dev:` id is a truncated SHA-256 of it, which cannot be reversed — see
|
|
40
|
+
* `participantHandle` in rooms-protocol for the part you may show a room.
|
|
41
|
+
*/
|
|
42
|
+
/** Separator: U+001F cannot appear in a header value, so fields cannot run together. */
|
|
43
|
+
const SEP = '\u001f';
|
|
44
|
+
function digest(domain, tier, material) {
|
|
45
|
+
return createHash('sha256')
|
|
46
|
+
.update(`${domain}${SEP}${tier}${SEP}${material}`, 'utf8')
|
|
47
|
+
.digest('hex')
|
|
48
|
+
.slice(0, PARTICIPANT_ID_DIGEST_LENGTH);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Bind a derivation to one app's domain.
|
|
52
|
+
*
|
|
53
|
+
* A FACTORY rather than a `(input, domain)` function so the domain is chosen
|
|
54
|
+
* once, at module scope, and no call site can mint a second identity space for
|
|
55
|
+
* the same person by passing a different string. The highest available tier
|
|
56
|
+
* wins: an account id (or, deprecated, a friend key) beats a device id beats
|
|
57
|
+
* the socket id; an account id beats a friend key. Account material present
|
|
58
|
+
* alongside a device id ignores the device id entirely —
|
|
59
|
+
* preferring the client-controlled input while authenticated is the one
|
|
60
|
+
* combination that lets somebody hold two identities at once.
|
|
61
|
+
*/
|
|
62
|
+
export function createParticipantIdDeriver(domain) {
|
|
63
|
+
if (!domain)
|
|
64
|
+
throw new Error('participant id domain is required');
|
|
65
|
+
return (input) => {
|
|
66
|
+
const tier = resolveParticipantTier(input);
|
|
67
|
+
switch (tier) {
|
|
68
|
+
case 'account':
|
|
69
|
+
// The material is LABELLED so a userId and a credential can never
|
|
70
|
+
// digest to the same body, whatever their values.
|
|
71
|
+
return {
|
|
72
|
+
id: formatParticipantId(tier, isUsableAccountId(input.accountId)
|
|
73
|
+
? digest(domain, tier, `uid${SEP}${input.accountId}`)
|
|
74
|
+
: digest(domain, tier, input.friendKey)),
|
|
75
|
+
tier,
|
|
76
|
+
};
|
|
77
|
+
case 'device':
|
|
78
|
+
return { id: formatParticipantId(tier, digest(domain, tier, input.deviceId)), tier };
|
|
79
|
+
case 'ephemeral':
|
|
80
|
+
// NOT hashed: a socket id is already a public, server-minted, opaque
|
|
81
|
+
// handle every client in the room sees, and it dies with the transport.
|
|
82
|
+
// Hashing it would cost a round of guessing for nothing and make logs
|
|
83
|
+
// harder to correlate with engine.io's own.
|
|
84
|
+
return { id: formatParticipantId(tier, input.socketId), tier };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @wolffm/rooms-server — room mechanics for a socket.io server.
|
|
3
|
+
*
|
|
4
|
+
* Room lifecycle and membership, stable participant identity, seating, avatar
|
|
5
|
+
* claims, name persistence across reconnect, backpressure-aware emit, per-key
|
|
6
|
+
* rate limiting, and idle/empty reaping.
|
|
7
|
+
*
|
|
8
|
+
* Carries no hosting-app vocabulary and no opinion about what a room is FOR —
|
|
9
|
+
* that lives in `CoreRoomState<TApp>`'s `app`. Three things a hosting app
|
|
10
|
+
* supplies rather than inherits: the avatar pool (`claimAvatar`), the emit
|
|
11
|
+
* priority classes (`EmitPolicy`), and its own logging/metrics via the policy
|
|
12
|
+
* callbacks. Each was hardcoded in the code this was extracted from, and each
|
|
13
|
+
* would have been a wrong answer for the second consumer.
|
|
14
|
+
*/
|
|
15
|
+
export * from './state.js';
|
|
16
|
+
export * from './identity.js';
|
|
17
|
+
export * from './emit.js';
|
|
18
|
+
export * from './gc.js';
|
|
19
|
+
export * from './rateLimiter.js';
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,kBAAkB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @wolffm/rooms-server — room mechanics for a socket.io server.
|
|
3
|
+
*
|
|
4
|
+
* Room lifecycle and membership, stable participant identity, seating, avatar
|
|
5
|
+
* claims, name persistence across reconnect, backpressure-aware emit, per-key
|
|
6
|
+
* rate limiting, and idle/empty reaping.
|
|
7
|
+
*
|
|
8
|
+
* Carries no hosting-app vocabulary and no opinion about what a room is FOR —
|
|
9
|
+
* that lives in `CoreRoomState<TApp>`'s `app`. Three things a hosting app
|
|
10
|
+
* supplies rather than inherits: the avatar pool (`claimAvatar`), the emit
|
|
11
|
+
* priority classes (`EmitPolicy`), and its own logging/metrics via the policy
|
|
12
|
+
* callbacks. Each was hardcoded in the code this was extracted from, and each
|
|
13
|
+
* would have been a wrong answer for the second consumer.
|
|
14
|
+
*/
|
|
15
|
+
export * from './state.js';
|
|
16
|
+
export * from './identity.js';
|
|
17
|
+
export * from './emit.js';
|
|
18
|
+
export * from './gc.js';
|
|
19
|
+
export * from './rateLimiter.js';
|
|
20
|
+
// NOT `export * from '@wolffm/rooms-protocol'`. Re-exporting it would give
|
|
21
|
+
// every symbol two import paths, and the first thing that drifts is which one
|
|
22
|
+
// a file happens to use. Consumers depend on both packages explicitly.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-bucket rate limiting, per key.
|
|
3
|
+
*
|
|
4
|
+
* The limits themselves are NOT here — a hosting app picks its own capacities
|
|
5
|
+
* and windows (watchparty has four: control, seek, chat, game). This module is
|
|
6
|
+
* only the mechanism.
|
|
7
|
+
*/
|
|
8
|
+
export interface RateLimitConfig {
|
|
9
|
+
capacity: number;
|
|
10
|
+
refillAmount: number;
|
|
11
|
+
refillIntervalMs: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class MultiRateLimiter<K = string> {
|
|
14
|
+
private cfg;
|
|
15
|
+
private buckets;
|
|
16
|
+
constructor(cfg: RateLimitConfig);
|
|
17
|
+
/** Remove this limiter from the sweep registry (call on socket disconnect). */
|
|
18
|
+
deregister(): void;
|
|
19
|
+
allow(key: K, tokens?: number, now?: number): boolean;
|
|
20
|
+
remaining(key: K): number;
|
|
21
|
+
/** Remove buckets not accessed within maxAgeMs. */
|
|
22
|
+
prune(maxAgeMs: number, now?: number): number;
|
|
23
|
+
}
|
|
24
|
+
/** Prune stale buckets from every live MultiRateLimiter. */
|
|
25
|
+
export declare function pruneAllLimiters(maxAgeMs: number): number;
|
|
26
|
+
//# sourceMappingURL=rateLimiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rateLimiter.d.ts","sourceRoot":"","sources":["../src/rateLimiter.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAkDD,qBAAa,gBAAgB,CAAC,CAAC,GAAG,MAAM;IAE1B,OAAO,CAAC,GAAG;IADvB,OAAO,CAAC,OAAO,CAAgC;gBAC3B,GAAG,EAAE,eAAe;IAIxC,+EAA+E;IAC/E,UAAU,IAAI,IAAI;IAKlB,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,MAAM,GAAE,MAAU,EAAE,GAAG,GAAE,MAAmB,GAAG,OAAO;IAWpE,SAAS,CAAC,GAAG,EAAE,CAAC,GAAG,MAAM;IAMzB,mDAAmD;IACnD,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM;CAU1D;AAED,4DAA4D;AAC5D,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAOzD"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-bucket rate limiting, per key.
|
|
3
|
+
*
|
|
4
|
+
* The limits themselves are NOT here — a hosting app picks its own capacities
|
|
5
|
+
* and windows (watchparty has four: control, seek, chat, game). This module is
|
|
6
|
+
* only the mechanism.
|
|
7
|
+
*/
|
|
8
|
+
class TokenBucket {
|
|
9
|
+
config;
|
|
10
|
+
state;
|
|
11
|
+
constructor(config, now = Date.now()) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.state = { tokens: config.capacity, lastRefill: now };
|
|
14
|
+
}
|
|
15
|
+
tryRemove(tokens = 1, now = Date.now()) {
|
|
16
|
+
this.refill(now);
|
|
17
|
+
if (this.state.tokens >= tokens) {
|
|
18
|
+
this.state.tokens -= tokens;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
refill(now) {
|
|
24
|
+
const { refillIntervalMs, refillAmount, capacity } = this.config;
|
|
25
|
+
if (now <= this.state.lastRefill)
|
|
26
|
+
return;
|
|
27
|
+
const elapsed = now - this.state.lastRefill;
|
|
28
|
+
if (elapsed < refillIntervalMs)
|
|
29
|
+
return;
|
|
30
|
+
const intervals = Math.floor(elapsed / refillIntervalMs);
|
|
31
|
+
if (intervals <= 0)
|
|
32
|
+
return;
|
|
33
|
+
this.state.tokens = Math.min(capacity, this.state.tokens + intervals * refillAmount);
|
|
34
|
+
this.state.lastRefill += intervals * refillIntervalMs;
|
|
35
|
+
}
|
|
36
|
+
get remaining() {
|
|
37
|
+
this.refill(Date.now());
|
|
38
|
+
return this.state.tokens;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Registry of live limiters, so a periodic sweep can prune every one's stale
|
|
42
|
+
// buckets without the caller tracking them. `deregister()` on disconnect is
|
|
43
|
+
// what keeps this from being a leak.
|
|
44
|
+
const allLimiters = [];
|
|
45
|
+
export class MultiRateLimiter {
|
|
46
|
+
cfg;
|
|
47
|
+
buckets = new Map();
|
|
48
|
+
constructor(cfg) {
|
|
49
|
+
this.cfg = cfg;
|
|
50
|
+
allLimiters.push(this);
|
|
51
|
+
}
|
|
52
|
+
/** Remove this limiter from the sweep registry (call on socket disconnect). */
|
|
53
|
+
deregister() {
|
|
54
|
+
const idx = allLimiters.indexOf(this);
|
|
55
|
+
if (idx !== -1)
|
|
56
|
+
allLimiters.splice(idx, 1);
|
|
57
|
+
}
|
|
58
|
+
allow(key, tokens = 1, now = Date.now()) {
|
|
59
|
+
let entry = this.buckets.get(key);
|
|
60
|
+
if (!entry) {
|
|
61
|
+
entry = { key, bucket: new TokenBucket(this.cfg, now), lastAccessed: now };
|
|
62
|
+
this.buckets.set(key, entry);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
entry.lastAccessed = now;
|
|
66
|
+
}
|
|
67
|
+
return entry.bucket.tryRemove(tokens, now);
|
|
68
|
+
}
|
|
69
|
+
remaining(key) {
|
|
70
|
+
const entry = this.buckets.get(key);
|
|
71
|
+
if (!entry)
|
|
72
|
+
return this.cfg.capacity;
|
|
73
|
+
return entry.bucket.remaining;
|
|
74
|
+
}
|
|
75
|
+
/** Remove buckets not accessed within maxAgeMs. */
|
|
76
|
+
prune(maxAgeMs, now = Date.now()) {
|
|
77
|
+
let pruned = 0;
|
|
78
|
+
for (const [key, entry] of this.buckets) {
|
|
79
|
+
if (now - entry.lastAccessed >= maxAgeMs) {
|
|
80
|
+
this.buckets.delete(key);
|
|
81
|
+
pruned++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return pruned;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Prune stale buckets from every live MultiRateLimiter. */
|
|
88
|
+
export function pruneAllLimiters(maxAgeMs) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
let total = 0;
|
|
91
|
+
for (const limiter of allLimiters) {
|
|
92
|
+
total += limiter.prune(maxAgeMs, now);
|
|
93
|
+
}
|
|
94
|
+
return total;
|
|
95
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { type ParticipantId, type ParticipantIdTier, type SeatTable } from '@wolffm/rooms-protocol';
|
|
2
|
+
/** Builds the seat table for one room. See `RoomRegistry`'s constructor. */
|
|
3
|
+
export type SeatTableFactory = () => SeatTable;
|
|
4
|
+
/**
|
|
5
|
+
* Room state and membership, generic over the hosting app's own state.
|
|
6
|
+
*
|
|
7
|
+
* Everything here is true of ANY room. Anything specific to what the room is
|
|
8
|
+
* FOR belongs in `TApp` — see `CoreRoomState.app`.
|
|
9
|
+
*/
|
|
10
|
+
export interface ClientMeta<TClient = unknown> {
|
|
11
|
+
/** Ephemeral per-connection id. In practice the socket id. */
|
|
12
|
+
clientId: string;
|
|
13
|
+
socketId: string;
|
|
14
|
+
/**
|
|
15
|
+
* Stable identity of the PERSON, surviving reconnect. Unlike `clientId` this
|
|
16
|
+
* is durable at the account and device tiers — see rooms-protocol's
|
|
17
|
+
* participantId.ts for the tier contract, and why `device` is not an
|
|
18
|
+
* authorization boundary.
|
|
19
|
+
*/
|
|
20
|
+
participantId: ParticipantId;
|
|
21
|
+
joinedAt: number;
|
|
22
|
+
/** Presence heartbeat timestamp (ms). */
|
|
23
|
+
lastSeen: number;
|
|
24
|
+
name?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Chosen avatar, from a pool the HOSTING APP supplies (`claimAvatar`). The
|
|
27
|
+
* pool is not a room concept — watchparty's is a list of character sprites,
|
|
28
|
+
* and baking that vocabulary in here is exactly what this package must not do.
|
|
29
|
+
*/
|
|
30
|
+
avatarId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Authenticated credential for this connection, when there is one. Server-
|
|
33
|
+
* internal: it IS the auth material, so it must never reach a payload.
|
|
34
|
+
*/
|
|
35
|
+
friendKey?: string;
|
|
36
|
+
/**
|
|
37
|
+
* The platform account id (registry `userId`) this connection resolved to,
|
|
38
|
+
* when it resolved to one. The identity anything DURABLE about this person
|
|
39
|
+
* keys on — stats, ballots, ownership — because unlike `friendKey` it
|
|
40
|
+
* survives key rotation. Server-resolved; never taken from a payload.
|
|
41
|
+
*/
|
|
42
|
+
accountId?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The hosting app's own per-client data, the same split as
|
|
45
|
+
* `CoreRoomState.app`. Watchparty keeps its key type, room key and health
|
|
46
|
+
* telemetry here; a card game would keep whatever it needs.
|
|
47
|
+
*
|
|
48
|
+
* Generic rather than `unknown` so an app reads its own fields without a
|
|
49
|
+
* cast, and rather than absent so the package never grows a field like
|
|
50
|
+
* `keyType: 'viewer' | 'leader'` — which is app vocabulary wearing a
|
|
51
|
+
* room-shaped name.
|
|
52
|
+
*/
|
|
53
|
+
app: TClient;
|
|
54
|
+
}
|
|
55
|
+
export interface CoreRoomState<TApp, TClient = unknown> {
|
|
56
|
+
roomId: string;
|
|
57
|
+
/** Provided at join (auth). Not broadcast. */
|
|
58
|
+
roomKey: string;
|
|
59
|
+
/** The room's authority, by clientId, or null. */
|
|
60
|
+
leaderId: string | null;
|
|
61
|
+
/** Monotonic sequence for outbound events. */
|
|
62
|
+
lastEventSeq: number;
|
|
63
|
+
createdAt: number;
|
|
64
|
+
updatedAt: number;
|
|
65
|
+
/** clientId -> meta. Keyed by the EPHEMERAL id; see `clientByParticipant`. */
|
|
66
|
+
clients: Map<string, ClientMeta<TClient>>;
|
|
67
|
+
/** Avatars currently claimed by connected clients. */
|
|
68
|
+
usedAvatars: Set<string>;
|
|
69
|
+
/**
|
|
70
|
+
* ParticipantId -> custom name. Durable tiers survive a reconnect; the
|
|
71
|
+
* ephemeral tier does not, and is dropped on leave.
|
|
72
|
+
*/
|
|
73
|
+
clientNames: Map<ParticipantId, string>;
|
|
74
|
+
/** Who is in the room and in what ORDER — see rooms-protocol's seats.ts. */
|
|
75
|
+
seats: SeatTable;
|
|
76
|
+
/** The hosting app's own state. */
|
|
77
|
+
app: TApp;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A set of rooms.
|
|
81
|
+
*
|
|
82
|
+
* A CLASS rather than the module-level `const rooms = new Map()` this was
|
|
83
|
+
* extracted from. In an application a module singleton is merely implicit; in a
|
|
84
|
+
* library it means two consumers in one process silently share a room table.
|
|
85
|
+
* A hosting app that wants one registry holds one instance — same behaviour,
|
|
86
|
+
* stated rather than assumed.
|
|
87
|
+
*/
|
|
88
|
+
export declare class RoomRegistry<TApp, TClient = unknown> {
|
|
89
|
+
private createApp;
|
|
90
|
+
private createSeats;
|
|
91
|
+
private rooms;
|
|
92
|
+
private onDelete;
|
|
93
|
+
/**
|
|
94
|
+
* @param createApp the room's own app state, per room.
|
|
95
|
+
* @param createSeats the room's seat table, per room. Defaults to
|
|
96
|
+
* unbounded with no reservations, which is right for a room with no slot
|
|
97
|
+
* limit (watching video) and wrong for almost any game.
|
|
98
|
+
*
|
|
99
|
+
* A FACTORY, not a table: one `SeatTable` shared across rooms would seat
|
|
100
|
+
* every player in every room. And a parameter rather than a hardcoded
|
|
101
|
+
* `createSeatTable(null)`, because capacity and seat-hold are product
|
|
102
|
+
* decisions this package has no business making — hardcoding them made the
|
|
103
|
+
* first consumer reach in and overwrite `room.seats` immediately after
|
|
104
|
+
* `seed`, which is two owners of one field and the shape of a race the day
|
|
105
|
+
* something else reads it in between.
|
|
106
|
+
*/
|
|
107
|
+
constructor(createApp: (now: number) => TApp, createSeats?: SeatTableFactory);
|
|
108
|
+
/**
|
|
109
|
+
* Create a room deterministically. Returns the existing one if present.
|
|
110
|
+
*
|
|
111
|
+
* `createSeats` overrides the registry's default for THIS room — a pod size
|
|
112
|
+
* that varies per room is a normal thing for a game to want, and it must be
|
|
113
|
+
* settable at creation rather than patched afterwards.
|
|
114
|
+
*/
|
|
115
|
+
seed(roomId: string, roomKey: string, createSeats?: SeatTableFactory): CoreRoomState<TApp, TClient>;
|
|
116
|
+
get(roomId: string): CoreRoomState<TApp, TClient> | undefined;
|
|
117
|
+
findByKey(roomKey: string): CoreRoomState<TApp, TClient> | undefined;
|
|
118
|
+
all(): Array<CoreRoomState<TApp, TClient>>;
|
|
119
|
+
get size(): number;
|
|
120
|
+
/** Register a callback for room deletion (cleanup of app-side caches). */
|
|
121
|
+
onRoomDelete(cb: (roomId: string) => void): void;
|
|
122
|
+
delete(roomId: string): void;
|
|
123
|
+
}
|
|
124
|
+
export declare function joinRoom<TApp, TClient>(room: CoreRoomState<TApp, TClient>, clientId: string, socketId: string, participantId: ParticipantId, createClientApp: () => TClient): ClientMeta<TClient>;
|
|
125
|
+
/**
|
|
126
|
+
* Remove ONE CONNECTION from a room.
|
|
127
|
+
*
|
|
128
|
+
* A connection, not a person — and the difference is the whole of the ordering
|
|
129
|
+
* below. `clients` is keyed by the ephemeral client id, so one participant can
|
|
130
|
+
* hold several entries at once: a second tab, or a stale socket whose close
|
|
131
|
+
* lands AFTER its replacement has already joined. Both are routine.
|
|
132
|
+
*
|
|
133
|
+
* **The seat is given up only when this was the person's LAST connection**, and
|
|
134
|
+
* the delete therefore happens FIRST so that question can be asked honestly.
|
|
135
|
+
* Unseating unconditionally takes the seat from a player who is sitting right
|
|
136
|
+
* there on a live connection — and on a reconnect race it does so *after* the
|
|
137
|
+
* new connection re-seated them, so the repair is invisible to the code that
|
|
138
|
+
* caused it. The first consumer to build a game on this package found it and
|
|
139
|
+
* wrapped every call in a guard that re-seats the participant afterwards; that
|
|
140
|
+
* guard belongs here, once, not in each consumer.
|
|
141
|
+
*
|
|
142
|
+
* The avatar is released on every close regardless, because it is claimed per
|
|
143
|
+
* CONNECTION (`ClientMeta.avatarId`), not per participant.
|
|
144
|
+
*/
|
|
145
|
+
export declare function leaveRoom<TApp, TClient>(room: CoreRoomState<TApp, TClient>, clientId: string): void;
|
|
146
|
+
/**
|
|
147
|
+
* The live client for a participant, or undefined when not connected.
|
|
148
|
+
*
|
|
149
|
+
* `clients` is keyed by the ephemeral id, so this is the reverse lookup from
|
|
150
|
+
* the stable identity a seat holds back to the current connection. Linear,
|
|
151
|
+
* which is fine at room scale and keeps a second index from drifting.
|
|
152
|
+
*/
|
|
153
|
+
export declare function clientByParticipant<TApp, TClient>(room: CoreRoomState<TApp, TClient>, participantId: ParticipantId): ClientMeta<TClient> | undefined;
|
|
154
|
+
/** Claim an avatar. False when it is not in the pool, or already taken. */
|
|
155
|
+
export declare function claimAvatar<TApp, TClient>(room: CoreRoomState<TApp, TClient>, avatarId: string, pool: readonly string[]): boolean;
|
|
156
|
+
/** Release an avatar back to the pool. Idempotent. */
|
|
157
|
+
export declare function releaseAvatar<TApp, TClient>(room: CoreRoomState<TApp, TClient>, avatarId: string | undefined): void;
|
|
158
|
+
/** Unclaimed avatars, in pool order. */
|
|
159
|
+
export declare function availableAvatars<TApp, TClient>(room: CoreRoomState<TApp, TClient>, pool: readonly string[]): string[];
|
|
160
|
+
/** Claimed avatars, in pool order. */
|
|
161
|
+
export declare function usedAvatars<TApp, TClient>(room: CoreRoomState<TApp, TClient>, pool: readonly string[]): string[];
|
|
162
|
+
export type IdentitySource = 'client-sent' | 'restored' | 'none';
|
|
163
|
+
export interface IdentityResolution {
|
|
164
|
+
/** The name to apply, if any. */
|
|
165
|
+
name?: string;
|
|
166
|
+
/** How `name` was determined (or 'none' if the caller assigns a default). */
|
|
167
|
+
source: IdentitySource;
|
|
168
|
+
/** Durability of the identity it was persisted under. Callers log this. */
|
|
169
|
+
tier: ParticipantIdTier | null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Resolve (and persist) a client's name on join. A pure decision over the
|
|
173
|
+
* room's name store, so it is testable without a live socket. Priority:
|
|
174
|
+
* 1. A client-sent name is authoritative — apply and persist it.
|
|
175
|
+
* 2. Otherwise restore whatever this participant was called last time.
|
|
176
|
+
* 3. Otherwise 'none' (the caller assigns a default).
|
|
177
|
+
*
|
|
178
|
+
* Callers MUST log the returned resolution, so a restore that silently did NOT
|
|
179
|
+
* happen can never read as a success.
|
|
180
|
+
*/
|
|
181
|
+
export declare function resolveJoinName<TApp, TClient>(room: CoreRoomState<TApp, TClient>, participantId: ParticipantId, sentName: string | undefined): IdentityResolution;
|
|
182
|
+
/** Store a custom name against a participant identity. */
|
|
183
|
+
export declare function setClientName<TApp, TClient>(room: CoreRoomState<TApp, TClient>, participantId: ParticipantId, name: string): void;
|
|
184
|
+
/** A participant's stored custom name, if any. */
|
|
185
|
+
export declare function getClientName<TApp, TClient>(room: CoreRoomState<TApp, TClient>, participantId: ParticipantId): string | undefined;
|
|
186
|
+
//# sourceMappingURL=state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"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"}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { participantIdTier, isDurableParticipantId, createSeatTable, seatParticipant, unseatParticipant, } from '@wolffm/rooms-protocol';
|
|
2
|
+
/**
|
|
3
|
+
* A set of rooms.
|
|
4
|
+
*
|
|
5
|
+
* A CLASS rather than the module-level `const rooms = new Map()` this was
|
|
6
|
+
* extracted from. In an application a module singleton is merely implicit; in a
|
|
7
|
+
* library it means two consumers in one process silently share a room table.
|
|
8
|
+
* A hosting app that wants one registry holds one instance — same behaviour,
|
|
9
|
+
* stated rather than assumed.
|
|
10
|
+
*/
|
|
11
|
+
export class RoomRegistry {
|
|
12
|
+
createApp;
|
|
13
|
+
createSeats;
|
|
14
|
+
rooms = new Map();
|
|
15
|
+
onDelete = [];
|
|
16
|
+
/**
|
|
17
|
+
* @param createApp the room's own app state, per room.
|
|
18
|
+
* @param createSeats the room's seat table, per room. Defaults to
|
|
19
|
+
* unbounded with no reservations, which is right for a room with no slot
|
|
20
|
+
* limit (watching video) and wrong for almost any game.
|
|
21
|
+
*
|
|
22
|
+
* A FACTORY, not a table: one `SeatTable` shared across rooms would seat
|
|
23
|
+
* every player in every room. And a parameter rather than a hardcoded
|
|
24
|
+
* `createSeatTable(null)`, because capacity and seat-hold are product
|
|
25
|
+
* decisions this package has no business making — hardcoding them made the
|
|
26
|
+
* first consumer reach in and overwrite `room.seats` immediately after
|
|
27
|
+
* `seed`, which is two owners of one field and the shape of a race the day
|
|
28
|
+
* something else reads it in between.
|
|
29
|
+
*/
|
|
30
|
+
constructor(createApp, createSeats = () => createSeatTable(null)) {
|
|
31
|
+
this.createApp = createApp;
|
|
32
|
+
this.createSeats = createSeats;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create a room deterministically. Returns the existing one if present.
|
|
36
|
+
*
|
|
37
|
+
* `createSeats` overrides the registry's default for THIS room — a pod size
|
|
38
|
+
* that varies per room is a normal thing for a game to want, and it must be
|
|
39
|
+
* settable at creation rather than patched afterwards.
|
|
40
|
+
*/
|
|
41
|
+
seed(roomId, roomKey, createSeats = this.createSeats) {
|
|
42
|
+
const existing = this.rooms.get(roomId);
|
|
43
|
+
if (existing)
|
|
44
|
+
return existing;
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const state = {
|
|
47
|
+
roomId,
|
|
48
|
+
roomKey,
|
|
49
|
+
leaderId: null,
|
|
50
|
+
lastEventSeq: 0,
|
|
51
|
+
createdAt: now,
|
|
52
|
+
updatedAt: now,
|
|
53
|
+
clients: new Map(),
|
|
54
|
+
usedAvatars: new Set(),
|
|
55
|
+
clientNames: new Map(),
|
|
56
|
+
seats: createSeats(),
|
|
57
|
+
app: this.createApp(now),
|
|
58
|
+
};
|
|
59
|
+
this.rooms.set(roomId, state);
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
get(roomId) {
|
|
63
|
+
return this.rooms.get(roomId);
|
|
64
|
+
}
|
|
65
|
+
findByKey(roomKey) {
|
|
66
|
+
for (const r of this.rooms.values()) {
|
|
67
|
+
if (r.roomKey === roomKey)
|
|
68
|
+
return r;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
all() {
|
|
73
|
+
return Array.from(this.rooms.values());
|
|
74
|
+
}
|
|
75
|
+
get size() {
|
|
76
|
+
return this.rooms.size;
|
|
77
|
+
}
|
|
78
|
+
/** Register a callback for room deletion (cleanup of app-side caches). */
|
|
79
|
+
onRoomDelete(cb) {
|
|
80
|
+
this.onDelete.push(cb);
|
|
81
|
+
}
|
|
82
|
+
delete(roomId) {
|
|
83
|
+
for (const cb of this.onDelete)
|
|
84
|
+
cb(roomId);
|
|
85
|
+
this.rooms.delete(roomId);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ── Membership ──────────────────────────────────────────────────────────────
|
|
89
|
+
export function joinRoom(room, clientId, socketId, participantId, createClientApp) {
|
|
90
|
+
const meta = {
|
|
91
|
+
clientId,
|
|
92
|
+
socketId,
|
|
93
|
+
participantId,
|
|
94
|
+
joinedAt: Date.now(),
|
|
95
|
+
lastSeen: Date.now(),
|
|
96
|
+
app: createClientApp(),
|
|
97
|
+
};
|
|
98
|
+
room.clients.set(clientId, meta);
|
|
99
|
+
// Seat them, reclaiming the seat this participant last held. Returns null
|
|
100
|
+
// only when a bounded table is full of people, in which case they are a
|
|
101
|
+
// spectator — `roleOf` reads the table rather than a stored flag.
|
|
102
|
+
seatParticipant(room.seats, participantId);
|
|
103
|
+
room.updatedAt = Date.now();
|
|
104
|
+
return meta;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Remove ONE CONNECTION from a room.
|
|
108
|
+
*
|
|
109
|
+
* A connection, not a person — and the difference is the whole of the ordering
|
|
110
|
+
* below. `clients` is keyed by the ephemeral client id, so one participant can
|
|
111
|
+
* hold several entries at once: a second tab, or a stale socket whose close
|
|
112
|
+
* lands AFTER its replacement has already joined. Both are routine.
|
|
113
|
+
*
|
|
114
|
+
* **The seat is given up only when this was the person's LAST connection**, and
|
|
115
|
+
* the delete therefore happens FIRST so that question can be asked honestly.
|
|
116
|
+
* Unseating unconditionally takes the seat from a player who is sitting right
|
|
117
|
+
* there on a live connection — and on a reconnect race it does so *after* the
|
|
118
|
+
* new connection re-seated them, so the repair is invisible to the code that
|
|
119
|
+
* caused it. The first consumer to build a game on this package found it and
|
|
120
|
+
* wrapped every call in a guard that re-seats the participant afterwards; that
|
|
121
|
+
* guard belongs here, once, not in each consumer.
|
|
122
|
+
*
|
|
123
|
+
* The avatar is released on every close regardless, because it is claimed per
|
|
124
|
+
* CONNECTION (`ClientMeta.avatarId`), not per participant.
|
|
125
|
+
*/
|
|
126
|
+
export function leaveRoom(room, clientId) {
|
|
127
|
+
const meta = room.clients.get(clientId);
|
|
128
|
+
room.clients.delete(clientId);
|
|
129
|
+
if (meta) {
|
|
130
|
+
// Guarantee the avatar returns to the pool on EVERY leave path, not just
|
|
131
|
+
// the disconnect handler. Idempotent, so a caller that already released is
|
|
132
|
+
// harmless.
|
|
133
|
+
releaseAvatar(room, meta.avatarId);
|
|
134
|
+
// Drop the name only for an EPHEMERAL identity — that id is the socket id,
|
|
135
|
+
// so it can never be matched again after reconnect, and keeping it would
|
|
136
|
+
// leak one entry per connection for the life of the room. A DURABLE
|
|
137
|
+
// identity keeps its name so a reconnect restores it.
|
|
138
|
+
if (!isDurableParticipantId(meta.participantId)) {
|
|
139
|
+
room.clientNames.delete(meta.participantId);
|
|
140
|
+
}
|
|
141
|
+
// Release the seat but REMEMBER it, so a reconnect reclaims the same place
|
|
142
|
+
// instead of being appended after everyone who stayed — and only once
|
|
143
|
+
// nothing else is carrying this identity. See the header.
|
|
144
|
+
if (clientByParticipant(room, meta.participantId) === undefined) {
|
|
145
|
+
unseatParticipant(room.seats, meta.participantId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
room.updatedAt = Date.now();
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The live client for a participant, or undefined when not connected.
|
|
152
|
+
*
|
|
153
|
+
* `clients` is keyed by the ephemeral id, so this is the reverse lookup from
|
|
154
|
+
* the stable identity a seat holds back to the current connection. Linear,
|
|
155
|
+
* which is fine at room scale and keeps a second index from drifting.
|
|
156
|
+
*/
|
|
157
|
+
export function clientByParticipant(room, participantId) {
|
|
158
|
+
for (const meta of room.clients.values()) {
|
|
159
|
+
if (meta.participantId === participantId)
|
|
160
|
+
return meta;
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
// ── Avatars ─────────────────────────────────────────────────────────────────
|
|
165
|
+
//
|
|
166
|
+
// The POOL is the hosting app's, passed in at each call. That is deliberate:
|
|
167
|
+
// an avatar list is app content (watchparty's is a set of character sprites),
|
|
168
|
+
// and a package that owned it would be asserting what rooms look like.
|
|
169
|
+
/** Claim an avatar. False when it is not in the pool, or already taken. */
|
|
170
|
+
export function claimAvatar(room, avatarId, pool) {
|
|
171
|
+
if (!pool.includes(avatarId))
|
|
172
|
+
return false;
|
|
173
|
+
if (room.usedAvatars.has(avatarId))
|
|
174
|
+
return false;
|
|
175
|
+
room.usedAvatars.add(avatarId);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
/** Release an avatar back to the pool. Idempotent. */
|
|
179
|
+
export function releaseAvatar(room, avatarId) {
|
|
180
|
+
if (!avatarId)
|
|
181
|
+
return;
|
|
182
|
+
room.usedAvatars.delete(avatarId);
|
|
183
|
+
}
|
|
184
|
+
/** Unclaimed avatars, in pool order. */
|
|
185
|
+
export function availableAvatars(room, pool) {
|
|
186
|
+
return pool.filter((a) => !room.usedAvatars.has(a));
|
|
187
|
+
}
|
|
188
|
+
/** Claimed avatars, in pool order. */
|
|
189
|
+
export function usedAvatars(room, pool) {
|
|
190
|
+
return pool.filter((a) => room.usedAvatars.has(a));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Resolve (and persist) a client's name on join. A pure decision over the
|
|
194
|
+
* room's name store, so it is testable without a live socket. Priority:
|
|
195
|
+
* 1. A client-sent name is authoritative — apply and persist it.
|
|
196
|
+
* 2. Otherwise restore whatever this participant was called last time.
|
|
197
|
+
* 3. Otherwise 'none' (the caller assigns a default).
|
|
198
|
+
*
|
|
199
|
+
* Callers MUST log the returned resolution, so a restore that silently did NOT
|
|
200
|
+
* happen can never read as a success.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveJoinName(room, participantId, sentName) {
|
|
203
|
+
const tier = participantIdTier(participantId);
|
|
204
|
+
if (sentName) {
|
|
205
|
+
room.clientNames.set(participantId, sentName);
|
|
206
|
+
return { name: sentName, source: 'client-sent', tier };
|
|
207
|
+
}
|
|
208
|
+
const restored = room.clientNames.get(participantId);
|
|
209
|
+
if (restored !== undefined) {
|
|
210
|
+
return { name: restored, source: 'restored', tier };
|
|
211
|
+
}
|
|
212
|
+
return { source: 'none', tier };
|
|
213
|
+
}
|
|
214
|
+
/** Store a custom name against a participant identity. */
|
|
215
|
+
export function setClientName(room, participantId, name) {
|
|
216
|
+
room.clientNames.set(participantId, name);
|
|
217
|
+
}
|
|
218
|
+
/** A participant's stored custom name, if any. */
|
|
219
|
+
export function getClientName(room, participantId) {
|
|
220
|
+
return room.clientNames.get(participantId);
|
|
221
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hadoku/rooms-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.43.15",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Room lifecycle, membership, seating, identity and backpressure-aware emit for a socket.io server.",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"!dist/.tsbuildinfo"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/WolffM/hadoku-watchparty.git",
|
|
22
|
+
"directory": "packages/rooms-server"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@wolffm/rooms-protocol": "npm:@hadoku/rooms-protocol@0.43.15"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"socket.io": "^4.8.3"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/README.md
DELETED