@mmstack/mesh-protocol 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.
Files changed (4) hide show
  1. package/README.md +149 -0
  2. package/index.d.ts +216 -0
  3. package/index.js +318 -0
  4. package/package.json +36 -0
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # @mmstack/mesh-protocol
2
+
3
+ > **Experimental.** The API may still change and this package is not yet battle-tested in production. Pin a version and expect some churn.
4
+
5
+ The wire and room layer of the mmstack op protocol: envelope and message types, an `OpPolicy`
6
+ seam with tripwire semantics, and a runtime-agnostic reference relay. Zero dependencies. It
7
+ runs in Node, Bun, Cloudflare Durable Objects, or anywhere else, because it never touches a
8
+ socket or a clock directly. You inject those.
9
+
10
+ The Angular client that syncs a signal store over this protocol lives in
11
+ [`@mmstack/mesh`](https://www.npmjs.com/package/@mmstack/mesh). This package is the piece a
12
+ server (or a peer) runs.
13
+
14
+ ```bash
15
+ npm install @mmstack/mesh-protocol
16
+ ```
17
+
18
+ ## What it is, and is not
19
+
20
+ The op protocol replicates a store as a stream of small structural operations rather than whole
21
+ snapshots (the same op-log that backs `@mmstack/worker`). A client emits an `OpEnvelope` per
22
+ change; the relay assigns each one a room-scoped sequence number and fans it out. Because the
23
+ relay only orders and stores opaque ops, it needs to understand nothing about your data. That
24
+ is the point: a smart client with a dumb server.
25
+
26
+ The relay is deliberately small. It sequences, keeps a journal, compacts a snapshot, answers a
27
+ joining client with whatever it is missing, routes presence, and enforces an optional policy.
28
+ It does not merge, validate schemas, or hold application logic. Conflict resolution is the
29
+ client's job, and it happens per path, so two people editing different fields of the same
30
+ record never collide.
31
+
32
+ ## The envelope
33
+
34
+ ```ts
35
+ type OpEnvelope = {
36
+ proto: number; // wire format version
37
+ origin: string; // the emitting log instance (one per tab or device)
38
+ writer: string; // the authenticated principal (a person or an agent)
39
+ version: number; // per-origin counter, for gap detection
40
+ hlc: { p: number; l: number }; // hybrid logical clock, for last-writer-wins ordering
41
+ policyVersion: number; // the room policy this writer validated against
42
+ ops: readonly StoreOp[];
43
+ };
44
+ ```
45
+
46
+ `origin` and `writer` are separate on purpose. Two tabs of one signed-in user share a `writer`
47
+ but differ by `origin`. `writer` is a stable, opaque pseudonym: the protocol forbids putting a
48
+ real name in the envelope, so a person's display name lives in a mutable directory outside the
49
+ journal and erasing them is a directory edit, not a history rewrite. The relay never mints
50
+ identity; your adapter supplies `writer` from whatever authenticated the connection.
51
+
52
+ ## The relay
53
+
54
+ ```ts
55
+ import { createRelay } from '@mmstack/mesh-protocol';
56
+
57
+ const relay = createRelay({
58
+ policyVersion: 1,
59
+ policy: myOpPolicy, // optional, see below
60
+ limits: { maxOpsPerEnvelope: 1024, maxEnvelopesPerSecond: 50 },
61
+ journalLimit: 1000, // ops kept for delta catch-up before folding to a snapshot
62
+ });
63
+ ```
64
+
65
+ `relay.connect(socket, ctx)` attaches one authenticated connection and returns
66
+ `{ receive, disconnect }`. You pump inbound frames into `receive` and call `disconnect` on
67
+ close. The `socket` is anything with `send(msg)` and `close()`, so the same relay drives a
68
+ `ws` server, a Durable Object, or an in-memory pair in a test.
69
+
70
+ When a client joins, the relay answers with one of three shapes:
71
+
72
+ - `up-to-date` when the client already has the latest sequence,
73
+ - `delta` with just the envelopes it missed, for a quick reconnect,
74
+ - `snapshot` with the full root, when the client is too far behind for the journal to cover.
75
+
76
+ Deletes fold correctly into the snapshot, so a late joiner never resurrects a removed key.
77
+
78
+ ## Trust: `OpPolicy` and the tripwire
79
+
80
+ Validation is a pure, versioned function, run the same way on the client (before it emits) and
81
+ on the relay (before it accepts). Because an honest client never emits an invalid op, any
82
+ invalid op the relay sees is a broken or hostile peer, so the relay ejects that writer for the
83
+ rest of the session rather than trying to repair the stream.
84
+
85
+ ```ts
86
+ import { pathPrefixAcl } from '@mmstack/mesh-protocol';
87
+
88
+ const policy = pathPrefixAcl([
89
+ { prefix: ['notes'], allow: () => true },
90
+ { prefix: ['cases', '*', 'plan'], allow: (ctx) => ctx.kind !== 'agent' },
91
+ ]);
92
+ ```
93
+
94
+ `pathPrefixAcl` grants write access by path prefix, and can discriminate by principal, so an
95
+ agent peer can be given a narrower surface than a human. It is deny-by-default once any rule
96
+ matches a path. For richer rules, write your own `OpPolicy` with `canWrite` and `validate`.
97
+ Schema-aware validation (deriving a policy from your data model) composes on top and stays in
98
+ your codebase, not here.
99
+
100
+ ## Adapter recipes
101
+
102
+ The relay is pure over injected sockets, so an adapter is a few lines of glue.
103
+
104
+ Node (`ws`):
105
+
106
+ ```ts
107
+ import { WebSocketServer } from 'ws';
108
+ import { createRelay } from '@mmstack/mesh-protocol';
109
+
110
+ const relay = createRelay({ policyVersion: 1 });
111
+ new WebSocketServer({ port: 8787 }).on('connection', (ws, req) => {
112
+ const writer = authenticate(req); // your auth (the relay never mints identity)
113
+ const conn = relay.connect(
114
+ { send: (m) => ws.send(JSON.stringify(m)), close: () => ws.close() },
115
+ { writer },
116
+ );
117
+ ws.on('message', (data) => conn.receive(JSON.parse(String(data))));
118
+ ws.on('close', () => conn.disconnect());
119
+ });
120
+ ```
121
+
122
+ Cloudflare Durable Objects (a room maps naturally onto an object):
123
+
124
+ ```ts
125
+ export class MeshRoom {
126
+ relay = createRelay();
127
+ async fetch(request: Request) {
128
+ const { 0: client, 1: server } = new WebSocketPair();
129
+ server.accept();
130
+ const writer = await authenticate(request);
131
+ const conn = this.relay.connect(
132
+ { send: (m) => server.send(JSON.stringify(m)), close: () => server.close() },
133
+ { writer },
134
+ );
135
+ server.addEventListener('message', (e) => conn.receive(JSON.parse(String(e.data))));
136
+ server.addEventListener('close', () => conn.disconnect());
137
+ return new Response(null, { status: 101, webSocket: client });
138
+ }
139
+ }
140
+ ```
141
+
142
+ Durable storage (journal persistence across restarts) rides the same seam and lands with a
143
+ later release. In-memory rooms cover dev and single-process deployments today.
144
+
145
+ ## WebRTC signaling
146
+
147
+ The relay also routes opaque `signal` messages between peers by origin, which is all a
148
+ peer-to-peer topology needs from a server. `@mmstack/mesh`'s `webRtcMesh` uses it to negotiate
149
+ data channels; the relay itself carries no media and inspects no payloads.
package/index.d.ts ADDED
@@ -0,0 +1,216 @@
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
+ type Key = string | number;
9
+ type StoreOp = {
10
+ kind: 'set';
11
+ path: readonly Key[];
12
+ next: unknown;
13
+ prev?: unknown;
14
+ } | {
15
+ kind: 'delete';
16
+ path: readonly Key[];
17
+ prev: unknown;
18
+ };
19
+ /** Hybrid logical clock stamp: physical epoch ms + logical counter. */
20
+ type Hlc = {
21
+ readonly p: number;
22
+ readonly l: number;
23
+ };
24
+ declare const MESH_PROTO_VERSION = 1;
25
+ type OpEnvelope = {
26
+ readonly proto: number;
27
+ readonly origin: string;
28
+ readonly writer: string;
29
+ readonly version: number;
30
+ readonly hlc: Hlc;
31
+ readonly policyVersion: number;
32
+ readonly ops: readonly StoreOp[];
33
+ };
34
+ /** An envelope the relay has ordered: `seq` is the room-scoped total order. */
35
+ type SeqEnvelope = OpEnvelope & {
36
+ readonly seq: number;
37
+ };
38
+ type PresenceState = {
39
+ readonly origin: string;
40
+ readonly writer: string; /** Consumer-defined activity payload (cursor, section, agent activity descriptor…). */
41
+ readonly data: unknown;
42
+ };
43
+ type HelloMsg = {
44
+ readonly t: 'hello';
45
+ readonly room: string;
46
+ readonly origin: string;
47
+ readonly proto: number;
48
+ readonly policyVersion: number; /** Last room seq this client has applied — enables the delta answer on reconnect. */
49
+ readonly seq?: number;
50
+ };
51
+ type ClientEnvMsg = {
52
+ readonly t: 'env';
53
+ readonly room: string;
54
+ readonly env: OpEnvelope;
55
+ };
56
+ type ClientPresenceMsg = {
57
+ readonly t: 'presence';
58
+ readonly room: string;
59
+ readonly data: unknown;
60
+ };
61
+ /** Peer-to-peer signaling payload (WebRTC offer/answer/ICE); the relay only routes it. */
62
+ type ClientSignalMsg = {
63
+ readonly t: 'signal';
64
+ readonly room: string;
65
+ readonly to: string;
66
+ readonly data: unknown;
67
+ };
68
+ type ClientMsg = HelloMsg | ClientEnvMsg | ClientPresenceMsg | ClientSignalMsg;
69
+ /** The tri-state join answer (RFC §6), plus the current presence roster. */
70
+ type WelcomeMsg = {
71
+ readonly t: 'welcome';
72
+ readonly room: string;
73
+ readonly seq: number;
74
+ /** Room-instance nonce: changes when a room is recreated (relay restart, DO eviction),
75
+ * so clients know their seq watermark belongs to a dead seq space. */
76
+ readonly epoch: string;
77
+ readonly peers: readonly PresenceState[]; /** Origins currently in the room (membership ≠ presence) — the P2P bootstrap roster. */
78
+ readonly members: readonly string[];
79
+ } & ({
80
+ readonly mode: 'up-to-date';
81
+ } | {
82
+ readonly mode: 'delta';
83
+ readonly envs: readonly SeqEnvelope[];
84
+ } | {
85
+ readonly mode: 'snapshot';
86
+ readonly root: unknown;
87
+ });
88
+ type ServerEnvMsg = {
89
+ readonly t: 'env';
90
+ readonly room: string;
91
+ readonly env: SeqEnvelope;
92
+ };
93
+ type ServerPresenceMsg = {
94
+ readonly t: 'presence';
95
+ readonly room: string;
96
+ readonly peer: PresenceState;
97
+ readonly gone?: boolean;
98
+ };
99
+ type RejectMsg = {
100
+ readonly t: 'reject';
101
+ readonly room: string;
102
+ readonly reason: 'proto' | 'policy-version' | 'unauthorized';
103
+ readonly expected?: number;
104
+ };
105
+ type EjectMsg = {
106
+ readonly t: 'eject';
107
+ readonly room: string;
108
+ readonly writer: string;
109
+ readonly reason: string;
110
+ };
111
+ /** Membership change broadcast (join/leave), independent of presence announcements. */
112
+ type MemberMsg = {
113
+ readonly t: 'member';
114
+ readonly room: string;
115
+ readonly origin: string;
116
+ readonly gone?: boolean;
117
+ };
118
+ type ServerSignalMsg = {
119
+ readonly t: 'signal';
120
+ readonly room: string;
121
+ readonly from: string;
122
+ readonly data: unknown;
123
+ };
124
+ type ServerMsg = WelcomeMsg | ServerEnvMsg | ServerPresenceMsg | RejectMsg | EjectMsg | MemberMsg | ServerSignalMsg;
125
+ /**
126
+ * Minimal pure op application for the relay's snapshot compaction — the same fold the L0
127
+ * `applyOps` performs, owned here so the protocol package stays dependency-free.
128
+ */
129
+ declare function applyWireOps<T>(root: T, ops: readonly StoreOp[]): T;
130
+ //#endregion
131
+ //#region src/lib/policy.d.ts
132
+ /**
133
+ * The principal behind a connection, as authenticated by the adapter (the relay never mints
134
+ * identity — RFC §3). `kind` distinguishes non-human peers: an agent is a user (§0), just one
135
+ * whose policy is usually narrower.
136
+ */
137
+ type PrincipalCtx = {
138
+ readonly writer: string;
139
+ readonly kind?: 'human' | 'agent' | (string & {});
140
+ readonly claims?: Readonly<Record<string, unknown>>;
141
+ };
142
+ /**
143
+ * Pure, deterministic, versioned validation (RFC §7). Run symmetrically on emit and on
144
+ * apply: honest peers never emit invalid ops, so any violation observed on the wire is a
145
+ * buggy or malicious writer — tripwire semantics eject it deterministically. Never skip an
146
+ * op mid-log.
147
+ */
148
+ type OpPolicy = {
149
+ canWrite?(ctx: PrincipalCtx, path: readonly Key[]): boolean;
150
+ validate?(op: StoreOp, ctx: PrincipalCtx): boolean;
151
+ };
152
+ type PolicyViolation = {
153
+ readonly writer: string;
154
+ readonly reason: 'can-write' | 'validate' | 'writer-mismatch' | 'proto' | 'ops-limit' | 'rate';
155
+ readonly path?: readonly Key[];
156
+ };
157
+ /** Check one envelope against a policy; `null` means clean. */
158
+ declare function checkEnvelope(policy: OpPolicy | undefined, env: OpEnvelope, ctx: PrincipalCtx): PolicyViolation | null;
159
+ /**
160
+ * S3-key-policy-style path ACL: each rule grants a principal predicate write access to one
161
+ * path prefix (`'*'` matches a single segment; a rule for `[]` grants the whole store).
162
+ * Composes into `OpPolicy.canWrite`; deny-by-default once any rule exists for a writer.
163
+ */
164
+ type PathAclRule = {
165
+ readonly prefix: readonly (Key | '*')[];
166
+ readonly allow: (ctx: PrincipalCtx) => boolean;
167
+ };
168
+ declare function pathPrefixAcl(rules: readonly PathAclRule[]): OpPolicy;
169
+ //#endregion
170
+ //#region src/lib/relay.d.ts
171
+ /** What the relay needs from a connection — implement over ws, a DO WebSocket, or a test pair. */
172
+ type RelaySocket = {
173
+ send(msg: ServerMsg): void;
174
+ close?(): void;
175
+ };
176
+ type RelayLimits = {
177
+ /** Ops per envelope; a larger envelope is a violation (default 1024). */readonly maxOpsPerEnvelope?: number; /** Sustained envelopes/second per writer (token bucket, burst = 2×; off by default). */
178
+ readonly maxEnvelopesPerSecond?: number;
179
+ };
180
+ type RelayOptions = {
181
+ /** Validation/ACL applied to every envelope; violations eject the writer (tripwire). */readonly policy?: OpPolicy;
182
+ readonly policyVersion?: number;
183
+ readonly limits?: RelayLimits; /** Seq-envelopes retained per room for delta answers; older fold into the snapshot (default 1000). */
184
+ readonly journalLimit?: number;
185
+ readonly now?: () => number;
186
+ readonly onViolation?: (room: string, violation: PolicyViolation) => void;
187
+ };
188
+ type RelayConnection = {
189
+ receive(msg: ClientMsg): void;
190
+ disconnect(): void;
191
+ };
192
+ type RoomInfo = {
193
+ readonly seq: number;
194
+ readonly members: number;
195
+ readonly journal: number;
196
+ };
197
+ type Relay = {
198
+ /** Attach an authenticated connection. `ctx.writer` is the trusted principal. */connect(socket: RelaySocket, ctx: PrincipalCtx): RelayConnection;
199
+ room(name: string): RoomInfo | undefined;
200
+ };
201
+ /**
202
+ * The reference relay core (op-protocol RFC §6/§7): room-scoped sequencing, journal +
203
+ * snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
204
+ * enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
205
+ * Object, or an in-memory test pair. The relay never interprets ops beyond folding them
206
+ * for snapshots, and never mints identity: `writer` comes from the adapter's auth.
207
+ *
208
+ * Room-initialization contract: a fresh room (seq 0) answers `up-to-date`; the first client
209
+ * then SEEDS it with a root-set envelope so the room's snapshot root is complete (deletes
210
+ * fold correctly, joiners replace-hydrate). Near-simultaneous first-joins of a brand-new
211
+ * room may last-writer-wins their seeds; rooms created by a single client first (the
212
+ * overwhelmingly common case) are unaffected.
213
+ */
214
+ declare function createRelay(opt?: RelayOptions): Relay;
215
+ //#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 };
package/index.js ADDED
@@ -0,0 +1,318 @@
1
+ //#region src/lib/policy.ts
2
+ /** Check one envelope against a policy; `null` means clean. */
3
+ function checkEnvelope(policy, env, ctx) {
4
+ if (env.writer !== ctx.writer) return {
5
+ writer: ctx.writer,
6
+ reason: "writer-mismatch"
7
+ };
8
+ if (!policy) return null;
9
+ for (const op of env.ops) {
10
+ if (policy.canWrite && !policy.canWrite(ctx, op.path)) return {
11
+ writer: ctx.writer,
12
+ reason: "can-write",
13
+ path: op.path
14
+ };
15
+ if (policy.validate && !policy.validate(op, ctx)) return {
16
+ writer: ctx.writer,
17
+ reason: "validate",
18
+ path: op.path
19
+ };
20
+ }
21
+ return null;
22
+ }
23
+ function pathPrefixAcl(rules) {
24
+ return { canWrite: (ctx, path) => rules.some((rule) => {
25
+ if (rule.prefix.length > path.length) return false;
26
+ for (let i = 0; i < rule.prefix.length; i++) if (rule.prefix[i] !== "*" && String(rule.prefix[i]) !== String(path[i])) return false;
27
+ return rule.allow(ctx);
28
+ }) };
29
+ }
30
+ //#endregion
31
+ //#region src/lib/wire.ts
32
+ const MESH_PROTO_VERSION = 1;
33
+ /**
34
+ * Minimal pure op application for the relay's snapshot compaction — the same fold the L0
35
+ * `applyOps` performs, owned here so the protocol package stays dependency-free.
36
+ */
37
+ function applyWireOps(root, ops) {
38
+ let next = root;
39
+ for (const op of ops) {
40
+ if (op.path.length === 0) {
41
+ if (op.kind === "set") next = op.next;
42
+ continue;
43
+ }
44
+ next = applyAt(next, op.path, 0, op);
45
+ }
46
+ return next;
47
+ }
48
+ function applyAt(container, path, idx, op) {
49
+ const seg = path[idx];
50
+ const base = Array.isArray(container) ? container.slice() : container !== null && typeof container === "object" ? { ...container } : typeof seg === "number" ? [] : {};
51
+ if (idx === path.length - 1) {
52
+ if (op.kind === "delete") delete base[seg];
53
+ else base[seg] = op.next;
54
+ return base;
55
+ }
56
+ base[seg] = applyAt(base[seg], path, idx + 1, op);
57
+ return base;
58
+ }
59
+ //#endregion
60
+ //#region src/lib/relay.ts
61
+ let epochCounter = 0;
62
+ /**
63
+ * The reference relay core (op-protocol RFC §6/§7): room-scoped sequencing, journal +
64
+ * snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
65
+ * enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
66
+ * Object, or an in-memory test pair. The relay never interprets ops beyond folding them
67
+ * for snapshots, and never mints identity: `writer` comes from the adapter's auth.
68
+ *
69
+ * Room-initialization contract: a fresh room (seq 0) answers `up-to-date`; the first client
70
+ * then SEEDS it with a root-set envelope so the room's snapshot root is complete (deletes
71
+ * fold correctly, joiners replace-hydrate). Near-simultaneous first-joins of a brand-new
72
+ * room may last-writer-wins their seeds; rooms created by a single client first (the
73
+ * overwhelmingly common case) are unaffected.
74
+ */
75
+ function createRelay(opt = {}) {
76
+ const rooms = /* @__PURE__ */ new Map();
77
+ const policyVersion = opt.policyVersion ?? 0;
78
+ const journalLimit = opt.journalLimit ?? 1e3;
79
+ const maxOps = opt.limits?.maxOpsPerEnvelope ?? 1024;
80
+ const rate = opt.limits?.maxEnvelopesPerSecond;
81
+ const now = opt.now ?? Date.now;
82
+ const roomOf = (name) => {
83
+ let room = rooms.get(name);
84
+ if (!room) {
85
+ room = {
86
+ seq: 0,
87
+ epoch: `${now().toString(36)}-${(++epochCounter).toString(36)}`,
88
+ root: void 0,
89
+ journal: [],
90
+ members: /* @__PURE__ */ new Set(),
91
+ presence: /* @__PURE__ */ new Map(),
92
+ ejected: /* @__PURE__ */ new Set(),
93
+ buckets: /* @__PURE__ */ new Map()
94
+ };
95
+ rooms.set(name, room);
96
+ }
97
+ return room;
98
+ };
99
+ const broadcast = (room, msg, except) => {
100
+ for (const member of room.members) if (member !== except) member.socket.send(msg);
101
+ };
102
+ const eject = (name, room, writer, violation) => {
103
+ room.ejected.add(writer);
104
+ opt.onViolation?.(name, violation);
105
+ broadcast(room, {
106
+ t: "eject",
107
+ room: name,
108
+ writer,
109
+ reason: violation.reason
110
+ });
111
+ for (const member of [...room.members]) {
112
+ if (member.ctx.writer !== writer) continue;
113
+ room.members.delete(member);
114
+ dropPresence(name, room, member);
115
+ broadcast(room, {
116
+ t: "member",
117
+ room: name,
118
+ origin: member.origin,
119
+ gone: true
120
+ });
121
+ member.socket.close?.();
122
+ }
123
+ };
124
+ const dropPresence = (name, room, member) => {
125
+ const entry = room.presence.get(member.origin);
126
+ if (!entry || entry.by !== member) return;
127
+ room.presence.delete(member.origin);
128
+ broadcast(room, {
129
+ t: "presence",
130
+ room: name,
131
+ peer: entry.peer,
132
+ gone: true
133
+ });
134
+ };
135
+ const overRate = (room, writer) => {
136
+ if (!rate) return false;
137
+ const at = now();
138
+ let bucket = room.buckets.get(writer);
139
+ if (!bucket) {
140
+ bucket = {
141
+ tokens: rate * 2,
142
+ last: at
143
+ };
144
+ room.buckets.set(writer, bucket);
145
+ }
146
+ bucket.tokens = Math.min(rate * 2, bucket.tokens + (at - bucket.last) / 1e3 * rate);
147
+ bucket.last = at;
148
+ if (bucket.tokens < 1) return true;
149
+ bucket.tokens -= 1;
150
+ return false;
151
+ };
152
+ return {
153
+ room: (name) => {
154
+ const room = rooms.get(name);
155
+ return room ? {
156
+ seq: room.seq,
157
+ members: room.members.size,
158
+ journal: room.journal.length
159
+ } : void 0;
160
+ },
161
+ connect: (socket, ctx) => {
162
+ const joined = /* @__PURE__ */ new Map();
163
+ const disconnect = () => {
164
+ for (const [name, member] of joined) {
165
+ const room = rooms.get(name);
166
+ if (!room || !room.members.has(member)) continue;
167
+ room.members.delete(member);
168
+ dropPresence(name, room, member);
169
+ broadcast(room, {
170
+ t: "member",
171
+ room: name,
172
+ origin: member.origin,
173
+ gone: true
174
+ });
175
+ }
176
+ joined.clear();
177
+ };
178
+ return {
179
+ disconnect,
180
+ receive: (msg) => {
181
+ const room = roomOf(msg.room);
182
+ if (msg.t === "hello") {
183
+ if (room.ejected.has(ctx.writer)) {
184
+ socket.send({
185
+ t: "reject",
186
+ room: msg.room,
187
+ reason: "unauthorized"
188
+ });
189
+ return;
190
+ }
191
+ if (msg.proto !== 1) {
192
+ socket.send({
193
+ t: "reject",
194
+ room: msg.room,
195
+ reason: "proto",
196
+ expected: 1
197
+ });
198
+ return;
199
+ }
200
+ if (msg.policyVersion !== policyVersion) {
201
+ socket.send({
202
+ t: "reject",
203
+ room: msg.room,
204
+ reason: "policy-version",
205
+ expected: policyVersion
206
+ });
207
+ return;
208
+ }
209
+ for (const prior of [...room.members]) {
210
+ if (prior.origin !== msg.origin) continue;
211
+ room.members.delete(prior);
212
+ dropPresence(msg.room, room, prior);
213
+ if (prior.socket !== socket) prior.socket.close?.();
214
+ }
215
+ const member = {
216
+ socket,
217
+ ctx,
218
+ origin: msg.origin
219
+ };
220
+ joined.set(msg.room, member);
221
+ room.members.add(member);
222
+ broadcast(room, {
223
+ t: "member",
224
+ room: msg.room,
225
+ origin: msg.origin
226
+ }, member);
227
+ const peers = [...room.presence.values()].map((e) => e.peer);
228
+ const base = {
229
+ t: "welcome",
230
+ room: msg.room,
231
+ seq: room.seq,
232
+ epoch: room.epoch,
233
+ peers,
234
+ members: [...room.members].filter((m) => m !== member).map((m) => m.origin)
235
+ };
236
+ if (room.seq === 0 || msg.seq === room.seq) socket.send({
237
+ ...base,
238
+ mode: "up-to-date"
239
+ });
240
+ else if (msg.seq !== void 0 && room.journal.length > 0 && msg.seq >= room.journal[0].seq - 1) {
241
+ const since = msg.seq;
242
+ socket.send({
243
+ ...base,
244
+ mode: "delta",
245
+ envs: room.journal.filter((e) => e.seq > since)
246
+ });
247
+ } else socket.send({
248
+ ...base,
249
+ mode: "snapshot",
250
+ root: room.root
251
+ });
252
+ return;
253
+ }
254
+ const member = joined.get(msg.room);
255
+ if (!member || room.ejected.has(ctx.writer)) return;
256
+ if (msg.t === "signal") {
257
+ for (const target of room.members) if (target.origin === msg.to) {
258
+ target.socket.send({
259
+ t: "signal",
260
+ room: msg.room,
261
+ from: member.origin,
262
+ data: msg.data
263
+ });
264
+ break;
265
+ }
266
+ return;
267
+ }
268
+ if (msg.t === "presence") {
269
+ const peer = {
270
+ origin: member.origin,
271
+ writer: ctx.writer,
272
+ data: msg.data
273
+ };
274
+ room.presence.set(member.origin, {
275
+ peer,
276
+ by: member
277
+ });
278
+ broadcast(room, {
279
+ t: "presence",
280
+ room: msg.room,
281
+ peer
282
+ }, member);
283
+ return;
284
+ }
285
+ const env = msg.env;
286
+ const violation = env.policyVersion !== policyVersion || env.proto !== 1 ? {
287
+ writer: ctx.writer,
288
+ reason: "proto"
289
+ } : env.ops.length > maxOps ? {
290
+ writer: ctx.writer,
291
+ reason: "ops-limit"
292
+ } : overRate(room, ctx.writer) ? {
293
+ writer: ctx.writer,
294
+ reason: "rate"
295
+ } : checkEnvelope(opt.policy, env, ctx);
296
+ if (violation) {
297
+ eject(msg.room, room, ctx.writer, violation);
298
+ return;
299
+ }
300
+ const seqEnv = {
301
+ ...env,
302
+ seq: ++room.seq
303
+ };
304
+ room.journal.push(seqEnv);
305
+ room.root = applyWireOps(room.root, env.ops);
306
+ if (room.journal.length > journalLimit) room.journal.shift();
307
+ broadcast(room, {
308
+ t: "env",
309
+ room: msg.room,
310
+ env: seqEnv
311
+ });
312
+ }
313
+ };
314
+ }
315
+ };
316
+ }
317
+ //#endregion
318
+ export { MESH_PROTO_VERSION, applyWireOps, checkEnvelope, createRelay, pathPrefixAcl };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@mmstack/mesh-protocol",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "module": "./index.js",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "*.js",
16
+ "*.d.ts",
17
+ "*.map"
18
+ ],
19
+ "keywords": [
20
+ "mmstack",
21
+ "mesh",
22
+ "sync",
23
+ "multiplayer",
24
+ "relay",
25
+ "op-log",
26
+ "local-first"
27
+ ],
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/mihajm/mmstack.git",
32
+ "directory": "packages/mesh/protocol"
33
+ },
34
+ "homepage": "https://github.com/mihajm/mmstack/blob/master/packages/mesh/protocol",
35
+ "sideEffects": false
36
+ }