@colyseus/core 0.17.44 → 0.18.1

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.
Files changed (157) hide show
  1. package/build/MatchMaker.cjs +22 -13
  2. package/build/MatchMaker.cjs.map +2 -2
  3. package/build/MatchMaker.d.ts +10 -0
  4. package/build/MatchMaker.mjs +22 -14
  5. package/build/MatchMaker.mjs.map +2 -2
  6. package/build/Protocol.cjs +102 -37
  7. package/build/Protocol.cjs.map +2 -2
  8. package/build/Protocol.d.ts +33 -2
  9. package/build/Protocol.mjs +102 -37
  10. package/build/Protocol.mjs.map +2 -2
  11. package/build/Rewind.cjs +385 -0
  12. package/build/Rewind.cjs.map +7 -0
  13. package/build/Rewind.d.ts +291 -0
  14. package/build/Rewind.mjs +359 -0
  15. package/build/Rewind.mjs.map +7 -0
  16. package/build/Room.cjs +425 -149
  17. package/build/Room.cjs.map +3 -3
  18. package/build/Room.d.ts +314 -27
  19. package/build/Room.mjs +434 -152
  20. package/build/Room.mjs.map +3 -3
  21. package/build/RoomMessages.cjs +265 -0
  22. package/build/RoomMessages.cjs.map +7 -0
  23. package/build/RoomMessages.d.ts +49 -0
  24. package/build/RoomMessages.mjs +240 -0
  25. package/build/RoomMessages.mjs.map +7 -0
  26. package/build/RoomPlugin.cjs +252 -0
  27. package/build/RoomPlugin.cjs.map +7 -0
  28. package/build/RoomPlugin.d.ts +271 -0
  29. package/build/RoomPlugin.mjs +220 -0
  30. package/build/RoomPlugin.mjs.map +7 -0
  31. package/build/Server.cjs +49 -11
  32. package/build/Server.cjs.map +2 -2
  33. package/build/Server.d.ts +26 -0
  34. package/build/Server.mjs +50 -12
  35. package/build/Server.mjs.map +2 -2
  36. package/build/Transport.cjs +64 -2
  37. package/build/Transport.cjs.map +2 -2
  38. package/build/Transport.d.ts +108 -5
  39. package/build/Transport.mjs +63 -2
  40. package/build/Transport.mjs.map +2 -2
  41. package/build/errors/RoomExceptions.cjs +7 -4
  42. package/build/errors/RoomExceptions.cjs.map +2 -2
  43. package/build/errors/RoomExceptions.d.ts +15 -3
  44. package/build/errors/RoomExceptions.mjs +5 -3
  45. package/build/errors/RoomExceptions.mjs.map +2 -2
  46. package/build/index.cjs +30 -4
  47. package/build/index.cjs.map +2 -2
  48. package/build/index.d.ts +9 -5
  49. package/build/index.mjs +29 -6
  50. package/build/index.mjs.map +2 -2
  51. package/build/input/InputBuffer.cjs +536 -0
  52. package/build/input/InputBuffer.cjs.map +7 -0
  53. package/build/input/InputBuffer.d.ts +226 -0
  54. package/build/input/InputBuffer.mjs +507 -0
  55. package/build/input/InputBuffer.mjs.map +7 -0
  56. package/build/input/RoomInput.cjs +306 -0
  57. package/build/input/RoomInput.cjs.map +7 -0
  58. package/build/input/RoomInput.d.ts +91 -0
  59. package/build/input/RoomInput.mjs +287 -0
  60. package/build/input/RoomInput.mjs.map +7 -0
  61. package/build/input/types.cjs +18 -0
  62. package/build/input/types.cjs.map +7 -0
  63. package/build/input/types.d.ts +476 -0
  64. package/build/input/types.mjs +0 -0
  65. package/build/input/types.mjs.map +7 -0
  66. package/build/internal.cjs +61 -0
  67. package/build/internal.cjs.map +7 -0
  68. package/build/internal.d.ts +9 -0
  69. package/build/internal.mjs +29 -0
  70. package/build/internal.mjs.map +7 -0
  71. package/build/matchmaker/LocalDriver/LocalDriver.cjs +13 -0
  72. package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +2 -2
  73. package/build/matchmaker/LocalDriver/LocalDriver.d.ts +1 -0
  74. package/build/matchmaker/LocalDriver/LocalDriver.mjs +13 -0
  75. package/build/matchmaker/LocalDriver/LocalDriver.mjs.map +2 -2
  76. package/build/matchmaker/driver.cjs.map +1 -1
  77. package/build/matchmaker/driver.d.ts +12 -0
  78. package/build/matchmaker/driver.mjs.map +1 -1
  79. package/build/presence/LocalPresence.d.ts +1 -1
  80. package/build/rooms/LobbyRoom.cjs +8 -10
  81. package/build/rooms/LobbyRoom.cjs.map +2 -2
  82. package/build/rooms/LobbyRoom.d.ts +4 -3
  83. package/build/rooms/LobbyRoom.mjs +8 -10
  84. package/build/rooms/LobbyRoom.mjs.map +2 -2
  85. package/build/rooms/QueueRoom.cjs +1 -1
  86. package/build/rooms/QueueRoom.cjs.map +2 -2
  87. package/build/rooms/QueueRoom.mjs +1 -1
  88. package/build/rooms/QueueRoom.mjs.map +2 -2
  89. package/build/rooms/RelayRoom.cjs +12 -16
  90. package/build/rooms/RelayRoom.cjs.map +2 -2
  91. package/build/rooms/RelayRoom.d.ts +32 -11
  92. package/build/rooms/RelayRoom.mjs +10 -16
  93. package/build/rooms/RelayRoom.mjs.map +2 -2
  94. package/build/router/default_routes.cjs +4 -1
  95. package/build/router/default_routes.cjs.map +2 -2
  96. package/build/router/default_routes.mjs +4 -1
  97. package/build/router/default_routes.mjs.map +2 -2
  98. package/build/router/index.cjs +71 -5
  99. package/build/router/index.cjs.map +2 -2
  100. package/build/router/index.d.ts +30 -6
  101. package/build/router/index.mjs +72 -7
  102. package/build/router/index.mjs.map +3 -3
  103. package/build/serializer/NoneSerializer.cjs +2 -2
  104. package/build/serializer/NoneSerializer.cjs.map +2 -2
  105. package/build/serializer/NoneSerializer.d.ts +3 -3
  106. package/build/serializer/NoneSerializer.mjs +2 -2
  107. package/build/serializer/NoneSerializer.mjs.map +2 -2
  108. package/build/serializer/SchemaSerializer.cjs +44 -14
  109. package/build/serializer/SchemaSerializer.cjs.map +2 -2
  110. package/build/serializer/SchemaSerializer.d.ts +3 -3
  111. package/build/serializer/SchemaSerializer.mjs +46 -16
  112. package/build/serializer/SchemaSerializer.mjs.map +2 -2
  113. package/build/serializer/Serializer.cjs.map +1 -1
  114. package/build/serializer/Serializer.d.ts +12 -2
  115. package/build/utils/DevMode.cjs +14 -14
  116. package/build/utils/DevMode.cjs.map +2 -2
  117. package/build/utils/DevMode.d.ts +0 -2
  118. package/build/utils/DevMode.mjs +14 -12
  119. package/build/utils/DevMode.mjs.map +2 -2
  120. package/build/utils/UserSessionIndex.cjs +162 -0
  121. package/build/utils/UserSessionIndex.cjs.map +7 -0
  122. package/build/utils/UserSessionIndex.d.ts +166 -0
  123. package/build/utils/UserSessionIndex.mjs +130 -0
  124. package/build/utils/UserSessionIndex.mjs.map +7 -0
  125. package/build/utils/Utils.cjs +3 -0
  126. package/build/utils/Utils.cjs.map +2 -2
  127. package/build/utils/Utils.d.ts +8 -0
  128. package/build/utils/Utils.mjs +2 -0
  129. package/build/utils/Utils.mjs.map +2 -2
  130. package/package.json +19 -14
  131. package/src/MatchMaker.ts +47 -15
  132. package/src/Protocol.ts +130 -59
  133. package/src/Rewind.ts +572 -0
  134. package/src/Room.ts +649 -235
  135. package/src/RoomMessages.ts +342 -0
  136. package/src/RoomPlugin.ts +563 -0
  137. package/src/Server.ts +82 -15
  138. package/src/Transport.ts +172 -9
  139. package/src/errors/RoomExceptions.ts +18 -4
  140. package/src/index.ts +23 -3
  141. package/src/input/InputBuffer.ts +549 -0
  142. package/src/input/RoomInput.ts +337 -0
  143. package/src/input/types.ts +503 -0
  144. package/src/internal.ts +46 -0
  145. package/src/matchmaker/LocalDriver/LocalDriver.ts +10 -0
  146. package/src/matchmaker/driver.ts +13 -0
  147. package/src/rooms/LobbyRoom.ts +12 -8
  148. package/src/rooms/QueueRoom.ts +1 -1
  149. package/src/rooms/RelayRoom.ts +9 -15
  150. package/src/router/default_routes.ts +6 -1
  151. package/src/router/index.ts +117 -11
  152. package/src/serializer/NoneSerializer.ts +3 -3
  153. package/src/serializer/SchemaSerializer.ts +110 -19
  154. package/src/serializer/Serializer.ts +13 -2
  155. package/src/utils/DevMode.ts +18 -13
  156. package/src/utils/UserSessionIndex.ts +311 -0
  157. package/src/utils/Utils.ts +9 -0
@@ -36,6 +36,10 @@ export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName"
36
36
  if (transport.protocol !== undefined) {
37
37
  response.protocol = transport.protocol;
38
38
  }
39
+ // WebTransport (h3): client pins the self-signed cert via serverCertificateHashes.
40
+ if (transport.fingerprint !== undefined) {
41
+ response.fingerprint = transport.fingerprint;
42
+ }
39
43
 
40
44
  const json = JSON.stringify(response);
41
45
 
@@ -57,5 +61,6 @@ export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName"
57
61
  });
58
62
 
59
63
  export function getDefaultRouter() {
60
- return createRouter({ postMatchmakeMethod });
64
+ // no public docs page — the docs surface is playground's gated /__apidocs
65
+ return createRouter({ postMatchmakeMethod }, { openapi: { disabled: true } });
61
66
  }
@@ -1,6 +1,7 @@
1
1
  import type express from "express";
2
2
  import type { IncomingMessage, ServerResponse } from "http";
3
- import { type Endpoint, type Router, type RouterConfig, createRouter as createBetterCallRouter, createEndpoint } from "@colyseus/better-call";
3
+ import { createHash, timingSafeEqual } from "node:crypto";
4
+ import { type Endpoint, type Router, type RouterConfig, createRouter as createBetterCallRouter, createEndpoint, createMiddleware, APIError } from "@colyseus/better-call";
4
5
  import { toNodeHandler, getRequest, setResponse } from "@colyseus/better-call/node";
5
6
  import { Transport } from "../Transport.ts";
6
7
  import { controller } from "../matchmaker/controller.ts";
@@ -120,19 +121,124 @@ function expressRootRoute(expressApp: express.Application) {
120
121
  return stack.find((layer: any) => layer.match('/') && !['query', 'expressInit'].includes(layer.name));
121
122
  }
122
123
 
123
- /**
124
- * Do not use this directly. This is used internally by `@colyseus/playground`.
125
- * TODO: refactor. Avoid using globals.
126
- * @internal
127
- */
128
- export let __globalEndpoints: Record<string, Endpoint> = {};
129
-
130
124
  export function createRouter<
131
125
  E extends Record<string, Endpoint>,
132
126
  Config extends RouterConfig
133
127
  >(endpoints: E, config: Config = {} as Config) {
134
- // TODO: refactor. Avoid using globals.
135
- __globalEndpoints = endpoints;
128
+ return createBetterCallRouter({ ...endpoints }, {
129
+ // better-call's /api/reference page dumps the full API surface
130
+ // unauthenticated — opt back in by passing `openapi` explicitly.
131
+ openapi: { disabled: true },
132
+ ...config,
133
+ });
134
+ }
135
+
136
+ export interface BasicAuthOptions {
137
+ /** username → password. The common static case. */
138
+ users?: Record<string, string>;
139
+ /** Custom validator (e.g. DB-backed). Takes precedence over `users`. */
140
+ validate?: (username: string, password: string) => boolean | Promise<boolean>;
141
+ /** Realm shown in the browser prompt. Default 'Restricted'. */
142
+ realm?: string;
143
+ }
144
+
145
+ /**
146
+ * HTTP Basic Auth middleware. Drop into any endpoint's `use:` slot to gate
147
+ * it behind a browser credentials prompt:
148
+ *
149
+ * playground({ use: [basicAuth({ users: { admin: 's3cret' } })] })
150
+ */
151
+ export function basicAuth(opts: BasicAuthOptions) {
152
+ const { users, validate } = opts;
153
+ if (!users && !validate) {
154
+ throw new Error('[basicAuth] provide `users` or `validate`');
155
+ }
156
+ // Realm is interpolated into a header — strip `"` so it can't break out.
157
+ const challenge = `Basic realm="${(opts.realm ?? 'Restricted').replace(/"/g, '')}", charset="UTF-8"`;
158
+
159
+ return createMiddleware(async (ctx) => {
160
+ const creds = parseBasicHeader(ctx.getHeader('authorization'));
161
+ const ok = !!creds && (validate
162
+ ? await validate(creds.username, creds.password)
163
+ : staticCheck(users!, creds.username, creds.password));
164
+ if (!ok) {
165
+ throw new APIError(401, { message: 'authentication required' }, { 'WWW-Authenticate': challenge });
166
+ }
167
+ });
168
+ }
169
+
170
+ function parseBasicHeader(header: string | null | undefined) {
171
+ if (!header) { return null; }
172
+ const sep = header.indexOf(' ');
173
+ if (sep < 0 || header.slice(0, sep).toLowerCase() !== 'basic') { return null; }
174
+ let decoded: string;
175
+ try { decoded = Buffer.from(header.slice(sep + 1), 'base64').toString('utf8'); } catch { return null; }
176
+ const colon = decoded.indexOf(':');
177
+ if (colon < 0) { return null; }
178
+ return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
179
+ }
180
+
181
+ function staticCheck(users: Record<string, string>, username: string, password: string): boolean {
182
+ const expected = Object.prototype.hasOwnProperty.call(users, username) ? users[username] : undefined;
183
+ // Compare even for an unknown user so reject timing doesn't reveal which
184
+ // usernames exist.
185
+ return safeEqual(password, expected ?? '\0') && expected !== undefined;
186
+ }
136
187
 
137
- return createBetterCallRouter({ ...endpoints }, config);
188
+ // Hash both sides first: equalizes length (timingSafeEqual throws on a
189
+ // length mismatch, which would itself leak the secret's length).
190
+ function safeEqual(a: string, b: string): boolean {
191
+ return timingSafeEqual(
192
+ createHash('sha256').update(a).digest(),
193
+ createHash('sha256').update(b).digest(),
194
+ );
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // dualModeEndpoints — shared express-compat layer for @colyseus/admin,
199
+ // @colyseus/monitor, @colyseus/playground. Builds the two local routers
200
+ // (specific = no catch-all, full = everything) and the matching node
201
+ // handlers, then packages the express middleware so the return value works
202
+ // both as `{...spread}` into createRouter AND as `app.use("/", x)` middleware.
203
+ // ---------------------------------------------------------------------------
204
+
205
+ export type ExpressMiddleware = (
206
+ req: IncomingMessage,
207
+ res: ServerResponse,
208
+ next: (err?: any) => void,
209
+ ) => void;
210
+
211
+ export type NodeHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
212
+
213
+ export interface DualModeHelpers {
214
+ specificRouter: Router;
215
+ specificHandler: NodeHandler;
216
+ fullRouter: Router;
217
+ fullHandler: NodeHandler;
218
+ }
219
+
220
+ export function dualModeEndpoints<E extends Record<string, Endpoint>>(
221
+ endpoints: E,
222
+ opts: {
223
+ /** Key in `endpoints` whose path is a catch-all. Excluded from `specificRouter` so it doesn't eat fall-through decisions. */
224
+ catchAllKey?: keyof E;
225
+ /** Build the express middleware given the pre-built routers + node handlers. */
226
+ buildMiddleware: (helpers: DualModeHelpers) => ExpressMiddleware;
227
+ },
228
+ ): ExpressMiddleware & E {
229
+ const fullRouter = createRouter(endpoints);
230
+ const fullHandler = toNodeHandler(fullRouter.handler) as NodeHandler;
231
+
232
+ const specificEndpoints = opts.catchAllKey
233
+ ? Object.fromEntries(
234
+ Object.entries(endpoints).filter(([k]) => k !== opts.catchAllKey),
235
+ ) as Partial<E>
236
+ : endpoints;
237
+ const specificRouter = createRouter(specificEndpoints as E);
238
+ const specificHandler = toNodeHandler(specificRouter.handler) as NodeHandler;
239
+
240
+ const middleware = opts.buildMiddleware({
241
+ specificRouter, specificHandler, fullRouter, fullHandler,
242
+ });
243
+ return Object.assign(middleware, endpoints) as ExpressMiddleware & E;
138
244
  }
@@ -1,16 +1,16 @@
1
1
  import type { Client } from '../Transport.ts';
2
- import type { Serializer } from './Serializer.ts';
2
+ import type { PatchTimingContext, Serializer } from './Serializer.ts';
3
3
 
4
4
  export class NoneSerializer<T= any> implements Serializer<T> {
5
5
  public id: string = 'none';
6
6
 
7
7
  public reset(data: any) {}
8
8
 
9
- public getFullState(client?: Client) {
9
+ public getFullState(_client?: Client, _timing?: PatchTimingContext) {
10
10
  return null;
11
11
  }
12
12
 
13
- public applyPatches(clients: Client[], state: T): boolean {
13
+ public applyPatches(_clients: Client[], _state: T, _timing?: PatchTimingContext): boolean {
14
14
  return false;
15
15
  }
16
16
  }
@@ -1,11 +1,85 @@
1
- import { Protocol } from '@colyseus/shared-types';
1
+ import { Protocol, ProtocolModifier } from '@colyseus/shared-types';
2
2
 
3
- import type { Serializer } from './Serializer.ts';
4
- import { type Client, ClientState } from '../Transport.ts';
3
+ import type { PatchTimingContext, Serializer } from './Serializer.ts';
4
+ import { type Client, type ClientPrivate, ClientState } from '../Transport.ts';
5
5
 
6
- import { type Iterator, Encoder, dumpChanges, Reflection, Schema, StateView } from '@colyseus/schema';
6
+ import { type Iterator, encode, Encoder, dumpChanges, Reflection, Schema, StateView } from '@colyseus/schema';
7
7
  import { debugPatch } from '../Debug.ts';
8
8
 
9
+ /**
10
+ * Size of the {@link ProtocolModifier.TIMED} prefix that follows the
11
+ * protocol byte:
12
+ * `[uint32 sNow][uint32 inputSeq]`
13
+ *
14
+ * - `sNow` — server clock as **milliseconds since room start**
15
+ * (`room.clock.elapsedTime`), NOT raw `performance.now()`. A portable,
16
+ * language-agnostic integer (C/C#/Lua have no `performance.now()`); the
17
+ * server's own time-keyed logic uses the SAME `clock.elapsedTime` so the
18
+ * client's reconstructed `serverNow()` stays in phase. Wraps at u32
19
+ * (~49.7 days uptime) — irrelevant per session. Drives the client clock
20
+ * offset / `serverNow()`.
21
+ * - `inputSeq` — seq VALUE of the last input CONSUMED into the authoritative
22
+ * state (the input buffer's `ackSeq`, i.e. last-PROCESSED input). Reliable:
23
+ * equals the consumed count (inputs are sequenced implicitly by receive
24
+ * order). Unreliable: the framework wire seq, so a fully-dropped input
25
+ * doesn't make the ack lag the client's sent seq by the lost count. This
26
+ * single number is the canonical reconciliation ack ("the sequence number
27
+ * of the last input the server processed"); the client prunes its
28
+ * pending-input buffer against it AND derives RTT from its round-trip
29
+ * (`now − sendTime(inputSeq)`). No separate received-seq / received-time —
30
+ * RTT-from-the-processed-ack is round-trip-inclusive, which is the standard.
31
+ */
32
+ const TIMED_PREFIX_SIZE = 8;
33
+
34
+ /**
35
+ * Construct a per-recipient TIMED buffer:
36
+ * `[code|TIMED][sNow][inputSeq][...schema bytes]`
37
+ *
38
+ * `encodedBody` is the shared encoded buffer whose byte 0 already holds the
39
+ * base protocol code (ROOM_STATE or ROOM_STATE_PATCH); bytes 1.. are the
40
+ * schema payload. We construct a fresh per-client Buffer so the transport
41
+ * never sees a buffer that might mutate between queue and send.
42
+ *
43
+ * Uses the schema `encode.*` helpers to keep the wire layout symmetric with
44
+ * the SDK's `decode.*`-driven reader.
45
+ */
46
+ function buildTimedFrame(
47
+ encodedBody: Uint8Array,
48
+ sNow: number,
49
+ inputSeq: number,
50
+ ): Buffer {
51
+ const schemaPayload = encodedBody.subarray(1);
52
+ const out = Buffer.allocUnsafe(1 + TIMED_PREFIX_SIZE + schemaPayload.length);
53
+ out[0] = encodedBody[0] | ProtocolModifier.TIMED;
54
+ const it: Iterator = { offset: 1 };
55
+ encode.uint32(out, sNow >>> 0, it); // ms since room start (clock.elapsedTime)
56
+ encode.uint32(out, inputSeq >>> 0, it);
57
+ out.set(schemaPayload, it.offset);
58
+ return out;
59
+ }
60
+
61
+ /** Seq VALUE of the last input consumed from the client's buffer — the
62
+ * reconciliation ack (and RTT correlation key). Reliable: equals the consumed
63
+ * count; unreliable: the framework wire seq (so packet loss doesn't skew it).
64
+ * 0 when the room doesn't buffer input. */
65
+ function clientProcessedInputSeq(client: Client): number {
66
+ return (client as Client & ClientPrivate)._inputBuffer?.ackSeq ?? 0;
67
+ }
68
+
69
+ /**
70
+ * Send a per-recipient patch frame: a TIMED-prefixed buffer when `timing` is set,
71
+ * else the shared `encodedBody` passed straight through (no per-client copy). Any
72
+ * `afterNextPatch` frames staged on `client._pendingFrames` are NOT coalesced here
73
+ * — they go out standalone right after the patch via `Room._flushPendingClientFrames`.
74
+ */
75
+ function sendClientFrame(client: Client, encodedBody: Uint8Array, timing?: PatchTimingContext): void {
76
+ if (timing) {
77
+ client.raw(buildTimedFrame(encodedBody, timing.sNow, clientProcessedInputSeq(client)));
78
+ } else {
79
+ client.raw(encodedBody);
80
+ }
81
+ }
82
+
9
83
  const SHARED_VIEW = {};
10
84
 
11
85
  export class SchemaSerializer<T extends Schema> implements Serializer<T> {
@@ -38,27 +112,29 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
38
112
  }
39
113
  }
40
114
 
41
- public getFullState(client?: Client) {
115
+ public getFullState(client?: Client, timing?: PatchTimingContext) {
42
116
  if (this.needFullEncode || this.encoder.root.changes.next !== undefined) {
43
117
  this.sharedOffsetCache = { offset: 1 };
44
118
  this.fullEncodeCache = this.encoder.encodeAll(this.sharedOffsetCache, this.fullEncodeBuffer);
45
119
  this.needFullEncode = false;
46
120
  }
47
121
 
48
- if (this.hasFilters && client?.view) {
49
- return this.encoder.encodeAllView(
50
- client.view,
51
- this.sharedOffsetCache.offset,
52
- { ...this.sharedOffsetCache },
53
- this.fullEncodeBuffer
54
- );
55
-
56
- } else {
57
- return this.fullEncodeCache;
122
+ const body = (this.hasFilters && client?.view)
123
+ ? this.encoder.encodeAllView(
124
+ client.view,
125
+ this.sharedOffsetCache.offset,
126
+ { ...this.sharedOffsetCache },
127
+ this.fullEncodeBuffer
128
+ )
129
+ : this.fullEncodeCache;
130
+
131
+ if (timing && client) {
132
+ return buildTimedFrame(body, timing.sNow, clientProcessedInputSeq(client));
58
133
  }
134
+ return body;
59
135
  }
60
136
 
61
- public applyPatches(clients: Client[]) {
137
+ public applyPatches(clients: Client[], _state?: unknown, timing?: PatchTimingContext) {
62
138
  let numClients = clients.length;
63
139
 
64
140
  if (numClients === 0) {
@@ -92,11 +168,26 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
92
168
  this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH;
93
169
 
94
170
  clientsWithViewChange.forEach((client) => {
95
- client.raw(this.encoder.encodeView(client.view, sharedOffset, it));
171
+ const encodedView = this.encoder.encodeView(client.view, sharedOffset, it);
172
+ sendClientFrame(client, encodedView, timing);
96
173
  });
97
174
  }
98
175
  }
99
176
 
177
+ // Heartbeat: clients that advertised CLIENT_TIMING still need
178
+ // periodic timing samples to keep their clock offset / RTT estimate
179
+ // fresh during quiet ticks. Emit a TIMED-prefixed ROOM_STATE_PATCH
180
+ // with an empty schema body — 1 byte protocol + 12 bytes prefix.
181
+ // No-op for rooms without `defineInput()` (timing is undefined).
182
+ if (timing) {
183
+ const heartbeatBody = Buffer.from([Protocol.ROOM_STATE_PATCH]);
184
+ for (let i = 0; i < clients.length; i++) {
185
+ const client = clients[i];
186
+ if (client.state !== ClientState.JOINED) continue;
187
+ sendClientFrame(client, heartbeatBody, timing);
188
+ }
189
+ }
190
+
100
191
  // skip patching state if:
101
192
  // - no clients are connected
102
193
  // - no changes were made
@@ -129,7 +220,7 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
129
220
  continue;
130
221
  }
131
222
 
132
- client.raw(encodedChanges);
223
+ sendClientFrame(client, encodedChanges, timing);
133
224
  }
134
225
 
135
226
  } else {
@@ -159,7 +250,7 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
159
250
  this.encodedViews.set(view, encodedView);
160
251
  }
161
252
 
162
- client.raw(encodedView);
253
+ sendClientFrame(client, encodedView, timing);
163
254
  }
164
255
 
165
256
  // clear views
@@ -1,9 +1,20 @@
1
1
  import type { Client } from '../Transport.ts';
2
2
 
3
+ /**
4
+ * Per-tick timing context, populated by the Room when
5
+ * {@link Room.defineInput} was called. The serializer uses it to emit a
6
+ * {@link ProtocolModifier.TIMED} prefix for clients that advertised
7
+ * the {@link HandshakeSection.CLIENT_TIMING} capability.
8
+ */
9
+ export interface PatchTimingContext {
10
+ /** `performance.now()` at the moment the patch is being applied. */
11
+ sNow: number;
12
+ }
13
+
3
14
  export interface Serializer<T> {
4
15
  id: string;
5
16
  reset(data: any): void;
6
- getFullState(client?: Client): Uint8Array;
7
- applyPatches(clients: Client[], state: T): boolean;
17
+ getFullState(client?: Client, timing?: PatchTimingContext): Uint8Array;
18
+ applyPatches(clients: Client[], state: T, timing?: PatchTimingContext): boolean;
8
19
  handshake?(): Uint8Array;
9
20
  }
@@ -31,11 +31,24 @@ export async function reloadFromCache() {
31
31
  debugDevMode("rooms to restore: %i", roomHistoryList.length);
32
32
 
33
33
  for (const [roomId, value] of roomHistoryList) {
34
- const roomHistory = JSON.parse(value);
35
- debugDevMode("restoring room %s (%s)", roomHistory.roomName, roomId);
36
-
37
- const recreatedRoomListing = await handleCreateRoom(roomHistory.roomName, roomHistory.clientOptions, roomId);
38
- const recreatedRoom = getLocalRoomById(recreatedRoomListing.roomId);
34
+ let roomHistory: any;
35
+ let recreatedRoom: Room;
36
+ let recreatedRoomListing: any;
37
+
38
+ try {
39
+ roomHistory = JSON.parse(value);
40
+ debugDevMode("restoring room %s (%s)", roomHistory.roomName, roomId);
41
+
42
+ recreatedRoomListing = await handleCreateRoom(roomHistory.roomName, roomHistory.clientOptions, roomId);
43
+ recreatedRoom = getLocalRoomById(recreatedRoomListing.roomId);
44
+
45
+ } catch (e: any) {
46
+ // unrestorable entry (e.g. room type renamed/removed) — drop it
47
+ // instead of wedging the boot
48
+ debugAndPrintError(`❌ couldn't restore room '${roomId}':\n${e.stack}`);
49
+ await presence.hdel(getRoomRestoreListKey(), roomId);
50
+ continue;
51
+ }
39
52
 
40
53
  // Restore previous state
41
54
  if (roomHistory.hasOwnProperty("state")) {
@@ -144,14 +157,6 @@ export async function cacheRoomHistory(rooms: { [roomId: string]: Room }) {
144
157
  }
145
158
  }
146
159
 
147
- export async function getPreviousProcessId(hostname: string = '') {
148
- return await presence.hget(getProcessRestoreKey(), hostname);
149
- }
150
-
151
160
  export function getRoomRestoreListKey() {
152
161
  return 'roomhistory';
153
162
  }
154
-
155
- export function getProcessRestoreKey() {
156
- return 'processhistory';
157
- }