@irtio/schema 0.5.2 → 0.7.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.d.ts CHANGED
@@ -159,6 +159,46 @@ declare function compilePhysics(at: string, kind: 'entity' | 'singleton', fields
159
159
  declare function canonicalPhysics(p: PhysicsDesc): Record<string, unknown>;
160
160
  /** Reads a canonical physics fragment back (`schemaFromCanonical`). */
161
161
  declare function physicsFromCanonical(at: string, raw: unknown): EntityPhysics;
162
+ /**
163
+ * One planar body's state, in the vocabulary a 2D engine speaks. This is the only place the
164
+ * mapping from two dimensions onto the thirteen 3D channels is written down, and it lives in the
165
+ * schema package precisely because two sides have to agree about it: the room's server-side world
166
+ * writes through it, and anything reading those channels back (a renderer deriving the angle, a
167
+ * client-side world) reverses it. A second copy of these six lines somewhere else would be a
168
+ * silent way for the two to disagree by a sign.
169
+ *
170
+ * `angle` rides the quaternion about Z, which is what a rotation in the xy plane *is*:
171
+ * `qz = sin(a/2)`, `qw = cos(a/2)`, and back out with `2 * atan2(qz, qw)`. Channels a plane has no
172
+ * use for (`z`, `qx`, `qy`, `vz`, `wx`, `wy`) read as constant zero rather than being omitted, so a
173
+ * schema written for one engine still decodes under the other.
174
+ */
175
+ interface Body2dState {
176
+ readonly x: number;
177
+ readonly y: number;
178
+ /** Radians, counter-clockwise. */
179
+ readonly angle: number;
180
+ readonly vx: number;
181
+ readonly vy: number;
182
+ /** Radians per second about Z. */
183
+ readonly angularVelocity: number;
184
+ }
185
+ /** The value a planar body puts on one 3D channel. */
186
+ declare function channelOf2d(channel: PhysicsBodyChannel, b: Body2dState): number;
187
+ /** Reads the angle back out of the two quaternion channels a plane uses. */
188
+ declare function angleFrom2d(qz: number, qw: number): number;
189
+ /**
190
+ * The reverse of {@link channelOf2d}: folds one channel's value into a mutable planar state.
191
+ * `qz`/`qw` are accumulated and the angle recomputed, so either order of arrival works.
192
+ */
193
+ declare function applyChannel2d(channel: PhysicsBodyChannel, value: number, target: {
194
+ x: number;
195
+ y: number;
196
+ qz: number;
197
+ qw: number;
198
+ vx: number;
199
+ vy: number;
200
+ wz: number;
201
+ }): void;
162
202
 
163
203
  /**
164
204
  * `entity`, `singleton`, `server`, `client`, `defineSchema`, and the compiled schema
@@ -204,6 +244,23 @@ interface EntityOptions {
204
244
  * the schema hash, and `schemaFromCanonical` does not round-trip it. Requires `physics`.
205
245
  */
206
246
  readonly predicted?: boolean;
247
+ /**
248
+ * D71: `false` → instances of this collection the client does not predict get **no body** in the
249
+ * client's local world, which is what every unpredicted body had before proxies existed.
250
+ *
251
+ * The default is `true`. An instance the predictor skips — one past `maxPredictedBodies` in a
252
+ * `predicted` collection, or any instance of a collection that is not `predicted` — is given a
253
+ * **kinematic proxy**: the same collider, built by the same body factory, moved each local step
254
+ * to the pose the renderer draws for it and never simulated forward. Predicted bodies collide
255
+ * with it; local forces never move it. So a player stands on a crate instead of falling through
256
+ * it. Turn it off for decoration nothing collides with, and for anything whose collider is
257
+ * expensive and whose contacts do not matter.
258
+ *
259
+ * Like `interpolate` and `predicted`, this is client-side simulation behavior, not server shape:
260
+ * it is not part of the canonical form or the schema hash, and `schemaFromCanonical` does not
261
+ * round-trip it. Requires `physics`, and does nothing without a client-side body factory.
262
+ */
263
+ readonly proxy?: boolean;
207
264
  /**
208
265
  * D22: this collection's instances are backed by rigid bodies. `body` names the fields the
209
266
  * simulation owns (read-only everywhere else), `intents` the owner-written inputs the step
@@ -290,6 +347,8 @@ interface CollectionDesc {
290
347
  readonly interpolate: boolean;
291
348
  /** D21: clients simulate non-owned bodies ahead. Client-side only — not in the hash. */
292
349
  readonly predicted: boolean;
350
+ /** D71: unpredicted instances get a kinematic proxy locally. Client-side only — not in the hash. */
351
+ readonly proxy: boolean;
293
352
  /** D22 body/intent field split, compiled. `undefined` for ordinary collections. In the hash. */
294
353
  readonly physics: PhysicsDesc | undefined;
295
354
  }
@@ -302,24 +361,56 @@ interface RpcDesc {
302
361
  /** `undefined` = void. */
303
362
  readonly returns: readonly FieldDesc[] | undefined;
304
363
  }
305
- interface SchemaOptions<Rpc extends RpcMap, Roles extends readonly string[]> {
364
+ /** Declared message shapes: name the fields one message of that name carries. */
365
+ type MessageMap = Readonly<Record<string, Fields>>;
366
+ /**
367
+ * One compiled message shape. `index` is its position in `schema.messages` (sorted by name) and
368
+ * is what rides the wire, so adding a message whose name sorts early is a breaking change in
369
+ * exactly the way adding an early-sorting collection is (see `diffSchemas`).
370
+ */
371
+ interface MessageDesc {
372
+ readonly name: string;
373
+ /** Index in `schema.messages` (sorted by name) = wire index. */
374
+ readonly index: number;
375
+ readonly fields: readonly FieldDesc[];
376
+ }
377
+ /**
378
+ * D70: the declared ceiling on message shapes. The wire index is a `u16` — this is the number a
379
+ * schema is refused at, deliberately far below what the encoding could carry, so that raising it
380
+ * later is additive rather than a wire change.
381
+ */
382
+ declare const MAX_MESSAGES = 256;
383
+ interface SchemaOptions<Rpc extends RpcMap, Roles extends readonly string[], Msgs extends MessageMap = MessageMap> {
306
384
  readonly rpc?: Rpc;
307
385
  readonly roles?: Roles;
386
+ /**
387
+ * D70: peer message shapes. `messages: { emote: { kind: enumOf('wave', 'laugh'), x: f32 } }`
388
+ * gives the client `room.messages.emote.send(target, value)` / `.on(cb)` and the room the same
389
+ * `send` plus a decoded sixth argument on `onMessage`. Part of the hash — a client and a room
390
+ * that disagree about a message shape disagree about the bytes — but emitted into the canonical
391
+ * form only when at least one is declared, so every schema written before this feature keeps
392
+ * the hash it had.
393
+ */
394
+ readonly messages?: Msgs;
308
395
  /** The public project id, written by `irtio init`. Identity, not shape: not part of the hash. */
309
396
  readonly project?: string;
310
397
  /** Internal: lets `@irtio/protocol` build the runtime-extended schema with built-in collections. */
311
398
  readonly allowReservedNames?: boolean;
312
399
  }
313
- interface Schema<Defs extends DefMap = DefMap, Rpc extends RpcMap = RpcMap, Roles extends readonly string[] = readonly string[]> {
400
+ interface Schema<Defs extends DefMap = DefMap, Rpc extends RpcMap = RpcMap, Roles extends readonly string[] = readonly string[], Msgs extends MessageMap = MessageMap> {
314
401
  readonly defs: Defs;
315
402
  readonly rpc: Rpc;
316
403
  readonly roles: Roles;
404
+ /** D70: the declared message map, as written. */
405
+ readonly messageDefs: Msgs;
317
406
  readonly project: string | undefined;
318
407
  /** Collections sorted by name; index = wire index. */
319
408
  readonly collections: readonly CollectionDesc[];
320
409
  readonly collection: (name: string) => CollectionDesc;
321
410
  /** Builder RPCs sorted by name; index = wire `rpcId` (protocol appends built-ins after). */
322
411
  readonly rpcs: readonly RpcDesc[];
412
+ /** D70: declared messages sorted by name; index = wire index. Empty when none are declared. */
413
+ readonly messages: readonly MessageDesc[];
323
414
  /** Canonical JSON (sorted keys, `canonicalVersion: 1`). */
324
415
  readonly canonical: string;
325
416
  /** SHA-256 of the canonical form, hex. */
@@ -327,8 +418,8 @@ interface Schema<Defs extends DefMap = DefMap, Rpc extends RpcMap = RpcMap, Role
327
418
  /** First 8 bytes of the hash (HELLO / snapshot / delta header). */
328
419
  readonly hash8: Uint8Array;
329
420
  }
330
- type AnySchema = Schema<any, any, any>;
331
- declare function defineSchema<const Defs extends DefMap, const Rpc extends RpcMap = {}, const Roles extends readonly string[] = readonly string[]>(defs: Defs, options?: SchemaOptions<Rpc, Roles>): Schema<Defs, Rpc, Roles>;
421
+ type AnySchema = Schema<any, any, any, any>;
422
+ declare function defineSchema<const Defs extends DefMap, const Rpc extends RpcMap = {}, const Roles extends readonly string[] = readonly string[], const Msgs extends MessageMap = {}>(defs: Defs, options?: SchemaOptions<Rpc, Roles, Msgs>): Schema<Defs, Rpc, Roles, Msgs>;
332
423
  declare const CANONICAL_VERSION = 1;
333
424
  declare function canonicalType(d: TypeDesc): Record<string, unknown>;
334
425
  /**
@@ -445,10 +536,42 @@ type DeepWritable<T> = T extends readonly (infer U)[] ? DeepWritable<U>[] : T ex
445
536
  } : T;
446
537
  /** The writable view of an instance the client owns (the client SDK hands these out). */
447
538
  type Owned<T> = DeepWritable<T>;
448
- type SchemaDefs<S> = S extends Schema<infer D, any, any> ? D : never;
449
- type SchemaRpc<S> = S extends Schema<any, infer R, any> ? R : never;
450
- type SchemaRoles<S> = S extends Schema<any, any, infer R> ? R : never;
539
+ type SchemaDefs<S> = S extends Schema<infer D, any, any, any> ? D : never;
540
+ type SchemaRpc<S> = S extends Schema<any, infer R, any, any> ? R : never;
541
+ type SchemaRoles<S> = S extends Schema<any, any, infer R, any> ? R : never;
451
542
  type RoleOf<S> = SchemaRoles<S>[number];
543
+ /** The declared message map of a schema (`{}` for a schema that declares none). */
544
+ type SchemaMessages<S> = S extends Schema<any, any, any, infer M> ? M : never;
545
+ /** Alias reading the way call sites do: `MessagesOf<typeof schema>`. */
546
+ type MessagesOf<S> = SchemaMessages<S>;
547
+ /** Every declared message name, as a union of string literals. */
548
+ type MessageNames<S> = keyof SchemaMessages<S> & string;
549
+ /**
550
+ * The plain TS value one message of name `N` carries — exactly what `send` takes and exactly
551
+ * what `on` hands back, `.opt` fields included.
552
+ */
553
+ type MessageValue<S, N extends MessageNames<S>> = SchemaMessages<S>[N] extends infer F ? F extends Fields ? InferFields<F> : never : never;
554
+ /**
555
+ * `room.messages` on both sides, given the target and sender types each side uses.
556
+ *
557
+ * The two sides differ only in those two: a client sends to a `MessageTarget` and hears from
558
+ * `'server' | clientId`; a room sends to its own `MessageTarget` and observes through
559
+ * `onMessage`. Sharing the mapped type is what keeps `send` and `on` agreeing about the shape.
560
+ */
561
+ type MessageChannels<S, Target, From, Unsub> = {
562
+ [K in MessageNames<S>]: {
563
+ send(target: Target, value: MessageValue<S, K>): void;
564
+ on(cb: (from: From, value: MessageValue<S, K>) => void): Unsub;
565
+ };
566
+ };
567
+ /** The server half: `send` only (a room observes through `onMessage`, never through `on`). */
568
+ type ServerMessageChannels<S, Target> = {
569
+ [K in MessageNames<S>]: {
570
+ send(target: Target, value: MessageValue<S, K>): void;
571
+ };
572
+ };
573
+ /** Guard for `MessageMap`-shaped generics at declaration sites. */
574
+ type AnyMessageMap = MessageMap;
452
575
  /** Plain instance type of an entity/singleton definition. */
453
576
  type InstanceOf<D> = D extends EntityDef<infer F, any> ? InferFields<F> : D extends SingletonDef<infer F, any> ? InferFields<F> : never;
454
577
  /** Init type (`add()` values) of an entity definition. */
@@ -662,7 +785,17 @@ declare class ByteReader {
662
785
  bytes(n: number): Uint8Array;
663
786
  /** varint length + raw bytes (a view). */
664
787
  blob(): Uint8Array;
665
- str(): string;
788
+ /**
789
+ * varint length + UTF-8 bytes.
790
+ *
791
+ * `max` is the declared byte ceiling for this string (`str(max)`, or `ID_MAX_BYTES` for a
792
+ * `ref`). Checked against the length prefix **before** the bytes are read, so a hostile length
793
+ * costs a comparison rather than a read — and so a decoder is as strict as the encoder, which
794
+ * has always refused an oversize value. The encoder's guarantee only ever covered our own
795
+ * encoders; this covers bytes that arrived from somewhere else (D70: a peer's typed message is
796
+ * decoded by every receiver).
797
+ */
798
+ str(max?: number): string;
666
799
  /** The rest of the buffer (a view). */
667
800
  rest(): Uint8Array;
668
801
  }
@@ -774,6 +907,59 @@ declare function applyDelta(schema: AnySchema, state: PlainState, delta: Delta):
774
907
  /** Field-level diff between two plain states: what would have to be sent to turn `from` into `to`. */
775
908
  declare function computeDirty(schema: AnySchema, from: PlainState, to: PlainState): DirtySet;
776
909
 
910
+ /**
911
+ * The measurement walker: a reader's-eye pass over *encoded* delta and snapshot bytes that
912
+ * reports where each byte went, keyed by collection and field.
913
+ *
914
+ * This is deliberately a second reader of the wire format rather than an instrumented writer.
915
+ * The encoders and `ValueSink` are untouched, so the hot path stays byte-identical and
916
+ * cost-identical whether or not anything is profiling; and because the walker reads exactly what
917
+ * `decodeDeltaFrom`/`decodeSnapshot` read, it works on the server after an encode and on the
918
+ * client before a decode, from the same code.
919
+ *
920
+ * The copy is kept honest by conservation: the spans it reports partition the bytes it consumed,
921
+ * with nothing counted twice and nothing left over (`walk.test.ts` proves it against
922
+ * `encodeDelta(...).length` over random schemas). If the format changes here without changing
923
+ * `codec.ts`, or the other way round, that property test fails.
924
+ *
925
+ * Nothing here allocates a decoded value: it skips values by width, so a profiled room pays for
926
+ * a second pass over the bytes and not for a second decode.
927
+ */
928
+
929
+ /**
930
+ * Where the bytes went. Every callback reports a byte count, and for one walk the counts sum to
931
+ * the number of bytes the walk consumed.
932
+ */
933
+ interface WalkEvents {
934
+ /**
935
+ * Framing that belongs to no single field: `tick`, `hash8`, the dirty-collection count, and
936
+ * each collection's index and op count (snapshot: the header and each entity count).
937
+ */
938
+ header(bytes: number): void;
939
+ /**
940
+ * One op's own framing: the tag byte, the id string, the owner string (add), the dirty mask
941
+ * and the owner string when the owner bit is set (update). Always called before that op's
942
+ * fields, so a consumer can route a whole op somewhere else by latching on this call.
943
+ *
944
+ * A snapshot's records report as `add` ops whose framing is the id and owner strings; a
945
+ * snapshot singleton has no op framing at all, only fields.
946
+ */
947
+ op(collection: CollectionDesc, kind: 'add' | 'update' | 'remove', id: string, bytes: number): void;
948
+ /** One field value, nested struct masks and presence bytes included. */
949
+ field(collection: CollectionDesc, field: FieldDesc, bytes: number): void;
950
+ }
951
+ /**
952
+ * Walks one delta body, reporting spans through `events`. Accepts raw bytes or a positioned
953
+ * reader, which it leaves at the first byte after the body exactly as `decodeDeltaFrom` does
954
+ * (the `CORRECT` frame's tick suffix is the caller's). Returns the number of bytes consumed.
955
+ */
956
+ declare function walkDelta(schema: AnySchema, bytes: Uint8Array | ByteReader, events: WalkEvents): number;
957
+ /**
958
+ * Walks one snapshot body, reporting spans through `events`. Entity records report as `add` ops;
959
+ * a singleton reports only its fields. Returns the number of bytes consumed.
960
+ */
961
+ declare function walkSnapshot(schema: AnySchema, bytes: Uint8Array | ByteReader, events: WalkEvents): number;
962
+
777
963
  /**
778
964
  * `track()` — a proxy tree over a plain state that records every write into a `DirtySet`.
779
965
  *
@@ -824,4 +1010,4 @@ interface SchemaChange {
824
1010
  /** Compares two compiled schemas and returns every classified change, sorted by path then code. */
825
1011
  declare function diffSchemas(oldSchema: AnySchema, newSchema: AnySchema): SchemaChange[];
826
1012
 
827
- export { type AddOptions, type AnyDef, type AnyRpc, type AnySchema, type AnyType, type BodyFieldsOf, type BroadcastProxy, ByteReader, ByteWriter, CANONICAL_VERSION, type ClientCallProxy, type ClientImplementations, type ClientRpcs, type ClientState, type Collection, type CollectionDesc, type CollectionDirty, type DecodedSnapshot, type DeepReadonly, type DeepWritable, type DefMap, type Delta, type DeltaCollection, type DeltaOp, type DirtySet, EntityCollection, type EntityDef, type EntityOptions, type EntityPhysics, type FieldDesc, type FieldMask, type Fields, type FromCanonicalOptions, type GridDesc, type GridOptions, type Header, ID_MAX_BYTES, type Implementations, type Infer, type InferFields, type InitFields, type InitOf, type InstanceOf, type Kind, type OwnableKeys, type Owned, PHYSICS_BODY_CHANNELS, type PhysicsBodyChannel, type PhysicsBodyMap, type PhysicsDesc, type PhysicsKeys, type PlainState, RESERVED_COLLECTION_NAMES, RESERVED_RPC_NAMES, type ReadonlyBodyFields, type ReadonlyCollection, type RecordDirty, type RoleOf, type RpcDef, type RpcDesc, type RpcDirection, type RpcMap, type RpcParams, type RpcReturns, type RpcSpec, SERVER_OWNER, SINGLETON_ID, type Schema, type SchemaChange, type SchemaDefs, type SchemaIssue, type SchemaOptions, type SchemaRoles, type SchemaRpc, type ServerCallProxy, type ServerOwnedKeys, type ServerRpcs, type SingletonDef, type SnapshotOptions, type State, type Tracked, type Type, type TypeDesc, type ValueSink, type Visibility, type VisibleKeys, applyDelta, bool, bytesEqual, canonicalPhysics, canonicalType, client, cloneValue, collectionDirty, compilePhysics, computeDirty, createDirtySet, createFieldMask, createState, decodeDelta, decodeDeltaFrom, decodeFields, decodeSnapshot, defaultRecord, defaultValue, defineSchema, describeType, diffSchemas, encodeDelta, encodeFields, encodeSnapshot, entity, enumOf, estimateSize, f32, f64, filterDirty, frozenProxy, i32, isDirtyEmpty, isWhole, list, markAdd, markField, markOwner, markPath, markRemove, mergeDirty, mergeMask, normalizeRecord, normalizeValue, physicsFromCanonical, readValue, ref, schemaFromCanonical, server, sha256, sha256Hex, singleton, stableStringify, str, struct, toHex, track, u16, u32, u8, validateForDeploy, validateValue, writeValue };
1013
+ export { type AddOptions, type AnyDef, type AnyMessageMap, type AnyRpc, type AnySchema, type AnyType, type Body2dState, type BodyFieldsOf, type BroadcastProxy, ByteReader, ByteWriter, CANONICAL_VERSION, type ClientCallProxy, type ClientImplementations, type ClientRpcs, type ClientState, type Collection, type CollectionDesc, type CollectionDirty, type DecodedSnapshot, type DeepReadonly, type DeepWritable, type DefMap, type Delta, type DeltaCollection, type DeltaOp, type DirtySet, EntityCollection, type EntityDef, type EntityOptions, type EntityPhysics, type FieldDesc, type FieldMask, type Fields, type FromCanonicalOptions, type GridDesc, type GridOptions, type Header, ID_MAX_BYTES, type Implementations, type Infer, type InferFields, type InitFields, type InitOf, type InstanceOf, type Kind, MAX_MESSAGES, type MessageChannels, type MessageDesc, type MessageMap, type MessageNames, type MessageValue, type MessagesOf, type OwnableKeys, type Owned, PHYSICS_BODY_CHANNELS, type PhysicsBodyChannel, type PhysicsBodyMap, type PhysicsDesc, type PhysicsKeys, type PlainState, RESERVED_COLLECTION_NAMES, RESERVED_RPC_NAMES, type ReadonlyBodyFields, type ReadonlyCollection, type RecordDirty, type RoleOf, type RpcDef, type RpcDesc, type RpcDirection, type RpcMap, type RpcParams, type RpcReturns, type RpcSpec, SERVER_OWNER, SINGLETON_ID, type Schema, type SchemaChange, type SchemaDefs, type SchemaIssue, type SchemaMessages, type SchemaOptions, type SchemaRoles, type SchemaRpc, type ServerCallProxy, type ServerMessageChannels, type ServerOwnedKeys, type ServerRpcs, type SingletonDef, type SnapshotOptions, type State, type Tracked, type Type, type TypeDesc, type ValueSink, type Visibility, type VisibleKeys, type WalkEvents, angleFrom2d, applyChannel2d, applyDelta, bool, bytesEqual, canonicalPhysics, canonicalType, channelOf2d, client, cloneValue, collectionDirty, compilePhysics, computeDirty, createDirtySet, createFieldMask, createState, decodeDelta, decodeDeltaFrom, decodeFields, decodeSnapshot, defaultRecord, defaultValue, defineSchema, describeType, diffSchemas, encodeDelta, encodeFields, encodeSnapshot, entity, enumOf, estimateSize, f32, f64, filterDirty, frozenProxy, i32, isDirtyEmpty, isWhole, list, markAdd, markField, markOwner, markPath, markRemove, mergeDirty, mergeMask, normalizeRecord, normalizeValue, physicsFromCanonical, readValue, ref, schemaFromCanonical, server, sha256, sha256Hex, singleton, stableStringify, str, struct, toHex, track, u16, u32, u8, validateForDeploy, validateValue, walkDelta, walkSnapshot, writeValue };
package/dist/index.js CHANGED
@@ -318,6 +318,56 @@ function physicsFromCanonical(at, raw) {
318
318
  }
319
319
  return { body, intents };
320
320
  }
321
+ function channelOf2d(channel, b) {
322
+ switch (channel) {
323
+ case "x":
324
+ return b.x;
325
+ case "y":
326
+ return b.y;
327
+ case "qz":
328
+ return Math.sin(b.angle / 2);
329
+ case "qw":
330
+ return Math.cos(b.angle / 2);
331
+ case "vx":
332
+ return b.vx;
333
+ case "vy":
334
+ return b.vy;
335
+ case "wz":
336
+ return b.angularVelocity;
337
+ default:
338
+ return 0;
339
+ }
340
+ }
341
+ function angleFrom2d(qz, qw) {
342
+ return 2 * Math.atan2(qz, qw);
343
+ }
344
+ function applyChannel2d(channel, value, target) {
345
+ switch (channel) {
346
+ case "x":
347
+ target.x = value;
348
+ return;
349
+ case "y":
350
+ target.y = value;
351
+ return;
352
+ case "qz":
353
+ target.qz = value;
354
+ return;
355
+ case "qw":
356
+ target.qw = value;
357
+ return;
358
+ case "vx":
359
+ target.vx = value;
360
+ return;
361
+ case "vy":
362
+ target.vy = value;
363
+ return;
364
+ case "wz":
365
+ target.wz = value;
366
+ return;
367
+ default:
368
+ return;
369
+ }
370
+ }
321
371
 
322
372
  // src/sha256.ts
323
373
  var K = new Uint32Array([
@@ -492,6 +542,7 @@ function makeRpc(direction, spec) {
492
542
  }
493
543
  var RESERVED_RPC_NAMES = ["requestOwnership"];
494
544
  var RESERVED_COLLECTION_NAMES = ["clients"];
545
+ var MAX_MESSAGES = 256;
495
546
  function defineSchema(defs, options = {}) {
496
547
  const names = Object.keys(defs);
497
548
  for (const n of names) {
@@ -509,6 +560,11 @@ function defineSchema(defs, options = {}) {
509
560
  `${name}: predicted: true needs physics \u2014 only body-backed collections can be simulated ahead`
510
561
  );
511
562
  }
563
+ if (o.proxy !== void 0 && !o.physics) {
564
+ throw new Error(
565
+ `${name}: proxy needs physics \u2014 only body-backed collections get a body in a client's local world`
566
+ );
567
+ }
512
568
  return {
513
569
  name,
514
570
  index,
@@ -527,6 +583,7 @@ function defineSchema(defs, options = {}) {
527
583
  } : void 0,
528
584
  interpolate: o.interpolate !== false,
529
585
  predicted: o.predicted === true,
586
+ proxy: o.proxy !== false,
530
587
  physics: o.physics ? compilePhysics(name, def.kind, fields, o.physics) : void 0
531
588
  };
532
589
  });
@@ -558,13 +615,46 @@ function defineSchema(defs, options = {}) {
558
615
  });
559
616
  const roles = options.roles ?? [];
560
617
  if (new Set(roles).size !== roles.length) throw new Error("roles must be unique");
561
- const canonical = canonicalize({ collections, rpcs, roles });
618
+ const messageMap = options.messages ?? {};
619
+ const messageNames = Object.keys(messageMap);
620
+ if (messageNames.length > MAX_MESSAGES) {
621
+ throw new Error(
622
+ `at most ${MAX_MESSAGES} messages per schema, got ${messageNames.length} \u2014 a message index rides the wire, and this ceiling is what a client can be sure of`
623
+ );
624
+ }
625
+ const rpcNameSet = new Set(Object.keys(rpcMap));
626
+ const collectionNameSet = new Set(names);
627
+ for (const n of messageNames) {
628
+ assertFieldName(n);
629
+ if (collectionNameSet.has(n)) {
630
+ throw new Error(
631
+ `message name ${JSON.stringify(n)} is already a collection in this schema \u2014 pick another`
632
+ );
633
+ }
634
+ if (rpcNameSet.has(n)) {
635
+ throw new Error(
636
+ `message name ${JSON.stringify(n)} is already an RPC in this schema \u2014 pick another`
637
+ );
638
+ }
639
+ const fields = messageMap[n];
640
+ if (Object.keys(fields).length > 255) {
641
+ throw new Error(`message ${JSON.stringify(n)}: at most 255 fields`);
642
+ }
643
+ for (const f of Object.keys(fields)) assertFieldName(f);
644
+ }
645
+ const messages = [...messageNames].sort().map((name, index) => {
646
+ const fields = compileFields(messageMap[name]);
647
+ for (const f of fields) checkRefs(f.type, entityNames, `message ${name}.${f.name}`);
648
+ return { name, index, fields };
649
+ });
650
+ const canonical = canonicalize({ collections, rpcs, roles, messages });
562
651
  const hashBytes = sha256(new TextEncoder().encode(canonical));
563
652
  const byName = new Map(collections.map((c) => [c.name, c]));
564
653
  return {
565
654
  defs,
566
655
  rpc: rpcMap,
567
656
  roles,
657
+ messageDefs: messageMap,
568
658
  project: options.project,
569
659
  collections,
570
660
  collection: (name) => {
@@ -573,6 +663,7 @@ function defineSchema(defs, options = {}) {
573
663
  return c;
574
664
  },
575
665
  rpcs,
666
+ messages,
576
667
  canonical,
577
668
  hash: toHex(hashBytes),
578
669
  hash8: hashBytes.slice(0, 8)
@@ -648,6 +739,10 @@ function canonicalize(s) {
648
739
  // Emitted only when declared: `stableStringify` drops `undefined`, so every schema written
649
740
  // before D22 keeps the byte-identical canonical form — and therefore the hash — it had.
650
741
  physics: c.physics ? canonicalPhysics(c.physics) : void 0
742
+ // Deliberately absent, and this is the list: `interpolate` (D20), `predicted` (D21) and
743
+ // `proxy` (D71) are all decisions about what one client does with a body it was sent, not
744
+ // about the shape of the world. A room and a client that disagree about any of them still
745
+ // agree about every byte on the wire, so none of them may move the hash.
651
746
  })),
652
747
  rpcs: s.rpcs.map((r) => ({
653
748
  name: r.name,
@@ -655,7 +750,12 @@ function canonicalize(s) {
655
750
  params: canonicalFields(r.params),
656
751
  returns: r.returns ? canonicalFields(r.returns) : null
657
752
  })),
658
- roles: [...s.roles]
753
+ roles: [...s.roles],
754
+ // D70: emitted only when at least one message is declared. `stableStringify` drops
755
+ // `undefined`, so every schema written before typed messages keeps the byte-identical
756
+ // canonical form — and therefore the hash — it had, and no deployed project is reclassified.
757
+ // Same rule as `grid` and `physics` above, and for the same reason.
758
+ messages: s.messages.length > 0 ? s.messages.map((m) => ({ name: m.name, fields: canonicalFields(m.fields) })) : void 0
659
759
  };
660
760
  return stableStringify(doc);
661
761
  }
@@ -809,9 +909,17 @@ function schemaFromCanonical(json, options = {}) {
809
909
  if (typeof role !== "string") throw new Error("schemaFromCanonical: roles must be strings");
810
910
  roles.push(role);
811
911
  }
912
+ const messages = {};
913
+ if (d.messages !== void 0 && d.messages !== null) {
914
+ for (const m of arrayAt(d, "messages")) {
915
+ const name = stringAt(m, "name");
916
+ messages[name] = fieldsOf(arrayAt(m, "fields"), `message ${name}`);
917
+ }
918
+ }
812
919
  const schema = defineSchema(defs, {
813
920
  rpc,
814
921
  roles,
922
+ ...Object.keys(messages).length > 0 ? { messages } : {},
815
923
  // A canonical form produced by `withBuiltins` names the reserved `clients` collection; a
816
924
  // reader must be able to replay whatever it is handed.
817
925
  allowReservedNames: true,
@@ -1327,8 +1435,21 @@ var ByteReader = class {
1327
1435
  blob() {
1328
1436
  return this.bytes(this.varint());
1329
1437
  }
1330
- str() {
1331
- return decoder.decode(this.blob());
1438
+ /**
1439
+ * varint length + UTF-8 bytes.
1440
+ *
1441
+ * `max` is the declared byte ceiling for this string (`str(max)`, or `ID_MAX_BYTES` for a
1442
+ * `ref`). Checked against the length prefix **before** the bytes are read, so a hostile length
1443
+ * costs a comparison rather than a read — and so a decoder is as strict as the encoder, which
1444
+ * has always refused an oversize value. The encoder's guarantee only ever covered our own
1445
+ * encoders; this covers bytes that arrived from somewhere else (D70: a peer's typed message is
1446
+ * decoded by every receiver).
1447
+ */
1448
+ str(max) {
1449
+ if (max === void 0) return decoder.decode(this.blob());
1450
+ const n = this.varint();
1451
+ if (n > max) throw new Error(`string exceeds ${max} UTF-8 bytes (${n})`);
1452
+ return decoder.decode(this.bytes(n));
1332
1453
  }
1333
1454
  /** The rest of the buffer (a view). */
1334
1455
  rest() {
@@ -1490,7 +1611,7 @@ function readValue(r, desc) {
1490
1611
  return r.f64();
1491
1612
  case "str":
1492
1613
  case "ref":
1493
- return r.str();
1614
+ return r.str(desc.kind === "str" ? desc.max : ID_MAX_BYTES);
1494
1615
  case "enum": {
1495
1616
  const i = r.u8();
1496
1617
  const v = desc.values[i];
@@ -1901,6 +2022,147 @@ function computeDirty(schema, from, to) {
1901
2022
  return dirty;
1902
2023
  }
1903
2024
 
2025
+ // src/walk.ts
2026
+ function skip(r, n) {
2027
+ if (n < 0 || r.remaining < n) throw new Error("unexpected end of buffer");
2028
+ r.pos += n;
2029
+ }
2030
+ function structNames2(d) {
2031
+ return Object.keys(d.fields);
2032
+ }
2033
+ function skipValue(r, desc) {
2034
+ if (desc.opt && r.u8() === 0) return;
2035
+ switch (desc.kind) {
2036
+ case "bool":
2037
+ case "u8":
2038
+ case "enum":
2039
+ skip(r, 1);
2040
+ return;
2041
+ case "u16":
2042
+ skip(r, 2);
2043
+ return;
2044
+ case "u32":
2045
+ case "i32":
2046
+ case "f32":
2047
+ skip(r, 4);
2048
+ return;
2049
+ case "f64":
2050
+ skip(r, 8);
2051
+ return;
2052
+ case "str":
2053
+ case "ref":
2054
+ skip(r, r.varint());
2055
+ return;
2056
+ case "list": {
2057
+ const n = r.varint();
2058
+ for (let i = 0; i < n; i++) skipValue(r, desc.item);
2059
+ return;
2060
+ }
2061
+ case "struct": {
2062
+ for (const fd of Object.values(desc.fields)) skipValue(r, fd);
2063
+ return;
2064
+ }
2065
+ }
2066
+ }
2067
+ function readBit2(mask, i) {
2068
+ return ((mask[i >> 3] ?? 0) & 1 << (i & 7)) !== 0;
2069
+ }
2070
+ function skipDirtyField(r, desc) {
2071
+ if (desc.kind !== "struct") {
2072
+ skipValue(r, desc);
2073
+ return;
2074
+ }
2075
+ if (desc.opt && r.u8() === 0) return;
2076
+ const names = structNames2(desc);
2077
+ const maskBytes = r.bytes(names.length + 7 >> 3);
2078
+ for (let i = 0; i < names.length; i++) {
2079
+ if (!readBit2(maskBytes, i)) continue;
2080
+ skipDirtyField(r, desc.fields[names[i]]);
2081
+ }
2082
+ }
2083
+ function readerFor(bytes) {
2084
+ return bytes instanceof ByteReader ? bytes : new ByteReader(bytes);
2085
+ }
2086
+ function walkDelta(schema, bytes, events) {
2087
+ const r = readerFor(bytes);
2088
+ const from = r.pos;
2089
+ let at = r.pos;
2090
+ skip(r, 12);
2091
+ const count = r.varint();
2092
+ events.header(r.pos - at);
2093
+ for (let ci = 0; ci < count; ci++) {
2094
+ at = r.pos;
2095
+ const index = r.varint();
2096
+ const c = schema.collections[index];
2097
+ if (!c) throw new Error(`delta references unknown collection index ${index}`);
2098
+ const opCount = r.varint();
2099
+ events.header(r.pos - at);
2100
+ for (let oi = 0; oi < opCount; oi++) {
2101
+ at = r.pos;
2102
+ const tag = r.u8();
2103
+ const id = r.str();
2104
+ if (tag === 2) {
2105
+ events.op(c, "remove", id, r.pos - at);
2106
+ continue;
2107
+ }
2108
+ if (tag === 0) {
2109
+ skip(r, r.varint());
2110
+ events.op(c, "add", id, r.pos - at);
2111
+ for (const f of c.fields) {
2112
+ const start = r.pos;
2113
+ skipValue(r, f.type);
2114
+ events.field(c, f, r.pos - start);
2115
+ }
2116
+ continue;
2117
+ }
2118
+ if (tag !== 1) throw new Error(`unknown delta op byte ${tag} in ${c.name}`);
2119
+ const n = c.fields.length;
2120
+ const maskBytes = r.bytes(n + 1 + 7 >> 3).slice();
2121
+ if (readBit2(maskBytes, n)) skip(r, r.varint());
2122
+ events.op(c, "update", id, r.pos - at);
2123
+ for (let i = 0; i < n; i++) {
2124
+ if (!readBit2(maskBytes, i)) continue;
2125
+ const f = c.fields[i];
2126
+ const start = r.pos;
2127
+ skipDirtyField(r, f.type);
2128
+ events.field(c, f, r.pos - start);
2129
+ }
2130
+ }
2131
+ }
2132
+ return r.pos - from;
2133
+ }
2134
+ function walkSnapshot(schema, bytes, events) {
2135
+ const r = readerFor(bytes);
2136
+ const from = r.pos;
2137
+ skip(r, 12);
2138
+ events.header(12);
2139
+ for (const c of schema.collections) {
2140
+ if (c.kind === "entity") {
2141
+ let at = r.pos;
2142
+ const n = r.varint();
2143
+ events.header(r.pos - at);
2144
+ for (let i = 0; i < n; i++) {
2145
+ at = r.pos;
2146
+ const id = r.str();
2147
+ skip(r, r.varint());
2148
+ events.op(c, "add", id, r.pos - at);
2149
+ for (const f of c.fields) {
2150
+ const start = r.pos;
2151
+ skipValue(r, f.type);
2152
+ events.field(c, f, r.pos - start);
2153
+ }
2154
+ }
2155
+ } else {
2156
+ for (const f of c.fields) {
2157
+ const start = r.pos;
2158
+ skipValue(r, f.type);
2159
+ events.field(c, f, r.pos - start);
2160
+ }
2161
+ }
2162
+ }
2163
+ return r.pos - from;
2164
+ }
2165
+
1904
2166
  // src/track.ts
1905
2167
  var fieldTables = /* @__PURE__ */ new WeakMap();
1906
2168
  function structFields(d) {
@@ -2325,6 +2587,11 @@ function diffSchemas(oldSchema, newSchema) {
2325
2587
  );
2326
2588
  diffRpcs(oldSchema.rpcs, newSchema.rpcs, changes);
2327
2589
  diffRoles(oldSchema.roles, newSchema.roles, changes);
2590
+ diffMessages(
2591
+ oldSchema.messages ?? [],
2592
+ newSchema.messages ?? [],
2593
+ changes
2594
+ );
2328
2595
  changes.sort(
2329
2596
  (a, b) => a.path === b.path ? a.code.localeCompare(b.code) : a.path.localeCompare(b.path)
2330
2597
  );
@@ -2388,6 +2655,14 @@ function diffCollections(oldCollections, newCollections, changes) {
2388
2655
  message: `${oldC.name}: visible roles changed from [${(oldC.roles ?? []).join(", ")}] to [${(newC.roles ?? []).join(", ")}] \u2014 changes who sees this collection`
2389
2656
  });
2390
2657
  }
2658
+ if (oldC.index !== newC.index) {
2659
+ changes.push({
2660
+ kind: "breaking",
2661
+ code: `${oldC.kind}.reordered`,
2662
+ path: oldC.name,
2663
+ message: `${oldC.name}: wire index changed (${oldC.index} -> ${newC.index}) \u2014 collections are addressed positionally, so every delta and snapshot for this collection would be misread (breaking)`
2664
+ });
2665
+ }
2391
2666
  if (stableStringify(oldC.grid) !== stableStringify(newC.grid)) {
2392
2667
  changes.push({
2393
2668
  kind: "breaking",
@@ -2422,9 +2697,25 @@ function diffRpcs(oldRpcs, newRpcs, changes) {
2422
2697
  });
2423
2698
  }
2424
2699
  }
2700
+ if (oldRpcs.length !== newRpcs.length) {
2701
+ changes.push({
2702
+ kind: "breaking",
2703
+ code: "rpc.builtin_reordered",
2704
+ path: "rpc",
2705
+ message: `rpc: the number of RPCs changed from ${oldRpcs.length} to ${newRpcs.length}, which shifts the ids of irtio's built-in RPCs (they are numbered after yours) \u2014 connected clients would call the wrong one (breaking)`
2706
+ });
2707
+ }
2425
2708
  for (const oldR of oldRpcs) {
2426
2709
  const newR = newByName.get(oldR.name);
2427
2710
  if (!newR) continue;
2711
+ if (oldR.index !== newR.index) {
2712
+ changes.push({
2713
+ kind: "breaking",
2714
+ code: "rpc.reordered",
2715
+ path: `rpc.${oldR.name}`,
2716
+ message: `rpc ${oldR.name}: wire id changed (${oldR.index} -> ${newR.index}) \u2014 RPCs are addressed by a positional id, so calls would reach the wrong handler (breaking)`
2717
+ });
2718
+ }
2428
2719
  if (oldR.direction !== newR.direction) {
2429
2720
  changes.push({
2430
2721
  kind: "breaking",
@@ -2437,6 +2728,43 @@ function diffRpcs(oldRpcs, newRpcs, changes) {
2437
2728
  diffFields(oldR.returns ?? [], newR.returns ?? [], `rpc.${oldR.name}.returns`, changes);
2438
2729
  }
2439
2730
  }
2731
+ function diffMessages(oldMessages, newMessages, changes) {
2732
+ const oldByName = new Map(oldMessages.map((m) => [m.name, m]));
2733
+ const newByName = new Map(newMessages.map((m) => [m.name, m]));
2734
+ for (const m of oldMessages) {
2735
+ if (!newByName.has(m.name)) {
2736
+ changes.push({
2737
+ kind: "breaking",
2738
+ code: "message.removed",
2739
+ path: `message.${m.name}`,
2740
+ message: `message ${m.name}: was removed \u2014 peers still sending it would be dropped`
2741
+ });
2742
+ }
2743
+ }
2744
+ for (const m of newMessages) {
2745
+ if (!oldByName.has(m.name)) {
2746
+ changes.push({
2747
+ kind: "additive",
2748
+ code: "message.added",
2749
+ path: `message.${m.name}`,
2750
+ message: `message ${m.name}: new message shape added`
2751
+ });
2752
+ }
2753
+ }
2754
+ for (const oldM of oldMessages) {
2755
+ const newM = newByName.get(oldM.name);
2756
+ if (!newM) continue;
2757
+ if (oldM.index !== newM.index) {
2758
+ changes.push({
2759
+ kind: "breaking",
2760
+ code: "message.reordered",
2761
+ path: `message.${oldM.name}`,
2762
+ message: `message ${oldM.name}: wire index changed (${oldM.index} -> ${newM.index}) \u2014 messages are addressed positionally, so peers would decode this one as another (breaking)`
2763
+ });
2764
+ }
2765
+ diffFields(oldM.fields, newM.fields, `message.${oldM.name}`, changes);
2766
+ }
2767
+ }
2440
2768
  function diffRoles(oldRoles, newRoles, changes) {
2441
2769
  const oldSet = new Set(oldRoles);
2442
2770
  const newSet = new Set(newRoles);
@@ -2677,16 +3005,20 @@ export {
2677
3005
  CANONICAL_VERSION,
2678
3006
  EntityCollection,
2679
3007
  ID_MAX_BYTES,
3008
+ MAX_MESSAGES,
2680
3009
  PHYSICS_BODY_CHANNELS,
2681
3010
  RESERVED_COLLECTION_NAMES,
2682
3011
  RESERVED_RPC_NAMES,
2683
3012
  SERVER_OWNER,
2684
3013
  SINGLETON_ID,
3014
+ angleFrom2d,
3015
+ applyChannel2d,
2685
3016
  applyDelta,
2686
3017
  bool,
2687
3018
  bytesEqual,
2688
3019
  canonicalPhysics,
2689
3020
  canonicalType,
3021
+ channelOf2d,
2690
3022
  client,
2691
3023
  cloneValue,
2692
3024
  collectionDirty,
@@ -2745,5 +3077,7 @@ export {
2745
3077
  u8,
2746
3078
  validateForDeploy,
2747
3079
  validateValue,
3080
+ walkDelta,
3081
+ walkSnapshot,
2748
3082
  writeValue
2749
3083
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/schema",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "irtio schema DSL, type inference, canonical hash, codec, change tracking, and schema diff",
5
5
  "license": "MIT",
6
6
  "publishConfig": {