@brass-build/client 0.1.0 → 0.2.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/AGENTS.md +72 -63
- package/CHANGELOG.md +86 -0
- package/README.md +317 -231
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +217 -75
- package/dist/client.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +19 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +43 -14
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +871 -223
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +253 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +15 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1851 -321
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +336 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +591 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +226 -75
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +140 -19
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/ws-client.d.ts +23 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +339 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +19 -12
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +17 -20
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +225 -78
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +7 -7
- package/src/document-streams.ts +95 -9
- package/src/documents-namespace.ts +18 -3
- package/src/documents.ts +983 -277
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3406 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +261 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +1 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2355 -515
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +687 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +249 -80
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +163 -25
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/ws-client.ts +392 -92
- package/src/ws-frame-limit.ts +19 -12
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The `__brass_embed` postMessage envelope, shared by the SDK's three bridge
|
|
2
|
+
// endpoints: the hosted-UI launcher (`hosted-ui.ts`) and the version bar
|
|
3
|
+
// (`version-bar.ts`), where the SDK is the host, and the export guest
|
|
4
|
+
// (`export-guest.ts`), where the SDK is the iframe.
|
|
5
|
+
//
|
|
6
|
+
// Both sides drop any `message` whose `data[EMBED_ENVELOPE]` is not a version
|
|
7
|
+
// they speak, so unrelated postMessage traffic never reaches a handshake.
|
|
8
|
+
//
|
|
9
|
+
// The SDK's half of every bridge freezes into each app's published bundle,
|
|
10
|
+
// so it speaks exactly the version it was built with: it announces this
|
|
11
|
+
// version on first contact and accepts only this version inbound. Staying
|
|
12
|
+
// compatible across a bump is entirely the central (dashboard-served) side's
|
|
13
|
+
// job: that side accepts every version it has ever shipped and replies at the
|
|
14
|
+
// one the frozen counterpart announced. Two rules follow, and together they
|
|
15
|
+
// are the bridge's compatibility invariant:
|
|
16
|
+
//
|
|
17
|
+
// - the central side never drops a version while a published SDK still
|
|
18
|
+
// announces it, so an app pinned to an older client keeps working;
|
|
19
|
+
// - this constant rises only after a dashboard build that accepts the new
|
|
20
|
+
// version is deployed, because the SDK's first message (the launcher's
|
|
21
|
+
// `init`, the guest's `ready`) is what the central side negotiates from.
|
|
22
|
+
// A bump that lands first is dropped by the deployed dashboard, and every
|
|
23
|
+
// hosted dialog in an app carrying it waits out its timeout.
|
|
24
|
+
export const EMBED_ENVELOPE = '__brass_embed';
|
|
25
|
+
export const EMBED_PROTOCOL_VERSION = 1;
|
package/src/emitter.ts
CHANGED
|
@@ -113,7 +113,14 @@ export function createEmitter<T>(opts: EmitterOptions<T> = {}): Emitter<T> {
|
|
|
113
113
|
retained = value;
|
|
114
114
|
hasValue = true;
|
|
115
115
|
}
|
|
116
|
-
|
|
116
|
+
// Over a snapshot, because a listener may detach or clear the rest
|
|
117
|
+
// while the fanout is running: the session's own `onGone` handler
|
|
118
|
+
// closes the document, which clears its listener set, and a `Set`
|
|
119
|
+
// never visits an entry removed before its turn. Iterating live there
|
|
120
|
+
// silently drops every listener registered after the session's, which
|
|
121
|
+
// is all of the app's.
|
|
122
|
+
const receiving = Array.from(listeners);
|
|
123
|
+
for (const cb of receiving) dispatch(cb, value);
|
|
117
124
|
if (oneShot) listeners.clear();
|
|
118
125
|
},
|
|
119
126
|
clear() {
|
package/src/export-guest.ts
CHANGED
|
@@ -15,10 +15,8 @@
|
|
|
15
15
|
import { Document } from './documents.js';
|
|
16
16
|
import type { DocumentStreamInfo } from './documents-namespace.js';
|
|
17
17
|
import type { StreamRuntime } from './document-streams.js';
|
|
18
|
-
import type { DocumentSerializer } from './serializer.js';
|
|
19
|
-
|
|
20
|
-
const ENVELOPE = '__brass_embed';
|
|
21
|
-
const PROTOCOL_VERSION = 1;
|
|
18
|
+
import type { DocumentSerializer, StoredDocument } from './serializer.js';
|
|
19
|
+
import { EMBED_ENVELOPE, EMBED_PROTOCOL_VERSION } from './embed-protocol.js';
|
|
22
20
|
|
|
23
21
|
// The URL flag Brass appends when it frames an app purely to serialize.
|
|
24
22
|
// A normal app load never carries it, so a real user visit is untouched.
|
|
@@ -102,7 +100,7 @@ export function startExportGuest(): ExportGuestController {
|
|
|
102
100
|
}, STREAM_REQUEST_TIMEOUT_MS);
|
|
103
101
|
pendingStreamFetches.set(requestId, { resolve, reject, timer });
|
|
104
102
|
window.parent.postMessage(
|
|
105
|
-
{ [
|
|
103
|
+
{ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type, requestId, ...extra },
|
|
106
104
|
hostOrigin,
|
|
107
105
|
);
|
|
108
106
|
});
|
|
@@ -145,7 +143,7 @@ export function startExportGuest(): ExportGuestController {
|
|
|
145
143
|
| { ok: false; code: string; message: string },
|
|
146
144
|
): void {
|
|
147
145
|
window.parent.postMessage(
|
|
148
|
-
{ [
|
|
146
|
+
{ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome },
|
|
149
147
|
hostOrigin,
|
|
150
148
|
);
|
|
151
149
|
}
|
|
@@ -168,7 +166,10 @@ export function startExportGuest(): ExportGuestController {
|
|
|
168
166
|
: Document.create(identity);
|
|
169
167
|
for (const op of tailOps) doc.applyRemoteOp(op);
|
|
170
168
|
doc.setStreamRuntime(makeStreamRuntime(hostOrigin));
|
|
171
|
-
|
|
169
|
+
// A serializer runs detached, with no subscription to have been fed by,
|
|
170
|
+
// and it needs the document once, so it is handed the containers whole
|
|
171
|
+
// (`StoredDocument`) rather than the fed surface an app holds.
|
|
172
|
+
const bytes = await serializer(doc as StoredDocument & Document);
|
|
172
173
|
reply(hostOrigin, requestId, { ok: true, bytes });
|
|
173
174
|
} catch (err) {
|
|
174
175
|
reply(hostOrigin, requestId, {
|
|
@@ -180,8 +181,13 @@ export function startExportGuest(): ExportGuestController {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
function onMessage(event: MessageEvent): void {
|
|
184
|
+
// Source pinning, the counterpart of the origin pins below and of the
|
|
185
|
+
// one the host side applies: `window.parent` is the framer this guest
|
|
186
|
+
// replies to, so a message from any other window is not part of this
|
|
187
|
+
// exchange whatever origin it claims.
|
|
188
|
+
if (event.source !== window.parent) return;
|
|
183
189
|
const data = event.data as Record<string, unknown> | null;
|
|
184
|
-
if (!data || data[
|
|
190
|
+
if (!data || data[EMBED_ENVELOPE] !== EMBED_PROTOCOL_VERSION) return;
|
|
185
191
|
|
|
186
192
|
// A reply to a proxied stream fetch. Accept only from the pinned framer
|
|
187
193
|
// origin (set once the export request arrives), so a second frame cannot
|
|
@@ -262,7 +268,7 @@ export function startExportGuest(): ExportGuestController {
|
|
|
262
268
|
window.addEventListener('message', onMessage);
|
|
263
269
|
// Announce readiness. Posted with `'*'` because the host origin is not
|
|
264
270
|
// known until its first message arrives; the ping carries no data.
|
|
265
|
-
window.parent.postMessage({ [
|
|
271
|
+
window.parent.postMessage({ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'ready' }, '*');
|
|
266
272
|
|
|
267
273
|
return {
|
|
268
274
|
setSerializer(next: DocumentSerializer): void {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// A deadline for a request whose caller has no other way to stop waiting.
|
|
2
|
+
//
|
|
3
|
+
// `fetch` has no timeout of its own, so a request that draws no answer stays
|
|
4
|
+
// pending for as long as the page is open. On the op-write path that pins the
|
|
5
|
+
// op's entry in the outstanding tracker, which holds `doc.savingCount` above
|
|
6
|
+
// zero, which is what an app renders as "Saving...". The reader is then shown
|
|
7
|
+
// a save in progress that nothing will ever resolve, and no error reaches
|
|
8
|
+
// `Brass.onError` because nothing failed.
|
|
9
|
+
//
|
|
10
|
+
// The deadline covers reading the body as well as getting a response, because
|
|
11
|
+
// `fetch` resolves as soon as the headers land: a server that answers and then
|
|
12
|
+
// stalls mid-body is the same hang, and clearing the timer on the response
|
|
13
|
+
// would leave exactly the case this exists for uncovered. The helper therefore
|
|
14
|
+
// reads the body itself rather than handing back a `Response` whose consumption
|
|
15
|
+
// nothing bounds.
|
|
16
|
+
//
|
|
17
|
+
// An abort is deliberately reported as a request that drew no answer at all
|
|
18
|
+
// (status `null`), because that is what it is: the write may or may not have
|
|
19
|
+
// reached the handler. Callers price that the same way they price a network
|
|
20
|
+
// throw.
|
|
21
|
+
|
|
22
|
+
// How long a request that carries no meaningful body may go unanswered.
|
|
23
|
+
//
|
|
24
|
+
// The value matches the WebSocket transport's forced-resend ceiling, because
|
|
25
|
+
// it answers the same question about the same op: whether a request still
|
|
26
|
+
// outstanding this long after it went out is coming back. It sits well above
|
|
27
|
+
// any latency a working connection produces.
|
|
28
|
+
//
|
|
29
|
+
// What a false positive costs is bounded. Staging is replayed, which writes
|
|
30
|
+
// nothing durable until the op write names the key. The op write itself is
|
|
31
|
+
// terminal for that op, which arms the durability handshake, and the
|
|
32
|
+
// handshake pushes whatever the durable frontier is actually missing rather
|
|
33
|
+
// than replaying the op blindly.
|
|
34
|
+
export const OP_WRITE_TIMEOUT_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
// The uplink an upload is assumed to manage in the worst case, in bytes per
|
|
37
|
+
// millisecond (16 KB/s). A request carrying megabytes is not unanswered while
|
|
38
|
+
// its body is still going out, so a flat deadline would abort a working upload
|
|
39
|
+
// on a slow mobile connection and fail an op that was about to land. The
|
|
40
|
+
// allowance is generous on purpose: a deadline that fires early on real work is
|
|
41
|
+
// a worse failure than one that takes longer to notice a dead request, since
|
|
42
|
+
// the whole point is to distinguish silence from slowness.
|
|
43
|
+
const ASSUMED_MIN_UPLOAD_BYTES_PER_MS = 16;
|
|
44
|
+
|
|
45
|
+
/** Deadline for a request whose body is `byteLength` bytes of upload. */
|
|
46
|
+
export function deadlineForUpload(byteLength: number): number {
|
|
47
|
+
return (
|
|
48
|
+
OP_WRITE_TIMEOUT_MS +
|
|
49
|
+
Math.ceil(Math.max(0, byteLength) / ASSUMED_MIN_UPLOAD_BYTES_PER_MS)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class FetchDeadlineError extends Error {
|
|
54
|
+
override name = 'FetchDeadlineError';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DeadlineResponse {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
status: number;
|
|
60
|
+
text: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run a fetch under a deadline covering the response and its body, aborting
|
|
65
|
+
* the request when it expires.
|
|
66
|
+
*
|
|
67
|
+
* The timer is cleared on every exit, so a completed request leaves nothing
|
|
68
|
+
* pending. An environment whose `fetch` ignores `AbortSignal` still rejects,
|
|
69
|
+
* because the deadline is raced against the request rather than relying on the
|
|
70
|
+
* abort landing.
|
|
71
|
+
*/
|
|
72
|
+
export async function fetchWithDeadline(
|
|
73
|
+
input: string,
|
|
74
|
+
init: RequestInit,
|
|
75
|
+
timeoutMs: number,
|
|
76
|
+
): Promise<DeadlineResponse> {
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
let expired = false;
|
|
79
|
+
let fire: (() => void) | undefined;
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
expired = true;
|
|
82
|
+
controller.abort();
|
|
83
|
+
fire?.();
|
|
84
|
+
}, timeoutMs);
|
|
85
|
+
// A long-lived deadline must not hold a Node process open on its own.
|
|
86
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
87
|
+
|
|
88
|
+
const expiry = new Promise<never>((_resolve, reject) => {
|
|
89
|
+
fire = () =>
|
|
90
|
+
reject(
|
|
91
|
+
new FetchDeadlineError(
|
|
92
|
+
`The request did not answer within ${Math.round(timeoutMs / 1000)}s.`,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([
|
|
99
|
+
(async (): Promise<DeadlineResponse> => {
|
|
100
|
+
const response = await fetch(input, {
|
|
101
|
+
...init,
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
const text = await response.text();
|
|
105
|
+
return { ok: response.ok, status: response.status, text };
|
|
106
|
+
})(),
|
|
107
|
+
expiry,
|
|
108
|
+
]);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (expired) {
|
|
111
|
+
throw new FetchDeadlineError(
|
|
112
|
+
`The request did not answer within ${Math.round(timeoutMs / 1000)}s.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/fetch-retry.ts
CHANGED
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
//
|
|
15
15
|
// - A GET is idempotent, so replaying it is always safe: retry the whole
|
|
16
16
|
// transient 5xx / 429 set AND a thrown network error (`RETRY_READ`).
|
|
17
|
-
// - A write is NOT idempotent, so only a 503 is safe to replay
|
|
18
|
-
//
|
|
19
|
-
// throttle / scale-up / deploy-window blip)
|
|
20
|
-
//
|
|
21
|
-
//
|
|
17
|
+
// - A write is NOT idempotent, so only a 503 is safe to replay, and only on
|
|
18
|
+
// a route where a 503 means the write did not land: the gateway's own 503
|
|
19
|
+
// (a transient throttle / scale-up / deploy-window blip) is returned before
|
|
20
|
+
// the request reaches the handler, and a handler that answers 503 does so
|
|
21
|
+
// for a write it refused rather than applied. A 502 / 504 / 429 or a network
|
|
22
|
+
// throw might mean the handler ran, so those stay hard failures
|
|
23
|
+
// (`RETRY_WRITE_503`).
|
|
22
24
|
// - A 500 is never retried under either policy: an unhandled server error
|
|
23
25
|
// is usually deterministic, so a replay just doubles the cost without
|
|
24
26
|
// changing the outcome.
|
|
@@ -62,6 +64,9 @@ export interface RetryOptions {
|
|
|
62
64
|
policy?: RetryPolicy;
|
|
63
65
|
// Total attempts including the first. Default 3 (so up to 2 replays).
|
|
64
66
|
maxAttempts?: number;
|
|
67
|
+
// Attempts a thrown network error gets, when the policy replays throws.
|
|
68
|
+
// Defaults to `maxAttempts`.
|
|
69
|
+
maxNetworkAttempts?: number;
|
|
65
70
|
// Base backoff; attempt N waits ~base * 2^(N-1) with jitter. Default 300.
|
|
66
71
|
baseDelayMs?: number;
|
|
67
72
|
// Invoked before each replay with the 1-based attempt that just failed and
|
|
@@ -86,10 +91,12 @@ export async function fetchWithRetry(
|
|
|
86
91
|
): Promise<Response> {
|
|
87
92
|
const policy = options.policy ?? RETRY_READ;
|
|
88
93
|
const maxAttempts = options.maxAttempts ?? 3;
|
|
94
|
+
const maxNetworkAttempts = options.maxNetworkAttempts ?? maxAttempts;
|
|
89
95
|
const baseDelayMs = options.baseDelayMs ?? 300;
|
|
90
96
|
const sleep = options.sleep ?? defaultSleep;
|
|
91
97
|
|
|
92
98
|
let lastError: unknown;
|
|
99
|
+
let networkThrows = 0;
|
|
93
100
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
94
101
|
let response: Response | undefined;
|
|
95
102
|
try {
|
|
@@ -99,6 +106,8 @@ export async function fetchWithRetry(
|
|
|
99
106
|
// a write policy rethrows it rather than risk a double-apply.
|
|
100
107
|
if (!policy.replayNetworkErrors) throw err;
|
|
101
108
|
lastError = err;
|
|
109
|
+
networkThrows += 1;
|
|
110
|
+
if (networkThrows >= maxNetworkAttempts) throw err;
|
|
102
111
|
}
|
|
103
112
|
if (response && !policy.replayStatuses.has(response.status)) {
|
|
104
113
|
return response;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// The handle surface's public types, kept apart from the `handles.ts` code
|
|
2
|
+
// that implements them against Loro. Every type the package entry re-exports
|
|
3
|
+
// pulls its declaring module into a consumer's typecheck graph, so declaring
|
|
4
|
+
// them here is what makes the public surface engine-neutral all the way down
|
|
5
|
+
// rather than only in the signatures an app calls.
|
|
6
|
+
// `public-types-engine-free.test.ts` holds that line.
|
|
7
|
+
|
|
8
|
+
// What changed on a map, the first argument to a map subscriber. Reconcile
|
|
9
|
+
// by it: upsert every entry in `set` (it carries the new value, so reconcile
|
|
10
|
+
// never calls `get`), drop every key in `deleted`. The first fire carries
|
|
11
|
+
// the whole map as `set`, so a subscriber is one loop with no
|
|
12
|
+
// initial-vs-update branch. Each later fire is proportional to what the change
|
|
13
|
+
// wrote, never to the map's size.
|
|
14
|
+
export interface Change<T = unknown> {
|
|
15
|
+
// Entries the change wrote, by key, with the value the map now holds for
|
|
16
|
+
// them. `set` names what a change WROTE, which is a wider answer than what
|
|
17
|
+
// differs: assigning a record the value it already holds names it, and above
|
|
18
|
+
// the size at which a large map stores its records in groups, a mutation
|
|
19
|
+
// that replaces every record of a group names every record in it. An edit of
|
|
20
|
+
// one record names that record at every size. So reconcile by value and the
|
|
21
|
+
// two readings agree; to attribute a change instead (highlight a peer's
|
|
22
|
+
// edit, mark a record dirty, recompute per record), compare the value
|
|
23
|
+
// against the one you hold and act on the entries that moved.
|
|
24
|
+
readonly set: ReadonlyMap<string, T>;
|
|
25
|
+
// Keys removed from the map.
|
|
26
|
+
readonly deleted: readonly string[];
|
|
27
|
+
// Live keys in order, present ONLY on an ordered-map fire whose order
|
|
28
|
+
// changed. Re-sequence the views you hold to it; absent on a plain map and
|
|
29
|
+
// on an order-preserving field edit, keeping re-sequencing off the
|
|
30
|
+
// per-keystroke path.
|
|
31
|
+
readonly order?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// What produced a change: read-only metadata, the optional SECOND argument
|
|
35
|
+
// to a map subscriber. A render never needs it (the projection reconciles by
|
|
36
|
+
// value regardless of cause, which makes local / remote / undo one path);
|
|
37
|
+
// read it only to attribute a change, e.g. highlight a peer's edit
|
|
38
|
+
// (`source === 'remote'`). It says who wrote the change, and `set` says what
|
|
39
|
+
// the change wrote, so an attributing subscriber pairs it with the value
|
|
40
|
+
// comparison `set` describes.
|
|
41
|
+
//
|
|
42
|
+
// A move through version history is not among them: it swaps the whole
|
|
43
|
+
// container rather than writing into it, so it reaches a subscriber as a
|
|
44
|
+
// whole-container fire with no origin. `doc.subscribe` reports that move as
|
|
45
|
+
// `{ kind: 'history' }`, carrying the version it landed on.
|
|
46
|
+
export type ChangeSource = 'local' | 'remote' | 'undo' | 'redo';
|
|
47
|
+
export interface ChangeOrigin {
|
|
48
|
+
readonly source: ChangeSource;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A map subscriber. Fires once on registration (the whole map as
|
|
52
|
+
// `change.set`), then on every change. Both fires are synchronous: the
|
|
53
|
+
// registration fire runs before `subscribe` returns, and a change fire runs
|
|
54
|
+
// inside the `mutate` that caused it. So a subscriber cannot reach a `const`
|
|
55
|
+
// its own registration fire initialises, and work it defers to a later tick
|
|
56
|
+
// runs after the mutation has finished.
|
|
57
|
+
export type MapSubscriber<T = unknown> = (
|
|
58
|
+
change: Change<T>,
|
|
59
|
+
origin?: ChangeOrigin,
|
|
60
|
+
) => void;
|
|
61
|
+
|
|
62
|
+
// Internal (not exported): a map subscriber that also learns whether a fire is
|
|
63
|
+
// a whole-container catch-up (`resync: true`: registration, or a checkout /
|
|
64
|
+
// return-to-head / hydrate swapping the source) or an incremental delta
|
|
65
|
+
// (`false`). This is the "full re-sync vs incremental" distinction, ORTHOGONAL
|
|
66
|
+
// to `origin` (which is authorship: local / remote / undo / redo, all one path
|
|
67
|
+
// to a render). The ordered map needs it because a source swap can reorder
|
|
68
|
+
// `instance_order` with the same key set, which its key-set move test cannot
|
|
69
|
+
// see; keying off this explicit flag keeps that check gated to the rare
|
|
70
|
+
// re-sync fires without overloading `origin`. The public `subscribe` wraps an
|
|
71
|
+
// app `MapSubscriber` and drops the flag, so it never reaches an app.
|
|
72
|
+
export type InternalMapSubscriber<T> = (
|
|
73
|
+
change: Change<T>,
|
|
74
|
+
origin: ChangeOrigin | undefined,
|
|
75
|
+
resync: boolean,
|
|
76
|
+
) => void;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One positional edit, applied in the order the change carries them.
|
|
80
|
+
*
|
|
81
|
+
* `at` indexes the sequence as the preceding splices in the same change left
|
|
82
|
+
* it, so applying them in order to what you already hold leaves you current.
|
|
83
|
+
* That is the whole contract: a subscriber never has to ask what the list
|
|
84
|
+
* holds, because every fire says how to get from its last state to this one.
|
|
85
|
+
*/
|
|
86
|
+
export interface Splice<E> {
|
|
87
|
+
readonly at: number;
|
|
88
|
+
readonly removed: number;
|
|
89
|
+
readonly inserted: readonly E[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What changed in a list. Fires once on registration carrying the whole list
|
|
94
|
+
* as a single splice, then on every change carrying only what moved.
|
|
95
|
+
*
|
|
96
|
+
* A registration fire and a resync are not special cases a subscriber has to
|
|
97
|
+
* recognise: both arrive as splices that replace what it was last told, so
|
|
98
|
+
* one apply loop is the entire subscriber.
|
|
99
|
+
*/
|
|
100
|
+
export interface ListChange<T> {
|
|
101
|
+
readonly splices: readonly Splice<T>[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type ListSubscriber<T = unknown> = (change: ListChange<T>) => void;
|
|
105
|
+
|
|
106
|
+
// Internal (not exported): a list subscriber that also learns what produced
|
|
107
|
+
// the change, so the ordered map can stamp an `order` frame its order list's
|
|
108
|
+
// own move produced. Undefined on a whole-container catch-up (registration, or
|
|
109
|
+
// a checkout / return-to-head / hydrate swapping the source), which describes
|
|
110
|
+
// no authored transition, matching `MapSubscriber`'s optional origin.
|
|
111
|
+
export type InternalListSubscriber = (
|
|
112
|
+
change: ListChange<unknown>,
|
|
113
|
+
origin?: ChangeOrigin,
|
|
114
|
+
) => void;
|
|
115
|
+
|
|
116
|
+
export type HandleUnsubscribe = () => void;
|
|
117
|
+
|
|
118
|
+
// Hooks the owning Document provides so handles participate in the SDK's
|
|
119
|
+
// commit + origin machinery. Defined here (not in documents.ts) so handles.ts
|
|
120
|
+
// has no compile-time dependency on the Document class.
|
|
121
|
+
export interface HandleOwner {
|
|
122
|
+
// Throws if the Document is closed, suspended, or read-only.
|
|
123
|
+
assertWritable(): void;
|
|
124
|
+
// Throws if the Document is closed. `close()` releases the CRDT state the
|
|
125
|
+
// handles read through, so a read taken after it reaches released memory and
|
|
126
|
+
// fails with whatever the engine says about that. An app holds a handle
|
|
127
|
+
// across a close by design (it is what a subscription is registered on), so
|
|
128
|
+
// this is an ordinary race: a callback, a frame, or a settled promise
|
|
129
|
+
// reading one tick after the close. The gate is what makes that answer
|
|
130
|
+
// `Document is closed` in the app's own vocabulary, like a write does.
|
|
131
|
+
//
|
|
132
|
+
// Closed only, never suspended: a parked Document keeps its state, and its
|
|
133
|
+
// reads answer from it.
|
|
134
|
+
assertReadable(): void;
|
|
135
|
+
// Lazily, idempotently attaches the local-updates hook before first mutate.
|
|
136
|
+
ensureLocalUpdatesSubscribed(): void;
|
|
137
|
+
// Stamps the next commit with the mutate origin tag and commits, so the
|
|
138
|
+
// change's `origin.source` reads `'local'`.
|
|
139
|
+
commitMutate(): void;
|
|
140
|
+
// Classify a Loro event by what produced it. Derived from the commit's
|
|
141
|
+
// `by` + origin tag, so it survives microtask-deferred delivery.
|
|
142
|
+
originOf(event: unknown): ChangeSource;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Public handle interfaces. Concrete classes below.
|
|
146
|
+
|
|
147
|
+
export interface MapHandle<T = unknown> {
|
|
148
|
+
// Per-entry read. Returns undefined when missing.
|
|
149
|
+
get(entryKey: string): T | undefined;
|
|
150
|
+
// Keys currently in the map.
|
|
151
|
+
keys(): string[];
|
|
152
|
+
// Whether an entry exists at `entryKey`.
|
|
153
|
+
has(entryKey: string): boolean;
|
|
154
|
+
// Mutate through a typed JS-proxy, scoped to this container; same
|
|
155
|
+
// ergonomics as `doc.mutate(d => { d.entities[id].field = ... })`.
|
|
156
|
+
mutate(fn: (draft: Record<string, T>) => void): void;
|
|
157
|
+
// ONE channel for every change whatever produced it (local / remote / undo
|
|
158
|
+
// / redo / checkout): the projection reconciles by value. The callback gets
|
|
159
|
+
// `change` and optional `origin` a render ignores. Fires once on
|
|
160
|
+
// registration with the whole map as `change.set`, then on every change
|
|
161
|
+
// (each later fire proportional to what that change wrote).
|
|
162
|
+
subscribe(cb: MapSubscriber<T>): HandleUnsubscribe;
|
|
163
|
+
/**
|
|
164
|
+
* Report each time this container has been delivered whole: on
|
|
165
|
+
* registration, and again after a version checkout, a return to head or a
|
|
166
|
+
* hydrate replaces it.
|
|
167
|
+
*
|
|
168
|
+
* This is where the work that follows on the collection belongs rather than
|
|
169
|
+
* on any single change: frame the camera on what arrived, run the first pass
|
|
170
|
+
* over it. It repeats because that work is owed again each time the
|
|
171
|
+
* container is replaced.
|
|
172
|
+
*
|
|
173
|
+
* It reports that this peer holds what it had converged to when the delivery
|
|
174
|
+
* began, which is the only claim available: more always arrives afterwards.
|
|
175
|
+
*/
|
|
176
|
+
onSettled(cb: () => void): HandleUnsubscribe;
|
|
177
|
+
|
|
178
|
+
// Child handle for `entries[entryKey]` when that entry is itself a map /
|
|
179
|
+
// list. Reads see empty until the container exists; the first mutate
|
|
180
|
+
// creates it, and a subscription taken before that delivers the container
|
|
181
|
+
// whole on whichever write creates it.
|
|
182
|
+
map<U>(entryKey: string): MapHandle<U>;
|
|
183
|
+
list<U>(entryKey: string): ListHandle<U>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The array a list `mutate` hands its callback: an ordinary `T[]` plus
|
|
188
|
+
* `move`.
|
|
189
|
+
*
|
|
190
|
+
* `move` is here because a plain array has no way to say "this entry is now
|
|
191
|
+
* at that position", and the difference matters once two people edit at once.
|
|
192
|
+
* Spelled as a removal and an insertion, a reposition merges with another
|
|
193
|
+
* peer's reposition of the SAME entry by keeping BOTH insertions, so the entry
|
|
194
|
+
* ends up in the list twice and a reader has to guess which position was
|
|
195
|
+
* meant. `move` states the reposition, and the two merge to one entry.
|
|
196
|
+
*/
|
|
197
|
+
export interface ListDraft<T> extends Array<T> {
|
|
198
|
+
/** Reposition the entry at `from` so it sits at `to`. Indices are clamped
|
|
199
|
+
* to the list, and a `from` outside it does nothing. */
|
|
200
|
+
move(from: number, to: number): void;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ListHandle<T = unknown> {
|
|
204
|
+
length(): number;
|
|
205
|
+
mutate(fn: (draft: ListDraft<T>) => void): void;
|
|
206
|
+
// Fires once on registration carrying the whole list as one splice, then on
|
|
207
|
+
// every change carrying only what moved, so a subscriber applies splices to
|
|
208
|
+
// what it already holds and never re-reads. A keyed collection belongs in a
|
|
209
|
+
// map (or `orderedMap`), which reconciles by key; a list is for ordering
|
|
210
|
+
// keys and small positional data.
|
|
211
|
+
subscribe(cb: ListSubscriber<T>): HandleUnsubscribe;
|
|
212
|
+
/**
|
|
213
|
+
* Report each time this container has been delivered whole: on
|
|
214
|
+
* registration, and again after a version checkout, a return to head or a
|
|
215
|
+
* hydrate replaces it.
|
|
216
|
+
*
|
|
217
|
+
* This is where the work that follows on the collection belongs rather than
|
|
218
|
+
* on any single change: frame the camera on what arrived, run the first pass
|
|
219
|
+
* over it. It repeats because that work is owed again each time the
|
|
220
|
+
* container is replaced.
|
|
221
|
+
*
|
|
222
|
+
* It reports that this peer holds what it had converged to when the delivery
|
|
223
|
+
* began, which is the only claim available: more always arrives afterwards.
|
|
224
|
+
*/
|
|
225
|
+
onSettled(cb: () => void): HandleUnsubscribe;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// An ordered map: a keyed map whose key insertion order is mirrored into a
|
|
229
|
+
// sibling list, kept in lockstep. The record store (`instances` +
|
|
230
|
+
// `instance_order`) is the motivating adopter, reached as
|
|
231
|
+
// `doc.orderedMap('instances', 'instance_order')`, but the primitive names no
|
|
232
|
+
// format: two ordinary root containers bound so an app cannot forget to
|
|
233
|
+
// update the order list on add / remove.
|
|
234
|
+
//
|
|
235
|
+
// Add appends to the order list, delete removes from it, both in ONE commit
|
|
236
|
+
// (one undo step). Reassigning a key's value (a `params` edit) leaves the
|
|
237
|
+
// order untouched and stays O(1): only structural add / delete touches it.
|
|
238
|
+
// `insert` and `move` place a key somewhere other than the end.
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Where an ordered map puts a key.
|
|
242
|
+
*
|
|
243
|
+
* The two anchor forms name a neighbouring key, which is what a reader's
|
|
244
|
+
* gesture actually means (insert after the paragraph the caret is in, drop
|
|
245
|
+
* this step on that one) and what survives a peer editing the collection
|
|
246
|
+
* between the render and the write. `index` counts positions in `keys()`, for
|
|
247
|
+
* a surface that works in indices; anything at or past the end appends, and a
|
|
248
|
+
* negative index goes to the front.
|
|
249
|
+
*
|
|
250
|
+
* An anchor key the order list does not name puts the key at the end.
|
|
251
|
+
*/
|
|
252
|
+
export type OrderedMapPosition =
|
|
253
|
+
| { before: string }
|
|
254
|
+
| { after: string }
|
|
255
|
+
| { index: number };
|
|
256
|
+
|
|
257
|
+
export interface OrderedMapHandle<T = unknown> {
|
|
258
|
+
// Per-entry read. Returns undefined when missing.
|
|
259
|
+
get(entryKey: string): T | undefined;
|
|
260
|
+
// Whether an entry exists in the map.
|
|
261
|
+
has(entryKey: string): boolean;
|
|
262
|
+
// Keys in order-list order, filtered to keys still present in the map.
|
|
263
|
+
keys(): string[];
|
|
264
|
+
// `[key, value]` records walking the order list, so an app reading in
|
|
265
|
+
// source order doesn't re-walk the order list by hand.
|
|
266
|
+
ordered(): { key: string; value: T }[];
|
|
267
|
+
// Mutate the map; the sibling order list reconciles in the same commit
|
|
268
|
+
// (new keys appended in insertion order, deleted keys pruned). Drive it
|
|
269
|
+
// like `MapHandle.mutate`: `m[key] = record`, `delete m[key]`,
|
|
270
|
+
// `m[key].field = ...`.
|
|
271
|
+
mutate(fn: (draft: Record<string, T>) => void): void;
|
|
272
|
+
// Write `value` under `entryKey` and put that key at `at`, both in ONE
|
|
273
|
+
// commit (one undo step) like every other structural edit here. A key the
|
|
274
|
+
// map already holds is written and repositioned rather than named twice.
|
|
275
|
+
insert(entryKey: string, value: T, at: OrderedMapPosition): void;
|
|
276
|
+
// Reposition a key the map already holds, writing the order list alone. A
|
|
277
|
+
// key the map does not hold does nothing, so a reorder racing a peer's
|
|
278
|
+
// delete of the same key leaves the delete standing.
|
|
279
|
+
move(entryKey: string, to: OrderedMapPosition): void;
|
|
280
|
+
// Fires once on registration (the whole map as `change.set` in order),
|
|
281
|
+
// then on every change. A field edit carries only `set` / `deleted`; a
|
|
282
|
+
// change that moves the order also carries `change.order` (live keys in
|
|
283
|
+
// order) so the app re-sequences the views it holds. A move the map does
|
|
284
|
+
// not see (an edit of the sibling order list, a merge that resequences
|
|
285
|
+
// keys already present) arrives as an `order` of its own with an empty
|
|
286
|
+
// `set`. Content stays proportional to what the change wrote; re-sequencing
|
|
287
|
+
// happens only when the order moves, and at most once per change.
|
|
288
|
+
subscribe(cb: MapSubscriber<T>): HandleUnsubscribe;
|
|
289
|
+
/**
|
|
290
|
+
* Report each time this container has been delivered whole: on
|
|
291
|
+
* registration, and again after a version checkout, a return to head or a
|
|
292
|
+
* hydrate replaces it.
|
|
293
|
+
*
|
|
294
|
+
* This is where the work that follows on the collection belongs rather than
|
|
295
|
+
* on any single change: frame the camera on what arrived, run the first pass
|
|
296
|
+
* over it. It repeats because that work is owed again each time the
|
|
297
|
+
* container is replaced.
|
|
298
|
+
*
|
|
299
|
+
* It reports that this peer holds what it had converged to when the delivery
|
|
300
|
+
* began, which is the only claim available: more always arrives afterwards.
|
|
301
|
+
*/
|
|
302
|
+
onSettled(cb: () => void): HandleUnsubscribe;
|
|
303
|
+
}
|