@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 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
@@ -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"}
@@ -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
+ }
@@ -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
+ }
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.