@net-mesh/browser 0.37.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/CHANGELOG.md +517 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +666 -0
- package/dist/async-queue.d.ts +26 -0
- package/dist/async-queue.js +62 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.js +95 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.js +726 -0
- package/dist/events.d.ts +232 -0
- package/dist/events.js +298 -0
- package/dist/identity.d.ts +40 -0
- package/dist/identity.js +78 -0
- package/dist/index.bundle.js +2 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/leader/events.d.ts +111 -0
- package/dist/leader/events.js +116 -0
- package/dist/leader/index.d.ts +14 -0
- package/dist/leader/index.js +11 -0
- package/dist/leader/session.d.ts +539 -0
- package/dist/leader/session.js +770 -0
- package/dist/leader/wasm.d.ts +123 -0
- package/dist/leader/wasm.js +39 -0
- package/dist/lobby.d.ts +183 -0
- package/dist/lobby.js +504 -0
- package/dist/local.d.ts +80 -0
- package/dist/local.js +160 -0
- package/dist/net_leaf.d.ts +1494 -0
- package/dist/net_leaf.js +3753 -0
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +137 -0
- package/dist/netcode/client.d.ts +102 -0
- package/dist/netcode/client.js +296 -0
- package/dist/netcode/clock.d.ts +45 -0
- package/dist/netcode/clock.js +54 -0
- package/dist/netcode/host.d.ts +118 -0
- package/dist/netcode/host.js +276 -0
- package/dist/netcode/index.d.ts +19 -0
- package/dist/netcode/index.js +14 -0
- package/dist/netcode/interpolate.d.ts +50 -0
- package/dist/netcode/interpolate.js +100 -0
- package/dist/netcode/wire.d.ts +125 -0
- package/dist/netcode/wire.js +117 -0
- package/dist/node.d.ts +908 -0
- package/dist/node.js +1085 -0
- package/dist/org.d.ts +412 -0
- package/dist/org.js +567 -0
- package/dist/peer-driver.d.ts +117 -0
- package/dist/peer-driver.js +224 -0
- package/dist/store/assembly.d.ts +176 -0
- package/dist/store/assembly.js +325 -0
- package/dist/store/chunker.d.ts +83 -0
- package/dist/store/chunker.js +190 -0
- package/dist/store/core.d.ts +136 -0
- package/dist/store/core.js +477 -0
- package/dist/store/definition.d.ts +23 -0
- package/dist/store/definition.js +83 -0
- package/dist/store/errors.d.ts +77 -0
- package/dist/store/errors.js +94 -0
- package/dist/store/host.d.ts +247 -0
- package/dist/store/host.js +675 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.js +40 -0
- package/dist/store/interest.d.ts +40 -0
- package/dist/store/interest.js +77 -0
- package/dist/store/inventory.d.ts +81 -0
- package/dist/store/inventory.js +140 -0
- package/dist/store/join.d.ts +118 -0
- package/dist/store/join.js +724 -0
- package/dist/store/json.d.ts +50 -0
- package/dist/store/json.js +283 -0
- package/dist/store/ledger.d.ts +187 -0
- package/dist/store/ledger.js +292 -0
- package/dist/store/owner.d.ts +616 -0
- package/dist/store/owner.js +1711 -0
- package/dist/store/patch.d.ts +70 -0
- package/dist/store/patch.js +166 -0
- package/dist/store/player.d.ts +65 -0
- package/dist/store/player.js +261 -0
- package/dist/store/replica.d.ts +187 -0
- package/dist/store/replica.js +740 -0
- package/dist/store/state.d.ts +42 -0
- package/dist/store/state.js +165 -0
- package/dist/store/types.d.ts +200 -0
- package/dist/store/types.js +25 -0
- package/dist/store/visibility.d.ts +118 -0
- package/dist/store/visibility.js +282 -0
- package/dist/store/wire.d.ts +309 -0
- package/dist/store/wire.js +834 -0
- package/dist/stream.d.ts +174 -0
- package/dist/stream.js +238 -0
- package/dist/three/index.d.ts +109 -0
- package/dist/three/index.js +130 -0
- package/dist/udp-probe.d.ts +159 -0
- package/dist/udp-probe.js +245 -0
- package/dist/wasm.d.ts +642 -0
- package/dist/wasm.js +98 -0
- package/dist/world/border.d.ts +84 -0
- package/dist/world/border.js +99 -0
- package/dist/world/handoff.d.ts +187 -0
- package/dist/world/handoff.js +222 -0
- package/dist/world/index.d.ts +13 -0
- package/dist/world/index.js +9 -0
- package/dist/world/link.d.ts +179 -0
- package/dist/world/link.js +476 -0
- package/dist/world/regions.d.ts +154 -0
- package/dist/world/regions.js +314 -0
- package/package.json +72 -0
package/dist/wasm.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wasm-bindgen boundary, described in TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* These are the *only* shapes this package assumes about
|
|
5
|
+
* `net-mesh-leaf`. They are snake_case because that is what
|
|
6
|
+
* `wasm-bindgen` emits from Rust method names; every camelCase name a
|
|
7
|
+
* consumer sees is added by {@link BrowserNode} one layer up. Keeping
|
|
8
|
+
* the boundary in one file means a change on the Rust side breaks
|
|
9
|
+
* compilation here, at the seam, instead of somewhere in the middle of
|
|
10
|
+
* the wrapper.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* One instantiation per specifier, shared by every `connect()`.
|
|
14
|
+
*
|
|
15
|
+
* `wasm-bindgen`'s initialiser instantiates a fresh module every call;
|
|
16
|
+
* two `connect()`s in one page would otherwise ship two copies of the
|
|
17
|
+
* leaf into memory.
|
|
18
|
+
*/
|
|
19
|
+
const instantiated = new Map();
|
|
20
|
+
/** Resolve and instantiate the leaf wasm described by `source`. */
|
|
21
|
+
export async function loadLeafWasm(source = {}) {
|
|
22
|
+
if (source.wasm)
|
|
23
|
+
return source.wasm;
|
|
24
|
+
const specifier = source.wasmModule ?? new URL('./net_leaf.js', import.meta.url).href;
|
|
25
|
+
const key = `${specifier}|${String(source.wasmUrl ?? '')}`;
|
|
26
|
+
const pending = instantiated.get(key);
|
|
27
|
+
if (pending)
|
|
28
|
+
return pending;
|
|
29
|
+
const loading = importAndInit(specifier, source.wasmUrl);
|
|
30
|
+
instantiated.set(key, loading);
|
|
31
|
+
try {
|
|
32
|
+
return await loading;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
// A failed instantiation must not poison every later attempt: a
|
|
36
|
+
// page may retry with a corrected URL.
|
|
37
|
+
instantiated.delete(key);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function importAndInit(specifier, wasmUrl) {
|
|
42
|
+
// Genuinely runtime-selected, and a static import would be wrong
|
|
43
|
+
// twice over: the glue is a build artifact of the Rust crate that
|
|
44
|
+
// does not exist when this package is type-checked or published,
|
|
45
|
+
// and a page may point at any URL it serves it from.
|
|
46
|
+
const loaded = await import(/* @vite-ignore */ specifier);
|
|
47
|
+
const module = asLeafWasmModule(loaded, specifier);
|
|
48
|
+
if (module.default) {
|
|
49
|
+
await module.default(wasmUrl === undefined ? undefined : { module_or_path: wasmUrl });
|
|
50
|
+
}
|
|
51
|
+
return module;
|
|
52
|
+
}
|
|
53
|
+
function asLeafWasmModule(loaded, specifier) {
|
|
54
|
+
if (loaded === null || typeof loaded !== 'object' || !('LeafNode' in loaded)) {
|
|
55
|
+
throw new Error(`${specifier} is not a net-mesh-leaf module: no LeafNode export`);
|
|
56
|
+
}
|
|
57
|
+
// `wasm-bindgen` emits `LeafNode` as an ES class, so it is a
|
|
58
|
+
// *function* and `connect` is a static on it — not a plain object.
|
|
59
|
+
const leafNode = loaded.LeafNode;
|
|
60
|
+
if (typeof leafNode !== 'function' || !('connect' in leafNode)) {
|
|
61
|
+
throw new Error(`${specifier} exports a LeafNode without a static connect()`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof leafNode.connect !== 'function') {
|
|
64
|
+
throw new Error(`${specifier} exports a LeafNode whose connect is not callable`);
|
|
65
|
+
}
|
|
66
|
+
// Checked as far as a runtime check can reach: the export exists and
|
|
67
|
+
// `connect` is callable. Its argument and return types are
|
|
68
|
+
// `wasm-bindgen`'s contract, not something JS can inspect.
|
|
69
|
+
return loaded;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* One id argument of {@link LeafWasmNode.signal} as it crosses the
|
|
73
|
+
* wasm seam: a string passes through VERBATIM, and anything else is
|
|
74
|
+
* refused by the parsers' own names before it can reach the boundary.
|
|
75
|
+
*
|
|
76
|
+
* Refused, never coerced, and either alternative is fatal. A JS
|
|
77
|
+
* number cannot carry a `u64` id losslessly (`as f64 as u64`
|
|
78
|
+
* re-spells 511 of every 512 minted dialogs) — and a non-string must
|
|
79
|
+
* never reach `wasm-bindgen`'s String marshaling at all: it corrupts
|
|
80
|
+
* there, panicking `assert!(old_size > 0)` inside
|
|
81
|
+
* `passStringToWasm0`'s `__wbindgen_realloc`, and the call dies
|
|
82
|
+
* before `parse_peer_id` runs (finding #52's repro, on every peer
|
|
83
|
+
* spelling alike). The refusal carries the parser's name — "is not a
|
|
84
|
+
* peer id" / "is not a dialog id", as `name` picks — so a page that
|
|
85
|
+
* classifies a refusal by its text reads this one as the refusal it
|
|
86
|
+
* is. Peer before dialog at the call sites, the order
|
|
87
|
+
* `LeafNode::signal` parses in.
|
|
88
|
+
*/
|
|
89
|
+
export function idArg(value, name) {
|
|
90
|
+
if (typeof value === 'string')
|
|
91
|
+
return value;
|
|
92
|
+
// `String(value)` on a null-prototype object throws, and a refusal
|
|
93
|
+
// that dies is not a refusal.
|
|
94
|
+
const shown = typeof value === 'object' && value !== null ? Object.prototype.toString.call(value) : String(value);
|
|
95
|
+
throw new TypeError(`${shown} is not a ${name} id: a ${name} id crosses this boundary as 16 hex digits as a ` +
|
|
96
|
+
`STRING — the spelling this boundary hands out; a ${typeof value} is refused rather than ` +
|
|
97
|
+
`coerced, because a number cannot carry a u64 id`);
|
|
98
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-border actions (browser plan §9 item 4): region A asks region B to
|
|
3
|
+
* act on an entity B owns — a hit across the border, a door on the other
|
|
4
|
+
* side — and **B's authority decides**. At-most-once, like handoff:
|
|
5
|
+
*
|
|
6
|
+
* - A sends `{ id, name, input }` and repeats it until B answers.
|
|
7
|
+
* - B runs the action ONCE per id, records the outcome in `acts` in the same
|
|
8
|
+
* commit as its effect, makes both durable, and answers. Every repeat of
|
|
9
|
+
* that id gets the recorded answer and changes nothing.
|
|
10
|
+
* - A that hears nothing within `giveUpMs` reports `unresolved`: the action
|
|
11
|
+
* may or may not have happened, but it never happened twice. The caller
|
|
12
|
+
* decides whether to ask again under a NEW id.
|
|
13
|
+
*
|
|
14
|
+
* A's pending actions are not persisted: an action A loses in a crash either
|
|
15
|
+
* reached B (and happened once) or did not (and did not happen).
|
|
16
|
+
*/
|
|
17
|
+
import type { RegionState } from './handoff.js';
|
|
18
|
+
/** A to B: act on something B owns. */
|
|
19
|
+
export interface ForwardedAction {
|
|
20
|
+
readonly k: 'act';
|
|
21
|
+
readonly id: string;
|
|
22
|
+
/** The asking region. */
|
|
23
|
+
readonly from: string;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly input: unknown;
|
|
26
|
+
}
|
|
27
|
+
/** B to A: the recorded outcome of one action id, the same every time. */
|
|
28
|
+
export type ActionResult = {
|
|
29
|
+
readonly k: 'result';
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly from: string;
|
|
32
|
+
readonly ok: true;
|
|
33
|
+
readonly output: unknown;
|
|
34
|
+
} | {
|
|
35
|
+
readonly k: 'result';
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly from: string;
|
|
38
|
+
readonly ok: false;
|
|
39
|
+
readonly reason: string;
|
|
40
|
+
};
|
|
41
|
+
/** An outcome the target recorded for an action id. */
|
|
42
|
+
export interface ActRecord {
|
|
43
|
+
readonly at: number;
|
|
44
|
+
readonly ok: boolean;
|
|
45
|
+
readonly output?: unknown;
|
|
46
|
+
readonly reason?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* How a region applies one kind of forwarded action: the entities after it
|
|
50
|
+
* and the answer, or a refusal reason (string). Pure: the driver commits the
|
|
51
|
+
* returned entities, the record and nothing else.
|
|
52
|
+
*/
|
|
53
|
+
export type BorderAction<E> = (entities: Readonly<Record<string, E>>, input: unknown, from: string) => {
|
|
54
|
+
readonly entities: Readonly<Record<string, E>>;
|
|
55
|
+
readonly output: unknown;
|
|
56
|
+
} | string;
|
|
57
|
+
/**
|
|
58
|
+
* At the target: apply a forwarded action once per id and answer every copy
|
|
59
|
+
* of it the same way. An unknown action name is refused (and recorded, so
|
|
60
|
+
* the answer stays the same).
|
|
61
|
+
*/
|
|
62
|
+
export declare function onForwardedAction<E>(state: RegionState<E>, act: ForwardedAction, now: number, actions: Readonly<Record<string, BorderAction<E>>>): {
|
|
63
|
+
readonly state: RegionState<E>;
|
|
64
|
+
readonly reply: ActionResult;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Region A to neighbour B: A's entities near their shared border, read-only
|
|
68
|
+
* (plan §9 item 5, ghosting). Newer `seq` replaces older; nothing here is
|
|
69
|
+
* authoritative in B.
|
|
70
|
+
*/
|
|
71
|
+
export interface GhostFrame<E> {
|
|
72
|
+
readonly k: 'ghost';
|
|
73
|
+
readonly from: string;
|
|
74
|
+
readonly seq: number;
|
|
75
|
+
readonly entities: Readonly<Record<string, E>>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Which neighbours of region `r:<rx>:<rz>` (squares of `size`) should see an
|
|
79
|
+
* entity at (x, z) within `margin` of their edge — up to three (two sides
|
|
80
|
+
* and the corner between them).
|
|
81
|
+
*/
|
|
82
|
+
export declare function ghostTargets(region: string, x: number, z: number, size: number, margin: number): string[];
|
|
83
|
+
/** Forget action records older than `retentionMs` (which must outlast any source's retries). */
|
|
84
|
+
export declare function pruneActs<E>(state: RegionState<E>, now: number, retentionMs: number): RegionState<E>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-border actions (browser plan §9 item 4): region A asks region B to
|
|
3
|
+
* act on an entity B owns — a hit across the border, a door on the other
|
|
4
|
+
* side — and **B's authority decides**. At-most-once, like handoff:
|
|
5
|
+
*
|
|
6
|
+
* - A sends `{ id, name, input }` and repeats it until B answers.
|
|
7
|
+
* - B runs the action ONCE per id, records the outcome in `acts` in the same
|
|
8
|
+
* commit as its effect, makes both durable, and answers. Every repeat of
|
|
9
|
+
* that id gets the recorded answer and changes nothing.
|
|
10
|
+
* - A that hears nothing within `giveUpMs` reports `unresolved`: the action
|
|
11
|
+
* may or may not have happened, but it never happened twice. The caller
|
|
12
|
+
* decides whether to ask again under a NEW id.
|
|
13
|
+
*
|
|
14
|
+
* A's pending actions are not persisted: an action A loses in a crash either
|
|
15
|
+
* reached B (and happened once) or did not (and did not happen).
|
|
16
|
+
*/
|
|
17
|
+
const has = (record, key) => Object.prototype.hasOwnProperty.call(record, key);
|
|
18
|
+
function resultOf(region, id, record) {
|
|
19
|
+
return record.ok
|
|
20
|
+
? { k: 'result', id, from: region, ok: true, output: record.output ?? null }
|
|
21
|
+
: { k: 'result', id, from: region, ok: false, reason: record.reason ?? 'refused' };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* At the target: apply a forwarded action once per id and answer every copy
|
|
25
|
+
* of it the same way. An unknown action name is refused (and recorded, so
|
|
26
|
+
* the answer stays the same).
|
|
27
|
+
*/
|
|
28
|
+
export function onForwardedAction(state, act, now, actions) {
|
|
29
|
+
const acts = state.acts ?? {};
|
|
30
|
+
if (has(acts, act.id))
|
|
31
|
+
return { state, reply: resultOf(state.region, act.id, acts[act.id]) };
|
|
32
|
+
let record;
|
|
33
|
+
let entities = state.entities;
|
|
34
|
+
const handler = has(actions, act.name) ? actions[act.name] : undefined;
|
|
35
|
+
if (handler === undefined) {
|
|
36
|
+
record = { at: now, ok: false, reason: `no action '${act.name}' here` };
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
let outcome;
|
|
40
|
+
try {
|
|
41
|
+
outcome = handler(state.entities, act.input, act.from);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
outcome = `action threw: ${String(error)}`;
|
|
45
|
+
}
|
|
46
|
+
if (typeof outcome === 'string') {
|
|
47
|
+
record = { at: now, ok: false, reason: outcome };
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
entities = outcome.entities;
|
|
51
|
+
record = { at: now, ok: true, output: outcome.output };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const nextActs = { ...acts };
|
|
55
|
+
Object.defineProperty(nextActs, act.id, { value: record, enumerable: true, writable: true, configurable: true });
|
|
56
|
+
return { state: { ...state, entities, acts: nextActs }, reply: resultOf(state.region, act.id, record) };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Which neighbours of region `r:<rx>:<rz>` (squares of `size`) should see an
|
|
60
|
+
* entity at (x, z) within `margin` of their edge — up to three (two sides
|
|
61
|
+
* and the corner between them).
|
|
62
|
+
*/
|
|
63
|
+
export function ghostTargets(region, x, z, size, margin) {
|
|
64
|
+
const match = /^r:(-?\d+):(-?\d+)$/.exec(region);
|
|
65
|
+
if (match === null)
|
|
66
|
+
return [];
|
|
67
|
+
const rx = Number(match[1]);
|
|
68
|
+
const rz = Number(match[2]);
|
|
69
|
+
const near = (low, high, at) => {
|
|
70
|
+
const out = [0];
|
|
71
|
+
if (at - low < margin)
|
|
72
|
+
out.push(-1);
|
|
73
|
+
if (high - at <= margin)
|
|
74
|
+
out.push(1);
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const dx of near(rx * size, (rx + 1) * size, x)) {
|
|
79
|
+
for (const dz of near(rz * size, (rz + 1) * size, z)) {
|
|
80
|
+
if (dx !== 0 || dz !== 0)
|
|
81
|
+
out.push(`r:${rx + dx}:${rz + dz}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** Forget action records older than `retentionMs` (which must outlast any source's retries). */
|
|
87
|
+
export function pruneActs(state, now, retentionMs) {
|
|
88
|
+
const acts = state.acts;
|
|
89
|
+
if (acts === undefined)
|
|
90
|
+
return state;
|
|
91
|
+
let next = null;
|
|
92
|
+
for (const [id, record] of Object.entries(acts)) {
|
|
93
|
+
if (now - record.at > retentionMs) {
|
|
94
|
+
next ??= { ...acts };
|
|
95
|
+
delete next[id];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return next === null ? state : { ...state, acts: next };
|
|
99
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity handoff between region hosts — at-most-once, with a typed failure
|
|
3
|
+
* (browser plan §9 item 3, decision Q6).
|
|
4
|
+
*
|
|
5
|
+
* An entity crossing from region A to region B must never be live in both,
|
|
6
|
+
* and must never be applied twice. Exactly-once is not attempted; the
|
|
7
|
+
* failure a crash can cause is an entity that is **nowhere live for a
|
|
8
|
+
* while**, reported typed, never a duplicate.
|
|
9
|
+
*
|
|
10
|
+
* The protocol, over any lossy, duplicating, reordering carrier:
|
|
11
|
+
*
|
|
12
|
+
* 1. **A freezes.** The entity leaves A's live entities and waits in A's
|
|
13
|
+
* `outgoing` under a fresh handoff id, with its state. A answers inputs for
|
|
14
|
+
* it with `in-transit` (see {@link locate}).
|
|
15
|
+
* 2. **A offers** `{ id, entity, state }` to B, and repeats the offer until it
|
|
16
|
+
* hears back. The id is the idempotence key.
|
|
17
|
+
* 3. **B decides once per id.** On the first offer it either admits the
|
|
18
|
+
* entity (it is now live in B) or refuses it, and records the decision in
|
|
19
|
+
* `handled`. Every later copy of that offer gets the SAME answer and
|
|
20
|
+
* changes nothing.
|
|
21
|
+
* 4. **A settles.** `accept`: the frozen copy is dropped, and the entity is
|
|
22
|
+
* B's. `refuse`: the entity returns to A's live entities. Only an explicit
|
|
23
|
+
* refusal can bring it back to A, because silence proves nothing: B may
|
|
24
|
+
* have admitted it and the answer been lost.
|
|
25
|
+
* 5. **No answer.** A keeps it frozen and keeps offering. After `giveUpMs`,
|
|
26
|
+
* {@link retryHandoffs} reports it `unresolved` and stops offering. The
|
|
27
|
+
* entity stays frozen, still nowhere live twice. Recovery (the destination
|
|
28
|
+
* returning, an operator) re-offers with the same id through
|
|
29
|
+
* {@link reofferHandoff}, which B answers from its record.
|
|
30
|
+
*
|
|
31
|
+
* **Durability is the caller's, and it is the whole correctness argument.**
|
|
32
|
+
* Every step here is a pure function from one {@link RegionState} to the
|
|
33
|
+
* next. The host MUST make the returned state durable (e.g.
|
|
34
|
+
* `persistStore(…).flush()`) BEFORE it sends the messages the step returns. A
|
|
35
|
+
* crash then either loses the step with its messages, which is the same as a
|
|
36
|
+
* lost message, or keeps both. B must also remember a decision for longer
|
|
37
|
+
* than any A keeps offering: `handledRetentionMs` > `giveUpMs`.
|
|
38
|
+
*/
|
|
39
|
+
import type { ActRecord, ActionResult, ForwardedAction, GhostFrame } from './border.js';
|
|
40
|
+
/** A handoff's idempotence key: unique per crossing. */
|
|
41
|
+
export type HandoffId = string;
|
|
42
|
+
/** A to B: take this entity. */
|
|
43
|
+
export interface HandoffOffer<E> {
|
|
44
|
+
readonly k: 'offer';
|
|
45
|
+
readonly id: HandoffId;
|
|
46
|
+
/** The source region. */
|
|
47
|
+
readonly from: string;
|
|
48
|
+
readonly entity: string;
|
|
49
|
+
readonly state: E;
|
|
50
|
+
}
|
|
51
|
+
/** B to A: the decision for one handoff id, the same every time it is asked. */
|
|
52
|
+
export type HandoffReply = {
|
|
53
|
+
readonly k: 'accept';
|
|
54
|
+
readonly id: HandoffId;
|
|
55
|
+
readonly from: string;
|
|
56
|
+
} | {
|
|
57
|
+
readonly k: 'refuse';
|
|
58
|
+
readonly id: HandoffId;
|
|
59
|
+
readonly from: string;
|
|
60
|
+
readonly reason: string;
|
|
61
|
+
};
|
|
62
|
+
/** A message between region hosts, addressed to region `to`. */
|
|
63
|
+
export type HandoffMessage<E> = {
|
|
64
|
+
readonly to: string;
|
|
65
|
+
readonly body: HandoffOffer<E> | HandoffReply | ForwardedAction | ActionResult | GhostFrame<E>;
|
|
66
|
+
};
|
|
67
|
+
/** An entity frozen at the source, on its way out. */
|
|
68
|
+
export interface Outgoing<E> {
|
|
69
|
+
readonly entity: string;
|
|
70
|
+
readonly state: E;
|
|
71
|
+
readonly to: string;
|
|
72
|
+
/** When the handoff began, ms. */
|
|
73
|
+
readonly since: number;
|
|
74
|
+
/** When the offer was last sent, ms. */
|
|
75
|
+
readonly sentAt: number;
|
|
76
|
+
/** Stopped offering after `giveUpMs` (typed `unresolved`); still frozen. */
|
|
77
|
+
readonly unresolved: boolean;
|
|
78
|
+
}
|
|
79
|
+
/** A decision the target recorded for an id. */
|
|
80
|
+
export interface Handled {
|
|
81
|
+
readonly decision: 'accept' | 'refuse';
|
|
82
|
+
readonly reason?: string;
|
|
83
|
+
readonly at: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A region host's handoff-relevant state. Keep it IN the region's persisted
|
|
87
|
+
* document, and make it durable as one unit with the entities: a frozen
|
|
88
|
+
* entity must not come back live after a restart, and an admitted one must
|
|
89
|
+
* not be admitted again.
|
|
90
|
+
*/
|
|
91
|
+
export interface RegionState<E> {
|
|
92
|
+
readonly region: string;
|
|
93
|
+
readonly entities: Readonly<Record<string, E>>;
|
|
94
|
+
readonly outgoing: Readonly<Record<HandoffId, Outgoing<E>>>;
|
|
95
|
+
readonly handled: Readonly<Record<HandoffId, Handled>>;
|
|
96
|
+
/** Cross-border actions applied here, by id (`border.ts`). */
|
|
97
|
+
readonly acts?: Readonly<Record<string, ActRecord>>;
|
|
98
|
+
}
|
|
99
|
+
/** Timing for {@link retryHandoffs} and {@link pruneHandled}. */
|
|
100
|
+
export interface HandoffTiming {
|
|
101
|
+
/** Re-offer an unanswered handoff this often, ms. Default 250. */
|
|
102
|
+
readonly retryMs?: number;
|
|
103
|
+
/** Stop offering after this long and report `unresolved`, ms. Default 60 000. */
|
|
104
|
+
readonly giveUpMs?: number;
|
|
105
|
+
/** How long a target remembers a decision, ms. Default 600 000; must exceed `giveUpMs`. */
|
|
106
|
+
readonly handledRetentionMs?: number;
|
|
107
|
+
}
|
|
108
|
+
/** What one step did, for the host to act on AFTER making the state durable. */
|
|
109
|
+
export interface HandoffStep<E> {
|
|
110
|
+
readonly state: RegionState<E>;
|
|
111
|
+
/** Send these, after the state is durable. */
|
|
112
|
+
readonly send: readonly HandoffMessage<E>[];
|
|
113
|
+
/** Settled handoffs, for the game (e.g. tell the player where to go). */
|
|
114
|
+
readonly events: readonly HandoffEvent[];
|
|
115
|
+
}
|
|
116
|
+
/** A handoff outcome at the source, or an admission at the target. */
|
|
117
|
+
export type HandoffEvent = {
|
|
118
|
+
readonly type: 'moved';
|
|
119
|
+
readonly id: HandoffId;
|
|
120
|
+
readonly entity: string;
|
|
121
|
+
readonly to: string;
|
|
122
|
+
} | {
|
|
123
|
+
readonly type: 'refused';
|
|
124
|
+
readonly id: HandoffId;
|
|
125
|
+
readonly entity: string;
|
|
126
|
+
readonly to: string;
|
|
127
|
+
readonly reason: string;
|
|
128
|
+
} | {
|
|
129
|
+
readonly type: 'unresolved';
|
|
130
|
+
readonly id: HandoffId;
|
|
131
|
+
readonly entity: string;
|
|
132
|
+
readonly to: string;
|
|
133
|
+
} | {
|
|
134
|
+
readonly type: 'admitted';
|
|
135
|
+
readonly id: HandoffId;
|
|
136
|
+
readonly entity: string;
|
|
137
|
+
readonly from: string;
|
|
138
|
+
};
|
|
139
|
+
/** An empty region. */
|
|
140
|
+
export declare function regionState<E>(region: string, entities?: Readonly<Record<string, E>>): RegionState<E>;
|
|
141
|
+
/**
|
|
142
|
+
* Step 1–2 at the source: freeze `entity` and offer it to region `to`.
|
|
143
|
+
* Throws if the entity is not live here (frozen already, or unknown).
|
|
144
|
+
*/
|
|
145
|
+
export declare function beginHandoff<E>(state: RegionState<E>, entity: string, to: string, id: HandoffId, now: number): HandoffStep<E>;
|
|
146
|
+
/**
|
|
147
|
+
* Step 3 at the target: decide an offer once, and answer every copy of it
|
|
148
|
+
* the same way. `admit` is asked only the first time; it returns `true` or a
|
|
149
|
+
* refusal reason. An admitted entity whose id is already live here is
|
|
150
|
+
* refused (`occupied`), never overwritten.
|
|
151
|
+
*/
|
|
152
|
+
export declare function onHandoffOffer<E>(state: RegionState<E>, offer: HandoffOffer<E>, now: number, admit?: (entity: string, value: E) => true | string): HandoffStep<E>;
|
|
153
|
+
/**
|
|
154
|
+
* Step 4 at the source: settle on the target's answer. A reply for a handoff
|
|
155
|
+
* this region is not holding (already settled, or never begun) changes
|
|
156
|
+
* nothing: replies are duplicated and reordered like everything else.
|
|
157
|
+
*/
|
|
158
|
+
export declare function onHandoffReply<E>(state: RegionState<E>, reply: HandoffReply): HandoffStep<E>;
|
|
159
|
+
/**
|
|
160
|
+
* Step 2 and 5 at the source, on a timer: re-offer what is unanswered, and
|
|
161
|
+
* mark what has waited past `giveUpMs` as `unresolved` (reported once, still
|
|
162
|
+
* frozen, no longer offered).
|
|
163
|
+
*/
|
|
164
|
+
export declare function retryHandoffs<E>(state: RegionState<E>, now: number, options?: HandoffTiming): HandoffStep<E>;
|
|
165
|
+
/**
|
|
166
|
+
* Recovery: offer an `unresolved` handoff again, under the SAME id (the
|
|
167
|
+
* destination answers it from its record, so this cannot admit twice), and
|
|
168
|
+
* restart its give-up clock. Use it when the destination is known to be
|
|
169
|
+
* back. A no-op for a handoff that is not unresolved.
|
|
170
|
+
*/
|
|
171
|
+
export declare function reofferHandoff<E>(state: RegionState<E>, id: HandoffId, now: number): HandoffStep<E>;
|
|
172
|
+
/** Forget target decisions older than `handledRetentionMs` (which must outlast any source's offers). */
|
|
173
|
+
export declare function pruneHandled<E>(state: RegionState<E>, now: number, options?: HandoffTiming): RegionState<E>;
|
|
174
|
+
/**
|
|
175
|
+
* Where an entity is, from this region's point of view — what to answer an
|
|
176
|
+
* input for it: `here` (apply it), `in-transit` (frozen, on its way to
|
|
177
|
+
* `to`; refuse, never apply), or `unknown`.
|
|
178
|
+
*/
|
|
179
|
+
export declare function locate<E>(state: RegionState<E>, entity: string): {
|
|
180
|
+
readonly at: 'here';
|
|
181
|
+
} | {
|
|
182
|
+
readonly at: 'in-transit';
|
|
183
|
+
readonly to: string;
|
|
184
|
+
readonly id: HandoffId;
|
|
185
|
+
} | {
|
|
186
|
+
readonly at: 'unknown';
|
|
187
|
+
};
|