@irtio/runtime 0.1.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/LICENSE +21 -0
- package/dist/chunk-3DQHT4CM.js +129 -0
- package/dist/chunk-X5S364FY.js +1573 -0
- package/dist/contract-BhD88PGb.d.ts +120 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +48 -0
- package/dist/room-CBsSCueH.d.ts +246 -0
- package/dist/test/index.d.ts +283 -0
- package/dist/test/index.js +774 -0
- package/dist/worker/index.d.ts +173 -0
- package/dist/worker/index.js +280 -0
- package/package.json +39 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { AnySchema, PlainState, State, Tracked } from '@irtio/schema';
|
|
2
|
+
import { ErrorCodeName } from '@irtio/protocol';
|
|
3
|
+
import { RoomDefinition, Room, LeaveReason } from '@irtio/server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Live inspection for the dev page (`irtio dev`) and the supervisor admin API: a JSON view of
|
|
7
|
+
* the room's state plus a ring of recent events. Always on; recording is O(1) per event.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type RoomEventKind = 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'error';
|
|
11
|
+
interface RoomEvent {
|
|
12
|
+
readonly tick: number;
|
|
13
|
+
readonly kind: RoomEventKind;
|
|
14
|
+
readonly clientId?: string;
|
|
15
|
+
readonly detail?: string;
|
|
16
|
+
}
|
|
17
|
+
declare const EVENT_RING_SIZE = 100;
|
|
18
|
+
/** JSON-able view of a plain state: entities as `{id: {owner, value}}`, singletons as values. */
|
|
19
|
+
declare function inspectState(schema: AnySchema, plain: PlainState): Record<string, unknown>;
|
|
20
|
+
interface RoomInspection {
|
|
21
|
+
readonly tick: number;
|
|
22
|
+
readonly state: Record<string, unknown>;
|
|
23
|
+
readonly recent: readonly RoomEvent[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type LogLevel = 'info' | 'warn' | 'error';
|
|
27
|
+
/** Everything the room needs from the outside world. No timers, sockets, clocks, or console inside. */
|
|
28
|
+
interface RoomHost {
|
|
29
|
+
/** Monotonic ms. Fake in the harness. */
|
|
30
|
+
now(): number;
|
|
31
|
+
setTimeout(fn: () => void, ms: number): unknown;
|
|
32
|
+
clearTimeout(handle: unknown): void;
|
|
33
|
+
/** Deliver one fully framed protocol frame (type byte + payload) to a client. */
|
|
34
|
+
send(clientId: string, frame: Uint8Array): void;
|
|
35
|
+
/** The room wants this client gone (`room.kick`, protocol violation). Host closes the socket. */
|
|
36
|
+
kick(clientId: string, code: ErrorCodeName, reason?: string): void;
|
|
37
|
+
/** `room.close()`: host disconnects everyone and tears the room down. */
|
|
38
|
+
close(reason?: string): void;
|
|
39
|
+
/** Event mode: idle or `room.sleep()` — host should `serialize()` then `stop()`. */
|
|
40
|
+
sleep(): void;
|
|
41
|
+
log(level: LogLevel, args: unknown[]): void;
|
|
42
|
+
/** N consecutive tick throws; the runtime does not recover itself. */
|
|
43
|
+
crashed(reason: string): void;
|
|
44
|
+
}
|
|
45
|
+
interface RoomCoreOptions {
|
|
46
|
+
readonly roomId: string;
|
|
47
|
+
/** Seed for `room.random()`; default 1. */
|
|
48
|
+
readonly seed?: number;
|
|
49
|
+
/** Base URL for `room.link` (`<publicUrl>?room=<id>`); default `http://localhost/`. */
|
|
50
|
+
readonly publicUrl?: string;
|
|
51
|
+
/** Bytes from a previous `serialize()`; skips `onCreate`, runs `onWake`. */
|
|
52
|
+
readonly restoreFrom?: Uint8Array;
|
|
53
|
+
}
|
|
54
|
+
interface JoinOptions {
|
|
55
|
+
/** Requested role; unknown/missing → `roles[0]` (logged) or `''` when the schema has no roles. */
|
|
56
|
+
readonly role?: string;
|
|
57
|
+
readonly name?: string;
|
|
58
|
+
/** A resumed session: presence record kept, `ctx.reconnecting = true`. */
|
|
59
|
+
readonly reconnecting?: boolean;
|
|
60
|
+
}
|
|
61
|
+
interface JoinResult {
|
|
62
|
+
readonly tick: number;
|
|
63
|
+
readonly role: string;
|
|
64
|
+
/** Snapshot bytes for this client's view (extended schema). The host builds `WELCOME`. */
|
|
65
|
+
readonly snapshot: Uint8Array;
|
|
66
|
+
}
|
|
67
|
+
declare class RoomFullError extends Error {
|
|
68
|
+
readonly maxClients: number;
|
|
69
|
+
readonly name = "RoomFullError";
|
|
70
|
+
constructor(maxClients: number);
|
|
71
|
+
}
|
|
72
|
+
interface RoomStats {
|
|
73
|
+
ticks: number;
|
|
74
|
+
lastTickMs: number;
|
|
75
|
+
maxTickMs: number;
|
|
76
|
+
overruns: number;
|
|
77
|
+
framesIn: number;
|
|
78
|
+
framesOut: number;
|
|
79
|
+
bytesOut: number;
|
|
80
|
+
/** Per connected client. */
|
|
81
|
+
bytesOutByClient: Map<string, number>;
|
|
82
|
+
handlerErrors: number;
|
|
83
|
+
encodesLastFlush: number;
|
|
84
|
+
corrections: number;
|
|
85
|
+
}
|
|
86
|
+
/** The surface the worker host and the harness drive. Implemented by `RoomCore`. */
|
|
87
|
+
interface RoomCoreApi<S extends AnySchema = AnySchema> {
|
|
88
|
+
readonly definition: RoomDefinition<S>;
|
|
89
|
+
/** The builder's schema. */
|
|
90
|
+
readonly schema: S;
|
|
91
|
+
/** `withBuiltins(schema)`: what the codec and every frame use. */
|
|
92
|
+
readonly ext: AnySchema;
|
|
93
|
+
readonly roomId: string;
|
|
94
|
+
readonly tick: number;
|
|
95
|
+
/** Tracked authority (proxy tree incl. the built-in `clients` collection). */
|
|
96
|
+
readonly state: State<S>;
|
|
97
|
+
readonly tracked: Tracked<AnySchema>;
|
|
98
|
+
/** The `room` API object handed to handlers. */
|
|
99
|
+
readonly room: Room<S>;
|
|
100
|
+
readonly stats: RoomStats;
|
|
101
|
+
readonly mode: 'tick' | 'event';
|
|
102
|
+
/** Begin ticking (tick mode) / idle timer (event mode). Idempotent. */
|
|
103
|
+
start(): void;
|
|
104
|
+
/** Stop timers; no more frames are sent. */
|
|
105
|
+
stop(): void;
|
|
106
|
+
/** Throws `RoomFullError`. Runs `onJoin`, then snapshots the client's view. */
|
|
107
|
+
join(clientId: string, options?: JoinOptions): JoinResult;
|
|
108
|
+
/** Runs `onLeave`, removes presence, rejects pending RPCs for the client. */
|
|
109
|
+
leave(clientId: string, reason: LeaveReason): void;
|
|
110
|
+
/** Grace window opened by the host: presence `connected = false`; frames are no longer sent. */
|
|
111
|
+
markDisconnected(clientId: string): void;
|
|
112
|
+
/** One inbound protocol frame (`WRITE` | `CALL` | `REPLY` | `MSG`). Anything else → host.kick(E_BAD_FRAME). */
|
|
113
|
+
receive(clientId: string, frame: Uint8Array): void;
|
|
114
|
+
/** Full state + tick + rng for hibernation. Runs `onSleep`. Clears timers. */
|
|
115
|
+
serialize(): Uint8Array;
|
|
116
|
+
/** Live JSON view of the room (dev page / supervisor admin API). */
|
|
117
|
+
inspect(): RoomInspection;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { EVENT_RING_SIZE as E, type JoinOptions as J, type LogLevel as L, type RoomCoreApi as R, type JoinResult as a, type RoomCoreOptions as b, type RoomEvent as c, type RoomEventKind as d, RoomFullError as e, type RoomHost as f, type RoomInspection as g, type RoomStats as h, inspectState as i };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
export { E as EVENT_RING_SIZE, J as JoinOptions, a as JoinResult, L as LogLevel, R as RoomCoreApi, b as RoomCoreOptions, c as RoomEvent, d as RoomEventKind, e as RoomFullError, f as RoomHost, g as RoomInspection, h as RoomStats, i as inspectState } from './contract-BhD88PGb.js';
|
|
2
|
+
export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as Mulberry32, R as RoomCore } from './room-CBsSCueH.js';
|
|
3
|
+
import { AnySchema, PlainState, DirtySet, CollectionDesc } from '@irtio/schema';
|
|
4
|
+
import '@irtio/protocol';
|
|
5
|
+
import '@irtio/server';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* RPCs in both directions.
|
|
9
|
+
*
|
|
10
|
+
* client → server: `CALL` → `rpcByIdOf` → param decode + `validateValue` → the room's `rpc`
|
|
11
|
+
* implementation (or the built-in `requestOwnership`) → `REPLY`. A throw becomes an error reply;
|
|
12
|
+
* nothing escapes into the loop.
|
|
13
|
+
*
|
|
14
|
+
* server → client: `room.call(clientId).<name>(params)` returns a Promise resolved by the
|
|
15
|
+
* client's `REPLY`, rejected on a 5 s host-clock timeout or on disconnect.
|
|
16
|
+
* `room.broadcast.<name>(params)` fans one `CALL` out with no pending entry.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Default server→client RPC timeout on the host clock. */
|
|
20
|
+
declare const RPC_TIMEOUT_MS = 5000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Views: which collections a role sees, how clients bucket into distinct views (so one flush
|
|
24
|
+
* encodes one `DELTA` per view, not per client), the per-view snapshot taken at `join`, and
|
|
25
|
+
* the catch-up delta `room.setRole` sends.
|
|
26
|
+
*
|
|
27
|
+
* A collection with `visibility: 'role'` is visible only to the roles it names — including its
|
|
28
|
+
* adds and removes, so a client never learns such a record exists.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Is `c` visible to `role`? */
|
|
32
|
+
declare function isVisible(c: CollectionDesc, role: string): boolean;
|
|
33
|
+
/** A `SnapshotOptions.collections` predicate for one role. */
|
|
34
|
+
declare function visibleTo(role: string): (c: CollectionDesc) => boolean;
|
|
35
|
+
/** Names of the collections `role` sees (memoised per schema + role). */
|
|
36
|
+
declare function visibleNames(ext: AnySchema, role: string): ReadonlySet<string>;
|
|
37
|
+
/**
|
|
38
|
+
* The view bucket for a role: `'all'` when the role is not named by any role-scoped collection
|
|
39
|
+
* (every such role sees exactly the same set), otherwise the role itself.
|
|
40
|
+
*/
|
|
41
|
+
declare function viewKeyFor(ext: AnySchema, role: string): string;
|
|
42
|
+
/** The `join` snapshot: full state restricted to the client's view. */
|
|
43
|
+
declare function encodeViewSnapshot(ext: AnySchema, plain: PlainState, role: string, tick: number): Uint8Array;
|
|
44
|
+
/** One view's `DELTA` payload, or `null` when nothing visible to `role` changed. */
|
|
45
|
+
declare function encodeViewDelta(ext: AnySchema, plain: PlainState, dirty: DirtySet, role: string, tick: number): Uint8Array | null;
|
|
46
|
+
/**
|
|
47
|
+
* `room.setRole` catch-up: `add` ops for every record of a collection that just became visible
|
|
48
|
+
* and `remove` ops for every record of one that stopped being visible. A singleton that becomes
|
|
49
|
+
* visible is sent as a whole-record update; one that stops being visible cannot be un-sent (the
|
|
50
|
+
* format has no singleton remove), so it is left alone — the client keeps stale values it can no
|
|
51
|
+
* longer see updated. Returns an empty set when the two roles have the same view.
|
|
52
|
+
*/
|
|
53
|
+
declare function catchUpDirty(ext: AnySchema, plain: PlainState, fromRole: string, toRole: string): DirtySet;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The hibernation blob envelope: a small fixed header the runtime writes around the codec
|
|
57
|
+
* snapshot, so a woken room gets its rng, tick and mode back.
|
|
58
|
+
*
|
|
59
|
+
* u8 format version
|
|
60
|
+
* u32 seed
|
|
61
|
+
* u32 rng state
|
|
62
|
+
* u32 tick
|
|
63
|
+
* u8 mode (1 = event, 0 = tick)
|
|
64
|
+
* ... encodeSnapshot(withBuiltins(schema), state)
|
|
65
|
+
*
|
|
66
|
+
* Note: the *deployment* version is deliberately **not** in here. It lives in the store
|
|
67
|
+
* key (`<project>/<room>@v<deployment>`), which keeps the blob format frozen — an old snapshot
|
|
68
|
+
* stays valid across deployments — and lets a wake path discover the version by listing keys
|
|
69
|
+
* instead of parsing bytes it may not yet know how to read.
|
|
70
|
+
*/
|
|
71
|
+
declare const SNAPSHOT_FORMAT_VERSION = 1;
|
|
72
|
+
type SnapshotMode = 'tick' | 'event';
|
|
73
|
+
interface SnapshotHeader {
|
|
74
|
+
readonly seed: number;
|
|
75
|
+
readonly rngState: number;
|
|
76
|
+
readonly tick: number;
|
|
77
|
+
readonly mode: SnapshotMode;
|
|
78
|
+
}
|
|
79
|
+
interface ParsedSnapshot extends SnapshotHeader {
|
|
80
|
+
/** The codec snapshot, encoded under `withBuiltins(schema)`. */
|
|
81
|
+
readonly snapshot: Uint8Array;
|
|
82
|
+
}
|
|
83
|
+
declare function parseHibernationBlob(bytes: Uint8Array): ParsedSnapshot;
|
|
84
|
+
declare function writeHibernationBlob(header: SnapshotHeader, snapshot: Uint8Array): Uint8Array;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wake-time schema migration.
|
|
88
|
+
*
|
|
89
|
+
* A room hibernated under deployment v1 and woken under v3 has bytes on disk that only v1's
|
|
90
|
+
* schema can decode. `migrateSnapshot` decodes them with the *stored* canonical schema of the
|
|
91
|
+
* deployment they were written under, hands the plain state to each deployment's `up` transform
|
|
92
|
+
* in order, and re-encodes under the target schema. The supervisor re-snapshots immediately
|
|
93
|
+
* afterwards, so the chain runs exactly once per room ("migrate once").
|
|
94
|
+
*
|
|
95
|
+
* The state a migration sees is deliberately plain JSON-ish, not the tracked proxy tree:
|
|
96
|
+
*
|
|
97
|
+
* { players: { 'c1': { owner: 'c1', value: { x: 1, y: 2 } } }, // entity
|
|
98
|
+
* match: { phase: 'lobby', round: 0 } } // singleton
|
|
99
|
+
*
|
|
100
|
+
* Entities are `{ id: { owner, value } }` maps; singletons are the record itself. The built-in
|
|
101
|
+
* `clients` presence collection is removed before `up` runs and comes back empty afterwards —
|
|
102
|
+
* presence is rebuilt from the sockets that rejoin, so a migration never has to think about it.
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
interface MigrationRecord {
|
|
106
|
+
owner: string;
|
|
107
|
+
value: Record<string, unknown>;
|
|
108
|
+
}
|
|
109
|
+
/** An entity collection as a migration sees it: id → `{ owner, value }`. */
|
|
110
|
+
type MigrationEntity = Record<string, MigrationRecord>;
|
|
111
|
+
/** A whole room's state: entity maps and singleton records, keyed by collection name. */
|
|
112
|
+
type MigrationState = Record<string, MigrationEntity | Record<string, unknown>>;
|
|
113
|
+
/** Handed to `up` as its second argument. Small on purpose, to keep migrations honest. */
|
|
114
|
+
interface MigrationHelpers {
|
|
115
|
+
/** The deployment version this migration produces. */
|
|
116
|
+
readonly version: number;
|
|
117
|
+
/** The deployment version its input was written under. */
|
|
118
|
+
readonly fromVersion: number;
|
|
119
|
+
/** The room being migrated. */
|
|
120
|
+
readonly roomId: string;
|
|
121
|
+
/** Goes to the room's log, prefixed with the version. */
|
|
122
|
+
log(...args: unknown[]): void;
|
|
123
|
+
}
|
|
124
|
+
interface MigrationModule {
|
|
125
|
+
up(state: MigrationState, s: MigrationHelpers): MigrationState | void;
|
|
126
|
+
/** Scaffolded by `irtio migrate create` and stored; unused until rollback ships. */
|
|
127
|
+
down?(state: MigrationState, s: MigrationHelpers): MigrationState | void;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* One deployment in the chain the tenant was handed. `migration` is absent for versions whose
|
|
131
|
+
* schema change was purely additive — the codec fills new fields from their defaults, so there
|
|
132
|
+
* is nothing to transform.
|
|
133
|
+
*/
|
|
134
|
+
interface MigrationStep {
|
|
135
|
+
readonly version: number;
|
|
136
|
+
/** `schema.canonical` as stored on the deployment record. */
|
|
137
|
+
readonly schemaJson: string;
|
|
138
|
+
readonly migration?: MigrationModule;
|
|
139
|
+
}
|
|
140
|
+
interface MigrateOptions {
|
|
141
|
+
readonly roomId: string;
|
|
142
|
+
log?(level: 'info' | 'warn' | 'error', args: unknown[]): void;
|
|
143
|
+
}
|
|
144
|
+
interface MigrationOutcome {
|
|
145
|
+
/** A hibernation blob encoded under the last step's schema. */
|
|
146
|
+
readonly bytes: Uint8Array;
|
|
147
|
+
readonly fromVersion: number;
|
|
148
|
+
readonly toVersion: number;
|
|
149
|
+
/** Versions whose `up` actually ran. */
|
|
150
|
+
readonly applied: readonly number[];
|
|
151
|
+
readonly ms: number;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Runs the chain. `chain` must be sorted ascending and start with the deployment the snapshot
|
|
155
|
+
* was written under (its `up` is *not* run — it is only there to supply the decoding schema).
|
|
156
|
+
*/
|
|
157
|
+
declare function migrateSnapshot(blob: Uint8Array, chain: readonly MigrationStep[], options: MigrateOptions): MigrationOutcome;
|
|
158
|
+
declare function toMigrationState(schema: AnySchema, plain: PlainState): MigrationState;
|
|
159
|
+
declare function fromMigrationState(schema: AnySchema, state: MigrationState, version: number): PlainState;
|
|
160
|
+
|
|
161
|
+
export { type MigrateOptions, type MigrationEntity, type MigrationHelpers, type MigrationModule, type MigrationOutcome, type MigrationRecord, type MigrationState, type MigrationStep, type ParsedSnapshot, RPC_TIMEOUT_MS, SNAPSHOT_FORMAT_VERSION, type SnapshotHeader, type SnapshotMode, catchUpDirty, encodeViewDelta, encodeViewSnapshot, fromMigrationState, isVisible, migrateSnapshot, parseHibernationBlob, toMigrationState, viewKeyFor, visibleNames, visibleTo, writeHibernationBlob };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fromMigrationState,
|
|
3
|
+
migrateSnapshot,
|
|
4
|
+
toMigrationState
|
|
5
|
+
} from "./chunk-3DQHT4CM.js";
|
|
6
|
+
import {
|
|
7
|
+
CRASH_AFTER_THROWS,
|
|
8
|
+
EVENT_RING_SIZE,
|
|
9
|
+
MAX_CATCHUP,
|
|
10
|
+
Mulberry32,
|
|
11
|
+
RPC_TIMEOUT_MS,
|
|
12
|
+
RoomCore,
|
|
13
|
+
RoomFullError,
|
|
14
|
+
SNAPSHOT_FORMAT_VERSION,
|
|
15
|
+
catchUpDirty,
|
|
16
|
+
encodeViewDelta,
|
|
17
|
+
encodeViewSnapshot,
|
|
18
|
+
inspectState,
|
|
19
|
+
isVisible,
|
|
20
|
+
parseHibernationBlob,
|
|
21
|
+
viewKeyFor,
|
|
22
|
+
visibleNames,
|
|
23
|
+
visibleTo,
|
|
24
|
+
writeHibernationBlob
|
|
25
|
+
} from "./chunk-X5S364FY.js";
|
|
26
|
+
export {
|
|
27
|
+
CRASH_AFTER_THROWS,
|
|
28
|
+
EVENT_RING_SIZE,
|
|
29
|
+
MAX_CATCHUP,
|
|
30
|
+
Mulberry32,
|
|
31
|
+
RPC_TIMEOUT_MS,
|
|
32
|
+
RoomCore,
|
|
33
|
+
RoomFullError,
|
|
34
|
+
SNAPSHOT_FORMAT_VERSION,
|
|
35
|
+
catchUpDirty,
|
|
36
|
+
encodeViewDelta,
|
|
37
|
+
encodeViewSnapshot,
|
|
38
|
+
fromMigrationState,
|
|
39
|
+
inspectState,
|
|
40
|
+
isVisible,
|
|
41
|
+
migrateSnapshot,
|
|
42
|
+
parseHibernationBlob,
|
|
43
|
+
toMigrationState,
|
|
44
|
+
viewKeyFor,
|
|
45
|
+
visibleNames,
|
|
46
|
+
visibleTo,
|
|
47
|
+
writeHibernationBlob
|
|
48
|
+
};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
|
|
2
|
+
import { RoomDefinition, RoomMode, Room, Ctx, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
|
|
3
|
+
import { f as RoomHost, h as RoomStats, L as LogLevel, R as RoomCoreApi, b as RoomCoreOptions, d as RoomEventKind, g as RoomInspection, J as JoinOptions, a as JoinResult } from './contract-BhD88PGb.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `room.random()` — mulberry32 over a u32 seed. Deterministic, tiny, and serializable: the
|
|
7
|
+
* generator's whole state is one u32, so `serialize()`/`restore()` round-trip it exactly.
|
|
8
|
+
*/
|
|
9
|
+
declare class Mulberry32 {
|
|
10
|
+
/** Current internal state (u32). Survives hibernation. */
|
|
11
|
+
state: number;
|
|
12
|
+
constructor(seed: number);
|
|
13
|
+
/** Next float in `[0, 1)`. */
|
|
14
|
+
next(): number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The internal seam between `RoomCore` (core/room.ts) and the modules it delegates to
|
|
19
|
+
* (`loop`, `views`, `writes`, `rpc`, `messages`, `room-api`). Nothing here is public API —
|
|
20
|
+
* `src/contract.ts` is. `RoomCore` implements `RoomInternals` structurally, so the modules
|
|
21
|
+
* take it as a parameter and never import `room.ts` (no cycles).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type AnyRecord = Record<string, unknown>;
|
|
25
|
+
/** `${collection}\0${id}` → leaf-path key → the value the runtime wrote for that leaf. */
|
|
26
|
+
type AcceptedWrites = Map<string, Map<string, unknown>>;
|
|
27
|
+
/** One joined client. Presence is the wire truth; this is the runtime's bookkeeping. */
|
|
28
|
+
interface ClientEntry {
|
|
29
|
+
readonly clientId: string;
|
|
30
|
+
role: string;
|
|
31
|
+
name: string;
|
|
32
|
+
connected: boolean;
|
|
33
|
+
/** Pending `CORRECT` mask, encoded from current state at the next flush. */
|
|
34
|
+
correction: DirtySet | undefined;
|
|
35
|
+
/** Leaves this client's own accepted `WRITE`s produced during the current flush window. */
|
|
36
|
+
readonly accepted: AcceptedWrites;
|
|
37
|
+
/**
|
|
38
|
+
* The tick stamped on the last `WRITE` this client sent (the client-local write counter in the
|
|
39
|
+
* delta header — week 8, D19), `0` before its first write. Every `CORRECT` sent to this client
|
|
40
|
+
* carries it as `clientTick`: "your writes through this tick are reflected in these values".
|
|
41
|
+
* An unprompted correction (a server-wins overwrite with no triggering write) echoes it
|
|
42
|
+
* unchanged — for a client that never wrote, that is `0`.
|
|
43
|
+
*/
|
|
44
|
+
lastClientTick: number;
|
|
45
|
+
/**
|
|
46
|
+
* Set at `join`: this client's own record(s) — its presence row and whatever entities its own
|
|
47
|
+
* `onJoin` added, owned by it — as they stood right after join, per collection. Consumed once
|
|
48
|
+
* by the very next `flush()`, which strips exactly the ids whose value (and owner) still match
|
|
49
|
+
* this capture from this client's delta only — an id mutated again before that flush (e.g. a
|
|
50
|
+
* same-window `room.setRole`) is left alone, since the join snapshot never saw that value.
|
|
51
|
+
* Every other client's delta is unaffected.
|
|
52
|
+
*/
|
|
53
|
+
pendingJoinAdds: Map<string, ReadonlyMap<string, JoinAddCapture>> | undefined;
|
|
54
|
+
}
|
|
55
|
+
/** What `join` captured for one of a client's own just-added records, for `flush`'s comparison. */
|
|
56
|
+
interface JoinAddCapture {
|
|
57
|
+
readonly value: unknown;
|
|
58
|
+
readonly owner: string | undefined;
|
|
59
|
+
}
|
|
60
|
+
type GuardResult<T> = {
|
|
61
|
+
readonly ok: true;
|
|
62
|
+
readonly value: T;
|
|
63
|
+
} | {
|
|
64
|
+
readonly ok: false;
|
|
65
|
+
};
|
|
66
|
+
/** One queued inbound frame (tick mode). */
|
|
67
|
+
interface QueuedFrame {
|
|
68
|
+
readonly clientId: string;
|
|
69
|
+
readonly type: number;
|
|
70
|
+
readonly payload: Uint8Array;
|
|
71
|
+
}
|
|
72
|
+
interface LoopApi {
|
|
73
|
+
start(): void;
|
|
74
|
+
stop(): void;
|
|
75
|
+
/** Event mode: an inbound frame or a join re-arms the idle timer. */
|
|
76
|
+
noteActivity(): void;
|
|
77
|
+
/** `room.setTimeout` / `room.setInterval`. Returns a numeric handle. */
|
|
78
|
+
setTimer(ms: number, fn: () => void, repeat: boolean): number;
|
|
79
|
+
clearTimer(handle: number): void;
|
|
80
|
+
/** Drops every room timer (hibernation, stop). */
|
|
81
|
+
clearAllTimers(): void;
|
|
82
|
+
/** Internal one-shot on the host clock (RPC timeouts in event mode). */
|
|
83
|
+
after(ms: number, fn: () => void): void;
|
|
84
|
+
}
|
|
85
|
+
/** What the core modules are allowed to see of `RoomCore`. */
|
|
86
|
+
interface RoomInternals {
|
|
87
|
+
readonly definition: RoomDefinition;
|
|
88
|
+
/** `withBuiltins(definition.schema)` — what every frame and the codec use. */
|
|
89
|
+
readonly ext: AnySchema;
|
|
90
|
+
readonly host: RoomHost;
|
|
91
|
+
readonly roomId: string;
|
|
92
|
+
readonly mode: RoomMode;
|
|
93
|
+
/** The plain state the tracked proxies wrap (what the codec reads). */
|
|
94
|
+
readonly plain: PlainState;
|
|
95
|
+
readonly tracked: Tracked<AnySchema>;
|
|
96
|
+
/** The tracked proxy tree, loosely typed for internal use. */
|
|
97
|
+
readonly anyState: AnyRecord;
|
|
98
|
+
readonly room: Room;
|
|
99
|
+
readonly rng: Mulberry32;
|
|
100
|
+
readonly stats: RoomStats;
|
|
101
|
+
/** Joined clients in join order. */
|
|
102
|
+
readonly clients: Map<string, ClientEntry>;
|
|
103
|
+
readonly loop: LoopApi;
|
|
104
|
+
tick: number;
|
|
105
|
+
stopped: boolean;
|
|
106
|
+
/** Runs a room handler; a throw is logged and counted, never rethrown. */
|
|
107
|
+
guard<T>(name: string, fn: () => T): T | undefined;
|
|
108
|
+
recordEvent(kind: 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'error', clientId?: string, detail?: string): void;
|
|
109
|
+
/** `guard` that also reports whether the handler threw (the tick loop needs this). */
|
|
110
|
+
tryRun<T>(name: string, fn: () => T): GuardResult<T>;
|
|
111
|
+
log(level: LogLevel, ...args: unknown[]): void;
|
|
112
|
+
/** Sends one framed protocol frame to a connected client and counts it. */
|
|
113
|
+
send(clientId: string, frame: Uint8Array): void;
|
|
114
|
+
ctxFor(clientId: string, reconnecting?: boolean): Ctx;
|
|
115
|
+
/** The client's pending `CORRECT` dirty set, created on demand. */
|
|
116
|
+
correctionFor(clientId: string): DirtySet | undefined;
|
|
117
|
+
/** Flushes the tracked dirty set: corrections first, then one `DELTA` per distinct view. */
|
|
118
|
+
flush(): void;
|
|
119
|
+
/** Invalidates the cached `room.clients` array. */
|
|
120
|
+
invalidateClients(): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Scheduling: the tick-mode fixed-step loop with catch-up, event-mode idle detection, and
|
|
125
|
+
* `room.setTimeout` / `room.setInterval`.
|
|
126
|
+
*
|
|
127
|
+
* Tick mode: one host timeout re-armed every `1000 / tickRate` ms. Each wake accumulates real
|
|
128
|
+
* elapsed time and runs up to `MAX_CATCHUP` ticks; a bigger backlog is dropped and counted as an
|
|
129
|
+
* overrun (logged once per burst). Room timers are tick-granular: `setTimeout(ms)` fires on the
|
|
130
|
+
* first tick at or after `now + ms`.
|
|
131
|
+
*
|
|
132
|
+
* Event mode: no tick timer. Room timers fire on the host clock, each firing advancing the tick
|
|
133
|
+
* and flushing. After `idleMs` with no inbound frames and no connected clients the host is asked
|
|
134
|
+
* to hibernate exactly once (re-armed by the next frame or join).
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/** Most ticks one wake may run before the backlog is dropped. */
|
|
138
|
+
declare const MAX_CATCHUP = 5;
|
|
139
|
+
/** Consecutive `tick()` throws before the host is told the room crashed. */
|
|
140
|
+
declare const CRASH_AFTER_THROWS = 3;
|
|
141
|
+
declare class Loop implements LoopApi {
|
|
142
|
+
private readonly core;
|
|
143
|
+
readonly inbound: QueuedFrame[];
|
|
144
|
+
private readonly timers;
|
|
145
|
+
private readonly internal;
|
|
146
|
+
private nextTimerId;
|
|
147
|
+
private tickHandle;
|
|
148
|
+
private idleHandle;
|
|
149
|
+
private running;
|
|
150
|
+
private lastWake;
|
|
151
|
+
private accumulator;
|
|
152
|
+
private overrunLogged;
|
|
153
|
+
private consecutiveThrows;
|
|
154
|
+
private lastActivity;
|
|
155
|
+
private slept;
|
|
156
|
+
constructor(core: RoomInternals);
|
|
157
|
+
get intervalMs(): number;
|
|
158
|
+
start(): void;
|
|
159
|
+
stop(): void;
|
|
160
|
+
enqueue(frame: QueuedFrame): void;
|
|
161
|
+
dropFramesFor(clientId: string): void;
|
|
162
|
+
private drainInbound;
|
|
163
|
+
/** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
|
|
164
|
+
applyFrame(f: QueuedFrame): boolean;
|
|
165
|
+
private scheduleTick;
|
|
166
|
+
private onWake;
|
|
167
|
+
/** One tick: inbound → room timers → `tick(state, dt, room)` → flush. */
|
|
168
|
+
runTick(): void;
|
|
169
|
+
private fireDueTimers;
|
|
170
|
+
/** Applies one frame as its own event: tick++, apply, flush. */
|
|
171
|
+
applyEvent(f: QueuedFrame): boolean;
|
|
172
|
+
noteActivity(): void;
|
|
173
|
+
private armIdle;
|
|
174
|
+
private onIdleCheck;
|
|
175
|
+
setTimer(ms: number, fn: () => void, repeat: boolean): number;
|
|
176
|
+
private armHostTimer;
|
|
177
|
+
clearTimer(handle: number): void;
|
|
178
|
+
clearAllTimers(): void;
|
|
179
|
+
after(ms: number, fn: () => void): void;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `RoomCore` — the host-agnostic room runtime. It owns the extended schema, the
|
|
184
|
+
* tracked authority state, presence, the loop, and the frame dispatch; everything outside comes
|
|
185
|
+
* through `RoomHost` (`src/contract.ts`). The worker host and the test harness are adapters over
|
|
186
|
+
* exactly this surface and produce byte-identical frames.
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S> {
|
|
190
|
+
readonly definition: RoomDefinition<S>;
|
|
191
|
+
readonly ext: AnySchema;
|
|
192
|
+
readonly host: RoomHost;
|
|
193
|
+
readonly roomId: string;
|
|
194
|
+
readonly mode: RoomMode;
|
|
195
|
+
readonly plain: PlainState;
|
|
196
|
+
readonly tracked: Tracked<AnySchema>;
|
|
197
|
+
readonly anyState: AnyRecord;
|
|
198
|
+
readonly rng: Mulberry32;
|
|
199
|
+
readonly clients: Map<string, ClientEntry>;
|
|
200
|
+
readonly loop: Loop;
|
|
201
|
+
readonly stats: RoomStats;
|
|
202
|
+
tick: number;
|
|
203
|
+
stopped: boolean;
|
|
204
|
+
private readonly seed;
|
|
205
|
+
private readonly api;
|
|
206
|
+
private readonly internals;
|
|
207
|
+
private started;
|
|
208
|
+
constructor(definition: RoomDefinition<S>, host: RoomHost, options: RoomCoreOptions);
|
|
209
|
+
/** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
|
|
210
|
+
static restore<S2 extends AnySchema>(definition: RoomDefinition<S2>, bytes: Uint8Array, host: RoomHost, options: Omit<RoomCoreOptions, 'restoreFrom'>): RoomCore<S2>;
|
|
211
|
+
get schema(): S;
|
|
212
|
+
get config(): ResolvedRoomConfig<S>;
|
|
213
|
+
get state(): State<S>;
|
|
214
|
+
get room(): Room<S>;
|
|
215
|
+
tryRun<T>(name: string, fn: () => T): GuardResult<T>;
|
|
216
|
+
private readonly events;
|
|
217
|
+
recordEvent(kind: RoomEventKind, clientId?: string, detail?: string): void;
|
|
218
|
+
/** Live JSON view of the room for the dev page / supervisor admin API. */
|
|
219
|
+
inspect(): RoomInspection;
|
|
220
|
+
guard<T>(name: string, fn: () => T): T | undefined;
|
|
221
|
+
log(level: LogLevel, ...args: unknown[]): void;
|
|
222
|
+
start(): void;
|
|
223
|
+
stop(): void;
|
|
224
|
+
serialize(): Uint8Array;
|
|
225
|
+
private get presence();
|
|
226
|
+
private resolveRole;
|
|
227
|
+
join(clientId: string, options?: JoinOptions): JoinResult;
|
|
228
|
+
/** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
|
|
229
|
+
private eventFlush;
|
|
230
|
+
leave(clientId: string, reason: LeaveReason): void;
|
|
231
|
+
markDisconnected(clientId: string): void;
|
|
232
|
+
ctxFor(clientId: string, reconnecting?: boolean): Ctx;
|
|
233
|
+
correctionFor(clientId: string): DirtySet | undefined;
|
|
234
|
+
invalidateClients(): void;
|
|
235
|
+
send(clientId: string, frame: Uint8Array): void;
|
|
236
|
+
private badFrame;
|
|
237
|
+
receive(clientId: string, frame: Uint8Array): void;
|
|
238
|
+
/**
|
|
239
|
+
* Hands the tracked dirty set out: server-wins corrections first, then per connected client its
|
|
240
|
+
* pending `CORRECT` (before the delta, so it sees the correction and then the broadcast) and
|
|
241
|
+
* its view's `DELTA` — encoded once per distinct view.
|
|
242
|
+
*/
|
|
243
|
+
flush(): void;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export { CRASH_AFTER_THROWS as C, MAX_CATCHUP as M, RoomCore as R, Mulberry32 as a };
|