@zubra/sync 5.8.0 → 5.9.1
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 +2 -2
- package/src/connect.js +11 -1
- package/src/durable-core.js +4 -2
- package/src/durable.d.ts +1 -1
- package/src/server.d.ts +9 -2
- package/src/server.js +24 -5
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.
|
|
3
|
+
"version": "5.9.1",
|
|
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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"test:binary": "ZUBRA_SYNC_BACKEND=binary node --test test/browser.test.js"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@zubra/runtime": "^5.
|
|
35
|
+
"@zubra/runtime": "^5.9.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@zubra/compiler": "file:../compiler",
|
package/src/connect.js
CHANGED
|
@@ -117,6 +117,7 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
let timer = null;
|
|
120
|
+
let warned = false;
|
|
120
121
|
const schedule = () => {
|
|
121
122
|
clearTimeout(timer);
|
|
122
123
|
timer = setTimeout(flush, 30);
|
|
@@ -131,7 +132,16 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
131
132
|
headers: { 'content-type': 'application/json' },
|
|
132
133
|
body: JSON.stringify({ changes: sending }),
|
|
133
134
|
});
|
|
134
|
-
if (!response.ok)
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
// A refused write is kept (it may be allowed later), but say why once: a server with no
|
|
137
|
+
// `authorize` refuses every write from pages, and a silent outbox looks like a bug.
|
|
138
|
+
if (response.status === 403 && !warned) {
|
|
139
|
+
warned = true;
|
|
140
|
+
const why = await response.json().then((body) => body?.error, () => '');
|
|
141
|
+
console.warn(`@zubra/sync: the server refused this page's changes${why ? `: ${why}` : ''}`);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
135
145
|
outbox.done(sending);
|
|
136
146
|
} catch {
|
|
137
147
|
// 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,13 @@ 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.
|
|
51
58
|
*/
|
|
52
|
-
authorize?: (from: { request: Request; collection: string; key: string; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
|
|
59
|
+
authorize?: (from: { request: Request; collection: string; key: string; field?: string; value?: unknown; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
|
|
53
60
|
/** Other origins allowed to push. */
|
|
54
61
|
/**
|
|
55
62
|
* 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
|
|
@@ -188,8 +194,19 @@ function documentsOf() {
|
|
|
188
194
|
};
|
|
189
195
|
}
|
|
190
196
|
|
|
191
|
-
|
|
197
|
+
/** For `createSyncServer({ authorize: allowAnyone })`: every page may read and write everything. */
|
|
198
|
+
export const allowAnyone = () => true;
|
|
199
|
+
|
|
200
|
+
/** The default: pages may read; writes from pages need an `authorize` that says who may make them. */
|
|
201
|
+
const readsOnly = ({ write }) => !write;
|
|
202
|
+
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".';
|
|
203
|
+
|
|
204
|
+
export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize: given, scope: serverScope = () => null, origins = [], heartbeat = 15_000, device = 'server' } = {}) {
|
|
192
205
|
if (!/^[\w-]{1,40}$/.test(device)) throw new Error('createSyncServer: `device` names the server in its clocks: letters, digits, - and _');
|
|
206
|
+
if (given !== undefined && typeof given !== 'function') throw new TypeError('createSyncServer: `authorize` is a function ({ request, collection, key, field, value, write, kind }) => boolean');
|
|
207
|
+
const authorize = given ?? readsOnly;
|
|
208
|
+
/** What a refused write says: why, when no authorize was given; the app's own refusal otherwise. */
|
|
209
|
+
const refused = (fallback) => json(403, { error: given ? fallback : NO_AUTHORIZE });
|
|
193
210
|
const base = mount.replace(/\/$/, '');
|
|
194
211
|
// Appends, one after another, so the log's order is the order they were made in.
|
|
195
212
|
let appending = Promise.resolve();
|
|
@@ -318,7 +335,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
318
335
|
if (!changes || !changes.every(valid)) return json(400, { error: 'Expected { changes: [{ collection, key, field, value, clock }] }' });
|
|
319
336
|
for (const change of changes) {
|
|
320
337
|
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
|
|
338
|
+
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
339
|
}
|
|
323
340
|
const appended = changes.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value, clock }));
|
|
324
341
|
const cursor = await append(appended);
|
|
@@ -340,7 +357,8 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
340
357
|
if (!room || !okPeer || !okState || JSON.stringify(message.state).length > 4096) {
|
|
341
358
|
return json(400, { error: 'Expected { room: "<collection>/<key>", peer, state: {…} | null }' });
|
|
342
359
|
}
|
|
343
|
-
if (!(await mayWrite(request, room.collection, room.key))
|
|
360
|
+
if (!(await mayWrite(request, room.collection, room.key))) return json(403, { error: 'Not yours to join' });
|
|
361
|
+
if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return refused('Not yours to join');
|
|
344
362
|
const clean = { room: message.room, peer: message.peer, state: message.state };
|
|
345
363
|
for (const listener of listeners) if (listener.peer === clean.peer) {
|
|
346
364
|
if (clean.state === null) listener.rooms.delete(clean.room);
|
|
@@ -365,7 +383,8 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
365
383
|
if (!room || !peerOk(message.from) || !peerOk(message.to) || !dataOk) {
|
|
366
384
|
return json(400, { error: 'Expected { room, from, to, data: {…} }' });
|
|
367
385
|
}
|
|
368
|
-
if (!(await mayWrite(request, room.collection, room.key))
|
|
386
|
+
if (!(await mayWrite(request, room.collection, room.key))) return json(403, { error: 'Not yours to join' });
|
|
387
|
+
if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return refused('Not yours to join');
|
|
369
388
|
const clean = { room: message.room, from: message.from, to: message.to, data: message.data };
|
|
370
389
|
for (const listener of listeners) if (listener.peer === clean.to) listener.send('signal', clean);
|
|
371
390
|
return json(200, { ok: true });
|