@mmstack/mesh-protocol 0.1.0 → 0.2.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/README.md +47 -2
- package/index.d.ts +51 -15
- package/index.js +32 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -97,6 +97,13 @@ matches a path. For richer rules, write your own `OpPolicy` with `canWrite` and
|
|
|
97
97
|
Schema-aware validation (deriving a policy from your data model) composes on top and stays in
|
|
98
98
|
your codebase, not here.
|
|
99
99
|
|
|
100
|
+
Two boundaries to be clear about. Policy gates writes, not reads: every member of a room sees
|
|
101
|
+
the whole root, so the room is the confidentiality boundary, and data with different audiences
|
|
102
|
+
belongs in different rooms. And because the relay folds envelopes into snapshots, it reads
|
|
103
|
+
plaintext; end-to-end encryption where the server sees only ciphertext is incompatible with
|
|
104
|
+
server-side compaction as designed. Encrypt the transport and the stored data, but treat the
|
|
105
|
+
relay as inside the trust boundary.
|
|
106
|
+
|
|
100
107
|
## Adapter recipes
|
|
101
108
|
|
|
102
109
|
The relay is pure over injected sockets, so an adapter is a few lines of glue.
|
|
@@ -139,8 +146,46 @@ export class MeshRoom {
|
|
|
139
146
|
}
|
|
140
147
|
```
|
|
141
148
|
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
## Persistence
|
|
150
|
+
|
|
151
|
+
Rooms live in memory. That covers dev and single-process deployments, and when the relay
|
|
152
|
+
restarts, the first client to rejoin seeds the room from its own local state, so nothing is
|
|
153
|
+
lost as long as somebody was online. For durability beyond that, the relay exposes a seam
|
|
154
|
+
rather than a storage engine, because the envelope already is the persistence record: an
|
|
155
|
+
event-sourced journal is just `snapshot + envelopes`, compacted by re-snapshotting.
|
|
156
|
+
|
|
157
|
+
`onCommit` fires after every envelope is sequenced and folded, with the envelope and the
|
|
158
|
+
room's current `{ seq, epoch, root }`. Append the envelope to your journal, and checkpoint the
|
|
159
|
+
root as often as you like. The relay never awaits it, so batching and backpressure belong to
|
|
160
|
+
your adapter:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const relay = createRelay({
|
|
164
|
+
onCommit: (room, env, state) => {
|
|
165
|
+
journal.append(room, env); // your DB, KV, or DO storage
|
|
166
|
+
if (state.seq % 100 === 0) checkpoints.put(room, state);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`relay.hydrate(room, snapshot)` restores a persisted room before clients join (relay boot, or
|
|
172
|
+
inside a Durable Object's `blockConcurrencyWhile`). It refuses once the room has state or
|
|
173
|
+
members, so your load can race a fast client without corrupting a live sequence space:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const saved = await checkpoints.get(roomName);
|
|
177
|
+
if (saved) {
|
|
178
|
+
relay.hydrate(roomName, {
|
|
179
|
+
...saved, // seq, epoch, root
|
|
180
|
+
journal: await journal.tail(roomName, saved.seq),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Restoring the persisted `epoch` is what lets clients that were connected before the restart
|
|
186
|
+
keep their sequence watermark and catch up with a cheap `delta` answer. Omit it and they fall
|
|
187
|
+
back to a full snapshot, which is always safe. The optional journal tail is only there to make
|
|
188
|
+
those delta answers possible; the room is complete without it.
|
|
144
189
|
|
|
145
190
|
## WebRTC signaling
|
|
146
191
|
|
package/index.d.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
//#region src/lib/wire.d.ts
|
|
2
|
-
/**
|
|
3
|
-
* Canonical wire types of the mmstack op protocol (op-protocol RFC §3/§6). Structurally
|
|
4
|
-
* identical to the L0 types in `@mmstack/primitives` — deliberately NOT imported from there,
|
|
5
|
-
* so this package stays zero-dependency and never drags Angular peers onto a server. The
|
|
6
|
-
* client package asserts mutual assignability at compile time.
|
|
7
|
-
*/
|
|
8
2
|
type Key = string | number;
|
|
9
3
|
type StoreOp = {
|
|
10
4
|
kind: 'set';
|
|
@@ -30,6 +24,12 @@ type OpEnvelope = {
|
|
|
30
24
|
readonly hlc: Hlc;
|
|
31
25
|
readonly policyVersion: number;
|
|
32
26
|
readonly ops: readonly StoreOp[];
|
|
27
|
+
/**
|
|
28
|
+
* Present only on a MIGRATION envelope: the new `schemaVersion` this envelope
|
|
29
|
+
* establishes. The relay bumps the room's schema + epoch when it sequences one; normal writes
|
|
30
|
+
* omit it.
|
|
31
|
+
*/
|
|
32
|
+
readonly schemaVersion?: number;
|
|
33
33
|
};
|
|
34
34
|
/** An envelope the relay has ordered: `seq` is the room-scoped total order. */
|
|
35
35
|
type SeqEnvelope = OpEnvelope & {
|
|
@@ -46,7 +46,8 @@ type HelloMsg = {
|
|
|
46
46
|
readonly origin: string;
|
|
47
47
|
readonly proto: number;
|
|
48
48
|
readonly policyVersion: number; /** Last room seq this client has applied — enables the delta answer on reconnect. */
|
|
49
|
-
readonly seq?: number;
|
|
49
|
+
readonly seq?: number; /** The data shape this client speaks, older-than-room is rejected `schema`. */
|
|
50
|
+
readonly schemaVersion?: number;
|
|
50
51
|
};
|
|
51
52
|
type ClientEnvMsg = {
|
|
52
53
|
readonly t: 'env';
|
|
@@ -66,14 +67,15 @@ type ClientSignalMsg = {
|
|
|
66
67
|
readonly data: unknown;
|
|
67
68
|
};
|
|
68
69
|
type ClientMsg = HelloMsg | ClientEnvMsg | ClientPresenceMsg | ClientSignalMsg;
|
|
69
|
-
/** The tri-state join answer
|
|
70
|
+
/** The tri-state join answer, plus the current presence roster. */
|
|
70
71
|
type WelcomeMsg = {
|
|
71
72
|
readonly t: 'welcome';
|
|
72
73
|
readonly room: string;
|
|
73
74
|
readonly seq: number;
|
|
74
75
|
/** Room-instance nonce: changes when a room is recreated (relay restart, DO eviction),
|
|
75
76
|
* so clients know their seq watermark belongs to a dead seq space. */
|
|
76
|
-
readonly epoch: string;
|
|
77
|
+
readonly epoch: string; /** The room's current data shape. */
|
|
78
|
+
readonly schemaVersion: number;
|
|
77
79
|
readonly peers: readonly PresenceState[]; /** Origins currently in the room (membership ≠ presence) — the P2P bootstrap roster. */
|
|
78
80
|
readonly members: readonly string[];
|
|
79
81
|
} & ({
|
|
@@ -99,7 +101,7 @@ type ServerPresenceMsg = {
|
|
|
99
101
|
type RejectMsg = {
|
|
100
102
|
readonly t: 'reject';
|
|
101
103
|
readonly room: string;
|
|
102
|
-
readonly reason: 'proto' | 'policy-version' | 'unauthorized';
|
|
104
|
+
readonly reason: 'proto' | 'policy-version' | 'unauthorized' | 'schema';
|
|
103
105
|
readonly expected?: number;
|
|
104
106
|
};
|
|
105
107
|
type EjectMsg = {
|
|
@@ -130,8 +132,7 @@ declare function applyWireOps<T>(root: T, ops: readonly StoreOp[]): T;
|
|
|
130
132
|
//#endregion
|
|
131
133
|
//#region src/lib/policy.d.ts
|
|
132
134
|
/**
|
|
133
|
-
* The principal behind a connection, as authenticated by the adapter
|
|
134
|
-
* identity — RFC §3). `kind` distinguishes non-human peers: an agent is a user (§0), just one
|
|
135
|
+
* The principal behind a connection, as authenticated by the adapter. `kind` distinguishes non-human peers: an agent is a user, just one
|
|
135
136
|
* whose policy is usually narrower.
|
|
136
137
|
*/
|
|
137
138
|
type PrincipalCtx = {
|
|
@@ -140,10 +141,13 @@ type PrincipalCtx = {
|
|
|
140
141
|
readonly claims?: Readonly<Record<string, unknown>>;
|
|
141
142
|
};
|
|
142
143
|
/**
|
|
143
|
-
* Pure, deterministic, versioned validation
|
|
144
|
+
* Pure, deterministic, versioned validation. Run symmetrically on emit and on
|
|
144
145
|
* apply: honest peers never emit invalid ops, so any violation observed on the wire is a
|
|
145
146
|
* buggy or malicious writer — tripwire semantics eject it deterministically. Never skip an
|
|
146
147
|
* op mid-log.
|
|
148
|
+
*
|
|
149
|
+
* This is a WRITE ACL, not a read ACL: every room member sees the whole root, so the room is
|
|
150
|
+
* the confidentiality boundary. Data with different audiences belongs in different rooms.
|
|
147
151
|
*/
|
|
148
152
|
type OpPolicy = {
|
|
149
153
|
canWrite?(ctx: PrincipalCtx, path: readonly Key[]): boolean;
|
|
@@ -184,6 +188,32 @@ type RelayOptions = {
|
|
|
184
188
|
readonly journalLimit?: number;
|
|
185
189
|
readonly now?: () => number;
|
|
186
190
|
readonly onViolation?: (room: string, violation: PolicyViolation) => void;
|
|
191
|
+
/**
|
|
192
|
+
* The persistence egress: fired after an envelope is sequenced, folded into the room
|
|
193
|
+
* snapshot, and broadcast. The envelope is the persistence record (append it to a journal);
|
|
194
|
+
* `state` carries the folded root for throttled checkpoints. Called synchronously and never
|
|
195
|
+
* awaited: batch, debounce, and store at the adapter layer. Pair with {@link Relay.hydrate}.
|
|
196
|
+
*/
|
|
197
|
+
readonly onCommit?: (room: string, env: SeqEnvelope, state: RoomState) => void;
|
|
198
|
+
};
|
|
199
|
+
/** The room's durable state at a commit: what a checkpoint needs to capture. */
|
|
200
|
+
type RoomState = {
|
|
201
|
+
readonly seq: number;
|
|
202
|
+
readonly epoch: string;
|
|
203
|
+
readonly root: unknown; /** The room's data shape; restored via {@link Relay.hydrate}. */
|
|
204
|
+
readonly schemaVersion: number;
|
|
205
|
+
};
|
|
206
|
+
/** A persisted room to restore via {@link Relay.hydrate}. */
|
|
207
|
+
type RoomSnapshot = {
|
|
208
|
+
readonly seq: number;
|
|
209
|
+
readonly root: unknown;
|
|
210
|
+
/**
|
|
211
|
+
* Restore the persisted epoch so clients reconnecting across the restart keep their seq
|
|
212
|
+
* watermark and get a `delta` answer; omit to mint a fresh one (they re-snapshot instead).
|
|
213
|
+
*/
|
|
214
|
+
readonly epoch?: string; /** Restore the persisted schema version (a compacted snapshot is post-migration). */
|
|
215
|
+
readonly schemaVersion?: number; /** Journal tail (ascending seq, entries at or below `seq`) enabling those delta answers. */
|
|
216
|
+
readonly journal?: readonly SeqEnvelope[];
|
|
187
217
|
};
|
|
188
218
|
type RelayConnection = {
|
|
189
219
|
receive(msg: ClientMsg): void;
|
|
@@ -197,9 +227,15 @@ type RoomInfo = {
|
|
|
197
227
|
type Relay = {
|
|
198
228
|
/** Attach an authenticated connection. `ctx.writer` is the trusted principal. */connect(socket: RelaySocket, ctx: PrincipalCtx): RelayConnection;
|
|
199
229
|
room(name: string): RoomInfo | undefined;
|
|
230
|
+
/**
|
|
231
|
+
* Restore a persisted room before clients join (relay boot, Durable Object wake). Refused
|
|
232
|
+
* (`false`) once the room has state or members: hydrating a live seq space would corrupt
|
|
233
|
+
* it. Load asynchronously at the adapter layer, then hydrate synchronously.
|
|
234
|
+
*/
|
|
235
|
+
hydrate(name: string, snapshot: RoomSnapshot): boolean;
|
|
200
236
|
};
|
|
201
237
|
/**
|
|
202
|
-
* The reference relay core
|
|
238
|
+
* The reference relay core: room-scoped sequencing, journal +
|
|
203
239
|
* snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
|
|
204
240
|
* enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
|
|
205
241
|
* Object, or an in-memory test pair. The relay never interprets ops beyond folding them
|
|
@@ -213,4 +249,4 @@ type Relay = {
|
|
|
213
249
|
*/
|
|
214
250
|
declare function createRelay(opt?: RelayOptions): Relay;
|
|
215
251
|
//#endregion
|
|
216
|
-
export { type ClientEnvMsg, type ClientMsg, type ClientPresenceMsg, type ClientSignalMsg, type EjectMsg, type HelloMsg, type Hlc, type Key, MESH_PROTO_VERSION, type MemberMsg, type OpEnvelope, type OpPolicy, type PathAclRule, type PolicyViolation, type PresenceState, type PrincipalCtx, type RejectMsg, type Relay, type RelayConnection, type RelayLimits, type RelayOptions, type RelaySocket, type RoomInfo, type SeqEnvelope, type ServerEnvMsg, type ServerMsg, type ServerPresenceMsg, type ServerSignalMsg, type StoreOp, type WelcomeMsg, applyWireOps, checkEnvelope, createRelay, pathPrefixAcl };
|
|
252
|
+
export { type ClientEnvMsg, type ClientMsg, type ClientPresenceMsg, type ClientSignalMsg, type EjectMsg, type HelloMsg, type Hlc, type Key, MESH_PROTO_VERSION, type MemberMsg, type OpEnvelope, type OpPolicy, type PathAclRule, type PolicyViolation, type PresenceState, type PrincipalCtx, type RejectMsg, type Relay, type RelayConnection, type RelayLimits, type RelayOptions, type RelaySocket, type RoomInfo, type RoomSnapshot, type RoomState, type SeqEnvelope, type ServerEnvMsg, type ServerMsg, type ServerPresenceMsg, type ServerSignalMsg, type StoreOp, type WelcomeMsg, applyWireOps, checkEnvelope, createRelay, pathPrefixAcl };
|
package/index.js
CHANGED
|
@@ -60,7 +60,7 @@ function applyAt(container, path, idx, op) {
|
|
|
60
60
|
//#region src/lib/relay.ts
|
|
61
61
|
let epochCounter = 0;
|
|
62
62
|
/**
|
|
63
|
-
* The reference relay core
|
|
63
|
+
* The reference relay core: room-scoped sequencing, journal +
|
|
64
64
|
* snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
|
|
65
65
|
* enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
|
|
66
66
|
* Object, or an in-memory test pair. The relay never interprets ops beyond folding them
|
|
@@ -85,6 +85,7 @@ function createRelay(opt = {}) {
|
|
|
85
85
|
room = {
|
|
86
86
|
seq: 0,
|
|
87
87
|
epoch: `${now().toString(36)}-${(++epochCounter).toString(36)}`,
|
|
88
|
+
schemaVersion: 0,
|
|
88
89
|
root: void 0,
|
|
89
90
|
journal: [],
|
|
90
91
|
members: /* @__PURE__ */ new Set(),
|
|
@@ -158,6 +159,16 @@ function createRelay(opt = {}) {
|
|
|
158
159
|
journal: room.journal.length
|
|
159
160
|
} : void 0;
|
|
160
161
|
},
|
|
162
|
+
hydrate: (name, snapshot) => {
|
|
163
|
+
const room = roomOf(name);
|
|
164
|
+
if (room.seq !== 0 || room.members.size > 0 || room.journal.length > 0) return false;
|
|
165
|
+
room.seq = snapshot.seq;
|
|
166
|
+
room.root = snapshot.root;
|
|
167
|
+
if (snapshot.epoch !== void 0) room.epoch = snapshot.epoch;
|
|
168
|
+
if (snapshot.schemaVersion !== void 0) room.schemaVersion = snapshot.schemaVersion;
|
|
169
|
+
if (snapshot.journal) room.journal = snapshot.journal.filter((e) => e.seq <= snapshot.seq).sort((a, b) => a.seq - b.seq).slice(-journalLimit);
|
|
170
|
+
return true;
|
|
171
|
+
},
|
|
161
172
|
connect: (socket, ctx) => {
|
|
162
173
|
const joined = /* @__PURE__ */ new Map();
|
|
163
174
|
const disconnect = () => {
|
|
@@ -206,6 +217,15 @@ function createRelay(opt = {}) {
|
|
|
206
217
|
});
|
|
207
218
|
return;
|
|
208
219
|
}
|
|
220
|
+
if (msg.schemaVersion !== void 0 && msg.schemaVersion < room.schemaVersion) {
|
|
221
|
+
socket.send({
|
|
222
|
+
t: "reject",
|
|
223
|
+
room: msg.room,
|
|
224
|
+
reason: "schema",
|
|
225
|
+
expected: room.schemaVersion
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
209
229
|
for (const prior of [...room.members]) {
|
|
210
230
|
if (prior.origin !== msg.origin) continue;
|
|
211
231
|
room.members.delete(prior);
|
|
@@ -230,6 +250,7 @@ function createRelay(opt = {}) {
|
|
|
230
250
|
room: msg.room,
|
|
231
251
|
seq: room.seq,
|
|
232
252
|
epoch: room.epoch,
|
|
253
|
+
schemaVersion: room.schemaVersion,
|
|
233
254
|
peers,
|
|
234
255
|
members: [...room.members].filter((m) => m !== member).map((m) => m.origin)
|
|
235
256
|
};
|
|
@@ -303,12 +324,22 @@ function createRelay(opt = {}) {
|
|
|
303
324
|
};
|
|
304
325
|
room.journal.push(seqEnv);
|
|
305
326
|
room.root = applyWireOps(room.root, env.ops);
|
|
327
|
+
if (env.schemaVersion !== void 0 && env.schemaVersion > room.schemaVersion) {
|
|
328
|
+
room.schemaVersion = env.schemaVersion;
|
|
329
|
+
room.epoch = `${now().toString(36)}-${(++epochCounter).toString(36)}`;
|
|
330
|
+
}
|
|
306
331
|
if (room.journal.length > journalLimit) room.journal.shift();
|
|
307
332
|
broadcast(room, {
|
|
308
333
|
t: "env",
|
|
309
334
|
room: msg.room,
|
|
310
335
|
env: seqEnv
|
|
311
336
|
});
|
|
337
|
+
opt.onCommit?.(msg.room, seqEnv, {
|
|
338
|
+
seq: room.seq,
|
|
339
|
+
epoch: room.epoch,
|
|
340
|
+
root: room.root,
|
|
341
|
+
schemaVersion: room.schemaVersion
|
|
342
|
+
});
|
|
312
343
|
}
|
|
313
344
|
};
|
|
314
345
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmstack/mesh-protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"module": "./index.js",
|
|
@@ -33,4 +33,4 @@
|
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://github.com/mihajm/mmstack/blob/master/packages/mesh/protocol",
|
|
35
35
|
"sideEffects": false
|
|
36
|
-
}
|
|
36
|
+
}
|