@zubra/sync 5.9.0 → 5.9.2
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 +1 -1
- package/package.json +1 -1
- package/src/connect.js +30 -5
- package/src/durable-core.js +4 -2
- package/src/durable.d.ts +1 -1
- package/src/server.d.ts +14 -2
- package/src/server.js +51 -10
package/README.md
CHANGED
|
@@ -12,4 +12,4 @@ Local-first state for Zubra components. `'use collection tasks'` at the top of a
|
|
|
12
12
|
<task-item doc="">…</task-item>
|
|
13
13
|
```
|
|
14
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
|
|
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({ authorize })` from `@zubra/sync/server` (without `authorize`, pages may read but not write); 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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/sync",
|
|
3
|
-
"version": "5.9.
|
|
3
|
+
"version": "5.9.2",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/connect.js
CHANGED
|
@@ -19,16 +19,31 @@ import { mesh } from './webrtc.js';
|
|
|
19
19
|
const CLOCKS = '$clocks';
|
|
20
20
|
const pad = (number, width) => String(number).padStart(width, '0');
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/** How far ahead of this device a clock it hears can be before it stops following it. */
|
|
23
|
+
const MAX_SKEW = 60_000;
|
|
24
|
+
|
|
25
|
+
/** A device's hybrid logical clock (exported for tests). */
|
|
26
|
+
export function deviceClock(client) {
|
|
23
27
|
let last = 0;
|
|
24
28
|
let counter = 0;
|
|
25
29
|
const tick = (seen = 0) => {
|
|
26
|
-
|
|
30
|
+
let now = Math.max(Date.now(), last, seen);
|
|
27
31
|
counter = now === last ? counter + 1 : 0;
|
|
32
|
+
// Five digits of counter; past them, the next millisecond (a clock must compare as a string).
|
|
33
|
+
if (counter > 99_999) {
|
|
34
|
+
now += 1;
|
|
35
|
+
counter = 0;
|
|
36
|
+
}
|
|
28
37
|
last = now;
|
|
29
38
|
return `${pad(now, 15)}-${pad(counter, 5)}-${client}`;
|
|
30
39
|
};
|
|
31
|
-
|
|
40
|
+
// A stamp far in the future (a device with a wrong clock, or one sent to poison the log) isn't
|
|
41
|
+
// followed: this device's writes keep its own time, and the server refuses such stamps anyway.
|
|
42
|
+
const see = (stamp) => {
|
|
43
|
+
const time = Number(stamp.slice(0, 15));
|
|
44
|
+
return tick(time > Date.now() + MAX_SKEW ? 0 : time);
|
|
45
|
+
};
|
|
46
|
+
return { tick, see };
|
|
32
47
|
}
|
|
33
48
|
|
|
34
49
|
/**
|
|
@@ -86,7 +101,7 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
86
101
|
keep('zubra-sync:client', id);
|
|
87
102
|
return id;
|
|
88
103
|
});
|
|
89
|
-
const clocks =
|
|
104
|
+
const clocks = deviceClock(client);
|
|
90
105
|
let cursor = remember(cursorName, () => '0');
|
|
91
106
|
const outbox = outboxOf(JSON.parse(remember('zubra-sync:outbox', () => '[]')), (pending) =>
|
|
92
107
|
keep('zubra-sync:outbox', JSON.stringify(pending)));
|
|
@@ -117,6 +132,7 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
117
132
|
});
|
|
118
133
|
|
|
119
134
|
let timer = null;
|
|
135
|
+
let warned = false;
|
|
120
136
|
const schedule = () => {
|
|
121
137
|
clearTimeout(timer);
|
|
122
138
|
timer = setTimeout(flush, 30);
|
|
@@ -131,7 +147,16 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
131
147
|
headers: { 'content-type': 'application/json' },
|
|
132
148
|
body: JSON.stringify({ changes: sending }),
|
|
133
149
|
});
|
|
134
|
-
if (!response.ok)
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
// A refused write is kept (it may be allowed later), but say why once: a server with no
|
|
152
|
+
// `authorize` refuses every write from pages, and a silent outbox looks like a bug.
|
|
153
|
+
if (response.status === 403 && !warned) {
|
|
154
|
+
warned = true;
|
|
155
|
+
const why = await response.json().then((body) => body?.error, () => '');
|
|
156
|
+
console.warn(`@zubra/sync: the server refused this page's changes${why ? `: ${why}` : ''}`);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
135
160
|
outbox.done(sending);
|
|
136
161
|
} catch {
|
|
137
162
|
// Offline: the outbox stays (in localStorage too), and goes with the next flush.
|
package/src/durable-core.js
CHANGED
|
@@ -7,7 +7,8 @@ import { durableObjectLog } from './durable-log.js';
|
|
|
7
7
|
export { MOUNT };
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* `owner` is the object: its `authorize(…)`, if it has one, decides as `createSyncServer`'s does
|
|
10
|
+
* `owner` is the object: its `authorize(…)`, if it has one, decides as `createSyncServer`'s does;
|
|
11
|
+
* without one, pages may read and not write.
|
|
11
12
|
* `heartbeat` is shorter than on Node (2 s, not 15): a Durable Object isn't told when a page's
|
|
12
13
|
* connection is cut, only that a keep-alive couldn't be written, which on workerd is the third
|
|
13
14
|
* after the cut. So a crashed page's presence lingers about three keep-alives (6 s). A page that
|
|
@@ -20,7 +21,8 @@ export function syncObject(ctx, owner, { mount = MOUNT, heartbeat = 2_000, devic
|
|
|
20
21
|
heartbeat,
|
|
21
22
|
device,
|
|
22
23
|
origins,
|
|
23
|
-
authorize
|
|
24
|
+
// Without an authorize of its own, the object takes reads only, as createSyncServer does.
|
|
25
|
+
authorize: typeof owner.authorize === 'function' ? (asked) => owner.authorize(asked) : undefined,
|
|
24
26
|
});
|
|
25
27
|
return {
|
|
26
28
|
async fetch(request) {
|
package/src/durable.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export class SyncObject<Env = unknown> {
|
|
|
24
24
|
protected ctx: any;
|
|
25
25
|
protected env: Env;
|
|
26
26
|
/** Refuse a write or hide a change; as `createSyncServer`'s. Allows everything if not defined. */
|
|
27
|
-
authorize?(asked: { request: Request; collection: string; key: string; write: boolean; kind: 'change' | 'presence' | 'read' }): boolean | Promise<boolean>;
|
|
27
|
+
authorize?(asked: { request: Request; collection: string; key: string; field?: string; value?: unknown; write: boolean; kind: 'change' | 'presence' | 'read' }): boolean | Promise<boolean>;
|
|
28
28
|
fetch(request: Request): Promise<Response>;
|
|
29
29
|
documents(): Promise<Documents>;
|
|
30
30
|
write(collection: string, key: string, fields: Record<string, unknown>): Promise<Change[]>;
|
package/src/server.d.ts
CHANGED
|
@@ -40,6 +40,9 @@ export function parseScope(text: string | null | undefined): Scope | null;
|
|
|
40
40
|
/** Whether a document is in a scope (null is everything). */
|
|
41
41
|
export function inScope(scope: Scope | null, collection: string, key: string): boolean;
|
|
42
42
|
|
|
43
|
+
/** For `createSyncServer({ authorize: allowAnyone })`: every page may read and write everything. */
|
|
44
|
+
export const allowAnyone: () => true;
|
|
45
|
+
|
|
43
46
|
export function createSyncServer(options?: {
|
|
44
47
|
log?: Log;
|
|
45
48
|
/** Default `/_zb/sync`. */
|
|
@@ -47,9 +50,18 @@ export function createSyncServer(options?: {
|
|
|
47
50
|
/**
|
|
48
51
|
* Refuse a write, or hide a change from a reader, by returning false. `kind` is what's asked:
|
|
49
52
|
* 'change' (a push), 'presence' (joining or signaling in a document's room; nothing is stored)
|
|
50
|
-
* or 'read' (a change about to be sent to this request). `write` is true for the first two.
|
|
53
|
+
* or 'read' (a change about to be sent to this request). `write` is true for the first two. A
|
|
54
|
+
* 'change' has the `field` and `value` it sets.
|
|
55
|
+
*
|
|
56
|
+
* Without it, pages may read but every write from a page is refused (403, saying why). Pass
|
|
57
|
+
* `allowAnyone` for a page everyone may edit. The server's own `write()` is never asked.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* How far ahead of the server's clock a pushed change's clock may be, in ms (default 60 000).
|
|
61
|
+
* Further ahead is refused (400): a clock far in the future would win every merge for ever.
|
|
51
62
|
*/
|
|
52
|
-
|
|
63
|
+
maxSkew?: number;
|
|
64
|
+
authorize?: (from: { request: Request; collection: string; key: string; field?: string; value?: unknown; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
|
|
53
65
|
/** Other origins allowed to push. */
|
|
54
66
|
/**
|
|
55
67
|
* The scope a request may see and write (tenancy: a business's keys under its own prefix), or
|
package/src/server.js
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
// `authorize({ request, collection, key, write, kind })` may refuse a change or hide one (return
|
|
11
11
|
// false). `kind` says what's asked: 'read' (a change being sent to this request), 'change' (one
|
|
12
12
|
// it pushed) or 'presence' (joining a document's room, or signaling in it: nothing is stored).
|
|
13
|
-
// `write` is true for the last two.
|
|
13
|
+
// `write` is true for the last two. A 'change' also has the `field` and `value` it sets, so an
|
|
14
|
+
// app can keep fields (or whole collections) for the server alone.
|
|
15
|
+
//
|
|
16
|
+
// Without `authorize`, pages may read but not write: every push, presence and signal is refused
|
|
17
|
+
// with a 403 saying so. Say who may change what, or, for a page everyone may edit (a shared
|
|
18
|
+
// board on a trusted network), `createSyncServer({ authorize: allowAnyone })`. The server's own
|
|
19
|
+
// `write()` is never asked.
|
|
14
20
|
//
|
|
15
21
|
// The server can take part too. `await sync.documents()` is every document as the devices see
|
|
16
22
|
// it (last writer wins, per field), kept current; `sync.write(collection, key, fields)` writes as
|
|
@@ -129,9 +135,19 @@ const clock = {
|
|
|
129
135
|
},
|
|
130
136
|
};
|
|
131
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Field names a change can't have: `$clocks` is where a document keeps its fields' clocks, and
|
|
140
|
+
* `key` is the document's own key wherever documents are listed (`{ key, ...fields }`), so a
|
|
141
|
+
* field by that name would pass one document off as another.
|
|
142
|
+
*/
|
|
143
|
+
const RESERVED = new Set(['$clocks', 'key']);
|
|
144
|
+
|
|
132
145
|
const valid = (change) =>
|
|
133
146
|
change && typeof change.collection === 'string' && typeof change.key === 'string' && typeof change.field === 'string' &&
|
|
134
|
-
clock.parse(change.clock) !== null && change.field
|
|
147
|
+
clock.parse(change.clock) !== null && !RESERVED.has(change.field);
|
|
148
|
+
|
|
149
|
+
/** The largest counter a clock can carry; past it, the clock moves on a millisecond. */
|
|
150
|
+
const MAX_COUNTER = 99_999;
|
|
135
151
|
|
|
136
152
|
/**
|
|
137
153
|
* How documents are read, wherever they're kept: `find(collection, key)` → fields or undefined,
|
|
@@ -152,7 +168,8 @@ export function documentsView({ find, list, names }) {
|
|
|
152
168
|
all: (collection, prefix = '') =>
|
|
153
169
|
list(collection)
|
|
154
170
|
.filter(({ key }) => key.startsWith(prefix))
|
|
155
|
-
|
|
171
|
+
// The key last: nothing in the fields can stand in for it.
|
|
172
|
+
.map(({ key, fields }) => ({ ...structuredClone(fields), key })),
|
|
156
173
|
/** The collections that have documents, in the order they were first written to. */
|
|
157
174
|
collections: () => names(),
|
|
158
175
|
};
|
|
@@ -188,8 +205,19 @@ function documentsOf() {
|
|
|
188
205
|
};
|
|
189
206
|
}
|
|
190
207
|
|
|
191
|
-
|
|
208
|
+
/** For `createSyncServer({ authorize: allowAnyone })`: every page may read and write everything. */
|
|
209
|
+
export const allowAnyone = () => true;
|
|
210
|
+
|
|
211
|
+
/** The default: pages may read; writes from pages need an `authorize` that says who may make them. */
|
|
212
|
+
const readsOnly = ({ write }) => !write;
|
|
213
|
+
const NO_AUTHORIZE = 'This sync server takes no writes from pages until it is told who may make them: pass createSyncServer({ authorize }) (or authorize: allowAnyone, for a page anyone may edit). See docs/sync.md, "Who may write".';
|
|
214
|
+
|
|
215
|
+
export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize: given, scope: serverScope = () => null, origins = [], heartbeat = 15_000, device = 'server', maxSkew = 60_000 } = {}) {
|
|
192
216
|
if (!/^[\w-]{1,40}$/.test(device)) throw new Error('createSyncServer: `device` names the server in its clocks: letters, digits, - and _');
|
|
217
|
+
if (given !== undefined && typeof given !== 'function') throw new TypeError('createSyncServer: `authorize` is a function ({ request, collection, key, field, value, write, kind }) => boolean');
|
|
218
|
+
const authorize = given ?? readsOnly;
|
|
219
|
+
/** What a refused write says: why, when no authorize was given; the app's own refusal otherwise. */
|
|
220
|
+
const refused = (fallback) => json(403, { error: given ? fallback : NO_AUTHORIZE });
|
|
193
221
|
const base = mount.replace(/\/$/, '');
|
|
194
222
|
// Appends, one after another, so the log's order is the order they were made in.
|
|
195
223
|
let appending = Promise.resolve();
|
|
@@ -198,13 +226,23 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
198
226
|
let lastTime = 0;
|
|
199
227
|
let counter = 0;
|
|
200
228
|
const tick = () => {
|
|
201
|
-
|
|
229
|
+
let now = Math.max(Date.now(), lastTime);
|
|
202
230
|
counter = now === lastTime ? counter + 1 : 0;
|
|
231
|
+
// A counter can't outgrow its five digits (the clock would stop comparing as a string).
|
|
232
|
+
if (counter > MAX_COUNTER) {
|
|
233
|
+
now += 1;
|
|
234
|
+
counter = 0;
|
|
235
|
+
}
|
|
203
236
|
lastTime = now;
|
|
204
237
|
return clock.format(now, counter, device);
|
|
205
238
|
};
|
|
239
|
+
/** Whether a clock is further ahead of this server's than `maxSkew`: a device can't push one. */
|
|
240
|
+
const ahead = (stamp) => clock.parse(stamp).time > Date.now() + maxSkew;
|
|
206
241
|
const see = (change) => {
|
|
207
242
|
const { time, counter: seen } = clock.parse(change.clock);
|
|
243
|
+
// One already in the log from before this check (or a log written elsewhere) doesn't drag
|
|
244
|
+
// this server's clock into the future with it.
|
|
245
|
+
if (time > Date.now() + maxSkew) return;
|
|
208
246
|
if (time > lastTime) {
|
|
209
247
|
lastTime = time;
|
|
210
248
|
counter = seen;
|
|
@@ -315,10 +353,11 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
315
353
|
return json(400, { error: 'Expected JSON' });
|
|
316
354
|
}
|
|
317
355
|
const changes = Array.isArray(body?.changes) ? body.changes : null;
|
|
318
|
-
if (!changes || !changes.every(valid)) return json(400, { error: 'Expected { changes: [{ collection, key, field, value, clock }] }' });
|
|
356
|
+
if (!changes || !changes.every(valid)) return json(400, { error: 'Expected { changes: [{ collection, key, field, value, clock }] }; a field can\'t be named key or $clocks' });
|
|
357
|
+
if (changes.some((change) => ahead(change.clock))) return json(400, { error: "A change's clock is ahead of the server's by more than maxSkew; check the device's time" });
|
|
319
358
|
for (const change of changes) {
|
|
320
359
|
if (!(await mayWrite(request, change.collection, change.key))) return json(403, { error: 'Not yours to change' });
|
|
321
|
-
if (!(await authorize({ request, collection: change.collection, key: change.key, write: true, kind: 'change' }))) return
|
|
360
|
+
if (!(await authorize({ request, collection: change.collection, key: change.key, field: change.field, value: change.value, write: true, kind: 'change' }))) return refused('Not yours to change');
|
|
322
361
|
}
|
|
323
362
|
const appended = changes.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value, clock }));
|
|
324
363
|
const cursor = await append(appended);
|
|
@@ -340,7 +379,8 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
340
379
|
if (!room || !okPeer || !okState || JSON.stringify(message.state).length > 4096) {
|
|
341
380
|
return json(400, { error: 'Expected { room: "<collection>/<key>", peer, state: {…} | null }' });
|
|
342
381
|
}
|
|
343
|
-
if (!(await mayWrite(request, room.collection, room.key))
|
|
382
|
+
if (!(await mayWrite(request, room.collection, room.key))) return json(403, { error: 'Not yours to join' });
|
|
383
|
+
if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return refused('Not yours to join');
|
|
344
384
|
const clean = { room: message.room, peer: message.peer, state: message.state };
|
|
345
385
|
for (const listener of listeners) if (listener.peer === clean.peer) {
|
|
346
386
|
if (clean.state === null) listener.rooms.delete(clean.room);
|
|
@@ -365,7 +405,8 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
365
405
|
if (!room || !peerOk(message.from) || !peerOk(message.to) || !dataOk) {
|
|
366
406
|
return json(400, { error: 'Expected { room, from, to, data: {…} }' });
|
|
367
407
|
}
|
|
368
|
-
if (!(await mayWrite(request, room.collection, room.key))
|
|
408
|
+
if (!(await mayWrite(request, room.collection, room.key))) return json(403, { error: 'Not yours to join' });
|
|
409
|
+
if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return refused('Not yours to join');
|
|
369
410
|
const clean = { room: message.room, from: message.from, to: message.to, data: message.data };
|
|
370
411
|
for (const listener of listeners) if (listener.peer === clean.to) listener.send('signal', clean);
|
|
371
412
|
return json(200, { ok: true });
|
|
@@ -471,7 +512,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
471
512
|
if (typeof collection !== 'string' || typeof key !== 'string' || !fields || typeof fields !== 'object' || Array.isArray(fields)) {
|
|
472
513
|
throw new TypeError('sync.write(collection, key, { field: value, … })');
|
|
473
514
|
}
|
|
474
|
-
if (
|
|
515
|
+
for (const name of RESERVED) if (name in fields) throw new TypeError(`sync.write: \`${name}\` can't be a field name`);
|
|
475
516
|
// A server that hasn't read its log yet (it just started, or a Durable Object was evicted)
|
|
476
517
|
// hasn't seen the clocks in it: read it first, so this write comes after all of them. Once
|
|
477
518
|
// the documents exist this doesn't wait, so a check and a write with no await between stay
|