@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.
- package/README.md +149 -0
- package/index.d.ts +216 -0
- package/index.js +318 -0
- 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
|
+
}
|