@zubra/sync 0.0.0-stage → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +14 -2
- package/package.json +50 -4
- package/src/connect.d.ts +22 -0
- package/src/connect.js +236 -0
- package/src/index.d.ts +33 -0
- package/src/index.js +302 -0
- package/src/server.d.ts +31 -0
- package/src/server.js +232 -0
- package/src/sqlite.d.ts +4 -0
- package/src/sqlite.js +53 -0
- package/src/webrtc.js +141 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Mikey Nichols
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zubra/sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local-first state for Zubra components. `'use collection tasks'` at the top of a component's script makes its top-level `let`s the fields of a document in IndexedDB, keyed by the `doc` prop. Writes are signal assignments, so the UI never waits; state survives reloads and works offline; other elements and tabs showing the same document follow.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<script>
|
|
7
|
+
'use collection tasks';
|
|
8
|
+
import { ephemeral } from '@zubra/sync';
|
|
9
|
+
let completed = false; // stored
|
|
10
|
+
let editing = ephemeral(false); // not stored
|
|
11
|
+
</script>
|
|
12
|
+
<task-item doc="">…</task-item>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`<task-item doc="t42">` is the document `tasks/t42`. To share documents between devices, `connect()` from `@zubra/sync/connect` and serve the log with `createSyncServer()` from `@zubra/sync/server`; components don't change. see [docs/sync.md](https://github.com/journey-to-code-org/zubra/blob/main/docs/sync.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/sync",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "5.3.0",
|
|
4
|
+
"description": "Local-first state for Zubra components: 'use collection' keeps a component's state in IndexedDB, across reloads, offline, and in step across tabs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./connect": {
|
|
14
|
+
"types": "./src/connect.d.ts",
|
|
15
|
+
"default": "./src/connect.js"
|
|
16
|
+
},
|
|
17
|
+
"./server": {
|
|
18
|
+
"types": "./src/server.d.ts",
|
|
19
|
+
"default": "./src/server.js"
|
|
20
|
+
},
|
|
21
|
+
"./sqlite": {
|
|
22
|
+
"types": "./src/sqlite.d.ts",
|
|
23
|
+
"default": "./src/sqlite.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test --test-force-exit \"test/*.test.js\"",
|
|
28
|
+
"test:binary": "ZUBRA_SYNC_BACKEND=binary node --test --test-force-exit test/browser.test.js"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@zubra/runtime": "^5.3.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@zubra/compiler": "file:../compiler",
|
|
35
|
+
"@zubra/runtime": "file:../runtime",
|
|
36
|
+
"@zubra/server": "file:../server",
|
|
37
|
+
"playwright": "1.63.0"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"src",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"license": "ISC",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/journey-to-code-org/zubra.git",
|
|
50
|
+
"directory": "sync"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/connect.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Share 'use collection' documents through a log at `url` (default `/_zb/sync`), pulling every
|
|
3
|
+
* `interval` ms (default 2000). Writes stay on the device first and go out when the network allows.
|
|
4
|
+
*/
|
|
5
|
+
export function connect(options?: {
|
|
6
|
+
url?: string;
|
|
7
|
+
/** How often to poll while the live stream is closed (ms, default 2000). */
|
|
8
|
+
interval?: number;
|
|
9
|
+
/** Open the live event stream (default true). */
|
|
10
|
+
live?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Send presence over WebRTC data channels to the peers that can be reached directly; the
|
|
13
|
+
* server carries introductions, goodbyes, signaling, and presence to anyone else.
|
|
14
|
+
*/
|
|
15
|
+
webrtc?: boolean | { iceServers?: RTCIceServer[] };
|
|
16
|
+
}): {
|
|
17
|
+
flush(): Promise<void>;
|
|
18
|
+
pull(): Promise<void>;
|
|
19
|
+
/** The peers presence currently goes to directly (webrtc). */
|
|
20
|
+
direct(): string[];
|
|
21
|
+
stop(): void;
|
|
22
|
+
};
|
package/src/connect.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Tier 1: share 'use collection' documents through a log on a server, so other devices (and
|
|
2
|
+
// other people) converge. The store stays the source of truth on the device: writes land there
|
|
3
|
+
// first, and go out when the network allows; changes from the log come in and are merged.
|
|
4
|
+
//
|
|
5
|
+
// import { connect } from '@zubra/sync/connect';
|
|
6
|
+
// connect({ url: '/_zb/sync' });
|
|
7
|
+
//
|
|
8
|
+
// The protocol (plan/sync-design.md), plain HTTP so any server can keep the log:
|
|
9
|
+
// POST <url>/push { changes: [Change] } → { ok: true }
|
|
10
|
+
// GET <url>/pull?since=<cursor> → { changes: [Change], cursor }
|
|
11
|
+
// Change = { collection, key, field, value, clock } (clock: a hybrid logical clock, a string
|
|
12
|
+
// that sorts by time, then counter, then client id)
|
|
13
|
+
//
|
|
14
|
+
// Merging is last-writer-wins per field, by clock. A device that was offline pushes what it
|
|
15
|
+
// wrote when it's back; whichever write has the later clock wins, on every device alike.
|
|
16
|
+
import { onWrite, receive, currentStore, onPresence, receivePresence, peerId } from './index.js';
|
|
17
|
+
import { mesh } from './webrtc.js';
|
|
18
|
+
|
|
19
|
+
const CLOCKS = '$clocks';
|
|
20
|
+
const pad = (number, width) => String(number).padStart(width, '0');
|
|
21
|
+
|
|
22
|
+
function clock(client) {
|
|
23
|
+
let last = 0;
|
|
24
|
+
let counter = 0;
|
|
25
|
+
const tick = (seen = 0) => {
|
|
26
|
+
const now = Math.max(Date.now(), last, seen);
|
|
27
|
+
counter = now === last ? counter + 1 : 0;
|
|
28
|
+
last = now;
|
|
29
|
+
return `${pad(now, 15)}-${pad(counter, 5)}-${client}`;
|
|
30
|
+
};
|
|
31
|
+
return { tick, see: (stamp) => tick(Number(stamp.slice(0, 15))) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @internal The changes waiting to be pushed. A push takes a snapshot (`take`), and when the
|
|
36
|
+
* server has it, exactly those changes leave (`done`): by identity, not by count, so a change
|
|
37
|
+
* added while an earlier push was out is never dropped by it. Two pushes can overlap (the debounce
|
|
38
|
+
* and the poll loop); this is why that's safe.
|
|
39
|
+
*/
|
|
40
|
+
export function outboxOf(changes = [], persist = () => {}) {
|
|
41
|
+
let pending = changes;
|
|
42
|
+
return {
|
|
43
|
+
get size() {
|
|
44
|
+
return pending.length;
|
|
45
|
+
},
|
|
46
|
+
pending: () => pending.slice(),
|
|
47
|
+
add(change) {
|
|
48
|
+
pending.push(change);
|
|
49
|
+
persist(pending);
|
|
50
|
+
},
|
|
51
|
+
take: () => pending.slice(),
|
|
52
|
+
done(sent) {
|
|
53
|
+
const gone = new Set(sent);
|
|
54
|
+
pending = pending.filter((change) => !gone.has(change));
|
|
55
|
+
persist(pending);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const remember = (name, fallback) => {
|
|
61
|
+
try { return localStorage.getItem(name) ?? fallback(); } catch { return fallback(); }
|
|
62
|
+
};
|
|
63
|
+
const keep = (name, value) => { try { localStorage.setItem(name, value); } catch {} };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Start sharing. `url`: where the log is (`/_zb/sync`). `interval`: how often to pull, in ms
|
|
67
|
+
* (default 2000). Returns `{ flush, pull, stop }`.
|
|
68
|
+
*/
|
|
69
|
+
export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrtc = false } = {}) {
|
|
70
|
+
const base = url.replace(/\/$/, '');
|
|
71
|
+
const client = remember('zubra-sync:client', () => {
|
|
72
|
+
const id = Math.random().toString(36).slice(2, 10);
|
|
73
|
+
keep('zubra-sync:client', id);
|
|
74
|
+
return id;
|
|
75
|
+
});
|
|
76
|
+
const clocks = clock(client);
|
|
77
|
+
let cursor = remember('zubra-sync:cursor', () => '0');
|
|
78
|
+
const outbox = outboxOf(JSON.parse(remember('zubra-sync:outbox', () => '[]')), (pending) =>
|
|
79
|
+
keep('zubra-sync:outbox', JSON.stringify(pending)));
|
|
80
|
+
|
|
81
|
+
// Local writes: stamp each field, remember the stamp beside the document, queue it.
|
|
82
|
+
const off = onWrite(async (collection, key, fields) => {
|
|
83
|
+
const stamped = {};
|
|
84
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
85
|
+
const stamp = clocks.tick();
|
|
86
|
+
stamped[field] = stamp;
|
|
87
|
+
outbox.add({ collection, key, field, value, clock: stamp });
|
|
88
|
+
}
|
|
89
|
+
const doc = (await currentStore().get(collection, key)) ?? {};
|
|
90
|
+
await currentStore().set(collection, key, { [CLOCKS]: { ...doc[CLOCKS], ...stamped } });
|
|
91
|
+
schedule();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let timer = null;
|
|
95
|
+
const schedule = () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
timer = setTimeout(flush, 30);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
async function flush() {
|
|
101
|
+
if (!outbox.size) return;
|
|
102
|
+
const sending = outbox.take();
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(`${base}/push`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'content-type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ changes: sending }),
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) return;
|
|
110
|
+
outbox.done(sending);
|
|
111
|
+
} catch {
|
|
112
|
+
// Offline: the outbox stays (in localStorage too), and goes with the next flush.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function pull() {
|
|
117
|
+
let body;
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetch(`${base}/pull?since=${encodeURIComponent(cursor)}`);
|
|
120
|
+
if (!response.ok) return;
|
|
121
|
+
body = await response.json();
|
|
122
|
+
} catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await apply(body);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Changes are applied one batch at a time, in the order they arrived.
|
|
129
|
+
let applying = Promise.resolve();
|
|
130
|
+
const apply = (body) => (applying = applying.then(() => merge(body)).catch(() => {}));
|
|
131
|
+
|
|
132
|
+
async function merge(body) {
|
|
133
|
+
// By document: apply the fields whose clock beats the one stored here.
|
|
134
|
+
const byDoc = new Map();
|
|
135
|
+
for (const change of body.changes) {
|
|
136
|
+
if (change.clock.endsWith(`-${client}`)) continue;
|
|
137
|
+
clocks.see(change.clock);
|
|
138
|
+
const id = JSON.stringify([change.collection, change.key]);
|
|
139
|
+
if (!byDoc.has(id)) byDoc.set(id, []);
|
|
140
|
+
byDoc.get(id).push(change);
|
|
141
|
+
}
|
|
142
|
+
for (const changes of byDoc.values()) {
|
|
143
|
+
const { collection, key } = changes[0];
|
|
144
|
+
const doc = (await currentStore().get(collection, key)) ?? {};
|
|
145
|
+
const known = { ...doc[CLOCKS] };
|
|
146
|
+
const fields = {};
|
|
147
|
+
for (const change of changes) {
|
|
148
|
+
if (known[change.field] && known[change.field] >= change.clock) continue;
|
|
149
|
+
known[change.field] = change.clock;
|
|
150
|
+
fields[change.field] = change.value;
|
|
151
|
+
}
|
|
152
|
+
if (Object.keys(fields).length) await receive(collection, key, { ...fields, [CLOCKS]: known });
|
|
153
|
+
}
|
|
154
|
+
// Never move the cursor back (a live backlog can arrive after a newer push).
|
|
155
|
+
const next = String(body.cursor);
|
|
156
|
+
if (!(/^\d+$/.test(next) && /^\d+$/.test(cursor) && Number(next) < Number(cursor))) cursor = next;
|
|
157
|
+
keep('zubra-sync:cursor', cursor);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Live: a server-sent event stream brings changes as they're pushed, and other viewers'
|
|
161
|
+
// presence. While it's open there's nothing to poll; while it isn't, polling carries on.
|
|
162
|
+
let source = null;
|
|
163
|
+
let open = false;
|
|
164
|
+
let retry = null;
|
|
165
|
+
const listen = () => {
|
|
166
|
+
if (!live || typeof EventSource === 'undefined' || stopped) return;
|
|
167
|
+
source = new EventSource(`${base}/live?since=${encodeURIComponent(cursor)}&peer=${encodeURIComponent(peerId())}`);
|
|
168
|
+
source.addEventListener('open', () => (open = true));
|
|
169
|
+
source.addEventListener('changes', (event) => apply(JSON.parse(event.data)));
|
|
170
|
+
source.addEventListener('presence', (event) => {
|
|
171
|
+
const message = JSON.parse(event.data);
|
|
172
|
+
rtc?.sawPeer(message.room, message.peer, message.state);
|
|
173
|
+
receivePresence(message);
|
|
174
|
+
});
|
|
175
|
+
source.addEventListener('signal', (event) => rtc?.receiveSignal(JSON.parse(event.data)));
|
|
176
|
+
source.addEventListener('error', () => {
|
|
177
|
+
// Reconnect from the current cursor (EventSource would reuse the first one).
|
|
178
|
+
open = false;
|
|
179
|
+
source?.close();
|
|
180
|
+
source = null;
|
|
181
|
+
clearTimeout(retry);
|
|
182
|
+
retry = setTimeout(listen, 1000);
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Presence goes straight out, unqueued: an old cursor position is worth nothing later.
|
|
187
|
+
// With webrtc, presence goes over data channels to the peers that have one; the server carries
|
|
188
|
+
// it only while someone in the room doesn't (and always carries goodbyes).
|
|
189
|
+
const rtc =
|
|
190
|
+
webrtc && typeof RTCPeerConnection !== 'undefined'
|
|
191
|
+
? mesh({
|
|
192
|
+
self: peerId(),
|
|
193
|
+
iceServers: webrtc.iceServers ?? [],
|
|
194
|
+
deliver: receivePresence,
|
|
195
|
+
signal: (room, to, data) =>
|
|
196
|
+
fetch(`${base}/signal`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'content-type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({ room, from: peerId(), to, data }),
|
|
200
|
+
}).catch(() => {}),
|
|
201
|
+
})
|
|
202
|
+
: null;
|
|
203
|
+
|
|
204
|
+
const offPresence = onPresence((message) => {
|
|
205
|
+
if (rtc?.send(message) && message.state !== null) return;
|
|
206
|
+
fetch(`${base}/presence`, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/json' },
|
|
209
|
+
body: JSON.stringify(message),
|
|
210
|
+
keepalive: message.state === null, // a leave on pagehide still gets there
|
|
211
|
+
}).catch(() => {});
|
|
212
|
+
});
|
|
213
|
+
let stopped = false;
|
|
214
|
+
|
|
215
|
+
const loop = setInterval(() => flush().then(() => (open ? undefined : pull())), interval);
|
|
216
|
+
const online = () => flush().then(pull);
|
|
217
|
+
addEventListener('online', online);
|
|
218
|
+
flush().then(pull).then(listen);
|
|
219
|
+
return {
|
|
220
|
+
flush,
|
|
221
|
+
pull,
|
|
222
|
+
/** The peers presence goes to directly (webrtc), by peer id. */
|
|
223
|
+
direct: () => rtc?.direct() ?? [],
|
|
224
|
+
stop() {
|
|
225
|
+
stopped = true;
|
|
226
|
+
off();
|
|
227
|
+
offPresence();
|
|
228
|
+
rtc?.close();
|
|
229
|
+
source?.close();
|
|
230
|
+
clearTimeout(retry);
|
|
231
|
+
clearInterval(loop);
|
|
232
|
+
clearTimeout(timer);
|
|
233
|
+
removeEventListener('online', online);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** `let editing = ephemeral(false)`: state a `'use collection'` component doesn't store. */
|
|
2
|
+
export function ephemeral<T>(value: T): T;
|
|
3
|
+
|
|
4
|
+
/** `let cursor = presence({ x: 0, y: 0 })`: state shared live with the document's other viewers, never stored. */
|
|
5
|
+
export function presence<T>(value: T): T;
|
|
6
|
+
|
|
7
|
+
/** `let others = peers()`: the document's other viewers, each `{ peer, ...presence }`. */
|
|
8
|
+
export function peers<T extends object = Record<string, unknown>>(): Array<T & { peer: string }>;
|
|
9
|
+
|
|
10
|
+
/** Where documents live. `set` merges `fields` into the stored document. */
|
|
11
|
+
export interface Store {
|
|
12
|
+
get(collection: string, key: string): Promise<Record<string, unknown> | undefined>;
|
|
13
|
+
set(collection: string, key: string, fields: Record<string, unknown>): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Use another store, before any `'use collection'` element starts. */
|
|
17
|
+
export function configure(options: { store?: Store }): void;
|
|
18
|
+
|
|
19
|
+
/** The default in browsers: one IndexedDB database (`zubra-sync`), one record per document. */
|
|
20
|
+
export function indexedDBStore(database?: string): Store;
|
|
21
|
+
|
|
22
|
+
/** A store in memory, for tests and places without IndexedDB. */
|
|
23
|
+
export function memoryStore(): Store;
|
|
24
|
+
|
|
25
|
+
/** Called by compiled components; not for direct use. */
|
|
26
|
+
export function collection(
|
|
27
|
+
name: string,
|
|
28
|
+
keys: string[],
|
|
29
|
+
props: Record<string, unknown>,
|
|
30
|
+
fields: Record<string, { get(): unknown; set(value: unknown): void }>,
|
|
31
|
+
presence?: Record<string, { get(): unknown; set(value: unknown): void }> | null,
|
|
32
|
+
peers?: { get(): unknown; set(value: unknown): void } | null,
|
|
33
|
+
): void;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// @zubra/sync: a component's state as a document in a local store.
|
|
2
|
+
//
|
|
3
|
+
// <script>
|
|
4
|
+
// 'use collection tasks';
|
|
5
|
+
// import { ephemeral } from '@zubra/sync';
|
|
6
|
+
// let completed = false; // stored: a field of the document
|
|
7
|
+
// let editing = ephemeral(false); // not stored
|
|
8
|
+
// </script>
|
|
9
|
+
// <task-item doc="">…</task-item> <!-- <task-item doc="t42"> is the document tasks/t42 -->
|
|
10
|
+
//
|
|
11
|
+
// The compiler turns the directive into one call, `collection("tasks", ["doc"], $p, { completed })`,
|
|
12
|
+
// at the end of the component's setup. This module does the rest, with the platform's pieces only:
|
|
13
|
+
//
|
|
14
|
+
// - a write is a signal assignment, so the UI never waits; the store catches up behind it;
|
|
15
|
+
// - the store is IndexedDB (in memory where there's none), so state survives reloads, offline;
|
|
16
|
+
// - other elements showing the same document, in this tab or another (BroadcastChannel), follow.
|
|
17
|
+
//
|
|
18
|
+
// Tier 0 of plan/sync-design.md: one device, no server. Merging is last-writer-wins per field.
|
|
19
|
+
import { effect, onUnmount, Signal } from '@zubra/runtime';
|
|
20
|
+
|
|
21
|
+
/** `let editing = ephemeral(false)`: state the collection doesn't store. */
|
|
22
|
+
export const ephemeral = (value) => value;
|
|
23
|
+
/**
|
|
24
|
+
* `let cursor = presence({ x: 0, y: 0 })`: state shared live with the document's other viewers
|
|
25
|
+
* (other tabs; other devices when connected), never stored. The compiler passes it on.
|
|
26
|
+
*/
|
|
27
|
+
export const presence = (value) => value;
|
|
28
|
+
/** `let others = peers()`: the document's other viewers' presence, `[{ peer, ...state }]`. */
|
|
29
|
+
export const peers = () => [];
|
|
30
|
+
|
|
31
|
+
let store = null;
|
|
32
|
+
/** Use another store (`{ get(collection, key), set(collection, key, fields) }`), before elements start. */
|
|
33
|
+
export function configure(options = {}) {
|
|
34
|
+
if (options.store) store = options.store;
|
|
35
|
+
}
|
|
36
|
+
const theStore = () => (store ??= typeof indexedDB === 'undefined' ? memoryStore() : indexedDBStore());
|
|
37
|
+
|
|
38
|
+
// What a connection (connect.js) listens to: every local save, with the fields it changed.
|
|
39
|
+
const writers = new Set();
|
|
40
|
+
/** @internal Called with `(collection, key, fields)` on each local save. */
|
|
41
|
+
export const onWrite = (fn) => (writers.add(fn), () => writers.delete(fn));
|
|
42
|
+
/** @internal A change from elsewhere: store it, and show it in this tab and the others. */
|
|
43
|
+
export async function receive(collection, key, fields) {
|
|
44
|
+
await theStore().set(collection, key, fields);
|
|
45
|
+
deliver(collection, key, fields, null);
|
|
46
|
+
channel?.postMessage({ collection, key, fields });
|
|
47
|
+
}
|
|
48
|
+
/** @internal The store in use. */
|
|
49
|
+
export const currentStore = () => theStore();
|
|
50
|
+
|
|
51
|
+
// Every bound element, by document, so a write reaches the others (this tab and, below, others).
|
|
52
|
+
const bound = new Map();
|
|
53
|
+
const docId = (collection, key) => `${collection}\u0000${key}`;
|
|
54
|
+
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('zubra-sync');
|
|
55
|
+
channel?.addEventListener('message', ({ data }) =>
|
|
56
|
+
data.kind === 'presence' ? receivePresence(data) : deliver(data.collection, data.key, data.fields, null));
|
|
57
|
+
|
|
58
|
+
// Presence: each tab is one peer per document ("room"). What it sends goes to the other tabs
|
|
59
|
+
// (BroadcastChannel) and, through a connection, to other devices; nothing is stored.
|
|
60
|
+
const tab = Math.random().toString(36).slice(2, 10);
|
|
61
|
+
const rooms = new Map(); // room → Set of (peer, state) handlers
|
|
62
|
+
const presenceSenders = new Set();
|
|
63
|
+
const joined = new Set(); // rooms this tab has presence in, to leave on pagehide
|
|
64
|
+
/** @internal Called with `{ room, peer, state }` for each presence this tab sends (state null: left). */
|
|
65
|
+
export const onPresence = (fn) => (presenceSenders.add(fn), () => presenceSenders.delete(fn));
|
|
66
|
+
/** @internal This tab's peer id. */
|
|
67
|
+
export const peerId = () => tab;
|
|
68
|
+
/** @internal Presence from elsewhere. */
|
|
69
|
+
export function receivePresence({ room, peer, state }) {
|
|
70
|
+
if (peer === tab) return;
|
|
71
|
+
for (const handler of rooms.get(room) ?? []) handler(peer, state);
|
|
72
|
+
}
|
|
73
|
+
function sendPresence(room, state) {
|
|
74
|
+
const message = { room, peer: tab, state };
|
|
75
|
+
if (state === null) joined.delete(room);
|
|
76
|
+
else joined.add(room);
|
|
77
|
+
channel?.postMessage({ kind: 'presence', ...message });
|
|
78
|
+
for (const send of presenceSenders) send(message);
|
|
79
|
+
}
|
|
80
|
+
if (typeof addEventListener === 'function') {
|
|
81
|
+
addEventListener('pagehide', () => {
|
|
82
|
+
for (const room of [...joined]) sendPresence(room, null);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function deliver(collection, key, fields, from) {
|
|
86
|
+
for (const apply of bound.get(docId(collection, key)) ?? []) if (apply !== from) apply(fields);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Plain JSON of a state value; reading it all also subscribes to every nested change. */
|
|
90
|
+
const snapshot = (value) => (value === undefined ? null : JSON.parse(JSON.stringify(value)));
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Bind `fields` (the component's state signals, by name) to the document keyed by the `keys`
|
|
94
|
+
* props in `collection`. Called by compiled components.
|
|
95
|
+
*/
|
|
96
|
+
export function collection(name, keys, props, fields, presenceFields = null, peersSignal = null) {
|
|
97
|
+
const keyOf = () => {
|
|
98
|
+
const parts = keys.map((key) => props[key]);
|
|
99
|
+
return parts.every((part) => part !== undefined && part !== null && part !== '') ? parts.join('/') : null;
|
|
100
|
+
};
|
|
101
|
+
const loaded = new Signal.State(null);
|
|
102
|
+
let current = null;
|
|
103
|
+
let last = null; // the JSON last loaded or saved, so a load isn't written back
|
|
104
|
+
let applying = false;
|
|
105
|
+
|
|
106
|
+
const apply = (doc) => {
|
|
107
|
+
if (!doc) return;
|
|
108
|
+
applying = true;
|
|
109
|
+
try {
|
|
110
|
+
for (const [field, signal] of Object.entries(fields)) if (field in doc) signal.set(doc[field]);
|
|
111
|
+
} finally {
|
|
112
|
+
applying = false;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/** Each field's current value, as JSON text. */
|
|
116
|
+
const values = () => Object.fromEntries(Object.entries(fields).map(([field, signal]) => [field, JSON.stringify(snapshot(signal.get()))]));
|
|
117
|
+
const onRemote = (doc) => {
|
|
118
|
+
apply(doc);
|
|
119
|
+
last = JSON.stringify(Object.fromEntries(Object.keys(fields).map((field) => [field, snapshot(fields[field].get())])));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const join = (key) => {
|
|
123
|
+
const id = docId(name, key);
|
|
124
|
+
if (!bound.has(id)) bound.set(id, new Set());
|
|
125
|
+
bound.get(id).add(onRemote);
|
|
126
|
+
};
|
|
127
|
+
const leave = (key) => {
|
|
128
|
+
const id = docId(name, key);
|
|
129
|
+
bound.get(id)?.delete(onRemote);
|
|
130
|
+
if (bound.get(id)?.size === 0) bound.delete(id);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Load the document whenever the key changes.
|
|
134
|
+
effect(() => {
|
|
135
|
+
const key = keyOf();
|
|
136
|
+
if (current !== null) leave(current);
|
|
137
|
+
current = key;
|
|
138
|
+
if (key === null) return;
|
|
139
|
+
join(key);
|
|
140
|
+
let cancelled = false;
|
|
141
|
+
// What the fields hold as loading starts, to tell what someone changed while it loaded.
|
|
142
|
+
const before = Signal.subtle.untrack(values);
|
|
143
|
+
theStore()
|
|
144
|
+
.get(name, key)
|
|
145
|
+
.then((doc = {}) => {
|
|
146
|
+
if (cancelled) return;
|
|
147
|
+
// An edit made before the document arrived is newer than it: keep the edit (and save
|
|
148
|
+
// it, below); take the document's values for the rest.
|
|
149
|
+
const now = values();
|
|
150
|
+
const edited = new Set(Object.keys(fields).filter((field) => now[field] !== before[field]));
|
|
151
|
+
apply(Object.fromEntries(Object.entries(doc).filter(([field]) => !edited.has(field))));
|
|
152
|
+
// `last` is what's stored, so the save effect writes exactly the edits.
|
|
153
|
+
last = JSON.stringify(Object.fromEntries(Object.keys(fields).map((field) => [field, field in doc ? snapshot(doc[field]) : JSON.parse(before[field])])));
|
|
154
|
+
loaded.set(key);
|
|
155
|
+
})
|
|
156
|
+
.catch((error) => console.error(`@zubra/sync: couldn't load ${name}/${key}`, error));
|
|
157
|
+
return () => (cancelled = true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Save on every change, once the document is loaded (so the defaults never overwrite it).
|
|
161
|
+
effect(() => {
|
|
162
|
+
const key = keyOf();
|
|
163
|
+
const doc = Object.fromEntries(Object.entries(fields).map(([field, signal]) => [field, snapshot(signal.get())]));
|
|
164
|
+
if (key === null || loaded.get() !== key || applying) return;
|
|
165
|
+
const text = JSON.stringify(doc);
|
|
166
|
+
if (text === last) return;
|
|
167
|
+
const before = last === null ? {} : JSON.parse(last);
|
|
168
|
+
last = text;
|
|
169
|
+
const changed = Object.fromEntries(Object.entries(doc).filter(([field, value]) => JSON.stringify(value) !== JSON.stringify(before[field])));
|
|
170
|
+
for (const write of writers) write(name, key, changed);
|
|
171
|
+
theStore()
|
|
172
|
+
.set(name, key, doc)
|
|
173
|
+
.catch((error) => console.error(`@zubra/sync: couldn't save ${name}/${key}`, error));
|
|
174
|
+
deliver(name, key, doc, onRemote);
|
|
175
|
+
channel?.postMessage({ collection: name, key, fields: doc });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
onUnmount(() => current !== null && leave(current));
|
|
179
|
+
if (presenceFields || peersSignal) live(name, keyOf, presenceFields ?? {}, peersSignal);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const PRUNE = 30_000; // a peer not heard from for this long has gone (a crash, a closed laptop)
|
|
183
|
+
const HEARTBEAT = 10_000;
|
|
184
|
+
|
|
185
|
+
/** Send this element's presence to its document's room, and keep `peers` up to date. */
|
|
186
|
+
function live(name, keyOf, fields, peersSignal) {
|
|
187
|
+
const others = new Map(); // peer → { state, seen }
|
|
188
|
+
const publish = () =>
|
|
189
|
+
peersSignal?.set([...others.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([peer, { state }]) => ({ ...state, peer })));
|
|
190
|
+
const sending = Object.keys(fields).length > 0;
|
|
191
|
+
const mine = () => Object.fromEntries(Object.entries(fields).map(([field, signal]) => [field, snapshot(signal.get())]));
|
|
192
|
+
let room = null;
|
|
193
|
+
let latest = null;
|
|
194
|
+
let queued = false;
|
|
195
|
+
const flush = () => {
|
|
196
|
+
queued = false;
|
|
197
|
+
if (room !== null && latest !== null) sendPresence(room, latest);
|
|
198
|
+
};
|
|
199
|
+
const queue = () => {
|
|
200
|
+
if (queued) return;
|
|
201
|
+
queued = true;
|
|
202
|
+
// At most once a frame: a pointer moves far more often than anyone can see.
|
|
203
|
+
(typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 16))(flush);
|
|
204
|
+
};
|
|
205
|
+
const onPeer = (peer, state) => {
|
|
206
|
+
const known = others.has(peer);
|
|
207
|
+
if (state === null) others.delete(peer);
|
|
208
|
+
else others.set(peer, { state, seen: Date.now() });
|
|
209
|
+
publish();
|
|
210
|
+
// Someone new: tell them where we are, so they don't wait for our next move.
|
|
211
|
+
if (!known && state !== null && sending) queue();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
effect(() => {
|
|
215
|
+
const key = keyOf();
|
|
216
|
+
const next = key === null ? null : `${name}/${key}`;
|
|
217
|
+
const state = sending ? mine() : null;
|
|
218
|
+
if (next !== room) {
|
|
219
|
+
if (room !== null) {
|
|
220
|
+
rooms.get(room)?.delete(onPeer);
|
|
221
|
+
if (sending) sendPresence(room, null);
|
|
222
|
+
others.clear();
|
|
223
|
+
publish();
|
|
224
|
+
}
|
|
225
|
+
room = next;
|
|
226
|
+
if (room !== null) {
|
|
227
|
+
if (!rooms.has(room)) rooms.set(room, new Set());
|
|
228
|
+
rooms.get(room).add(onPeer);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (room !== null && sending) {
|
|
232
|
+
latest = state;
|
|
233
|
+
queue();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const timer = setInterval(() => {
|
|
238
|
+
if (sending && room !== null && latest !== null) sendPresence(room, latest);
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
let pruned = false;
|
|
241
|
+
for (const [peer, { seen }] of others) if (now - seen > PRUNE) pruned = others.delete(peer) || pruned;
|
|
242
|
+
if (pruned) publish();
|
|
243
|
+
}, HEARTBEAT);
|
|
244
|
+
|
|
245
|
+
onUnmount(() => {
|
|
246
|
+
clearInterval(timer);
|
|
247
|
+
if (room !== null) {
|
|
248
|
+
rooms.get(room)?.delete(onPeer);
|
|
249
|
+
if (sending) sendPresence(room, null);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A store in memory: for servers, tests, and browsers without IndexedDB. */
|
|
255
|
+
export function memoryStore() {
|
|
256
|
+
const docs = new Map();
|
|
257
|
+
return {
|
|
258
|
+
async get(collection, key) {
|
|
259
|
+
const doc = docs.get(docId(collection, key));
|
|
260
|
+
return doc ? structuredClone(doc) : undefined;
|
|
261
|
+
},
|
|
262
|
+
async set(collection, key, fields) {
|
|
263
|
+
const id = docId(collection, key);
|
|
264
|
+
docs.set(id, { ...docs.get(id), ...structuredClone(fields) });
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The default store: one IndexedDB database, one record per document, fields merged on write. */
|
|
270
|
+
export function indexedDBStore(database = 'zubra-sync') {
|
|
271
|
+
let opening = null;
|
|
272
|
+
const open = () =>
|
|
273
|
+
(opening ??= new Promise((resolve, reject) => {
|
|
274
|
+
const request = indexedDB.open(database, 1);
|
|
275
|
+
request.onupgradeneeded = () => request.result.createObjectStore('docs');
|
|
276
|
+
request.onsuccess = () => resolve(request.result);
|
|
277
|
+
request.onerror = () => reject(request.error);
|
|
278
|
+
}));
|
|
279
|
+
const run = async (mode, work) => {
|
|
280
|
+
const db = await open();
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
const transaction = db.transaction('docs', mode);
|
|
283
|
+
const docs = transaction.objectStore('docs');
|
|
284
|
+
let result;
|
|
285
|
+
work(docs, (value) => (result = value));
|
|
286
|
+
transaction.oncomplete = () => resolve(result);
|
|
287
|
+
transaction.onerror = () => reject(transaction.error);
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
return {
|
|
291
|
+
get: (collection, key) =>
|
|
292
|
+
run('readonly', (docs, done) => {
|
|
293
|
+
const request = docs.get([collection, key]);
|
|
294
|
+
request.onsuccess = () => done(request.result);
|
|
295
|
+
}),
|
|
296
|
+
set: (collection, key, fields) =>
|
|
297
|
+
run('readwrite', (docs) => {
|
|
298
|
+
const request = docs.get([collection, key]);
|
|
299
|
+
request.onsuccess = () => docs.put({ ...request.result, ...fields }, [collection, key]);
|
|
300
|
+
}),
|
|
301
|
+
};
|
|
302
|
+
}
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface Change {
|
|
2
|
+
collection: string;
|
|
3
|
+
key: string;
|
|
4
|
+
field: string;
|
|
5
|
+
value: unknown;
|
|
6
|
+
/** A hybrid logical clock: `<15-digit ms>-<5-digit counter>-<client id>`. */
|
|
7
|
+
clock: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Log {
|
|
11
|
+
append(changes: Change[]): Promise<unknown>;
|
|
12
|
+
after(cursor: string, limit?: number): Promise<{ changes: Change[]; cursor: number | string }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function memoryLog(): Log;
|
|
16
|
+
|
|
17
|
+
export function createSyncServer(options?: {
|
|
18
|
+
log?: Log;
|
|
19
|
+
/** Default `/_zb/sync`. */
|
|
20
|
+
mount?: string;
|
|
21
|
+
/** Refuse a write, or hide a change from a reader, by returning false. */
|
|
22
|
+
authorize?: (from: { request: Request; collection: string; key: string; write: boolean }) => boolean | Promise<boolean>;
|
|
23
|
+
/** Other origins allowed to push. */
|
|
24
|
+
origins?: string[];
|
|
25
|
+
/** How often the live stream sends a keep-alive comment (ms, default 15000). */
|
|
26
|
+
heartbeat?: number;
|
|
27
|
+
}): {
|
|
28
|
+
fetch(request: Request): Promise<Response | null>;
|
|
29
|
+
/** Node's http: answers sync requests (streaming the live ones) and resolves true, else false. */
|
|
30
|
+
handle(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): Promise<boolean>;
|
|
31
|
+
};
|
package/src/server.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The log that 'use collection' devices share (tier 1). A fetch handler, so it runs on Node
|
|
2
|
+
// (through `node(req, res)`), Bun, Deno and workers; the log itself is pluggable, and in memory
|
|
3
|
+
// by default. It keeps changes in order and hands them out after a cursor; it doesn't merge:
|
|
4
|
+
// every device applies the same last-writer-wins rule to the same changes, so they converge.
|
|
5
|
+
//
|
|
6
|
+
// import { createSyncServer } from '@zubra/sync/server';
|
|
7
|
+
// const sync = createSyncServer();
|
|
8
|
+
// const response = await sync.fetch(request); // a Response, or null if not ours
|
|
9
|
+
//
|
|
10
|
+
// `authorize({ request, collection, key, write })` may refuse a change or hide one (return false).
|
|
11
|
+
//
|
|
12
|
+
// Live: `GET <mount>/live?since=<cursor>&peer=<id>` is a server-sent event stream: `changes` as
|
|
13
|
+
// they're pushed, and `presence` from the other viewers (POST <mount>/presence). Subscribers are
|
|
14
|
+
// this process's; several server processes need a shared log and a pub/sub between them.
|
|
15
|
+
//
|
|
16
|
+
// On Node, `handle(req, res)` answers sync requests (streaming the live ones) and returns true.
|
|
17
|
+
const MOUNT = '/_zb/sync';
|
|
18
|
+
|
|
19
|
+
/** A log in memory: `append(changes)` and `after(cursor)`. */
|
|
20
|
+
export function memoryLog() {
|
|
21
|
+
const changes = [];
|
|
22
|
+
return {
|
|
23
|
+
async append(more) {
|
|
24
|
+
for (const change of more) changes.push(change);
|
|
25
|
+
return changes.length;
|
|
26
|
+
},
|
|
27
|
+
async after(cursor, limit = 1000) {
|
|
28
|
+
const from = Number(cursor) || 0;
|
|
29
|
+
return { changes: changes.slice(from, from + limit), cursor: Math.min(changes.length, from + limit) };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const json = (status, value) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } });
|
|
35
|
+
|
|
36
|
+
const valid = (change) =>
|
|
37
|
+
change && typeof change.collection === 'string' && typeof change.key === 'string' && typeof change.field === 'string' &&
|
|
38
|
+
typeof change.clock === 'string' && /^\d{15}-\d{5}-[\w-]+$/.test(change.clock) && change.field !== '$clocks';
|
|
39
|
+
|
|
40
|
+
export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize = () => true, origins = [], heartbeat = 15_000 } = {}) {
|
|
41
|
+
const base = mount.replace(/\/$/, '');
|
|
42
|
+
// Live subscribers: { peer, request, send(event, data), rooms }.
|
|
43
|
+
const listeners = new Set();
|
|
44
|
+
const encoder = new TextEncoder();
|
|
45
|
+
const roomOf = (room) => {
|
|
46
|
+
const at = room.indexOf('/');
|
|
47
|
+
return at > 0 ? { collection: room.slice(0, at), key: room.slice(at + 1) } : null;
|
|
48
|
+
};
|
|
49
|
+
async function broadcastChanges(changes, cursor) {
|
|
50
|
+
for (const listener of listeners) {
|
|
51
|
+
const visible = [];
|
|
52
|
+
for (const change of changes) {
|
|
53
|
+
if (await authorize({ request: listener.request, collection: change.collection, key: change.key, write: false })) visible.push(change);
|
|
54
|
+
}
|
|
55
|
+
listener.send('changes', { changes: visible, cursor });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function broadcastPresence(message) {
|
|
59
|
+
const { collection, key } = roomOf(message.room);
|
|
60
|
+
for (const listener of listeners) {
|
|
61
|
+
if (listener.peer === message.peer) continue;
|
|
62
|
+
if (await authorize({ request: listener.request, collection, key, write: false })) listener.send('presence', message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const sameOrigin = (request) => {
|
|
66
|
+
const site = request.headers.get('sec-fetch-site');
|
|
67
|
+
if (site) return site === 'same-origin' || site === 'none';
|
|
68
|
+
const origin = request.headers.get('origin');
|
|
69
|
+
return !origin || origin === new URL(request.url).origin || origins.includes(origin);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
async function fetch(request) {
|
|
73
|
+
const url = new URL(request.url);
|
|
74
|
+
if (url.pathname === `${base}/push` && request.method === 'POST') {
|
|
75
|
+
if (!sameOrigin(request)) return json(403, { error: 'Cross-site writes are refused' });
|
|
76
|
+
let body;
|
|
77
|
+
try {
|
|
78
|
+
body = await request.json();
|
|
79
|
+
} catch {
|
|
80
|
+
return json(400, { error: 'Expected JSON' });
|
|
81
|
+
}
|
|
82
|
+
const changes = Array.isArray(body?.changes) ? body.changes : null;
|
|
83
|
+
if (!changes || !changes.every(valid)) return json(400, { error: 'Expected { changes: [{ collection, key, field, value, clock }] }' });
|
|
84
|
+
for (const change of changes) {
|
|
85
|
+
if (!(await authorize({ request, collection: change.collection, key: change.key, write: true }))) return json(403, { error: 'Not yours to change' });
|
|
86
|
+
}
|
|
87
|
+
const appended = changes.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value, clock }));
|
|
88
|
+
const cursor = await log.append(appended);
|
|
89
|
+
await broadcastChanges(appended, cursor);
|
|
90
|
+
return json(200, { ok: true });
|
|
91
|
+
}
|
|
92
|
+
if (url.pathname === `${base}/presence` && request.method === 'POST') {
|
|
93
|
+
if (!sameOrigin(request)) return json(403, { error: 'Cross-site writes are refused' });
|
|
94
|
+
let message;
|
|
95
|
+
try {
|
|
96
|
+
message = await request.json();
|
|
97
|
+
} catch {
|
|
98
|
+
return json(400, { error: 'Expected JSON' });
|
|
99
|
+
}
|
|
100
|
+
const room = typeof message?.room === 'string' ? roomOf(message.room) : null;
|
|
101
|
+
const okPeer = typeof message?.peer === 'string' && /^[\w-]{1,40}$/.test(message.peer);
|
|
102
|
+
const okState = message?.state === null || (typeof message?.state === 'object' && !Array.isArray(message.state));
|
|
103
|
+
if (!room || !okPeer || !okState || JSON.stringify(message.state).length > 4096) {
|
|
104
|
+
return json(400, { error: 'Expected { room: "<collection>/<key>", peer, state: {…} | null }' });
|
|
105
|
+
}
|
|
106
|
+
if (!(await authorize({ request, ...room, write: true }))) return json(403, { error: 'Not yours to join' });
|
|
107
|
+
const clean = { room: message.room, peer: message.peer, state: message.state };
|
|
108
|
+
for (const listener of listeners) if (listener.peer === clean.peer) {
|
|
109
|
+
if (clean.state === null) listener.rooms.delete(clean.room);
|
|
110
|
+
else listener.rooms.add(clean.room);
|
|
111
|
+
}
|
|
112
|
+
await broadcastPresence(clean);
|
|
113
|
+
return json(200, { ok: true });
|
|
114
|
+
}
|
|
115
|
+
if (url.pathname === `${base}/signal` && request.method === 'POST') {
|
|
116
|
+
// WebRTC signaling: an offer, answer or ICE candidate for one peer in a room, delivered on
|
|
117
|
+
// that peer's live stream. The server only passes it on.
|
|
118
|
+
if (!sameOrigin(request)) return json(403, { error: 'Cross-site writes are refused' });
|
|
119
|
+
let message;
|
|
120
|
+
try {
|
|
121
|
+
message = await request.json();
|
|
122
|
+
} catch {
|
|
123
|
+
return json(400, { error: 'Expected JSON' });
|
|
124
|
+
}
|
|
125
|
+
const room = typeof message?.room === 'string' ? roomOf(message.room) : null;
|
|
126
|
+
const peerOk = (peer) => typeof peer === 'string' && /^[\w-]{1,40}$/.test(peer);
|
|
127
|
+
const dataOk = message?.data && typeof message.data === 'object' && !Array.isArray(message.data) && JSON.stringify(message.data).length <= 16384;
|
|
128
|
+
if (!room || !peerOk(message.from) || !peerOk(message.to) || !dataOk) {
|
|
129
|
+
return json(400, { error: 'Expected { room, from, to, data: {…} }' });
|
|
130
|
+
}
|
|
131
|
+
if (!(await authorize({ request, ...room, write: true }))) return json(403, { error: 'Not yours to join' });
|
|
132
|
+
const clean = { room: message.room, from: message.from, to: message.to, data: message.data };
|
|
133
|
+
for (const listener of listeners) if (listener.peer === clean.to) listener.send('signal', clean);
|
|
134
|
+
return json(200, { ok: true });
|
|
135
|
+
}
|
|
136
|
+
if (url.pathname === `${base}/live` && request.method === 'GET') {
|
|
137
|
+
const peer = url.searchParams.get('peer') ?? '';
|
|
138
|
+
if (!/^[\w-]{0,40}$/.test(peer)) return json(400, { error: 'Bad peer id' });
|
|
139
|
+
const since = url.searchParams.get('since') ?? '0';
|
|
140
|
+
let listener;
|
|
141
|
+
let timer;
|
|
142
|
+
const stream = new ReadableStream({
|
|
143
|
+
start: async (controller) => {
|
|
144
|
+
const write = (text) => {
|
|
145
|
+
try {
|
|
146
|
+
controller.enqueue(encoder.encode(text));
|
|
147
|
+
} catch {
|
|
148
|
+
close();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const close = () => {
|
|
152
|
+
if (!listeners.delete(listener)) return;
|
|
153
|
+
clearInterval(timer);
|
|
154
|
+
// A viewer who disconnects leaves every room they were in.
|
|
155
|
+
for (const room of listener.rooms) broadcastPresence({ room, peer, state: null });
|
|
156
|
+
try { controller.close(); } catch {}
|
|
157
|
+
};
|
|
158
|
+
listener = { peer, request, rooms: new Set(), send: (event, data) => write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), close };
|
|
159
|
+
listeners.add(listener);
|
|
160
|
+
request.signal?.addEventListener('abort', close);
|
|
161
|
+
write('retry: 1000\n\n');
|
|
162
|
+
// Read the backlog after subscribing, so nothing pushed in between is missed (a change
|
|
163
|
+
// may come twice; applying one twice changes nothing).
|
|
164
|
+
// Page by page: a log longer than one page is sent whole before anything live.
|
|
165
|
+
// (One event even when there's nothing, so the client learns the cursor; no empty event
|
|
166
|
+
// after the last full page.)
|
|
167
|
+
let from = since;
|
|
168
|
+
for (let first = true; ; first = false) {
|
|
169
|
+
const backlog = await log.after(from);
|
|
170
|
+
if (!backlog.changes.length && !first) break;
|
|
171
|
+
const visible = [];
|
|
172
|
+
for (const change of backlog.changes) {
|
|
173
|
+
if (await authorize({ request, collection: change.collection, key: change.key, write: false })) visible.push(change);
|
|
174
|
+
}
|
|
175
|
+
listener.send('changes', { changes: visible, cursor: backlog.cursor });
|
|
176
|
+
if (!backlog.changes.length || String(backlog.cursor) === String(from)) break;
|
|
177
|
+
from = backlog.cursor;
|
|
178
|
+
}
|
|
179
|
+
timer = setInterval(() => write(': keep-alive\n\n'), heartbeat);
|
|
180
|
+
timer.unref?.();
|
|
181
|
+
},
|
|
182
|
+
cancel: () => listener?.close(),
|
|
183
|
+
});
|
|
184
|
+
return new Response(stream, {
|
|
185
|
+
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (url.pathname === `${base}/pull` && request.method === 'GET') {
|
|
189
|
+
const { changes, cursor } = await log.after(url.searchParams.get('since') ?? '0');
|
|
190
|
+
const visible = [];
|
|
191
|
+
for (const change of changes) {
|
|
192
|
+
if (await authorize({ request, collection: change.collection, key: change.key, write: false })) visible.push(change);
|
|
193
|
+
}
|
|
194
|
+
return json(200, { changes: visible, cursor });
|
|
195
|
+
}
|
|
196
|
+
if (url.pathname.startsWith(`${base}/`)) return json(404, { error: 'Not found' });
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Node's http: answers sync requests (streaming the live ones) and returns true, else false. */
|
|
201
|
+
async function handle(req, res) {
|
|
202
|
+
if (!req.url.startsWith(`${base}/`)) return false;
|
|
203
|
+
const controller = new AbortController();
|
|
204
|
+
res.on('close', () => controller.abort());
|
|
205
|
+
const chunks = [];
|
|
206
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') for await (const chunk of req) chunks.push(chunk);
|
|
207
|
+
const request = new Request(`http://${req.headers.host ?? 'localhost'}${req.url}`, {
|
|
208
|
+
method: req.method,
|
|
209
|
+
headers: Object.entries(req.headers).flatMap(([name, value]) => (Array.isArray(value) ? value.map((each) => [name, each]) : [[name, value]])),
|
|
210
|
+
body: chunks.length ? Buffer.concat(chunks) : undefined,
|
|
211
|
+
signal: controller.signal,
|
|
212
|
+
});
|
|
213
|
+
const response = await fetch(request);
|
|
214
|
+
if (!response) return false;
|
|
215
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
216
|
+
res.flushHeaders?.();
|
|
217
|
+
if (!response.body) return res.end(), true;
|
|
218
|
+
const reader = response.body.getReader();
|
|
219
|
+
controller.signal.addEventListener('abort', () => reader.cancel().catch(() => {}));
|
|
220
|
+
try {
|
|
221
|
+
for (;;) {
|
|
222
|
+
const { done, value } = await reader.read();
|
|
223
|
+
if (done) break;
|
|
224
|
+
res.write(value);
|
|
225
|
+
}
|
|
226
|
+
} catch {}
|
|
227
|
+
res.end();
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { fetch, handle };
|
|
232
|
+
}
|
package/src/sqlite.d.ts
ADDED
package/src/sqlite.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A sync log that survives a restart: one SQLite table, through the runtime's own SQLite (Node's
|
|
2
|
+
// `node:sqlite`, Bun's `bun:sqlite`), so there's nothing to install.
|
|
3
|
+
//
|
|
4
|
+
// import { createSyncServer } from '@zubra/sync/server';
|
|
5
|
+
// import { sqliteLog } from '@zubra/sync/sqlite';
|
|
6
|
+
// const sync = createSyncServer({ log: await sqliteLog('sync.db') });
|
|
7
|
+
//
|
|
8
|
+
// The cursor is the row id. Rows are only ever appended.
|
|
9
|
+
export async function sqliteLog(path = ':memory:') {
|
|
10
|
+
const db = await open(path);
|
|
11
|
+
db.exec(`CREATE TABLE IF NOT EXISTS changes (
|
|
12
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
collection TEXT NOT NULL,
|
|
14
|
+
key TEXT NOT NULL,
|
|
15
|
+
field TEXT NOT NULL,
|
|
16
|
+
value TEXT,
|
|
17
|
+
clock TEXT NOT NULL
|
|
18
|
+
)`);
|
|
19
|
+
const insert = db.prepare('INSERT INTO changes (collection, key, field, value, clock) VALUES (?, ?, ?, ?, ?)');
|
|
20
|
+
const select = db.prepare('SELECT seq, collection, key, field, value, clock FROM changes WHERE seq > ? ORDER BY seq LIMIT ?');
|
|
21
|
+
const last = db.prepare('SELECT COALESCE(MAX(seq), 0) AS seq FROM changes');
|
|
22
|
+
return {
|
|
23
|
+
async append(changes) {
|
|
24
|
+
db.exec('BEGIN');
|
|
25
|
+
try {
|
|
26
|
+
for (const { collection, key, field, value, clock } of changes) {
|
|
27
|
+
insert.run(collection, key, field, JSON.stringify(value ?? null), clock);
|
|
28
|
+
}
|
|
29
|
+
db.exec('COMMIT');
|
|
30
|
+
} catch (error) {
|
|
31
|
+
db.exec('ROLLBACK');
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
return Number(last.get().seq);
|
|
35
|
+
},
|
|
36
|
+
async after(cursor, limit = 1000) {
|
|
37
|
+
const rows = select.all(Number(cursor) || 0, limit);
|
|
38
|
+
const changes = rows.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value: JSON.parse(value), clock }));
|
|
39
|
+
const cursorNow = rows.length ? Number(rows[rows.length - 1].seq) : Number(cursor) || 0;
|
|
40
|
+
return { changes, cursor: cursorNow };
|
|
41
|
+
},
|
|
42
|
+
close: () => db.close(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function open(path) {
|
|
47
|
+
if (typeof globalThis.Bun !== 'undefined') {
|
|
48
|
+
const { Database } = await import('bun:sqlite');
|
|
49
|
+
return new Database(path);
|
|
50
|
+
}
|
|
51
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
52
|
+
return new DatabaseSync(path);
|
|
53
|
+
}
|
package/src/webrtc.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Presence device to device: once two viewers of a room have found each other through the
|
|
2
|
+
// server, they open a WebRTC data channel and send presence over it directly. On one network the
|
|
3
|
+
// traffic stays there; in any case the server stops carrying every pointer move. It still carries
|
|
4
|
+
// what a channel can't: introductions (a newcomer's first presence), goodbyes, the signaling to
|
|
5
|
+
// open a channel, and presence to anyone without one. Documents never go this way; their changes
|
|
6
|
+
// go through the log, which is what makes them durable.
|
|
7
|
+
//
|
|
8
|
+
// Turned on by connect({ webrtc: true }) or connect({ webrtc: { iceServers } }). Peers on one
|
|
9
|
+
// network need no ICE servers; across networks, give STUN, and TURN for pairs that can't connect
|
|
10
|
+
// directly (the app provides it; see docs/sync.md).
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `signal(room, to, data)` sends signaling through the server. Returns the transport's half:
|
|
14
|
+
* `{ receiveSignal, sawPeer, lostPeer, send(message) → true if every peer in the room got it
|
|
15
|
+
* directly, close }`. `deliver(message)` gets presence that arrives over a channel.
|
|
16
|
+
*/
|
|
17
|
+
const RETRY = 4000;
|
|
18
|
+
|
|
19
|
+
export function mesh({ self, signal, deliver, iceServers = [] }) {
|
|
20
|
+
const peers = new Map(); // peer → { connection, channel, rooms: Set }
|
|
21
|
+
const rooms = new Map(); // room → Set of peers seen in it
|
|
22
|
+
|
|
23
|
+
const open = (peer) => peers.get(peer)?.channel?.readyState === 'open';
|
|
24
|
+
|
|
25
|
+
function link(peer, room, fresh = false) {
|
|
26
|
+
let entry = peers.get(peer);
|
|
27
|
+
// A channel that hasn't opened in a while won't: a signal went missing (the other side's
|
|
28
|
+
// stream was reconnecting) or ICE failed. Start again; the next presence through the server
|
|
29
|
+
// brings us here, on either side.
|
|
30
|
+
if (entry && fresh && !open(peer) && Date.now() - entry.started > RETRY) {
|
|
31
|
+
drop(peer);
|
|
32
|
+
entry = null;
|
|
33
|
+
}
|
|
34
|
+
if (entry) {
|
|
35
|
+
entry.rooms.add(room);
|
|
36
|
+
return entry;
|
|
37
|
+
}
|
|
38
|
+
const connection = new RTCPeerConnection({ iceServers });
|
|
39
|
+
entry = { connection, channel: null, rooms: new Set([room]), pending: [], started: Date.now() };
|
|
40
|
+
peers.set(peer, entry);
|
|
41
|
+
connection.addEventListener('icecandidate', ({ candidate }) => {
|
|
42
|
+
if (candidate) signal(room, peer, { candidate: candidate.toJSON() });
|
|
43
|
+
});
|
|
44
|
+
connection.addEventListener('connectionstatechange', () => {
|
|
45
|
+
if (['failed', 'closed'].includes(connection.connectionState)) drop(peer);
|
|
46
|
+
});
|
|
47
|
+
const attach = (channel) => {
|
|
48
|
+
entry.channel = channel;
|
|
49
|
+
channel.addEventListener('message', ({ data }) => {
|
|
50
|
+
try {
|
|
51
|
+
const message = JSON.parse(data);
|
|
52
|
+
// Only presence, only from the peer on this channel.
|
|
53
|
+
if (message && message.peer === peer && typeof message.room === 'string') deliver(message);
|
|
54
|
+
} catch {}
|
|
55
|
+
});
|
|
56
|
+
channel.addEventListener('close', () => drop(peer));
|
|
57
|
+
};
|
|
58
|
+
connection.addEventListener('datachannel', ({ channel }) => attach(channel));
|
|
59
|
+
// One side starts: the lower id. Both know both ids, so they agree without talking.
|
|
60
|
+
if (self < peer) {
|
|
61
|
+
attach(connection.createDataChannel('presence', { ordered: false, maxRetransmits: 0 }));
|
|
62
|
+
connection
|
|
63
|
+
.createOffer()
|
|
64
|
+
.then((offer) => connection.setLocalDescription(offer))
|
|
65
|
+
.then(() => signal(room, peer, { description: connection.localDescription.toJSON() }))
|
|
66
|
+
.catch(() => drop(peer));
|
|
67
|
+
}
|
|
68
|
+
return entry;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function drop(peer) {
|
|
72
|
+
const entry = peers.get(peer);
|
|
73
|
+
if (!entry) return;
|
|
74
|
+
peers.delete(peer);
|
|
75
|
+
try { entry.connection.close(); } catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function receiveSignal({ room, from, data }) {
|
|
79
|
+
if (from === self) return;
|
|
80
|
+
// A new offer means the other side started again: so do we.
|
|
81
|
+
if (data.description?.type === 'offer' && peers.get(from)?.connection.remoteDescription) drop(from);
|
|
82
|
+
const entry = link(from, room);
|
|
83
|
+
const { connection } = entry;
|
|
84
|
+
try {
|
|
85
|
+
if (data.description) {
|
|
86
|
+
await connection.setRemoteDescription(data.description);
|
|
87
|
+
for (const candidate of entry.pending.splice(0)) await connection.addIceCandidate(candidate);
|
|
88
|
+
if (data.description.type === 'offer') {
|
|
89
|
+
await connection.setLocalDescription(await connection.createAnswer());
|
|
90
|
+
signal(room, from, { description: connection.localDescription.toJSON() });
|
|
91
|
+
}
|
|
92
|
+
} else if (data.candidate) {
|
|
93
|
+
// Candidates can arrive before the description they belong to.
|
|
94
|
+
if (connection.remoteDescription) await connection.addIceCandidate(data.candidate);
|
|
95
|
+
else entry.pending.push(data.candidate);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
drop(from);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
receiveSignal,
|
|
104
|
+
/** A peer's presence arrived through the server: meet it, if we're both here. */
|
|
105
|
+
sawPeer(room, peer, state) {
|
|
106
|
+
if (peer === self) return;
|
|
107
|
+
if (!rooms.has(room)) rooms.set(room, new Set());
|
|
108
|
+
if (state === null) {
|
|
109
|
+
rooms.get(room).delete(peer);
|
|
110
|
+
const entry = peers.get(peer);
|
|
111
|
+
entry?.rooms.delete(room);
|
|
112
|
+
if (entry && entry.rooms.size === 0) drop(peer);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
rooms.get(room).add(peer);
|
|
116
|
+
link(peer, room, true);
|
|
117
|
+
},
|
|
118
|
+
/** Send presence over the open channels; true if that reached everyone known in the room. */
|
|
119
|
+
send(message) {
|
|
120
|
+
const known = rooms.get(message.room);
|
|
121
|
+
let everyone = Boolean(known && known.size);
|
|
122
|
+
for (const peer of known ?? []) {
|
|
123
|
+
if (open(peer)) {
|
|
124
|
+
try {
|
|
125
|
+
peers.get(peer).channel.send(JSON.stringify(message));
|
|
126
|
+
} catch {
|
|
127
|
+
everyone = false;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
everyone = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return everyone;
|
|
134
|
+
},
|
|
135
|
+
/** The peers there's an open channel to. */
|
|
136
|
+
direct: () => [...peers.keys()].filter(open),
|
|
137
|
+
close() {
|
|
138
|
+
for (const peer of [...peers.keys()]) drop(peer);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|