@couch-kit/runtime 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,570 @@
1
+ // src/action-authorization.ts
2
+ import { InternalActionTypes } from "@couch-kit/core";
3
+ var INTERNAL_ACTION_TYPES = new Set([
4
+ InternalActionTypes.HYDRATE,
5
+ InternalActionTypes.PLAYER_JOINED,
6
+ InternalActionTypes.PLAYER_LEFT,
7
+ InternalActionTypes.PLAYER_RECONNECTED,
8
+ InternalActionTypes.PLAYER_REMOVED
9
+ ]);
10
+ function authorizeClientAction(actionType, resolvedPlayerId) {
11
+ if (INTERNAL_ACTION_TYPES.has(actionType)) {
12
+ return {
13
+ kind: "reject",
14
+ code: "FORBIDDEN_ACTION",
15
+ message: "Internal action types cannot be dispatched by clients"
16
+ };
17
+ }
18
+ if (!resolvedPlayerId) {
19
+ return {
20
+ kind: "reject",
21
+ code: "NOT_JOINED",
22
+ message: "You must JOIN before dispatching actions"
23
+ };
24
+ }
25
+ return { kind: "allow", playerId: resolvedPlayerId };
26
+ }
27
+ // src/broadcast-scheduler.ts
28
+ import { MessageTypes } from "@couch-kit/core";
29
+ var DEFAULT_STATE_THROTTLE_MS = 33;
30
+ var defaultTimerScheduler = {
31
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
32
+ clearTimeout: (timer) => clearTimeout(timer)
33
+ };
34
+
35
+ class BroadcastScheduler {
36
+ stateThrottleMs;
37
+ scheduler;
38
+ timer = null;
39
+ constructor(options = {}) {
40
+ this.stateThrottleMs = options.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS;
41
+ this.scheduler = options.scheduler ?? defaultTimerScheduler;
42
+ }
43
+ schedule(callback) {
44
+ this.cancel();
45
+ this.timer = this.scheduler.setTimeout(() => {
46
+ this.timer = null;
47
+ callback();
48
+ }, this.stateThrottleMs);
49
+ }
50
+ cancel() {
51
+ if (this.timer) {
52
+ this.scheduler.clearTimeout(this.timer);
53
+ this.timer = null;
54
+ }
55
+ }
56
+ setStateThrottleMs(stateThrottleMs) {
57
+ this.stateThrottleMs = stateThrottleMs;
58
+ }
59
+ hasPendingBroadcast() {
60
+ return this.timer !== null;
61
+ }
62
+ }
63
+ function createStateUpdateMessage(newState, actions, timestamp = Date.now()) {
64
+ return {
65
+ type: MessageTypes.STATE_UPDATE,
66
+ payload: {
67
+ newState,
68
+ timestamp,
69
+ ...actions.length === 1 ? { action: actions[0] } : actions.length > 1 ? { action: actions } : {}
70
+ }
71
+ };
72
+ }
73
+ // src/message-validation.ts
74
+ import { MessageTypes as MessageTypes2 } from "@couch-kit/core";
75
+ var DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024;
76
+ function frameByteLength(data) {
77
+ if (typeof data !== "string")
78
+ return data.byteLength;
79
+ let bytes = 0;
80
+ for (let i = 0;i < data.length; i++) {
81
+ const code = data.charCodeAt(i);
82
+ if (code < 128) {
83
+ bytes += 1;
84
+ } else if (code < 2048) {
85
+ bytes += 2;
86
+ } else if (code >= 55296 && code <= 56319) {
87
+ bytes += 4;
88
+ i++;
89
+ } else {
90
+ bytes += 3;
91
+ }
92
+ }
93
+ return bytes;
94
+ }
95
+ function isValidClientMessage(msg) {
96
+ if (typeof msg !== "object" || msg === null)
97
+ return false;
98
+ const m = msg;
99
+ if (typeof m.type !== "string")
100
+ return false;
101
+ switch (m.type) {
102
+ case MessageTypes2.JOIN:
103
+ return typeof m.payload === "object" && m.payload !== null && typeof m.payload.name === "string";
104
+ case MessageTypes2.ACTION:
105
+ return typeof m.payload === "object" && m.payload !== null && typeof m.payload.type === "string";
106
+ case MessageTypes2.PING:
107
+ return typeof m.payload === "object" && m.payload !== null && typeof m.payload.id === "string" && typeof m.payload.timestamp === "number";
108
+ case MessageTypes2.ASSETS_LOADED:
109
+ return m.payload === true;
110
+ default:
111
+ return false;
112
+ }
113
+ }
114
+ // src/rate-limiter.ts
115
+ var RATE_LIMIT_MAX = 60;
116
+ var RATE_LIMIT_WINDOW = 1000;
117
+
118
+ class ActionRateLimiter {
119
+ maxActions;
120
+ windowMs;
121
+ now;
122
+ limits = new Map;
123
+ constructor(options = {}) {
124
+ this.maxActions = options.maxActions ?? RATE_LIMIT_MAX;
125
+ this.windowMs = options.windowMs ?? RATE_LIMIT_WINDOW;
126
+ this.now = options.now ?? Date.now;
127
+ }
128
+ record(socketId) {
129
+ const now = this.now();
130
+ let rateInfo = this.limits.get(socketId);
131
+ if (!rateInfo || now - rateInfo.windowStart > this.windowMs) {
132
+ rateInfo = { count: 0, windowStart: now };
133
+ this.limits.set(socketId, rateInfo);
134
+ }
135
+ rateInfo.count++;
136
+ return {
137
+ ...rateInfo,
138
+ allowed: rateInfo.count <= this.maxActions
139
+ };
140
+ }
141
+ reset(socketId) {
142
+ this.limits.delete(socketId);
143
+ }
144
+ clear() {
145
+ this.limits.clear();
146
+ }
147
+ get(socketId) {
148
+ return this.limits.get(socketId);
149
+ }
150
+ }
151
+ // src/runtime.ts
152
+ import {
153
+ DEFAULT_DISCONNECT_TIMEOUT as DEFAULT_DISCONNECT_TIMEOUT2,
154
+ InternalActionTypes as InternalActionTypes3,
155
+ MessageTypes as MessageTypes3,
156
+ createGameReducer,
157
+ isValidSecret
158
+ } from "@couch-kit/core";
159
+
160
+ // src/session-manager.ts
161
+ import {
162
+ DEFAULT_DISCONNECT_TIMEOUT,
163
+ InternalActionTypes as InternalActionTypes2,
164
+ derivePlayerId,
165
+ derivePlayerIdLegacy
166
+ } from "@couch-kit/core";
167
+ var defaultSessionTimerScheduler = {
168
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
169
+ clearTimeout: (timer) => clearTimeout(timer)
170
+ };
171
+
172
+ class HostSessionManager {
173
+ sessions = new Map;
174
+ reverseMap = new Map;
175
+ cleanupTimers = new Map;
176
+ socketIdToPlayerId = new Map;
177
+ scheduler;
178
+ getDisconnectTimeout;
179
+ derivePlayerIdFn;
180
+ derivePlayerIdLegacyFn;
181
+ constructor(options = {}) {
182
+ this.scheduler = options.scheduler ?? defaultSessionTimerScheduler;
183
+ this.getDisconnectTimeout = options.getDisconnectTimeout ?? (() => options.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT);
184
+ this.derivePlayerIdFn = options.derivePlayerId ?? derivePlayerId;
185
+ this.derivePlayerIdLegacyFn = options.derivePlayerIdLegacy ?? derivePlayerIdLegacy;
186
+ }
187
+ async handleJoin(socketId, payload, playersSource) {
188
+ const hashedId = await this.derivePlayerIdFn(payload.secret);
189
+ const players = typeof playersSource === "function" ? playersSource() : playersSource;
190
+ let playerId = hashedId;
191
+ const legacyId = this.derivePlayerIdLegacyFn(payload.secret);
192
+ if (!players[playerId] && players[legacyId]) {
193
+ playerId = legacyId;
194
+ }
195
+ this.socketIdToPlayerId.set(socketId, playerId);
196
+ this.sessions.set(payload.secret, socketId);
197
+ this.reverseMap.set(socketId, payload.secret);
198
+ this.cancelRemoval(playerId);
199
+ const isReconnect = !!players[playerId];
200
+ const action = isReconnect ? {
201
+ type: InternalActionTypes2.PLAYER_RECONNECTED,
202
+ payload: { playerId }
203
+ } : {
204
+ type: InternalActionTypes2.PLAYER_JOINED,
205
+ payload: { id: playerId, name: payload.name, avatar: payload.avatar }
206
+ };
207
+ return {
208
+ playerId,
209
+ socketId,
210
+ secret: payload.secret,
211
+ isReconnect,
212
+ action
213
+ };
214
+ }
215
+ handleDisconnect(socketId) {
216
+ const playerId = this.socketIdToPlayerId.get(socketId);
217
+ this.socketIdToPlayerId.delete(socketId);
218
+ const secret = this.reverseMap.get(socketId);
219
+ this.reverseMap.delete(socketId);
220
+ if (!playerId || !secret)
221
+ return { kind: "unknown" };
222
+ if (this.sessions.get(secret) !== socketId) {
223
+ return { kind: "stale", playerId, secret };
224
+ }
225
+ return {
226
+ kind: "left",
227
+ playerId,
228
+ secret,
229
+ action: {
230
+ type: InternalActionTypes2.PLAYER_LEFT,
231
+ payload: { playerId }
232
+ }
233
+ };
234
+ }
235
+ scheduleRemoval(playerId, secret, onRemove) {
236
+ const timer = this.scheduler.setTimeout(() => {
237
+ this.cleanupTimers.delete(playerId);
238
+ this.sessions.delete(secret);
239
+ onRemove(playerId);
240
+ }, this.getDisconnectTimeout());
241
+ this.cleanupTimers.set(playerId, timer);
242
+ }
243
+ cancelRemoval(playerId) {
244
+ const existingTimer = this.cleanupTimers.get(playerId);
245
+ if (existingTimer) {
246
+ this.scheduler.clearTimeout(existingTimer);
247
+ this.cleanupTimers.delete(playerId);
248
+ }
249
+ }
250
+ clearRemovalTimers() {
251
+ for (const timer of this.cleanupTimers.values()) {
252
+ this.scheduler.clearTimeout(timer);
253
+ }
254
+ this.cleanupTimers.clear();
255
+ }
256
+ getPlayerIdForSocket(socketId) {
257
+ const secret = this.reverseMap.get(socketId);
258
+ if (!secret || this.sessions.get(secret) !== socketId) {
259
+ return;
260
+ }
261
+ return this.socketIdToPlayerId.get(socketId);
262
+ }
263
+ abandonConnection(socketId) {
264
+ const secret = this.reverseMap.get(socketId);
265
+ this.socketIdToPlayerId.delete(socketId);
266
+ this.reverseMap.delete(socketId);
267
+ if (secret && this.sessions.get(secret) === socketId) {
268
+ this.sessions.delete(secret);
269
+ }
270
+ }
271
+ getSocketIdForSecret(secret) {
272
+ return this.sessions.get(secret);
273
+ }
274
+ hasPendingRemoval(playerId) {
275
+ return this.cleanupTimers.has(playerId);
276
+ }
277
+ }
278
+
279
+ // src/runtime.ts
280
+ class GameHostRuntime {
281
+ config;
282
+ reducer;
283
+ state;
284
+ transport;
285
+ listeners = new Set;
286
+ sessionManager;
287
+ rateLimiter = new ActionRateLimiter;
288
+ broadcastScheduler;
289
+ assetsLoaded = new Map;
290
+ activeConnections = new Map;
291
+ joinedConnections = new Set;
292
+ pendingJoins = new Set;
293
+ actionQueue = [];
294
+ stateDirty = false;
295
+ nextConnectionGeneration = 1;
296
+ constructor(config, transport = null) {
297
+ this.config = config;
298
+ this.state = config.initialState;
299
+ this.reducer = createGameReducer(config.reducer);
300
+ this.transport = transport;
301
+ this.sessionManager = new HostSessionManager({
302
+ getDisconnectTimeout: () => this.config.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT2
303
+ });
304
+ this.broadcastScheduler = new BroadcastScheduler({
305
+ stateThrottleMs: config.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS
306
+ });
307
+ }
308
+ getState = () => this.state;
309
+ subscribe = (listener) => {
310
+ this.listeners.add(listener);
311
+ return () => {
312
+ this.listeners.delete(listener);
313
+ };
314
+ };
315
+ setTransport(transport) {
316
+ this.transport = transport;
317
+ if (transport && this.stateDirty) {
318
+ this.broadcastScheduler.schedule(this.broadcastState);
319
+ }
320
+ }
321
+ updateConfig(config) {
322
+ this.config = config;
323
+ this.broadcastScheduler.setStateThrottleMs(config.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS);
324
+ }
325
+ dispatch = (action) => {
326
+ this.applyAction(action);
327
+ };
328
+ handleConnection(connectionId) {
329
+ this.activeConnections.set(connectionId, this.nextConnectionGeneration++);
330
+ this.log(`[GameRuntime] Client connected: ${connectionId}`);
331
+ }
332
+ async handleMessage(connectionId, rawMessage) {
333
+ const connectionGeneration = this.activeConnections.get(connectionId);
334
+ if (connectionGeneration === undefined) {
335
+ this.log(`[GameRuntime] Ignoring message from inactive connection: ${connectionId}`);
336
+ return;
337
+ }
338
+ if (!isValidClientMessage(rawMessage)) {
339
+ this.log(`[GameRuntime] Invalid message from ${connectionId}:`, rawMessage);
340
+ this.send(connectionId, {
341
+ type: MessageTypes3.ERROR,
342
+ payload: {
343
+ code: "INVALID_MESSAGE",
344
+ message: "Malformed message"
345
+ }
346
+ });
347
+ return;
348
+ }
349
+ const message = rawMessage;
350
+ this.log(`[GameRuntime] Msg from ${connectionId}:`, message);
351
+ switch (message.type) {
352
+ case MessageTypes3.JOIN: {
353
+ const { secret, ...payload } = message.payload;
354
+ if (!secret || typeof secret !== "string" || !isValidSecret(secret)) {
355
+ this.send(connectionId, {
356
+ type: MessageTypes3.ERROR,
357
+ payload: {
358
+ code: "INVALID_SECRET",
359
+ message: "Invalid or missing session secret"
360
+ }
361
+ });
362
+ return;
363
+ }
364
+ if (this.joinedConnections.has(connectionId) || this.pendingJoins.has(connectionId)) {
365
+ this.send(connectionId, {
366
+ type: MessageTypes3.ERROR,
367
+ payload: {
368
+ code: "ALREADY_JOINED",
369
+ message: "This connection has already joined the game"
370
+ }
371
+ });
372
+ return;
373
+ }
374
+ this.pendingJoins.add(connectionId);
375
+ try {
376
+ const { playerId, isReconnect, action } = await this.sessionManager.handleJoin(connectionId, message.payload, () => this.state.players);
377
+ if (this.activeConnections.get(connectionId) !== connectionGeneration) {
378
+ this.sessionManager.abandonConnection(connectionId);
379
+ return;
380
+ }
381
+ this.applyAction(action);
382
+ this.joinedConnections.add(connectionId);
383
+ this.assetsLoaded.set(playerId, false);
384
+ this.invokeLifecycleCallback("onPlayerJoined", this.config.onPlayerJoined ? () => {
385
+ this.config.onPlayerJoined?.(playerId, payload.name);
386
+ } : undefined);
387
+ if (isReconnect) {
388
+ this.send(connectionId, {
389
+ type: MessageTypes3.RECONNECTED,
390
+ payload: {
391
+ playerId,
392
+ state: this.state
393
+ }
394
+ });
395
+ } else {
396
+ this.send(connectionId, {
397
+ type: MessageTypes3.WELCOME,
398
+ payload: {
399
+ playerId,
400
+ state: this.state,
401
+ serverTime: Date.now()
402
+ }
403
+ });
404
+ }
405
+ } catch (error) {
406
+ this.log("[GameRuntime] Failed to derive player ID:", error);
407
+ this.send(connectionId, {
408
+ type: MessageTypes3.ERROR,
409
+ payload: {
410
+ code: "JOIN_FAILED",
411
+ message: "Failed to process join request"
412
+ }
413
+ });
414
+ } finally {
415
+ this.pendingJoins.delete(connectionId);
416
+ }
417
+ break;
418
+ }
419
+ case MessageTypes3.ACTION: {
420
+ const actionPayload = message.payload;
421
+ const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
422
+ const authorization = authorizeClientAction(actionPayload.type, playerId);
423
+ if (authorization.kind === "reject") {
424
+ this.log(`[GameRuntime] Rejected action from ${connectionId} ` + `(${authorization.code}):`, actionPayload.type);
425
+ this.send(connectionId, {
426
+ type: MessageTypes3.ERROR,
427
+ payload: {
428
+ code: authorization.code,
429
+ message: authorization.message
430
+ }
431
+ });
432
+ return;
433
+ }
434
+ if (!this.rateLimiter.record(connectionId).allowed) {
435
+ this.log(`[GameRuntime] Rate limited ${connectionId}`);
436
+ this.send(connectionId, {
437
+ type: MessageTypes3.ERROR,
438
+ payload: {
439
+ code: "RATE_LIMITED",
440
+ message: "Too many actions, slow down"
441
+ }
442
+ });
443
+ return;
444
+ }
445
+ this.applyAction({
446
+ ...actionPayload,
447
+ playerId: authorization.playerId
448
+ });
449
+ this.actionQueue.push(actionPayload);
450
+ break;
451
+ }
452
+ case MessageTypes3.PING:
453
+ this.send(connectionId, {
454
+ type: MessageTypes3.PONG,
455
+ payload: {
456
+ id: message.payload.id,
457
+ origTimestamp: message.payload.timestamp,
458
+ serverTime: Date.now()
459
+ }
460
+ });
461
+ break;
462
+ case MessageTypes3.ASSETS_LOADED: {
463
+ const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
464
+ if (playerId) {
465
+ this.assetsLoaded.set(playerId, true);
466
+ this.log(`[GameRuntime] Assets loaded for ${playerId}`);
467
+ }
468
+ break;
469
+ }
470
+ }
471
+ }
472
+ handleDisconnect(connectionId) {
473
+ this.log(`[GameRuntime] Client disconnected: ${connectionId}`);
474
+ this.activeConnections.delete(connectionId);
475
+ this.joinedConnections.delete(connectionId);
476
+ this.rateLimiter.reset(connectionId);
477
+ const result = this.sessionManager.handleDisconnect(connectionId);
478
+ if (result.kind === "unknown")
479
+ return;
480
+ this.assetsLoaded.delete(result.playerId);
481
+ if (result.kind === "stale")
482
+ return;
483
+ this.applyAction(result.action);
484
+ this.invokeLifecycleCallback("onPlayerLeft", this.config.onPlayerLeft ? () => {
485
+ this.config.onPlayerLeft?.(result.playerId);
486
+ } : undefined);
487
+ this.sessionManager.scheduleRemoval(result.playerId, result.secret, (playerId) => {
488
+ this.applyAction({
489
+ type: InternalActionTypes3.PLAYER_REMOVED,
490
+ payload: { playerId }
491
+ });
492
+ });
493
+ }
494
+ handleError(error) {
495
+ this.log("[GameRuntime] Transport error:", error);
496
+ this.notifyError(error);
497
+ }
498
+ stop() {
499
+ this.broadcastScheduler.cancel();
500
+ this.sessionManager.clearRemovalTimers();
501
+ this.rateLimiter.clear();
502
+ this.activeConnections.clear();
503
+ this.joinedConnections.clear();
504
+ this.pendingJoins.clear();
505
+ this.transport = null;
506
+ }
507
+ applyAction(action) {
508
+ const nextState = this.reducer(this.state, action);
509
+ if (Object.is(nextState, this.state))
510
+ return;
511
+ this.state = nextState;
512
+ this.stateDirty = true;
513
+ for (const listener of this.listeners) {
514
+ listener();
515
+ }
516
+ this.broadcastScheduler.schedule(this.broadcastState);
517
+ }
518
+ broadcastState = () => {
519
+ if (!this.transport)
520
+ return;
521
+ const actions = this.actionQueue;
522
+ this.actionQueue = [];
523
+ this.stateDirty = false;
524
+ this.transport.broadcast(createStateUpdateMessage(this.state, actions));
525
+ };
526
+ send(connectionId, message) {
527
+ this.transport?.send(connectionId, message);
528
+ }
529
+ invokeLifecycleCallback(name, callback) {
530
+ if (!callback)
531
+ return;
532
+ try {
533
+ callback();
534
+ } catch (error) {
535
+ const cause = error instanceof Error ? error : new Error(String(error));
536
+ this.notifyError(new Error(`${name} callback failed: ${cause.message}`, { cause }));
537
+ }
538
+ }
539
+ notifyError(error) {
540
+ const onError = this.config.onError;
541
+ if (!onError) {
542
+ console.error("[GameRuntime] Unhandled error:", error);
543
+ return;
544
+ }
545
+ try {
546
+ onError(error);
547
+ } catch (callbackError) {
548
+ console.error("[GameRuntime] onError callback failed:", callbackError);
549
+ }
550
+ }
551
+ log(...args) {
552
+ if (this.config.debug) {
553
+ console.log(...args);
554
+ }
555
+ }
556
+ }
557
+ export {
558
+ isValidClientMessage,
559
+ frameByteLength,
560
+ createStateUpdateMessage,
561
+ authorizeClientAction,
562
+ RATE_LIMIT_WINDOW,
563
+ RATE_LIMIT_MAX,
564
+ HostSessionManager,
565
+ GameHostRuntime,
566
+ DEFAULT_STATE_THROTTLE_MS,
567
+ DEFAULT_MAX_MESSAGE_BYTES,
568
+ BroadcastScheduler,
569
+ ActionRateLimiter
570
+ };
@@ -0,0 +1,28 @@
1
+ /** Error codes surfaced when a client ACTION is rejected before dispatch. */
2
+ export type ActionRejectionCode = "FORBIDDEN_ACTION" | "NOT_JOINED";
3
+ /** Outcome of authorizing an inbound client ACTION message. */
4
+ export type ActionAuthorization = {
5
+ kind: "allow";
6
+ playerId: string;
7
+ } | {
8
+ kind: "reject";
9
+ code: ActionRejectionCode;
10
+ message: string;
11
+ };
12
+ /**
13
+ * Decide whether an inbound client ACTION may be dispatched by the runtime.
14
+ *
15
+ * Rejects, in order:
16
+ * - **Internal action types** — clients must not be able to inject framework
17
+ * actions (`__HYDRATE__`, `__PLAYER_JOINED__`, etc.) to forge state.
18
+ * - **Un-joined sockets** — a socket with no resolved player ID never completed
19
+ * a JOIN, so its actions have no owner and must not mutate state.
20
+ *
21
+ * The decision is pure so it can be unit-tested without a WebSocket or React.
22
+ *
23
+ * @param actionType - The `type` field of the client's action payload.
24
+ * @param resolvedPlayerId - The player ID cached for the socket at JOIN time, or
25
+ * `undefined` if the socket has not joined.
26
+ */
27
+ export declare function authorizeClientAction(actionType: string, resolvedPlayerId: string | undefined): ActionAuthorization;
28
+ //# sourceMappingURL=action-authorization.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"action-authorization.d.ts","sourceRoot":"","sources":["../src/action-authorization.ts"],"names":[],"mappings":"AAEA,6EAA6E;AAC7E,MAAM,MAAM,mBAAmB,GAAG,kBAAkB,GAAG,YAAY,CAAC;AAEpE,+DAA+D;AAC/D,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,mBAAmB,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAUnE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,MAAM,EAClB,gBAAgB,EAAE,MAAM,GAAG,SAAS,GACnC,mBAAmB,CAkBrB"}
@@ -0,0 +1,30 @@
1
+ import { MessageTypes, type HostMessage } from "@couch-kit/core";
2
+ /** Default state broadcast throttle (~30fps). */
3
+ export declare const DEFAULT_STATE_THROTTLE_MS = 33;
4
+ export type StateUpdateMessage = Extract<HostMessage, {
5
+ type: typeof MessageTypes.STATE_UPDATE;
6
+ }>;
7
+ export interface TimerScheduler<TTimer> {
8
+ setTimeout(callback: () => void, delayMs: number): TTimer;
9
+ clearTimeout(timer: TTimer): void;
10
+ }
11
+ export interface BroadcastSchedulerOptions<TTimer> {
12
+ stateThrottleMs?: number;
13
+ scheduler?: TimerScheduler<TTimer>;
14
+ }
15
+ /**
16
+ * Debounced state-broadcast scheduler used by the authoritative runtime.
17
+ * Rapid state changes are coalesced into one broadcast after the latest change.
18
+ */
19
+ export declare class BroadcastScheduler<TTimer = ReturnType<typeof setTimeout>> {
20
+ private stateThrottleMs;
21
+ private readonly scheduler;
22
+ private timer;
23
+ constructor(options?: BroadcastSchedulerOptions<TTimer>);
24
+ schedule(callback: () => void): void;
25
+ cancel(): void;
26
+ setStateThrottleMs(stateThrottleMs: number): void;
27
+ hasPendingBroadcast(): boolean;
28
+ }
29
+ export declare function createStateUpdateMessage(newState: unknown, actions: readonly unknown[], timestamp?: number): StateUpdateMessage;
30
+ //# sourceMappingURL=broadcast-scheduler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"broadcast-scheduler.d.ts","sourceRoot":"","sources":["../src/broadcast-scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEjE,iDAAiD;AACjD,eAAO,MAAM,yBAAyB,KAAK,CAAC;AAE5C,MAAM,MAAM,kBAAkB,GAAG,OAAO,CACtC,WAAW,EACX;IAAE,IAAI,EAAE,OAAO,YAAY,CAAC,YAAY,CAAA;CAAE,CAC3C,CAAC;AAEF,MAAM,WAAW,cAAc,CAAC,MAAM;IACpC,UAAU,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1D,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAOD,MAAM,WAAW,yBAAyB,CAAC,MAAM;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;CACpC;AAED;;;GAGG;AACH,qBAAa,kBAAkB,CAAC,MAAM,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC;IACpE,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyB;IACnD,OAAO,CAAC,KAAK,CAAuB;gBAExB,OAAO,GAAE,yBAAyB,CAAC,MAAM,CAAM;IAO3D,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI;IAQpC,MAAM,IAAI,IAAI;IAOd,kBAAkB,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI;IAIjD,mBAAmB,IAAI,OAAO;CAG/B;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,OAAO,EACjB,OAAO,EAAE,SAAS,OAAO,EAAE,EAC3B,SAAS,GAAE,MAAmB,GAC7B,kBAAkB,CAapB"}
package/lib/index.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./action-authorization.js";
2
+ export * from "./broadcast-scheduler.js";
3
+ export * from "./message-validation.js";
4
+ export * from "./rate-limiter.js";
5
+ export * from "./runtime.js";
6
+ export * from "./session-manager.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,0BAA0B,CAAC;AACzC,cAAc,yBAAyB,CAAC;AACxC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,sBAAsB,CAAC"}
@@ -0,0 +1,35 @@
1
+ import { MessageTypes, type ClientMessage } from "@couch-kit/core";
2
+ /**
3
+ * Default maximum size (in bytes) of an inbound client message. Frames larger
4
+ * than this are discarded before `JSON.parse`, bounding the memory a single
5
+ * malicious LAN client can force the runtime to allocate. Generous enough for
6
+ * data-URI avatars in JOIN payloads while blocking multi-megabyte abuse.
7
+ */
8
+ export declare const DEFAULT_MAX_MESSAGE_BYTES: number;
9
+ /**
10
+ * Compute the UTF-8 byte length of a raw WebSocket frame payload.
11
+ *
12
+ * For binary frames the `ArrayBuffer.byteLength` is exact. For text frames the
13
+ * UTF-8 size is counted directly from the JS string without allocating an
14
+ * encoder buffer, so an oversized frame can be rejected cheaply.
15
+ */
16
+ export declare function frameByteLength(data: string | ArrayBuffer): number;
17
+ type ClientMessageOf<TType extends ClientMessage["type"]> = Extract<ClientMessage, {
18
+ type: TType;
19
+ }>;
20
+ export type ValidatedClientMessage = {
21
+ type: typeof MessageTypes.JOIN;
22
+ payload: {
23
+ name: string;
24
+ secret?: unknown;
25
+ avatar?: unknown;
26
+ [key: string]: unknown;
27
+ };
28
+ } | ClientMessageOf<"ACTION"> | ClientMessageOf<"PING"> | ClientMessageOf<"ASSETS_LOADED">;
29
+ /**
30
+ * Validates that an incoming message has the expected shape.
31
+ * Returns true if the message has a processable client-message shape.
32
+ */
33
+ export declare function isValidClientMessage(msg: unknown): msg is ValidatedClientMessage;
34
+ export {};
35
+ //# sourceMappingURL=message-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message-validation.d.ts","sourceRoot":"","sources":["../src/message-validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,QAAa,CAAC;AAEpD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,CAmBlE;AAED,KAAK,eAAe,CAAC,KAAK,SAAS,aAAa,CAAC,MAAM,CAAC,IAAI,OAAO,CACjE,aAAa,EACb;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,CAChB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAC9B;IACE,IAAI,EAAE,OAAO,YAAY,CAAC,IAAI,CAAC;IAC/B,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;CACH,GACD,eAAe,CAAC,QAAQ,CAAC,GACzB,eAAe,CAAC,MAAM,CAAC,GACvB,eAAe,CAAC,eAAe,CAAC,CAAC;AAErC;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,GACX,GAAG,IAAI,sBAAsB,CA8B/B"}