@mostra/react 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +16 -2
- package/dist/form.d.ts +20 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +259 -0
- package/dist/form.js.map +1 -0
- package/dist/hooks-Dh_l8KqJ.js +2737 -0
- package/dist/hooks-Dh_l8KqJ.js.map +1 -0
- package/dist/index.d.ts +412 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +199 -0
- package/dist/index.js.map +1 -0
- package/dist/mount-Bpg0M5ln.js +516 -0
- package/dist/mount-Bpg0M5ln.js.map +1 -0
- package/dist/router.d.ts +28 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +52 -0
- package/dist/router.js.map +1 -0
- package/package.json +117 -3
- package/src/form.ts +326 -0
- package/src/hooks.ts +530 -0
- package/src/index.ts +75 -0
- package/src/mount.ts +78 -0
- package/src/router.ts +84 -0
- package/src/runtime/boot.ts +306 -0
- package/src/runtime/bridge-limits.ts +30 -0
- package/src/runtime/errors.ts +53 -0
- package/src/runtime/journal.ts +219 -0
- package/src/runtime/server-client.ts +745 -0
- package/src/runtime/store.ts +1641 -0
- package/src/runtime/transport.ts +342 -0
- package/src/runtime/verify-probe.ts +67 -0
- package/src/server-hooks.ts +291 -0
- package/src/server-reference.ts +54 -0
- package/src/shell-bridge.ts +212 -0
- package/src/storage.ts +165 -0
|
@@ -0,0 +1,2737 @@
|
|
|
1
|
+
import { BROWSER_PROTOCOL_REVISION, MCP_APPS_PROTOCOL_VERSION, MutationId, OPEN_LINK_SCHEMES, RemoteError, SIGNAL_NAME_PATTERN, ValueReader, connectBridge, deserializeAll, isFieldId, isFinalRunStatus, mostraControlsRequest, mostraReady, serializeAll, uiInitialize, uiInitialized, uiOpenLink } from "@mostra/protocol";
|
|
2
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
3
|
+
//#region src/runtime/errors.ts
|
|
4
|
+
/** An error with a Mostra diagnostic code, such as `MOSTRA_SUBMIT_CHANNEL_DENIED`. */
|
|
5
|
+
var MostraError = class extends Error {
|
|
6
|
+
/** The stable diagnostic code. */
|
|
7
|
+
code;
|
|
8
|
+
/** The HTTP status the gateway answered with, when the error came from it. */
|
|
9
|
+
status;
|
|
10
|
+
/** The diagnostic, when the gateway sent one. */
|
|
11
|
+
diagnostic;
|
|
12
|
+
/** Extra data the refusal carried, such as the existing submission of `MOSTRA_SUBMIT_ALREADY`. */
|
|
13
|
+
body;
|
|
14
|
+
constructor(code, message, options = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "MostraError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.status = options.status;
|
|
19
|
+
this.diagnostic = options.diagnostic;
|
|
20
|
+
this.body = options.body;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/** `MOSTRA_TARGET_UNAVAILABLE`: what the delivery target can't do (a static export, D111). */
|
|
24
|
+
function targetUnavailable(what) {
|
|
25
|
+
return new MostraError("MOSTRA_TARGET_UNAVAILABLE", `${what} needs the artifact's Mostra link; this copy of the page has no Mostra server.`);
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/runtime/journal.ts
|
|
29
|
+
/** The key of a pending edit: one per viewer, tab, scope and field. */
|
|
30
|
+
const pendingKey = (entry) => `p\u0000${entry.viewerId}\u0000${entry.tab}\u0000${entry.scope}\u0000${entry.op.id}`;
|
|
31
|
+
const mutationKey = (mutationId) => `m\u0000${mutationId}`;
|
|
32
|
+
const keyOf = (entry) => entry.kind === "pending" ? pendingKey(entry) : mutationKey(entry.mutationId);
|
|
33
|
+
function split(entries, viewerId) {
|
|
34
|
+
const mine = entries.filter((entry) => entry.viewerId === viewerId);
|
|
35
|
+
return {
|
|
36
|
+
pending: mine.filter((entry) => entry.kind === "pending"),
|
|
37
|
+
mutations: mine.filter((entry) => entry.kind === "mutation").sort((a, b) => a.seq - b.seq)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The journal in memory: for targets without IndexedDB, and for tests. */
|
|
41
|
+
var MemoryJournal = class {
|
|
42
|
+
durable = false;
|
|
43
|
+
#entries = /* @__PURE__ */ new Map();
|
|
44
|
+
async load(viewerId) {
|
|
45
|
+
return split([...this.#entries.values()], viewerId);
|
|
46
|
+
}
|
|
47
|
+
async putPending(entry) {
|
|
48
|
+
this.#entries.set(pendingKey(entry), entry);
|
|
49
|
+
}
|
|
50
|
+
async promote(mutation, consumed) {
|
|
51
|
+
for (const entry of consumed) this.#entries.delete(pendingKey(entry));
|
|
52
|
+
this.#entries.set(mutationKey(mutation.mutationId), mutation);
|
|
53
|
+
}
|
|
54
|
+
async remove(mutationId) {
|
|
55
|
+
this.#entries.delete(mutationKey(mutationId));
|
|
56
|
+
}
|
|
57
|
+
async dropPending(entry) {
|
|
58
|
+
this.#entries.delete(pendingKey(entry));
|
|
59
|
+
}
|
|
60
|
+
async adopt(entries, tab) {
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
this.#entries.delete(keyOf(entry));
|
|
63
|
+
const moved = {
|
|
64
|
+
...entry,
|
|
65
|
+
tab
|
|
66
|
+
};
|
|
67
|
+
const key = keyOf(moved);
|
|
68
|
+
if (!this.#entries.has(key)) this.#entries.set(key, moved);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const DB_NAME = "mostra";
|
|
73
|
+
const STORE = "journal";
|
|
74
|
+
function request(req) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
req.onsuccess = () => resolve(req.result);
|
|
77
|
+
req.onerror = () => reject(req.error);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function done(transaction) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
transaction.oncomplete = () => resolve();
|
|
83
|
+
transaction.onerror = () => reject(transaction.error);
|
|
84
|
+
transaction.onabort = () => reject(transaction.error ?? /* @__PURE__ */ new Error("The transaction aborted."));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The journal in the artifact origin's IndexedDB: one database per origin, so per artifact (D38).
|
|
89
|
+
* Each write starts its transaction at once, so an edit made just before the page closes is in it.
|
|
90
|
+
*/
|
|
91
|
+
var IndexedDbJournal = class IndexedDbJournal {
|
|
92
|
+
durable = true;
|
|
93
|
+
#db;
|
|
94
|
+
constructor(db) {
|
|
95
|
+
this.#db = db;
|
|
96
|
+
}
|
|
97
|
+
/** Opens the journal; `undefined` where IndexedDB can't be used (a private mode, a sandbox). */
|
|
98
|
+
static async open() {
|
|
99
|
+
if (typeof indexedDB === "undefined") return void 0;
|
|
100
|
+
try {
|
|
101
|
+
const open = indexedDB.open(DB_NAME, 1);
|
|
102
|
+
open.onupgradeneeded = () => {
|
|
103
|
+
if (!open.result.objectStoreNames.contains(STORE)) open.result.createObjectStore(STORE);
|
|
104
|
+
};
|
|
105
|
+
return new IndexedDbJournal(await request(open));
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
#write(change) {
|
|
111
|
+
const transaction = this.#db.transaction(STORE, "readwrite");
|
|
112
|
+
change(transaction.objectStore(STORE));
|
|
113
|
+
return done(transaction);
|
|
114
|
+
}
|
|
115
|
+
async load(viewerId) {
|
|
116
|
+
return split(await request(this.#db.transaction(STORE, "readonly").objectStore(STORE).getAll()), viewerId);
|
|
117
|
+
}
|
|
118
|
+
putPending(entry) {
|
|
119
|
+
return this.#write((store) => store.put(entry, pendingKey(entry)));
|
|
120
|
+
}
|
|
121
|
+
promote(mutation, consumed) {
|
|
122
|
+
return this.#write((store) => {
|
|
123
|
+
for (const entry of consumed) store.delete(pendingKey(entry));
|
|
124
|
+
store.put(mutation, mutationKey(mutation.mutationId));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
remove(mutationId) {
|
|
128
|
+
return this.#write((store) => store.delete(mutationKey(mutationId)));
|
|
129
|
+
}
|
|
130
|
+
dropPending(entry) {
|
|
131
|
+
return this.#write((store) => store.delete(pendingKey(entry)));
|
|
132
|
+
}
|
|
133
|
+
async adopt(entries, tab) {
|
|
134
|
+
const transaction = this.#db.transaction(STORE, "readwrite");
|
|
135
|
+
const store = transaction.objectStore(STORE);
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
store.delete(keyOf(entry));
|
|
138
|
+
const moved = {
|
|
139
|
+
...entry,
|
|
140
|
+
tab
|
|
141
|
+
};
|
|
142
|
+
const added = store.add(moved, keyOf(moved));
|
|
143
|
+
added.onerror = (event) => {
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
event.stopPropagation();
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
await done(transaction);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/runtime/transport.ts
|
|
153
|
+
/**
|
|
154
|
+
* How the client runtime reaches Mostra (INTERFACES §4): the browser protocol over the artifact's
|
|
155
|
+
* own origin, with its session cookie and CSRF token. A delivery target without a gateway (a
|
|
156
|
+
* static export, an MCP App before its transport, D111) gets another implementation of the same
|
|
157
|
+
* interface, so hooks never know which they talk to.
|
|
158
|
+
*/
|
|
159
|
+
/** The connection to the gateway failed before it answered: the page is offline. */
|
|
160
|
+
var NetworkError = class extends Error {
|
|
161
|
+
constructor(message) {
|
|
162
|
+
super(message);
|
|
163
|
+
this.name = "NetworkError";
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
/** After this many stream failures in a row, the client long-polls instead (FAILURE_MODEL §8). */
|
|
167
|
+
const STREAM_FAILURES_BEFORE_POLL = 2;
|
|
168
|
+
/**
|
|
169
|
+
* The browser protocol over HTTP, on the artifact's origin. The CSRF token comes from
|
|
170
|
+
* `/_mostra/session`, once; a refusal is a {@link MostraError} with the gateway's diagnostic, and
|
|
171
|
+
* an unreachable gateway a {@link NetworkError}.
|
|
172
|
+
*/
|
|
173
|
+
var HttpTransport = class {
|
|
174
|
+
#viewId;
|
|
175
|
+
#base;
|
|
176
|
+
#csrf;
|
|
177
|
+
/**
|
|
178
|
+
* `csrf` is the session's token when the caller already read it (the bridge does): a save made
|
|
179
|
+
* as the page closes must not wait for a request the closing page would cancel (IN-02).
|
|
180
|
+
*/
|
|
181
|
+
constructor(viewId, options = {}) {
|
|
182
|
+
this.#viewId = viewId;
|
|
183
|
+
this.#base = options.base ?? `/_mostra/v/${BROWSER_PROTOCOL_REVISION}`;
|
|
184
|
+
if (options.csrf) this.#csrf = Promise.resolve(options.csrf);
|
|
185
|
+
}
|
|
186
|
+
#token() {
|
|
187
|
+
this.#csrf ??= (async () => {
|
|
188
|
+
return (await this.#request("/_mostra/session", { method: "GET" })).csrf;
|
|
189
|
+
})().catch((error) => {
|
|
190
|
+
this.#csrf = void 0;
|
|
191
|
+
throw error;
|
|
192
|
+
});
|
|
193
|
+
return this.#csrf;
|
|
194
|
+
}
|
|
195
|
+
async #request(path, init) {
|
|
196
|
+
let response;
|
|
197
|
+
try {
|
|
198
|
+
response = await fetch(path, {
|
|
199
|
+
credentials: "same-origin",
|
|
200
|
+
cache: "no-store",
|
|
201
|
+
...init
|
|
202
|
+
});
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new NetworkError(error.message);
|
|
205
|
+
}
|
|
206
|
+
let body;
|
|
207
|
+
try {
|
|
208
|
+
body = await response.json();
|
|
209
|
+
} catch {
|
|
210
|
+
body = void 0;
|
|
211
|
+
}
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
const diagnostic = body ?? {};
|
|
214
|
+
if (typeof diagnostic.code !== "string") throw new NetworkError(`The gateway answered ${response.status}.`);
|
|
215
|
+
throw new MostraError(diagnostic.code, diagnostic.message ?? diagnostic.code, {
|
|
216
|
+
status: response.status,
|
|
217
|
+
diagnostic,
|
|
218
|
+
body: diagnostic
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return body;
|
|
222
|
+
}
|
|
223
|
+
async #post(path, body, keepalive = false) {
|
|
224
|
+
const text = JSON.stringify(body);
|
|
225
|
+
const csrf = await this.#token();
|
|
226
|
+
return this.#request(`${this.#base}${path}`, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: {
|
|
229
|
+
"content-type": "application/json",
|
|
230
|
+
"x-mostra-csrf": csrf
|
|
231
|
+
},
|
|
232
|
+
body: text,
|
|
233
|
+
keepalive: keepalive && text.length < 64512
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async snapshot() {
|
|
237
|
+
const result = await this.#request(`${this.#base}/events/poll?view=${this.#viewId}&wait=0`, { method: "GET" });
|
|
238
|
+
if (!result.snapshot) throw new NetworkError("The gateway sent no snapshot.");
|
|
239
|
+
return result.snapshot;
|
|
240
|
+
}
|
|
241
|
+
mutate(request, options) {
|
|
242
|
+
return this.#post("/fields", request, options?.keepalive === true);
|
|
243
|
+
}
|
|
244
|
+
submit(request) {
|
|
245
|
+
return this.#post("/submit", request);
|
|
246
|
+
}
|
|
247
|
+
signal(request) {
|
|
248
|
+
return this.#post("/signal", request);
|
|
249
|
+
}
|
|
250
|
+
async *call(fnId, request, signal) {
|
|
251
|
+
const csrf = await this.#token();
|
|
252
|
+
let response;
|
|
253
|
+
try {
|
|
254
|
+
response = await fetch(`${this.#base}/call/${encodeURIComponent(fnId)}`, {
|
|
255
|
+
method: "POST",
|
|
256
|
+
credentials: "same-origin",
|
|
257
|
+
cache: "no-store",
|
|
258
|
+
headers: {
|
|
259
|
+
"content-type": "application/json",
|
|
260
|
+
"x-mostra-csrf": csrf
|
|
261
|
+
},
|
|
262
|
+
body: JSON.stringify(request),
|
|
263
|
+
...signal ? { signal } : {}
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
throw new NetworkError(error.message);
|
|
267
|
+
}
|
|
268
|
+
if (!response.ok || !response.body) {
|
|
269
|
+
let body;
|
|
270
|
+
try {
|
|
271
|
+
body = await response.json();
|
|
272
|
+
} catch {
|
|
273
|
+
body = void 0;
|
|
274
|
+
}
|
|
275
|
+
const diagnostic = body ?? {};
|
|
276
|
+
if (typeof diagnostic.code !== "string") throw new NetworkError(`The gateway answered ${response.status}.`);
|
|
277
|
+
throw new MostraError(diagnostic.code, diagnostic.message ?? diagnostic.code, {
|
|
278
|
+
status: response.status,
|
|
279
|
+
diagnostic
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
283
|
+
let buffer = "";
|
|
284
|
+
try {
|
|
285
|
+
for (;;) {
|
|
286
|
+
let chunk;
|
|
287
|
+
try {
|
|
288
|
+
chunk = await reader.read();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
throw new NetworkError(error.message);
|
|
291
|
+
}
|
|
292
|
+
if (chunk.done) break;
|
|
293
|
+
buffer += chunk.value;
|
|
294
|
+
let newline = buffer.indexOf("\n");
|
|
295
|
+
while (newline !== -1) {
|
|
296
|
+
const line = buffer.slice(0, newline);
|
|
297
|
+
buffer = buffer.slice(newline + 1);
|
|
298
|
+
if (line.trim() !== "") yield JSON.parse(line);
|
|
299
|
+
newline = buffer.indexOf("\n");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} finally {
|
|
303
|
+
reader.releaseLock();
|
|
304
|
+
}
|
|
305
|
+
if (buffer.trim() !== "") yield JSON.parse(buffer);
|
|
306
|
+
}
|
|
307
|
+
startRun(request) {
|
|
308
|
+
return this.#post("/runs", request);
|
|
309
|
+
}
|
|
310
|
+
attachRuns(request) {
|
|
311
|
+
return this.#post("/runs/attach", request);
|
|
312
|
+
}
|
|
313
|
+
cancelRun(runId, viewId) {
|
|
314
|
+
return this.#post(`/runs/${encodeURIComponent(runId)}/cancel`, { viewId });
|
|
315
|
+
}
|
|
316
|
+
live(request) {
|
|
317
|
+
return this.#post("/live", request);
|
|
318
|
+
}
|
|
319
|
+
subscribe(cursor, handlers) {
|
|
320
|
+
let closed = false;
|
|
321
|
+
let last = cursor;
|
|
322
|
+
let source;
|
|
323
|
+
let failures = 0;
|
|
324
|
+
let polling = false;
|
|
325
|
+
let retry;
|
|
326
|
+
const query = () => `view=${this.#viewId}${last === void 0 ? "" : `&cursor=${last}`}`;
|
|
327
|
+
const poll = async () => {
|
|
328
|
+
polling = true;
|
|
329
|
+
let backoff = 1e3;
|
|
330
|
+
while (!closed) try {
|
|
331
|
+
const result = await this.#request(`${this.#base}/events/poll?${query()}&wait=25`, { method: "GET" });
|
|
332
|
+
backoff = 1e3;
|
|
333
|
+
handlers.onStatus("live");
|
|
334
|
+
if (result.snapshot) handlers.onSnapshot(result.snapshot);
|
|
335
|
+
if (result.events.length > 0) handlers.onEvents(result.events);
|
|
336
|
+
for (const message of result.ephemeral ?? []) handlers.onEphemeral?.(message);
|
|
337
|
+
last = result.cursor;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (error instanceof MostraError && error.code === "MOSTRA_HYGIENE_TOKEN_REVOKED") {
|
|
340
|
+
handlers.onStatus("revoked");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
handlers.onStatus("offline");
|
|
344
|
+
await new Promise((resolve) => {
|
|
345
|
+
retry = setTimeout(resolve, backoff);
|
|
346
|
+
});
|
|
347
|
+
backoff = Math.min(backoff * 2, 3e4);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const open = () => {
|
|
351
|
+
if (closed) return;
|
|
352
|
+
if (typeof EventSource === "undefined") {
|
|
353
|
+
poll();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
source = new EventSource(`${this.#base}/events?${query()}`);
|
|
357
|
+
source.addEventListener("open", () => {
|
|
358
|
+
failures = 0;
|
|
359
|
+
handlers.onStatus("live");
|
|
360
|
+
});
|
|
361
|
+
source.addEventListener("snapshot", (event) => {
|
|
362
|
+
failures = 0;
|
|
363
|
+
const snapshot = JSON.parse(event.data);
|
|
364
|
+
last = snapshot.cursor;
|
|
365
|
+
handlers.onSnapshot(snapshot);
|
|
366
|
+
});
|
|
367
|
+
source.addEventListener("mostra", (event) => {
|
|
368
|
+
failures = 0;
|
|
369
|
+
const record = JSON.parse(event.data);
|
|
370
|
+
last = record.cursor;
|
|
371
|
+
handlers.onEvents([record]);
|
|
372
|
+
});
|
|
373
|
+
source.addEventListener("run", (event) => {
|
|
374
|
+
const run = JSON.parse(event.data);
|
|
375
|
+
handlers.onEphemeral?.({
|
|
376
|
+
kind: "run",
|
|
377
|
+
run
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
source.addEventListener("live", (event) => {
|
|
381
|
+
const live = JSON.parse(event.data);
|
|
382
|
+
handlers.onEphemeral?.({
|
|
383
|
+
kind: "live",
|
|
384
|
+
live
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
source.addEventListener("error", () => {
|
|
388
|
+
if (closed) return;
|
|
389
|
+
failures += 1;
|
|
390
|
+
handlers.onStatus("offline");
|
|
391
|
+
if (source?.readyState === EventSource.CLOSED || failures >= STREAM_FAILURES_BEFORE_POLL) {
|
|
392
|
+
source?.close();
|
|
393
|
+
source = void 0;
|
|
394
|
+
if (!polling) poll();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
open();
|
|
399
|
+
return () => {
|
|
400
|
+
closed = true;
|
|
401
|
+
source?.close();
|
|
402
|
+
if (retry !== void 0) clearTimeout(retry);
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/runtime/server-client.ts
|
|
408
|
+
/**
|
|
409
|
+
* The page's server side (D54, D56; WP-08): plain calls to `"use server"` functions, durable action
|
|
410
|
+
* runs, and live sources, over the store's transport. Runs and sources are tracked per page: one
|
|
411
|
+
* record per run and per source however many hooks show it, fed by the stream's ephemeral
|
|
412
|
+
* messages and read again whenever a message may have been missed (a gap, a reconnect, a renewal),
|
|
413
|
+
* so what a page shows always converges on what the server holds.
|
|
414
|
+
*/
|
|
415
|
+
/** How often a page renews its interest in the runs and sources it shows (the lease is a minute). */
|
|
416
|
+
const RENEW_MS = 2e4;
|
|
417
|
+
/** How many times a run's start is retried after the network failed, with the same client key. */
|
|
418
|
+
const START_ATTEMPTS = 6;
|
|
419
|
+
/** How many runs' early updates a page keeps, and how many updates of each (see `#keepEarly`). */
|
|
420
|
+
const EARLY_RUNS = 64;
|
|
421
|
+
const EARLY_DELTAS_PER_RUN = 32;
|
|
422
|
+
/** The function ID a server reference carries, or `undefined` for any other function. */
|
|
423
|
+
function serverFunctionId(fn) {
|
|
424
|
+
const id = fn?.mostraServerFunctionId;
|
|
425
|
+
return typeof id === "string" ? id : void 0;
|
|
426
|
+
}
|
|
427
|
+
/** A random client key: a run's request idempotency (a retried POST joins its own run). */
|
|
428
|
+
function clientKey() {
|
|
429
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
430
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
431
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
432
|
+
}
|
|
433
|
+
/** The error a failed or cancelled run, or call, rejects with. */
|
|
434
|
+
function failure(error, fallback) {
|
|
435
|
+
return new MostraError(error?.code ?? fallback, error?.message ?? "The action failed.");
|
|
436
|
+
}
|
|
437
|
+
/** Plain calls, action runs and live sources for one page. */
|
|
438
|
+
var ServerClient = class {
|
|
439
|
+
#options;
|
|
440
|
+
#runs = /* @__PURE__ */ new Map();
|
|
441
|
+
#live = /* @__PURE__ */ new Map();
|
|
442
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
443
|
+
#version = 0;
|
|
444
|
+
#nextWatcher = 1;
|
|
445
|
+
#renewTimer;
|
|
446
|
+
#liveFlush;
|
|
447
|
+
#liveRefresh = /* @__PURE__ */ new Set();
|
|
448
|
+
#notifyScheduled = false;
|
|
449
|
+
/** Plain calls whose value hasn't arrived yet. */
|
|
450
|
+
#callsInFlight = 0;
|
|
451
|
+
/**
|
|
452
|
+
* Updates for runs the page doesn't track yet: a fast run's end can arrive on the stream before
|
|
453
|
+
* the answer that starts it, and is applied when that answer is (bounded, oldest dropped).
|
|
454
|
+
*/
|
|
455
|
+
#early = /* @__PURE__ */ new Map();
|
|
456
|
+
constructor(options) {
|
|
457
|
+
this.#options = options;
|
|
458
|
+
}
|
|
459
|
+
/** Calls `listener` after every change; returns the function that stops. */
|
|
460
|
+
subscribe = (listener) => {
|
|
461
|
+
this.#listeners.add(listener);
|
|
462
|
+
return () => this.#listeners.delete(listener);
|
|
463
|
+
};
|
|
464
|
+
/** A number that changes with every change, for `useSyncExternalStore`. */
|
|
465
|
+
version = () => this.#version;
|
|
466
|
+
/**
|
|
467
|
+
* Server work the page is waiting on: plain calls whose value hasn't arrived, runs that haven't
|
|
468
|
+
* ended, and live sources a component shows that have no first value yet. `verify`'s field
|
|
469
|
+
* probe reads it, so the runner judges the page once what it loads at mount has arrived (Opus
|
|
470
|
+
* review finding 1).
|
|
471
|
+
*/
|
|
472
|
+
pending() {
|
|
473
|
+
let count = this.#callsInFlight;
|
|
474
|
+
for (const tracked of this.#runs.values()) if (!isFinalRunStatus(tracked.view.status)) count += 1;
|
|
475
|
+
for (const tracked of this.#live.values()) if (tracked.view.loading && tracked.watchers.size > 0) count += 1;
|
|
476
|
+
return count;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Notes a change. Listeners hear about it once per animation frame (FAILURE_MODEL §6), or in a
|
|
480
|
+
* microtask where a page has no frames to draw (a hidden tab, outside a browser), so a source
|
|
481
|
+
* producing hundreds of values a second renders at most once a frame.
|
|
482
|
+
*/
|
|
483
|
+
#changed() {
|
|
484
|
+
this.#version += 1;
|
|
485
|
+
if (this.#notifyScheduled) return;
|
|
486
|
+
this.#notifyScheduled = true;
|
|
487
|
+
const notify = () => {
|
|
488
|
+
this.#notifyScheduled = false;
|
|
489
|
+
for (const listener of [...this.#listeners]) listener();
|
|
490
|
+
};
|
|
491
|
+
if ((typeof document === "undefined" ? false : document.visibilityState === "visible") && typeof requestAnimationFrame === "function") requestAnimationFrame(notify);
|
|
492
|
+
else queueMicrotask(notify);
|
|
493
|
+
}
|
|
494
|
+
#transport(what) {
|
|
495
|
+
const transport = this.#options.transport;
|
|
496
|
+
if (!transport?.call || this.#options.viewId === void 0) throw targetUnavailable(what);
|
|
497
|
+
return transport;
|
|
498
|
+
}
|
|
499
|
+
get #viewId() {
|
|
500
|
+
return this.#options.viewId ?? "";
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Calls a server function: no record, the result as soon as its first batch arrives (an async
|
|
504
|
+
* iterable result keeps yielding as later batches come).
|
|
505
|
+
*/
|
|
506
|
+
async call(fnId, args) {
|
|
507
|
+
const transport = this.#transport(`Calling the server function ${fnId}`);
|
|
508
|
+
const batches = await serializeAll([...args], {
|
|
509
|
+
mode: "argument",
|
|
510
|
+
serverFunctionId: (fn) => serverFunctionId(fn)
|
|
511
|
+
});
|
|
512
|
+
const reader = new ValueReader({ serverFunction: (id) => this.reference(id) });
|
|
513
|
+
const lines = transport.call(fnId, {
|
|
514
|
+
viewId: this.#viewId,
|
|
515
|
+
args: batches
|
|
516
|
+
})[Symbol.asyncIterator]();
|
|
517
|
+
let ended;
|
|
518
|
+
const pump = async () => {
|
|
519
|
+
for (;;) {
|
|
520
|
+
const next = await lines.next();
|
|
521
|
+
if (next.done) break;
|
|
522
|
+
const line = next.value;
|
|
523
|
+
if ("batch" in line) reader.push(line.batch);
|
|
524
|
+
else ended = line.end;
|
|
525
|
+
}
|
|
526
|
+
if (ended?.status === "failed") throw failure(ended.error, "MOSTRA_ACTION_THREW");
|
|
527
|
+
if (!ended) throw new NetworkError("The call's answer ended early.");
|
|
528
|
+
};
|
|
529
|
+
const pumped = pump();
|
|
530
|
+
this.#callsInFlight += 1;
|
|
531
|
+
try {
|
|
532
|
+
const root = await Promise.race([reader.root, pumped.then(() => reader.root)]);
|
|
533
|
+
pumped.catch((error) => reader.fail(error));
|
|
534
|
+
return root;
|
|
535
|
+
} finally {
|
|
536
|
+
this.#callsInFlight -= 1;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** The client stub of a server function a result refers to. */
|
|
540
|
+
reference(id) {
|
|
541
|
+
const fn = ((...args) => this.call(id, args));
|
|
542
|
+
Object.defineProperty(fn, "mostraServerFunctionId", {
|
|
543
|
+
value: id,
|
|
544
|
+
enumerable: true
|
|
545
|
+
});
|
|
546
|
+
return fn;
|
|
547
|
+
}
|
|
548
|
+
/** What the page knows of a run. */
|
|
549
|
+
run(runId) {
|
|
550
|
+
return this.#runs.get(runId)?.view;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Starts a run, or joins the one its key names (AC-03). The request carries a client key, so a
|
|
554
|
+
* retry after a dropped answer joins the run it started instead of starting another.
|
|
555
|
+
*/
|
|
556
|
+
async startRun(fnId, args, key) {
|
|
557
|
+
const transport = this.#transport("Running actions");
|
|
558
|
+
const batches = await serializeAll([...args], {
|
|
559
|
+
mode: "argument",
|
|
560
|
+
serverFunctionId: (fn) => serverFunctionId(fn)
|
|
561
|
+
});
|
|
562
|
+
const ck = clientKey();
|
|
563
|
+
let delay = 500;
|
|
564
|
+
for (let attempt = 1;; attempt++) try {
|
|
565
|
+
const state = await transport.startRun({
|
|
566
|
+
viewId: this.#viewId,
|
|
567
|
+
fnId,
|
|
568
|
+
args: batches,
|
|
569
|
+
...key !== void 0 ? { key } : {},
|
|
570
|
+
clientKey: ck
|
|
571
|
+
});
|
|
572
|
+
return this.#track(state);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (!(error instanceof NetworkError) || attempt >= START_ATTEMPTS) throw error;
|
|
575
|
+
await new Promise((resolve) => this.#setTimeout(() => resolve(void 0), delay));
|
|
576
|
+
delay = Math.min(delay * 2, 8e3);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/** Finds runs by ID, or by function and key (a reloaded page re-attaching, AC-02). */
|
|
580
|
+
async attach(input) {
|
|
581
|
+
const transport = this.#transport("Running actions");
|
|
582
|
+
const held = (input.runIds ?? []).flatMap((runId) => {
|
|
583
|
+
const tracked = this.#runs.get(runId);
|
|
584
|
+
return tracked && (tracked.view.log.length > 0 || tracked.batches.length > 0) ? [{
|
|
585
|
+
runId,
|
|
586
|
+
log: tracked.view.log.length,
|
|
587
|
+
batches: tracked.batches.length
|
|
588
|
+
}] : [];
|
|
589
|
+
});
|
|
590
|
+
const { runs } = await transport.attachRuns({
|
|
591
|
+
viewId: this.#viewId,
|
|
592
|
+
...input.runIds && input.runIds.length > 0 ? { runIds: input.runIds } : {},
|
|
593
|
+
...input.keyed && input.keyed.length > 0 ? { keyed: input.keyed } : {},
|
|
594
|
+
...held.length > 0 ? { held } : {}
|
|
595
|
+
});
|
|
596
|
+
return runs.map((state) => this.#track(state));
|
|
597
|
+
}
|
|
598
|
+
/** Cancels a run (AC-04): its `abortSignal` fires on the server. */
|
|
599
|
+
async cancel(runId) {
|
|
600
|
+
const state = await this.#transport("Cancelling actions").cancelRun(runId, this.#viewId);
|
|
601
|
+
this.#track(state);
|
|
602
|
+
}
|
|
603
|
+
/** Resolves with the run's result once it succeeded; rejects when it failed or was cancelled. */
|
|
604
|
+
settled(runId) {
|
|
605
|
+
const tracked = this.#runs.get(runId);
|
|
606
|
+
if (!tracked) return Promise.reject(new MostraError("MOSTRA_ACTION_RUN_NOT_FOUND", runId));
|
|
607
|
+
return tracked.settled;
|
|
608
|
+
}
|
|
609
|
+
#newRun(state) {
|
|
610
|
+
let resolve;
|
|
611
|
+
let reject;
|
|
612
|
+
const settled = new Promise((res, rej) => {
|
|
613
|
+
resolve = res;
|
|
614
|
+
reject = rej;
|
|
615
|
+
});
|
|
616
|
+
settled.catch(() => {});
|
|
617
|
+
return {
|
|
618
|
+
view: {
|
|
619
|
+
runId: state.runId,
|
|
620
|
+
fnId: state.fnId,
|
|
621
|
+
...state.key !== void 0 ? { key: state.key } : {},
|
|
622
|
+
status: state.status,
|
|
623
|
+
log: [],
|
|
624
|
+
partial: []
|
|
625
|
+
},
|
|
626
|
+
batches: [],
|
|
627
|
+
reader: new ValueReader({ serverFunction: (id) => this.reference(id) }),
|
|
628
|
+
rootDecoded: false,
|
|
629
|
+
settled,
|
|
630
|
+
resolve,
|
|
631
|
+
reject
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/** Merges a full run state: the server's account wins. */
|
|
635
|
+
#track(state) {
|
|
636
|
+
let tracked = this.#runs.get(state.runId);
|
|
637
|
+
if (!tracked) {
|
|
638
|
+
tracked = this.#newRun(state);
|
|
639
|
+
this.#runs.set(state.runId, tracked);
|
|
640
|
+
}
|
|
641
|
+
this.#merge(tracked, {
|
|
642
|
+
runId: state.runId,
|
|
643
|
+
status: state.status,
|
|
644
|
+
...state.reason !== void 0 ? { reason: state.reason } : {},
|
|
645
|
+
...state.progress ? { progress: state.progress } : {},
|
|
646
|
+
logFrom: state.logFrom ?? 0,
|
|
647
|
+
log: state.log,
|
|
648
|
+
batchFrom: state.batchFrom ?? 0,
|
|
649
|
+
batches: state.batches,
|
|
650
|
+
...state.error ? { error: state.error } : {},
|
|
651
|
+
...state.finishedAt ? { finishedAt: state.finishedAt } : {}
|
|
652
|
+
});
|
|
653
|
+
const early = this.#early.get(state.runId);
|
|
654
|
+
if (early) {
|
|
655
|
+
this.#early.delete(state.runId);
|
|
656
|
+
const current = tracked;
|
|
657
|
+
if (!early.every((delta) => this.#merge(current, delta))) this.attach({ runIds: [state.runId] }).catch(() => {});
|
|
658
|
+
}
|
|
659
|
+
this.#renewSoon();
|
|
660
|
+
return tracked.view;
|
|
661
|
+
}
|
|
662
|
+
/** Applies a run delta; returns `false` when it skipped something the page doesn't have. */
|
|
663
|
+
#merge(tracked, delta) {
|
|
664
|
+
const view = tracked.view;
|
|
665
|
+
if (delta.logFrom > view.log.length || delta.batchFrom > tracked.batches.length) return false;
|
|
666
|
+
if (isFinalRunStatus(view.status) && !isFinalRunStatus(delta.status)) return true;
|
|
667
|
+
const log = [...view.log, ...delta.log.slice(view.log.length - delta.logFrom)];
|
|
668
|
+
const fresh = delta.batches.slice(tracked.batches.length - delta.batchFrom);
|
|
669
|
+
for (const batch of fresh) {
|
|
670
|
+
tracked.batches.push([...batch]);
|
|
671
|
+
try {
|
|
672
|
+
tracked.reader.push(batch);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
tracked.reader.fail(error);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
tracked.view = {
|
|
678
|
+
...view,
|
|
679
|
+
status: delta.status,
|
|
680
|
+
...delta.reason !== void 0 ? { reason: delta.reason } : {},
|
|
681
|
+
...delta.progress ? { progress: delta.progress } : {},
|
|
682
|
+
log,
|
|
683
|
+
...delta.error ? { error: delta.error } : {},
|
|
684
|
+
...delta.finishedAt ? { finishedAt: delta.finishedAt } : {}
|
|
685
|
+
};
|
|
686
|
+
if (fresh.length > 0 && !tracked.rootDecoded) this.#decodeRoot(tracked);
|
|
687
|
+
if (isFinalRunStatus(delta.status)) this.#settle(tracked);
|
|
688
|
+
this.#changed();
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
#decodeRoot(tracked) {
|
|
692
|
+
tracked.rootDecoded = true;
|
|
693
|
+
tracked.reader.root.then((root) => {
|
|
694
|
+
tracked.view = {
|
|
695
|
+
...tracked.view,
|
|
696
|
+
data: root
|
|
697
|
+
};
|
|
698
|
+
this.#changed();
|
|
699
|
+
if (root !== null && typeof root === "object" && Symbol.asyncIterator in root) (async () => {
|
|
700
|
+
try {
|
|
701
|
+
for await (const item of root) {
|
|
702
|
+
tracked.view = {
|
|
703
|
+
...tracked.view,
|
|
704
|
+
partial: [...tracked.view.partial, item]
|
|
705
|
+
};
|
|
706
|
+
this.#changed();
|
|
707
|
+
}
|
|
708
|
+
} catch {}
|
|
709
|
+
})();
|
|
710
|
+
}, () => {});
|
|
711
|
+
}
|
|
712
|
+
/** Settles the run's promise once it reached a final state. */
|
|
713
|
+
#settle(tracked) {
|
|
714
|
+
const { status, error } = tracked.view;
|
|
715
|
+
if (status === "succeeded") {
|
|
716
|
+
if (tracked.batches.length === 0) {
|
|
717
|
+
tracked.resolve(void 0);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (!tracked.rootDecoded) this.#decodeRoot(tracked);
|
|
721
|
+
tracked.reader.root.then(tracked.resolve, tracked.reject);
|
|
722
|
+
queueMicrotask(() => {
|
|
723
|
+
if (!tracked.reader.complete) tracked.reader.fail(new RemoteError({ message: "The result ended early." }));
|
|
724
|
+
});
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const ended = status === "cancelled" ? error ?? {
|
|
728
|
+
code: "MOSTRA_ACTION_CANCELLED",
|
|
729
|
+
message: "The run was cancelled."
|
|
730
|
+
} : error;
|
|
731
|
+
tracked.reader.fail(new RemoteError(ended ?? { message: "The action failed." }));
|
|
732
|
+
tracked.reject(failure(ended, status === "cancelled" ? "MOSTRA_ACTION_CANCELLED" : "MOSTRA_ACTION_THREW"));
|
|
733
|
+
}
|
|
734
|
+
/** What the page knows of a live source. */
|
|
735
|
+
liveView(key) {
|
|
736
|
+
return this.#live.get(key)?.view;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Shows a live source: reads it now and keeps it produced while the page shows it. Returns the
|
|
740
|
+
* function that stops showing it.
|
|
741
|
+
*/
|
|
742
|
+
watchLive(key, window) {
|
|
743
|
+
let tracked = this.#live.get(key);
|
|
744
|
+
if (!tracked) {
|
|
745
|
+
tracked = {
|
|
746
|
+
view: {
|
|
747
|
+
key,
|
|
748
|
+
loading: true,
|
|
749
|
+
ring: []
|
|
750
|
+
},
|
|
751
|
+
watchers: /* @__PURE__ */ new Map(),
|
|
752
|
+
newest: 0,
|
|
753
|
+
ringRevisions: [],
|
|
754
|
+
applying: Promise.resolve()
|
|
755
|
+
};
|
|
756
|
+
this.#live.set(key, tracked);
|
|
757
|
+
}
|
|
758
|
+
const id = this.#nextWatcher++;
|
|
759
|
+
tracked.watchers.set(id, window);
|
|
760
|
+
this.#flushLive();
|
|
761
|
+
return () => {
|
|
762
|
+
tracked.watchers.delete(id);
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/** Asks the source's producer to load again now (`useLive`'s `refresh()`). */
|
|
766
|
+
refreshLive(key) {
|
|
767
|
+
this.#liveRefresh.add(key);
|
|
768
|
+
this.#flushLive();
|
|
769
|
+
}
|
|
770
|
+
#windowOf(tracked) {
|
|
771
|
+
let largest;
|
|
772
|
+
for (const window of tracked.watchers.values()) if (window !== void 0) largest = Math.max(largest ?? 0, window);
|
|
773
|
+
return largest;
|
|
774
|
+
}
|
|
775
|
+
/** Reads every shown source in one request, coalesced within a tick. */
|
|
776
|
+
#flushLive() {
|
|
777
|
+
if (this.#liveFlush) return;
|
|
778
|
+
this.#liveFlush = Promise.resolve().then(async () => {
|
|
779
|
+
this.#liveFlush = void 0;
|
|
780
|
+
const shown = [...this.#live.values()].filter((tracked) => tracked.watchers.size > 0);
|
|
781
|
+
if (shown.length === 0) return;
|
|
782
|
+
const refresh = this.#liveRefresh;
|
|
783
|
+
this.#liveRefresh = /* @__PURE__ */ new Set();
|
|
784
|
+
let transport;
|
|
785
|
+
try {
|
|
786
|
+
transport = this.#transport("Live data");
|
|
787
|
+
} catch (error) {
|
|
788
|
+
for (const tracked of shown) {
|
|
789
|
+
const diagnostic = error;
|
|
790
|
+
tracked.view = {
|
|
791
|
+
...tracked.view,
|
|
792
|
+
loading: false,
|
|
793
|
+
error: {
|
|
794
|
+
message: diagnostic.message,
|
|
795
|
+
code: diagnostic.code
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
this.#changed();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
const { sources } = await transport.live({
|
|
804
|
+
viewId: this.#viewId,
|
|
805
|
+
keys: shown.map((tracked) => {
|
|
806
|
+
const window = this.#windowOf(tracked);
|
|
807
|
+
return {
|
|
808
|
+
key: tracked.view.key,
|
|
809
|
+
...window !== void 0 ? { window } : {},
|
|
810
|
+
...refresh.has(tracked.view.key) ? { refresh: true } : {}
|
|
811
|
+
};
|
|
812
|
+
})
|
|
813
|
+
});
|
|
814
|
+
for (const source of sources) await this.#applyLiveState(source);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
for (const tracked of shown) {
|
|
817
|
+
if (tracked.view.revision !== void 0) continue;
|
|
818
|
+
tracked.view = {
|
|
819
|
+
...tracked.view,
|
|
820
|
+
...error instanceof MostraError ? {
|
|
821
|
+
loading: false,
|
|
822
|
+
error: {
|
|
823
|
+
message: error.message,
|
|
824
|
+
code: error.code
|
|
825
|
+
}
|
|
826
|
+
} : {}
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
this.#changed();
|
|
830
|
+
}
|
|
831
|
+
this.#renewSoon();
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
/** Applies `apply` to a source after everything that arrived for it before. */
|
|
835
|
+
#inOrder(key, apply) {
|
|
836
|
+
const tracked = this.#live.get(key);
|
|
837
|
+
if (!tracked) return Promise.resolve();
|
|
838
|
+
const next = tracked.applying.then(() => apply(tracked)).catch(() => {});
|
|
839
|
+
tracked.applying = next;
|
|
840
|
+
return next;
|
|
841
|
+
}
|
|
842
|
+
#applyLiveState(state) {
|
|
843
|
+
return this.#inOrder(state.key, (tracked) => this.#applyLiveStateNow(tracked, state));
|
|
844
|
+
}
|
|
845
|
+
#applyLiveUpdate(update) {
|
|
846
|
+
const tracked = this.#live.get(update.key);
|
|
847
|
+
if (!tracked) return Promise.resolve();
|
|
848
|
+
if (update.value !== void 0 && this.#windowOf(tracked) === void 0) {
|
|
849
|
+
const waiting = tracked.pending;
|
|
850
|
+
if (!waiting || Number(update.revision ?? 0) > Number(waiting.revision ?? 0)) tracked.pending = update;
|
|
851
|
+
if (waiting) return tracked.applying;
|
|
852
|
+
return this.#inOrder(update.key, async (current) => {
|
|
853
|
+
const next = current.pending;
|
|
854
|
+
delete current.pending;
|
|
855
|
+
if (next) await this.#applyLiveUpdateNow(current, next);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
return this.#inOrder(update.key, (current) => this.#applyLiveUpdateNow(current, update));
|
|
859
|
+
}
|
|
860
|
+
async #applyLiveStateNow(tracked, state) {
|
|
861
|
+
const revision = Number(state.revision ?? 0);
|
|
862
|
+
if (state.ring) {
|
|
863
|
+
const values = await Promise.all(state.ring.map((entry) => decode(entry.value)));
|
|
864
|
+
const revisions = state.ring.map((entry) => Number(entry.revision));
|
|
865
|
+
const last = revisions.at(-1) ?? 0;
|
|
866
|
+
const kept = tracked.ringRevisions.flatMap((held, index) => held > last ? [{
|
|
867
|
+
revision: held,
|
|
868
|
+
value: tracked.view.ring[index]
|
|
869
|
+
}] : []);
|
|
870
|
+
const window = this.#windowOf(tracked);
|
|
871
|
+
const merged = [...values, ...kept.map((entry) => entry.value)];
|
|
872
|
+
const mergedRevisions = [...revisions, ...kept.map((entry) => entry.revision)];
|
|
873
|
+
tracked.ringRevisions = window === void 0 ? mergedRevisions : mergedRevisions.slice(-window);
|
|
874
|
+
tracked.view = {
|
|
875
|
+
...tracked.view,
|
|
876
|
+
ring: window === void 0 ? merged : merged.slice(-window)
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
if (state.value && revision >= tracked.newest) {
|
|
880
|
+
tracked.newest = revision;
|
|
881
|
+
const data = await decode(state.value);
|
|
882
|
+
if (revision < tracked.newest) return;
|
|
883
|
+
tracked.view = {
|
|
884
|
+
...tracked.view,
|
|
885
|
+
data,
|
|
886
|
+
...state.revision !== void 0 ? { revision: state.revision } : {},
|
|
887
|
+
...state.at !== void 0 ? { at: state.at } : {}
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
tracked.view = {
|
|
891
|
+
...tracked.view,
|
|
892
|
+
loading: false,
|
|
893
|
+
...state.error ? { error: state.error } : {}
|
|
894
|
+
};
|
|
895
|
+
if (!state.error && tracked.view.error) {
|
|
896
|
+
const { error: _cleared, ...rest } = tracked.view;
|
|
897
|
+
tracked.view = rest;
|
|
898
|
+
}
|
|
899
|
+
this.#changed();
|
|
900
|
+
}
|
|
901
|
+
async #applyLiveUpdateNow(tracked, update) {
|
|
902
|
+
if (update.error && !update.value) {
|
|
903
|
+
tracked.view = {
|
|
904
|
+
...tracked.view,
|
|
905
|
+
loading: false,
|
|
906
|
+
error: update.error
|
|
907
|
+
};
|
|
908
|
+
this.#changed();
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const revision = Number(update.revision ?? 0);
|
|
912
|
+
if (!update.value || revision <= tracked.newest) return;
|
|
913
|
+
const skipped = tracked.newest > 0 && revision > tracked.newest + 1 && this.#windowOf(tracked) !== void 0;
|
|
914
|
+
tracked.newest = revision;
|
|
915
|
+
const data = await decode(update.value);
|
|
916
|
+
const window = this.#windowOf(tracked);
|
|
917
|
+
const ring = window === void 0 ? tracked.view.ring : [...tracked.view.ring, data].slice(-window);
|
|
918
|
+
if (window !== void 0) tracked.ringRevisions = [...tracked.ringRevisions, revision].slice(-window);
|
|
919
|
+
const { error: _cleared, ...rest } = tracked.view;
|
|
920
|
+
tracked.view = {
|
|
921
|
+
...rest,
|
|
922
|
+
loading: false,
|
|
923
|
+
data,
|
|
924
|
+
ring,
|
|
925
|
+
revision: String(revision),
|
|
926
|
+
...update.at !== void 0 ? { at: update.at } : {}
|
|
927
|
+
};
|
|
928
|
+
this.#changed();
|
|
929
|
+
if (skipped) this.#flushLive();
|
|
930
|
+
}
|
|
931
|
+
/** One ephemeral message from the page's stream. */
|
|
932
|
+
receive(message) {
|
|
933
|
+
if (message.kind === "run") {
|
|
934
|
+
const tracked = this.#runs.get(message.run.runId);
|
|
935
|
+
if (!tracked) {
|
|
936
|
+
this.#keepEarly(message.run);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (!this.#merge(tracked, message.run)) this.attach({ runIds: [message.run.runId] }).catch(() => {});
|
|
940
|
+
} else this.#applyLiveUpdate(message.live);
|
|
941
|
+
}
|
|
942
|
+
/** Keeps an update for a run the page will track once its start is answered. */
|
|
943
|
+
#keepEarly(delta) {
|
|
944
|
+
const kept = this.#early.get(delta.runId);
|
|
945
|
+
if (kept) {
|
|
946
|
+
if (kept.length < EARLY_DELTAS_PER_RUN) kept.push(delta);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (this.#early.size >= EARLY_RUNS) {
|
|
950
|
+
const oldest = this.#early.keys().next().value;
|
|
951
|
+
if (oldest !== void 0) this.#early.delete(oldest);
|
|
952
|
+
}
|
|
953
|
+
this.#early.set(delta.runId, [delta]);
|
|
954
|
+
}
|
|
955
|
+
/** The stream came back after a gap: read again what may have been missed. */
|
|
956
|
+
resync() {
|
|
957
|
+
const open = [...this.#runs.values()].filter((tracked) => !isFinalRunStatus(tracked.view.status));
|
|
958
|
+
if (open.length > 0) this.attach({ runIds: open.map((tracked) => tracked.view.runId) }).catch(() => {});
|
|
959
|
+
this.#flushLive();
|
|
960
|
+
}
|
|
961
|
+
/** Renews interest in open runs and shown sources before their lease ends. */
|
|
962
|
+
#renewSoon() {
|
|
963
|
+
if (this.#renewTimer !== void 0) return;
|
|
964
|
+
this.#renewTimer = this.#setTimeout(() => {
|
|
965
|
+
this.#renewTimer = void 0;
|
|
966
|
+
const open = [...this.#runs.values()].filter((tracked) => !isFinalRunStatus(tracked.view.status));
|
|
967
|
+
const shown = [...this.#live.values()].some((tracked) => tracked.watchers.size > 0);
|
|
968
|
+
if (open.length > 0 || shown) {
|
|
969
|
+
this.resync();
|
|
970
|
+
this.#renewSoon();
|
|
971
|
+
}
|
|
972
|
+
}, RENEW_MS);
|
|
973
|
+
}
|
|
974
|
+
#setTimeout(fn, ms) {
|
|
975
|
+
return this.#options.timers ? this.#options.timers.setTimeout(fn, ms) : setTimeout(fn, ms);
|
|
976
|
+
}
|
|
977
|
+
/** Stops renewing (the page is going away, or a test ends). */
|
|
978
|
+
stop() {
|
|
979
|
+
if (this.#renewTimer === void 0) return;
|
|
980
|
+
if (this.#options.timers) this.#options.timers.clearTimeout(this.#renewTimer);
|
|
981
|
+
else clearTimeout(this.#renewTimer);
|
|
982
|
+
this.#renewTimer = void 0;
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
/** A serialized live value, read back. */
|
|
986
|
+
function decode(value) {
|
|
987
|
+
return deserializeAll(value);
|
|
988
|
+
}
|
|
989
|
+
/** How long a submit waits for the event stream to reach the page's own last save. */
|
|
990
|
+
const CATCH_UP_MS = 5e3;
|
|
991
|
+
/** How soon waiting saves try again once the event stream shows the gateway answers. */
|
|
992
|
+
const RETRY_SOON_MS = 250;
|
|
993
|
+
/** The most ops one mutation carries (the browser protocol's limit). */
|
|
994
|
+
const OPS_PER_MUTATION = 1e3;
|
|
995
|
+
/** A field's key. */
|
|
996
|
+
const fieldKey = (scope, id) => `${scope}:${id}`;
|
|
997
|
+
const DELIVERY_SEVERITY = {
|
|
998
|
+
delivered: 0,
|
|
999
|
+
pending: 1,
|
|
1000
|
+
rejected: 2,
|
|
1001
|
+
undeliverable: 3
|
|
1002
|
+
};
|
|
1003
|
+
/**
|
|
1004
|
+
* One submission's two accounts merged: each delivery as its latest change says, so an answer
|
|
1005
|
+
* built before newer events never takes the state back (Claude review finding 8).
|
|
1006
|
+
*/
|
|
1007
|
+
function mergedSubmission(known, next) {
|
|
1008
|
+
if (known?.id !== next.id || known.deliveries === void 0) return next;
|
|
1009
|
+
const newer = new Map((next.deliveries ?? []).map((delivery) => [delivery.deliveryId, delivery]));
|
|
1010
|
+
for (const delivery of known.deliveries) {
|
|
1011
|
+
const other = newer.get(delivery.deliveryId);
|
|
1012
|
+
if (other === void 0 || delivery.updatedAt > other.updatedAt) newer.set(delivery.deliveryId, delivery);
|
|
1013
|
+
}
|
|
1014
|
+
const deliveries = [...newer.values()];
|
|
1015
|
+
let worst = "delivered";
|
|
1016
|
+
for (const delivery of deliveries) if (DELIVERY_SEVERITY[delivery.state] > DELIVERY_SEVERITY[worst]) worst = delivery.state;
|
|
1017
|
+
return {
|
|
1018
|
+
...next,
|
|
1019
|
+
deliveries,
|
|
1020
|
+
delivery: deliveries.length > 0 ? worst : next.delivery
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
/** Deep equality for JSON values. */
|
|
1024
|
+
function same(a, b) {
|
|
1025
|
+
if (a === b) return true;
|
|
1026
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
1027
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
1028
|
+
const keysA = Object.keys(a);
|
|
1029
|
+
const keysB = Object.keys(b);
|
|
1030
|
+
if (keysA.length !== keysB.length) return false;
|
|
1031
|
+
return keysA.every((key) => same(a[key], b[key]));
|
|
1032
|
+
}
|
|
1033
|
+
/** Whether a required field's value counts as missing. */
|
|
1034
|
+
function isEmpty(value) {
|
|
1035
|
+
return value === void 0 || value === null || typeof value === "string" && value.trim() === "" || Array.isArray(value) && value.length === 0;
|
|
1036
|
+
}
|
|
1037
|
+
const keyParts = (key) => {
|
|
1038
|
+
const at = key.indexOf(":");
|
|
1039
|
+
return {
|
|
1040
|
+
scope: key.slice(0, at),
|
|
1041
|
+
id: key.slice(at + 1)
|
|
1042
|
+
};
|
|
1043
|
+
};
|
|
1044
|
+
/** The runtime's store of one page. */
|
|
1045
|
+
var Store = class {
|
|
1046
|
+
mode;
|
|
1047
|
+
view;
|
|
1048
|
+
#options;
|
|
1049
|
+
#timers;
|
|
1050
|
+
#server = /* @__PURE__ */ new Map();
|
|
1051
|
+
/** The revision the event stream reached, per document: every change up to it was seen. */
|
|
1052
|
+
#seen = {
|
|
1053
|
+
viewer: 0,
|
|
1054
|
+
artifact: 0
|
|
1055
|
+
};
|
|
1056
|
+
#epoch = 1;
|
|
1057
|
+
#viewer;
|
|
1058
|
+
/** Whether the first snapshot arrived. */
|
|
1059
|
+
#loaded = false;
|
|
1060
|
+
/** Delivery events for a submission not known yet (the stream may beat the submit's answer). */
|
|
1061
|
+
#earlyDeliveries = [];
|
|
1062
|
+
#submission;
|
|
1063
|
+
/** Whether a submission closed the viewer's draft (`once`, D106). */
|
|
1064
|
+
#closed = false;
|
|
1065
|
+
#pending = /* @__PURE__ */ new Map();
|
|
1066
|
+
#inflight = /* @__PURE__ */ new Map();
|
|
1067
|
+
/** The value each in-flight mutation sends, per field. */
|
|
1068
|
+
#sending = /* @__PURE__ */ new Map();
|
|
1069
|
+
#conflicts = /* @__PURE__ */ new Map();
|
|
1070
|
+
/** Edits the gateway refused for good (too large, invalid): kept, shown with the reason. */
|
|
1071
|
+
#refused = /* @__PURE__ */ new Map();
|
|
1072
|
+
/** Mutations waiting for the network, in order. */
|
|
1073
|
+
#queue = [];
|
|
1074
|
+
#registrations = /* @__PURE__ */ new Map();
|
|
1075
|
+
/** Who hears each mount and unmount: `verify`'s field probe (D138). */
|
|
1076
|
+
#mountObservers = /* @__PURE__ */ new Set();
|
|
1077
|
+
#ready = false;
|
|
1078
|
+
#readyWaiters = [];
|
|
1079
|
+
#online = true;
|
|
1080
|
+
#revoked = false;
|
|
1081
|
+
#debounce;
|
|
1082
|
+
#retry;
|
|
1083
|
+
#retryMs = 1e3;
|
|
1084
|
+
#sendingQueue = false;
|
|
1085
|
+
#seq = 0;
|
|
1086
|
+
#version = 0;
|
|
1087
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
1088
|
+
#keyVersions = /* @__PURE__ */ new Map();
|
|
1089
|
+
#broadcast;
|
|
1090
|
+
#submitState = "open";
|
|
1091
|
+
#catchUp = [];
|
|
1092
|
+
/**
|
|
1093
|
+
* Fields another writer changed whose new value the page hasn't got (a sensitive value isn't in
|
|
1094
|
+
* events, D52): an edit of one is based on what the page last had, so it conflicts instead of
|
|
1095
|
+
* overwriting, and a submit waits until a snapshot brings the value.
|
|
1096
|
+
*/
|
|
1097
|
+
#unknown = /* @__PURE__ */ new Set();
|
|
1098
|
+
#resyncing;
|
|
1099
|
+
#beforeSubmit = /* @__PURE__ */ new Set();
|
|
1100
|
+
#stopStream;
|
|
1101
|
+
/** Plain calls, action runs and live sources (WP-08), over the same transport and view. */
|
|
1102
|
+
server;
|
|
1103
|
+
constructor(options) {
|
|
1104
|
+
this.#options = options;
|
|
1105
|
+
this.server = new ServerClient({
|
|
1106
|
+
...options.mode === "live" && options.transport ? { transport: options.transport } : {},
|
|
1107
|
+
...options.mode === "live" && options.view ? { viewId: options.view.viewId } : {},
|
|
1108
|
+
...options.timers ? { timers: options.timers } : {}
|
|
1109
|
+
});
|
|
1110
|
+
this.mode = options.mode;
|
|
1111
|
+
this.view = options.view;
|
|
1112
|
+
this.#viewer = options.viewer;
|
|
1113
|
+
this.#timers = options.timers ?? {
|
|
1114
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
1115
|
+
clearTimeout: (handle) => clearTimeout(handle)
|
|
1116
|
+
};
|
|
1117
|
+
if (options.view?.policy.policy === "none") this.#submitState = "closed";
|
|
1118
|
+
}
|
|
1119
|
+
/** Calls `listener` after every change; returns the function that stops. */
|
|
1120
|
+
subscribe = (listener) => {
|
|
1121
|
+
this.#listeners.add(listener);
|
|
1122
|
+
return () => this.#listeners.delete(listener);
|
|
1123
|
+
};
|
|
1124
|
+
/** A number that changes with every change, for `useSyncExternalStore`. */
|
|
1125
|
+
version = () => this.#version;
|
|
1126
|
+
/** A number that changes whenever the field's value or meta may have. */
|
|
1127
|
+
keyVersion(key) {
|
|
1128
|
+
return this.#keyVersions.get(key) ?? 0;
|
|
1129
|
+
}
|
|
1130
|
+
#changed(keys = []) {
|
|
1131
|
+
this.#version += 1;
|
|
1132
|
+
for (const key of keys) this.#keyVersions.set(key, (this.#keyVersions.get(key) ?? 0) + 1);
|
|
1133
|
+
for (const listener of [...this.#listeners]) listener();
|
|
1134
|
+
}
|
|
1135
|
+
/** Whether the first snapshot arrived (or, in local mode, at once). */
|
|
1136
|
+
get ready() {
|
|
1137
|
+
return this.#ready;
|
|
1138
|
+
}
|
|
1139
|
+
/** Resolves once the store is ready, or after `timeoutMs`. */
|
|
1140
|
+
whenReady(timeoutMs = Number.POSITIVE_INFINITY) {
|
|
1141
|
+
if (this.#ready) return Promise.resolve();
|
|
1142
|
+
return new Promise((resolve) => {
|
|
1143
|
+
this.#readyWaiters.push(resolve);
|
|
1144
|
+
if (Number.isFinite(timeoutMs)) this.#timers.setTimeout(resolve, timeoutMs);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
#markReady() {
|
|
1148
|
+
if (this.#ready) return;
|
|
1149
|
+
this.#ready = true;
|
|
1150
|
+
for (const resolve of this.#readyWaiters.splice(0)) resolve();
|
|
1151
|
+
this.#changed(this.#allKeys());
|
|
1152
|
+
}
|
|
1153
|
+
#allKeys() {
|
|
1154
|
+
return [.../* @__PURE__ */ new Set([...this.#server.keys(), ...this.#registrations.keys()])];
|
|
1155
|
+
}
|
|
1156
|
+
/** Starts: subscribes to the event stream (live) or reads this browser's values (local). */
|
|
1157
|
+
start() {
|
|
1158
|
+
if (this.mode === "local") {
|
|
1159
|
+
this.#markReady();
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const transport = this.#transport();
|
|
1163
|
+
this.#stopStream = transport.subscribe(void 0, {
|
|
1164
|
+
onSnapshot: (snapshot) => this.#applySnapshot(snapshot),
|
|
1165
|
+
onEvents: (events) => this.#applyEvents(events),
|
|
1166
|
+
onStatus: (status) => {
|
|
1167
|
+
if (status === "revoked") this.#revoked = true;
|
|
1168
|
+
const online = status === "live";
|
|
1169
|
+
if (online !== this.#online) {
|
|
1170
|
+
this.#online = online;
|
|
1171
|
+
if (online) {
|
|
1172
|
+
this.#kickQueue();
|
|
1173
|
+
this.server.resync();
|
|
1174
|
+
}
|
|
1175
|
+
this.#changed(this.#allKeys());
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
onEphemeral: (message) => this.server.receive(message)
|
|
1179
|
+
});
|
|
1180
|
+
if (typeof window !== "undefined") window.addEventListener("online", () => this.#kickQueue());
|
|
1181
|
+
}
|
|
1182
|
+
/** Stops the stream and timers (the page is going away, or a test ends). */
|
|
1183
|
+
stop() {
|
|
1184
|
+
this.#stopStream?.();
|
|
1185
|
+
this.server.stop();
|
|
1186
|
+
this.#broadcast?.close();
|
|
1187
|
+
if (this.#debounce !== void 0) this.#timers.clearTimeout(this.#debounce);
|
|
1188
|
+
if (this.#retry !== void 0) this.#timers.clearTimeout(this.#retry);
|
|
1189
|
+
}
|
|
1190
|
+
#transport() {
|
|
1191
|
+
const transport = this.#options.transport;
|
|
1192
|
+
if (!transport) throw targetUnavailable("Saving");
|
|
1193
|
+
return transport;
|
|
1194
|
+
}
|
|
1195
|
+
#applySnapshot(snapshot) {
|
|
1196
|
+
this.#gatewayHeard();
|
|
1197
|
+
const first = !this.#loaded;
|
|
1198
|
+
this.#loaded = true;
|
|
1199
|
+
this.#viewer = snapshot.viewer;
|
|
1200
|
+
const touched = new Set(this.#allKeys());
|
|
1201
|
+
const load = (scope, document) => {
|
|
1202
|
+
const revision = Number(document.revision);
|
|
1203
|
+
const replace = first || revision >= this.#seen[scope];
|
|
1204
|
+
if (replace) {
|
|
1205
|
+
for (const key of [...this.#server.keys()]) if (keyParts(key).scope === scope) this.#server.delete(key);
|
|
1206
|
+
for (const key of [...this.#unknown]) if (keyParts(key).scope === scope) this.#unknown.delete(key);
|
|
1207
|
+
this.#seen[scope] = revision;
|
|
1208
|
+
}
|
|
1209
|
+
for (const [id, field] of Object.entries(document.fields)) {
|
|
1210
|
+
const key = fieldKey(scope, id);
|
|
1211
|
+
const current = this.#server.get(key);
|
|
1212
|
+
if (!replace && current && current.revision >= Number(field.revision)) continue;
|
|
1213
|
+
touched.add(key);
|
|
1214
|
+
this.#unknown.delete(key);
|
|
1215
|
+
this.#server.set(key, {
|
|
1216
|
+
value: field.value,
|
|
1217
|
+
revision: Number(field.revision),
|
|
1218
|
+
deleted: false,
|
|
1219
|
+
...field.updatedBy ? { updatedBy: field.updatedBy } : {}
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
return replace;
|
|
1223
|
+
};
|
|
1224
|
+
if (load("viewer", snapshot.draft)) this.#epoch = snapshot.draft.epoch;
|
|
1225
|
+
load("artifact", snapshot.state);
|
|
1226
|
+
if (snapshot.submission) this.#adoptSubmission(snapshot.submission, true);
|
|
1227
|
+
this.#resolveCatchUp();
|
|
1228
|
+
this.#changed(touched);
|
|
1229
|
+
if (first) {
|
|
1230
|
+
this.#openChannel();
|
|
1231
|
+
this.#recoverJournal().finally(() => {
|
|
1232
|
+
this.#markReady();
|
|
1233
|
+
if (this.#pending.size > 0) this.#schedule();
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
#applyEvents(events) {
|
|
1238
|
+
this.#gatewayHeard();
|
|
1239
|
+
const touched = /* @__PURE__ */ new Set();
|
|
1240
|
+
let resync = false;
|
|
1241
|
+
for (const event of events) {
|
|
1242
|
+
const ownMutation = event.causation?.["mutationId"] !== void 0 && (this.#inflight.has(event.causation["mutationId"]) || [...this.#sending.values()].some((s) => s.mutationId === event.causation?.["mutationId"]));
|
|
1243
|
+
if (event.type === "draft.changed" || event.type === "state.changed") {
|
|
1244
|
+
const scope = event.type === "draft.changed" ? "viewer" : "artifact";
|
|
1245
|
+
const payload = event.payload;
|
|
1246
|
+
const revision = Number(payload.revision);
|
|
1247
|
+
if (scope === "viewer" && payload.epoch !== void 0 && payload.epoch > this.#epoch) this.#epoch = payload.epoch;
|
|
1248
|
+
for (const change of payload.changes) {
|
|
1249
|
+
const key = fieldKey(scope, change.id);
|
|
1250
|
+
touched.add(key);
|
|
1251
|
+
const current = this.#server.get(key);
|
|
1252
|
+
if (current && current.revision >= revision) continue;
|
|
1253
|
+
if (change.sensitive && !change.deleted) {
|
|
1254
|
+
const mine = this.#sending.get(key);
|
|
1255
|
+
if (ownMutation && mine) this.#server.set(key, {
|
|
1256
|
+
value: mine.value,
|
|
1257
|
+
revision,
|
|
1258
|
+
deleted: false,
|
|
1259
|
+
updatedBy: event.actor
|
|
1260
|
+
});
|
|
1261
|
+
else {
|
|
1262
|
+
this.#unknown.add(key);
|
|
1263
|
+
resync = true;
|
|
1264
|
+
}
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
this.#unknown.delete(key);
|
|
1268
|
+
this.#server.set(key, {
|
|
1269
|
+
value: change.deleted ? void 0 : change.value,
|
|
1270
|
+
revision,
|
|
1271
|
+
deleted: change.deleted === true,
|
|
1272
|
+
updatedBy: event.actor
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
this.#seen[scope] = Math.max(this.#seen[scope], revision);
|
|
1276
|
+
} else if (event.type === "submission.created") {
|
|
1277
|
+
const payload = event.payload;
|
|
1278
|
+
const own = payload.viewerId !== void 0 && payload.viewerId === this.#viewer?.id;
|
|
1279
|
+
if (own && this.#submission?.id !== payload.submissionId) resync = true;
|
|
1280
|
+
else if (!own && this.view?.policy.policy === "once") {
|
|
1281
|
+
if (this.view.policy.scope !== "per-viewer") {
|
|
1282
|
+
this.#closed = true;
|
|
1283
|
+
this.#submitState = "closed";
|
|
1284
|
+
for (const key of this.#allKeys()) touched.add(key);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
} else if (event.type.startsWith("submission.")) this.#deliveryEvent(event);
|
|
1288
|
+
}
|
|
1289
|
+
this.#resolveCatchUp();
|
|
1290
|
+
if (resync) this.#resync();
|
|
1291
|
+
if (touched.size > 0) this.#changed(touched);
|
|
1292
|
+
}
|
|
1293
|
+
/** Loads a fresh snapshot, once at a time; a failure leaves it to the next event or submit. */
|
|
1294
|
+
#resync() {
|
|
1295
|
+
this.#resyncing ??= (async () => {
|
|
1296
|
+
try {
|
|
1297
|
+
this.#applySnapshot(await this.#transport().snapshot());
|
|
1298
|
+
} catch {} finally {
|
|
1299
|
+
this.#resyncing = void 0;
|
|
1300
|
+
}
|
|
1301
|
+
})();
|
|
1302
|
+
return this.#resyncing;
|
|
1303
|
+
}
|
|
1304
|
+
/** Whether the gateway holds a value for the field (deleted fields don't count). */
|
|
1305
|
+
stored(key) {
|
|
1306
|
+
const server = this.#server.get(key);
|
|
1307
|
+
return server !== void 0 && !server.deleted;
|
|
1308
|
+
}
|
|
1309
|
+
/** The field's value as the viewer sees it: their edit, else the gateway's, else `fallback`. */
|
|
1310
|
+
value(key, fallback) {
|
|
1311
|
+
if (this.mode === "local") {
|
|
1312
|
+
const stored = this.#localRead(key);
|
|
1313
|
+
const pending = this.#pending.get(key);
|
|
1314
|
+
if (pending) return pending.op.delete ? fallback : pending.op.value;
|
|
1315
|
+
return stored === void 0 ? fallback : stored;
|
|
1316
|
+
}
|
|
1317
|
+
const pending = this.#pending.get(key);
|
|
1318
|
+
if (pending) return pending.op.delete ? fallback : pending.op.value;
|
|
1319
|
+
const sending = this.#sending.get(key);
|
|
1320
|
+
if (sending) return sending.value === void 0 ? fallback : sending.value;
|
|
1321
|
+
const server = this.#server.get(key);
|
|
1322
|
+
if (server && !server.deleted) return server.value;
|
|
1323
|
+
return fallback;
|
|
1324
|
+
}
|
|
1325
|
+
/** The field's open conflict, if any. */
|
|
1326
|
+
conflict(key) {
|
|
1327
|
+
return this.#conflicts.get(key);
|
|
1328
|
+
}
|
|
1329
|
+
/** Who last wrote the field (artifact state, D63). */
|
|
1330
|
+
updatedBy(key) {
|
|
1331
|
+
return this.#server.get(key)?.updatedBy;
|
|
1332
|
+
}
|
|
1333
|
+
/** Why the gateway refused the field's edit for good, if it did: its code and message. */
|
|
1334
|
+
refusal(key) {
|
|
1335
|
+
return this.#refused.get(key);
|
|
1336
|
+
}
|
|
1337
|
+
/** Whether the field takes edits: not a closed draft, not a view-only link. */
|
|
1338
|
+
writable(key) {
|
|
1339
|
+
if (this.mode === "local") return true;
|
|
1340
|
+
const { scope } = keyParts(key);
|
|
1341
|
+
if (!this.#canSave()) return false;
|
|
1342
|
+
return scope === "artifact" || !this.#closed;
|
|
1343
|
+
}
|
|
1344
|
+
/** Whether the link may submit (D97): its `submit` scope. */
|
|
1345
|
+
canSubmit() {
|
|
1346
|
+
return this.#options.scopes === void 0 || this.#options.scopes.includes("submit");
|
|
1347
|
+
}
|
|
1348
|
+
#canSave() {
|
|
1349
|
+
return this.#options.scopes === void 0 || this.#options.scopes.includes("draft");
|
|
1350
|
+
}
|
|
1351
|
+
/** The field's save state (INTERFACES §2.3). */
|
|
1352
|
+
save(key) {
|
|
1353
|
+
if (this.mode === "local") return "readonly";
|
|
1354
|
+
if (this.#conflicts.has(key)) return "conflict";
|
|
1355
|
+
if (!this.writable(key)) return "readonly";
|
|
1356
|
+
if (this.#refused.has(key)) return "offline";
|
|
1357
|
+
if (!(this.#pending.has(key) || this.#sending.has(key))) return "saved";
|
|
1358
|
+
return this.#online && !this.#revoked ? "saving" : "offline";
|
|
1359
|
+
}
|
|
1360
|
+
/** The page's save state, for the shell's rail. */
|
|
1361
|
+
pageSave() {
|
|
1362
|
+
if (this.mode === "local") return "saved";
|
|
1363
|
+
if (this.#conflicts.size > 0) return "conflict";
|
|
1364
|
+
if (!(this.#pending.size > 0 || this.#inflight.size > 0 || this.#queue.length > 0) && this.#refused.size === 0) return "saved";
|
|
1365
|
+
return this.#online && !this.#revoked && this.#refused.size === 0 ? "saving" : "offline";
|
|
1366
|
+
}
|
|
1367
|
+
/** Whether edits wait to be saved. */
|
|
1368
|
+
unsaved() {
|
|
1369
|
+
return this.#pending.size > 0 || this.#inflight.size > 0 || this.#queue.length > 0 || this.#refused.size > 0;
|
|
1370
|
+
}
|
|
1371
|
+
/** The viewer the page acts as, once known. */
|
|
1372
|
+
viewer() {
|
|
1373
|
+
return this.#viewer;
|
|
1374
|
+
}
|
|
1375
|
+
/** Every field ID of a scope the viewer sees, with its value (for `window.storage.list`). */
|
|
1376
|
+
ids(scope) {
|
|
1377
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1378
|
+
if (this.mode === "local") for (const id of this.#localIds(scope)) ids.add(id);
|
|
1379
|
+
for (const [key, server] of this.#server) if (!server.deleted && key.startsWith(`${scope}:`)) ids.add(keyParts(key).id);
|
|
1380
|
+
for (const key of [...this.#pending.keys(), ...this.#sending.keys()]) if (key.startsWith(`${scope}:`)) ids.add(keyParts(key).id);
|
|
1381
|
+
return [...ids].filter((id) => this.value(fieldKey(scope, id), void 0) !== void 0).sort();
|
|
1382
|
+
}
|
|
1383
|
+
/** Registers a mounted field; returns the function that unregisters it. */
|
|
1384
|
+
register(registration) {
|
|
1385
|
+
const key = fieldKey(registration.scope, registration.id);
|
|
1386
|
+
const existing = this.#registrations.get(key);
|
|
1387
|
+
if (existing) {
|
|
1388
|
+
existing.count += 1;
|
|
1389
|
+
existing.registration = {
|
|
1390
|
+
...registration,
|
|
1391
|
+
initial: existing.registration.initial
|
|
1392
|
+
};
|
|
1393
|
+
} else this.#registrations.set(key, {
|
|
1394
|
+
registration,
|
|
1395
|
+
count: 1
|
|
1396
|
+
});
|
|
1397
|
+
for (const observer of this.#mountObservers) observer(registration, true);
|
|
1398
|
+
this.#changed([key]);
|
|
1399
|
+
let released = false;
|
|
1400
|
+
return () => {
|
|
1401
|
+
const entry = this.#registrations.get(key);
|
|
1402
|
+
if (!entry) return;
|
|
1403
|
+
if (!released) {
|
|
1404
|
+
released = true;
|
|
1405
|
+
for (const observer of this.#mountObservers) observer(registration, false);
|
|
1406
|
+
}
|
|
1407
|
+
entry.count -= 1;
|
|
1408
|
+
if (entry.count <= 0) this.#registrations.delete(key);
|
|
1409
|
+
this.#changed([key]);
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Hears every field mount and unmount, with the registration as the hook gave it; returns the
|
|
1414
|
+
* function that stops. `verify`'s field probe uses it (D138). @internal
|
|
1415
|
+
*/
|
|
1416
|
+
observeMounts(observer) {
|
|
1417
|
+
this.#mountObservers.add(observer);
|
|
1418
|
+
return () => this.#mountObservers.delete(observer);
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Tells the mount observers about a field a hook reads and writes without registering it
|
|
1422
|
+
* (`useChoice`'s choice field): `verify`'s field probe hears it, and nothing else changes, so
|
|
1423
|
+
* no default is written for it (D110). Returns the function that announces its unmount.
|
|
1424
|
+
*/
|
|
1425
|
+
announceMount(registration) {
|
|
1426
|
+
if (this.#mountObservers.size === 0) return () => void 0;
|
|
1427
|
+
for (const observer of this.#mountObservers) observer(registration, true);
|
|
1428
|
+
let released = false;
|
|
1429
|
+
return () => {
|
|
1430
|
+
if (released) return;
|
|
1431
|
+
released = true;
|
|
1432
|
+
for (const observer of this.#mountObservers) observer(registration, false);
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
/** Updates a mounted field's options or validation message. */
|
|
1436
|
+
updateRegistration(registration) {
|
|
1437
|
+
const key = fieldKey(registration.scope, registration.id);
|
|
1438
|
+
const entry = this.#registrations.get(key);
|
|
1439
|
+
if (!entry) return;
|
|
1440
|
+
const next = {
|
|
1441
|
+
...registration,
|
|
1442
|
+
initial: entry.registration.initial
|
|
1443
|
+
};
|
|
1444
|
+
if (entry.registration.label === next.label && entry.registration.required === next.required && entry.registration.sensitive === next.sensitive && entry.registration.error === next.error) return;
|
|
1445
|
+
entry.registration = next;
|
|
1446
|
+
this.#changed([key]);
|
|
1447
|
+
}
|
|
1448
|
+
/** Every mounted field. */
|
|
1449
|
+
registrations() {
|
|
1450
|
+
return [...this.#registrations.values()].map((entry) => entry.registration);
|
|
1451
|
+
}
|
|
1452
|
+
/** What blocks a submit now (INTERFACES §2.3): required, invalid and conflicting fields. */
|
|
1453
|
+
blockers() {
|
|
1454
|
+
const blockers = [];
|
|
1455
|
+
for (const [key, { registration }] of this.#registrations) {
|
|
1456
|
+
const label = registration.label !== void 0 ? { label: registration.label } : {};
|
|
1457
|
+
if (this.#conflicts.has(key)) blockers.push({
|
|
1458
|
+
fieldId: registration.id,
|
|
1459
|
+
...label,
|
|
1460
|
+
reason: "conflict"
|
|
1461
|
+
});
|
|
1462
|
+
else if (registration.error !== void 0) blockers.push({
|
|
1463
|
+
fieldId: registration.id,
|
|
1464
|
+
...label,
|
|
1465
|
+
reason: "invalid"
|
|
1466
|
+
});
|
|
1467
|
+
else if (registration.required && isEmpty(this.value(key, registration.initial))) blockers.push({
|
|
1468
|
+
fieldId: registration.id,
|
|
1469
|
+
...label,
|
|
1470
|
+
reason: "required"
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
return blockers.sort((a, b) => a.fieldId < b.fieldId ? -1 : a.fieldId > b.fieldId ? 1 : 0);
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Sets a field: shown at once, journaled at once, sent after the debounce (`immediate` sends
|
|
1477
|
+
* now). Refused with `MOSTRA_FIELD_ID_INVALID` for an ID that isn't one; a closed draft or a
|
|
1478
|
+
* view-only link refuses with `MOSTRA_SUBMIT_ALREADY` or `MOSTRA_HYGIENE_SCOPE_DENIED`.
|
|
1479
|
+
*/
|
|
1480
|
+
set(scope, id, value, options = {}) {
|
|
1481
|
+
if (!isFieldId(id)) throw new MostraError("MOSTRA_FIELD_ID_INVALID", `${JSON.stringify(id)} isn't a field ID.`);
|
|
1482
|
+
const key = fieldKey(scope, id);
|
|
1483
|
+
if (!this.writable(key)) throw this.#closed && scope === "viewer" ? new MostraError("MOSTRA_SUBMIT_ALREADY", "This draft was submitted and takes no edits.") : new MostraError("MOSTRA_HYGIENE_SCOPE_DENIED", "This link can't save answers.");
|
|
1484
|
+
if (value !== void 0) try {
|
|
1485
|
+
JSON.stringify(value);
|
|
1486
|
+
} catch {
|
|
1487
|
+
throw new MostraError("MOSTRA_HOST_INVALID_PARAMS", `The value of ${id} isn't JSON.`);
|
|
1488
|
+
}
|
|
1489
|
+
const earlierOp = this.#pending.get(fieldKey(scope, id))?.op;
|
|
1490
|
+
const registered = this.#registrations.get(fieldKey(scope, id))?.registration;
|
|
1491
|
+
const label = options.label ?? earlierOp?.label ?? registered?.label;
|
|
1492
|
+
const required = options.required ?? earlierOp?.required ?? registered?.required;
|
|
1493
|
+
const sensitive = options.sensitive ?? earlierOp?.sensitive ?? registered?.sensitive;
|
|
1494
|
+
const op = {
|
|
1495
|
+
id,
|
|
1496
|
+
...options.delete || value === void 0 ? { delete: true } : { value },
|
|
1497
|
+
...label !== void 0 ? { label } : {},
|
|
1498
|
+
...required !== void 0 ? { required } : {},
|
|
1499
|
+
...sensitive !== void 0 ? { sensitive } : {}
|
|
1500
|
+
};
|
|
1501
|
+
if (this.mode === "local") {
|
|
1502
|
+
this.#localWrite(key, op.delete ? void 0 : op.value);
|
|
1503
|
+
this.#changed([key]);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const earlier = this.#pending.get(key);
|
|
1507
|
+
const entry = {
|
|
1508
|
+
kind: "pending",
|
|
1509
|
+
viewerId: this.#viewer?.id ?? "",
|
|
1510
|
+
tab: this.#options.tab,
|
|
1511
|
+
viewId: this.view?.viewId ?? "",
|
|
1512
|
+
scope,
|
|
1513
|
+
op,
|
|
1514
|
+
base: earlier ? earlier.base : this.#baseFor(key),
|
|
1515
|
+
at: Date.now()
|
|
1516
|
+
};
|
|
1517
|
+
this.#refused.delete(key);
|
|
1518
|
+
this.#pending.set(key, entry);
|
|
1519
|
+
if (entry.viewerId !== "") this.#options.journal.putPending(entry).catch(() => void 0);
|
|
1520
|
+
this.#changed([key]);
|
|
1521
|
+
if (options.immediate) this.flush();
|
|
1522
|
+
else this.#schedule();
|
|
1523
|
+
}
|
|
1524
|
+
/** The base of a new edit: what the page has seen of the field (null while it is in flight). */
|
|
1525
|
+
#baseFor(key) {
|
|
1526
|
+
if (this.#sending.has(key)) return null;
|
|
1527
|
+
if (this.#unknown.has(key)) return this.#server.get(key)?.revision ?? 0;
|
|
1528
|
+
const { scope } = keyParts(key);
|
|
1529
|
+
return Math.max(this.#server.get(key)?.revision ?? 0, this.#seen[scope]);
|
|
1530
|
+
}
|
|
1531
|
+
#schedule() {
|
|
1532
|
+
if (this.#debounce !== void 0) this.#timers.clearTimeout(this.#debounce);
|
|
1533
|
+
this.#debounce = this.#timers.setTimeout(() => {
|
|
1534
|
+
this.#debounce = void 0;
|
|
1535
|
+
this.flush();
|
|
1536
|
+
}, 400);
|
|
1537
|
+
}
|
|
1538
|
+
/** Keeps the viewer's value of a conflicting field: written over theirs (FAILURE_MODEL §5). */
|
|
1539
|
+
keepMine(key) {
|
|
1540
|
+
const conflict = this.#conflicts.get(key);
|
|
1541
|
+
const pending = this.#pending.get(key);
|
|
1542
|
+
if (!conflict || !pending) return;
|
|
1543
|
+
this.#conflicts.delete(key);
|
|
1544
|
+
const entry = {
|
|
1545
|
+
...pending,
|
|
1546
|
+
base: conflict.revision,
|
|
1547
|
+
at: Date.now()
|
|
1548
|
+
};
|
|
1549
|
+
this.#pending.set(key, entry);
|
|
1550
|
+
this.#options.journal.putPending(entry).catch(() => void 0);
|
|
1551
|
+
this.#changed([key]);
|
|
1552
|
+
this.flush();
|
|
1553
|
+
}
|
|
1554
|
+
/** Takes the other writer's value of a conflicting field and drops the viewer's. */
|
|
1555
|
+
takeTheirs(key) {
|
|
1556
|
+
const pending = this.#pending.get(key);
|
|
1557
|
+
this.#conflicts.delete(key);
|
|
1558
|
+
this.#refused.delete(key);
|
|
1559
|
+
this.#pending.delete(key);
|
|
1560
|
+
if (pending) this.#options.journal.dropPending(pending).catch(() => void 0);
|
|
1561
|
+
this.#changed([key]);
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Sends every edit now and waits for the answers. `keepalive` is for `pagehide`: the requests
|
|
1565
|
+
* outlive the page, and the journal keeps whatever they don't deliver (IN-02).
|
|
1566
|
+
*/
|
|
1567
|
+
async flush(options = {}) {
|
|
1568
|
+
if (this.mode === "local" || this.#viewer === void 0 || !this.#loaded) return {
|
|
1569
|
+
revision: this.#seen.viewer,
|
|
1570
|
+
unsaved: this.unsaved()
|
|
1571
|
+
};
|
|
1572
|
+
if (this.#debounce !== void 0) {
|
|
1573
|
+
this.#timers.clearTimeout(this.#debounce);
|
|
1574
|
+
this.#debounce = void 0;
|
|
1575
|
+
}
|
|
1576
|
+
const ready = [...this.#pending.entries()].filter(([key, entry]) => entry.base !== null && !this.#conflicts.has(key) && !this.#refused.has(key));
|
|
1577
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1578
|
+
for (const [, entry] of ready) {
|
|
1579
|
+
const group = `${entry.scope}\u0000${entry.base}\u0000${entry.viewId}`;
|
|
1580
|
+
const list = groups.get(group) ?? [];
|
|
1581
|
+
list.push(entry);
|
|
1582
|
+
groups.set(group, list);
|
|
1583
|
+
}
|
|
1584
|
+
const created = [];
|
|
1585
|
+
for (const entries of groups.values()) for (let start = 0; start < entries.length; start += OPS_PER_MUTATION) {
|
|
1586
|
+
const chunk = entries.slice(start, start + OPS_PER_MUTATION);
|
|
1587
|
+
const first = chunk[0];
|
|
1588
|
+
const mutation = {
|
|
1589
|
+
kind: "mutation",
|
|
1590
|
+
viewerId: this.#viewer.id,
|
|
1591
|
+
tab: this.#options.tab,
|
|
1592
|
+
viewId: first.viewId !== "" ? first.viewId : this.view?.viewId ?? "",
|
|
1593
|
+
scope: first.scope,
|
|
1594
|
+
mutationId: MutationId.create(),
|
|
1595
|
+
baseRevision: first.base,
|
|
1596
|
+
ops: chunk.map((entry) => entry.op),
|
|
1597
|
+
seq: Date.now() * 1e3 + this.#seq++ % 1e3
|
|
1598
|
+
};
|
|
1599
|
+
for (const entry of chunk) {
|
|
1600
|
+
const key = fieldKey(entry.scope, entry.op.id);
|
|
1601
|
+
this.#pending.delete(key);
|
|
1602
|
+
this.#sending.set(key, {
|
|
1603
|
+
value: entry.op.delete ? void 0 : entry.op.value,
|
|
1604
|
+
mutationId: mutation.mutationId
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
this.#options.journal.promote(mutation, chunk).catch(() => void 0);
|
|
1608
|
+
created.push(mutation);
|
|
1609
|
+
this.#queue.push(mutation);
|
|
1610
|
+
}
|
|
1611
|
+
if (created.length > 0) this.#changed(created.flatMap((m) => m.ops.map((op) => fieldKey(m.scope, op.id))));
|
|
1612
|
+
await this.#drain(options.keepalive === true);
|
|
1613
|
+
return {
|
|
1614
|
+
revision: this.#ownRevision(),
|
|
1615
|
+
unsaved: this.unsaved()
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
/** The draft revision the page's own saves reached. */
|
|
1619
|
+
#ownRevision() {
|
|
1620
|
+
let revision = this.#seen.viewer;
|
|
1621
|
+
for (const [key, server] of this.#server) if (key.startsWith("viewer:")) revision = Math.max(revision, server.revision);
|
|
1622
|
+
return revision;
|
|
1623
|
+
}
|
|
1624
|
+
/** Sends queued mutations one at a time, in order, until the queue is empty or the page is offline. */
|
|
1625
|
+
async #drain(keepalive) {
|
|
1626
|
+
if (keepalive) {
|
|
1627
|
+
for (const mutation of [...this.#queue]) this.#send(mutation, true).then((outcome) => {
|
|
1628
|
+
if (outcome === "done") this.#dequeue(mutation);
|
|
1629
|
+
}).catch(() => void 0);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (this.#sendingQueue) {
|
|
1633
|
+
await new Promise((resolve) => {
|
|
1634
|
+
const check = () => {
|
|
1635
|
+
if (!this.#sendingQueue) resolve();
|
|
1636
|
+
else this.#timers.setTimeout(check, 20);
|
|
1637
|
+
};
|
|
1638
|
+
check();
|
|
1639
|
+
});
|
|
1640
|
+
if (this.#queue.length === 0) return;
|
|
1641
|
+
}
|
|
1642
|
+
this.#sendingQueue = true;
|
|
1643
|
+
try {
|
|
1644
|
+
while (this.#queue.length > 0 && !this.#revoked) {
|
|
1645
|
+
const head = this.#queue[0];
|
|
1646
|
+
if (await this.#send(head, false) === "retry") {
|
|
1647
|
+
this.#scheduleRetry();
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
this.#dequeue(head);
|
|
1651
|
+
}
|
|
1652
|
+
this.#retryMs = 1e3;
|
|
1653
|
+
} finally {
|
|
1654
|
+
this.#sendingQueue = false;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/** Removes an answered mutation from the queue; the page's save state changes only now. */
|
|
1658
|
+
#dequeue(mutation) {
|
|
1659
|
+
const index = this.#queue.indexOf(mutation);
|
|
1660
|
+
if (index === -1) return;
|
|
1661
|
+
this.#queue.splice(index, 1);
|
|
1662
|
+
this.#changed(mutation.ops.map((op) => fieldKey(mutation.scope, op.id)));
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* The event stream just brought something, so the gateway is reachable: saves waiting out a
|
|
1666
|
+
* backoff after a network failure try again soon instead of after up to 30 s (FAILURE_MODEL
|
|
1667
|
+
* §5, "on reconnect the journal replays"). The backoff itself isn't reset, so a path that keeps
|
|
1668
|
+
* failing while the stream works (a proxy refusing POSTs) is tried at most once per message.
|
|
1669
|
+
*/
|
|
1670
|
+
#gatewayHeard() {
|
|
1671
|
+
if (this.#retry === void 0) return;
|
|
1672
|
+
this.#timers.clearTimeout(this.#retry);
|
|
1673
|
+
this.#retry = this.#timers.setTimeout(() => {
|
|
1674
|
+
this.#retry = void 0;
|
|
1675
|
+
this.#drain(false);
|
|
1676
|
+
}, RETRY_SOON_MS);
|
|
1677
|
+
}
|
|
1678
|
+
#scheduleRetry() {
|
|
1679
|
+
if (this.#retry !== void 0) return;
|
|
1680
|
+
this.#retry = this.#timers.setTimeout(() => {
|
|
1681
|
+
this.#retry = void 0;
|
|
1682
|
+
this.#drain(false);
|
|
1683
|
+
}, this.#retryMs);
|
|
1684
|
+
this.#retryMs = Math.min(this.#retryMs * 2, 3e4);
|
|
1685
|
+
}
|
|
1686
|
+
#kickQueue() {
|
|
1687
|
+
if (this.#retry !== void 0) {
|
|
1688
|
+
this.#timers.clearTimeout(this.#retry);
|
|
1689
|
+
this.#retry = void 0;
|
|
1690
|
+
}
|
|
1691
|
+
this.#retryMs = 1e3;
|
|
1692
|
+
this.flush();
|
|
1693
|
+
}
|
|
1694
|
+
/** Sends one mutation; `retry` when the network failed and it stays queued. */
|
|
1695
|
+
async #send(mutation, keepalive) {
|
|
1696
|
+
this.#inflight.set(mutation.mutationId, mutation);
|
|
1697
|
+
let result;
|
|
1698
|
+
try {
|
|
1699
|
+
result = await this.#transport().mutate({
|
|
1700
|
+
mutationId: mutation.mutationId,
|
|
1701
|
+
scope: mutation.scope,
|
|
1702
|
+
baseRevision: String(mutation.baseRevision),
|
|
1703
|
+
viewId: mutation.viewId,
|
|
1704
|
+
ops: mutation.ops
|
|
1705
|
+
}, { keepalive });
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
if (error instanceof NetworkError) {
|
|
1708
|
+
this.#online = false;
|
|
1709
|
+
this.#changed(mutation.ops.map((op) => fieldKey(mutation.scope, op.id)));
|
|
1710
|
+
return "retry";
|
|
1711
|
+
}
|
|
1712
|
+
return this.#refusedMutation(mutation, error);
|
|
1713
|
+
} finally {
|
|
1714
|
+
this.#inflight.delete(mutation.mutationId);
|
|
1715
|
+
}
|
|
1716
|
+
if (!this.#online) this.#online = true;
|
|
1717
|
+
this.#answered(mutation, result);
|
|
1718
|
+
return "done";
|
|
1719
|
+
}
|
|
1720
|
+
/** A mutation the gateway answered. */
|
|
1721
|
+
#answered(mutation, result) {
|
|
1722
|
+
const revision = Number(result.revision);
|
|
1723
|
+
const touched = [];
|
|
1724
|
+
const conflicts = new Map(result.conflicts.map((conflict) => [conflict.id, conflict]));
|
|
1725
|
+
const applied = [];
|
|
1726
|
+
for (const op of mutation.ops) {
|
|
1727
|
+
const key = fieldKey(mutation.scope, op.id);
|
|
1728
|
+
touched.push(key);
|
|
1729
|
+
const sending = this.#sending.get(key);
|
|
1730
|
+
const mine = op.delete ? void 0 : op.value;
|
|
1731
|
+
if (sending?.mutationId === mutation.mutationId) this.#sending.delete(key);
|
|
1732
|
+
const conflict = conflicts.get(op.id);
|
|
1733
|
+
if (conflict) {
|
|
1734
|
+
this.#conflicted(key, op, conflict, mutation);
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
if (result.applied.includes(op.id)) {
|
|
1738
|
+
const current = this.#server.get(key);
|
|
1739
|
+
if (!current || current.revision < revision) this.#server.set(key, {
|
|
1740
|
+
value: mine,
|
|
1741
|
+
revision,
|
|
1742
|
+
deleted: op.delete === true,
|
|
1743
|
+
updatedBy: {
|
|
1744
|
+
kind: "viewer",
|
|
1745
|
+
...this.#viewer ? { id: this.#viewer.id } : {}
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
applied.push({
|
|
1749
|
+
id: op.id,
|
|
1750
|
+
value: mine
|
|
1751
|
+
});
|
|
1752
|
+
const later = this.#pending.get(key);
|
|
1753
|
+
if (later && later.base === null) {
|
|
1754
|
+
const based = {
|
|
1755
|
+
...later,
|
|
1756
|
+
base: revision
|
|
1757
|
+
};
|
|
1758
|
+
this.#pending.set(key, based);
|
|
1759
|
+
this.#options.journal.putPending(based).catch(() => void 0);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
this.#options.journal.remove(mutation.mutationId).catch(() => void 0);
|
|
1764
|
+
if (applied.length > 0) this.#broadcast?.postMessage({
|
|
1765
|
+
kind: "applied",
|
|
1766
|
+
scope: mutation.scope,
|
|
1767
|
+
revision,
|
|
1768
|
+
changes: applied
|
|
1769
|
+
});
|
|
1770
|
+
this.#resolveCatchUp();
|
|
1771
|
+
this.#changed(touched);
|
|
1772
|
+
if ([...this.#pending.values()].some((entry) => entry.base !== null)) this.#schedule();
|
|
1773
|
+
}
|
|
1774
|
+
#conflicted(key, op, conflict, mutation) {
|
|
1775
|
+
const theirs = conflict.deleted ? void 0 : conflict.value;
|
|
1776
|
+
const mine = op.delete ? void 0 : op.value;
|
|
1777
|
+
this.#unknown.delete(key);
|
|
1778
|
+
this.#server.set(key, {
|
|
1779
|
+
value: theirs,
|
|
1780
|
+
revision: Number(conflict.revision),
|
|
1781
|
+
deleted: conflict.deleted === true,
|
|
1782
|
+
...conflict.updatedBy ? { updatedBy: conflict.updatedBy } : {}
|
|
1783
|
+
});
|
|
1784
|
+
const later = this.#pending.get(key);
|
|
1785
|
+
if (same(theirs, later ? later.op.delete ? void 0 : later.op.value : mine)) {
|
|
1786
|
+
if (later) {
|
|
1787
|
+
this.#pending.delete(key);
|
|
1788
|
+
this.#options.journal.dropPending(later).catch(() => void 0);
|
|
1789
|
+
}
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
const held = later !== void 0 ? later.base === null ? {
|
|
1793
|
+
...later,
|
|
1794
|
+
base: mutation.baseRevision
|
|
1795
|
+
} : later : {
|
|
1796
|
+
kind: "pending",
|
|
1797
|
+
viewerId: mutation.viewerId,
|
|
1798
|
+
tab: this.#options.tab,
|
|
1799
|
+
viewId: mutation.viewId,
|
|
1800
|
+
scope: mutation.scope,
|
|
1801
|
+
op,
|
|
1802
|
+
base: mutation.baseRevision,
|
|
1803
|
+
at: Date.now()
|
|
1804
|
+
};
|
|
1805
|
+
this.#pending.set(key, held);
|
|
1806
|
+
this.#options.journal.putPending(held).catch(() => void 0);
|
|
1807
|
+
this.#conflicts.set(key, {
|
|
1808
|
+
theirs,
|
|
1809
|
+
deleted: conflict.deleted === true,
|
|
1810
|
+
revision: Number(conflict.revision),
|
|
1811
|
+
...conflict.updatedBy ? { updatedBy: conflict.updatedBy } : {}
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
/** A mutation the gateway refused: dropped from the queue, its values handled per refusal. */
|
|
1815
|
+
async #refusedMutation(mutation, error) {
|
|
1816
|
+
const code = error instanceof MostraError ? error.code : "MOSTRA_HOST_INTERNAL_ERROR";
|
|
1817
|
+
const keys = mutation.ops.map((op) => fieldKey(mutation.scope, op.id));
|
|
1818
|
+
const forget = () => {
|
|
1819
|
+
for (const key of keys) if (this.#sending.get(key)?.mutationId === mutation.mutationId) this.#sending.delete(key);
|
|
1820
|
+
this.#options.journal.remove(mutation.mutationId).catch(() => void 0);
|
|
1821
|
+
};
|
|
1822
|
+
switch (code) {
|
|
1823
|
+
case "MOSTRA_HYGIENE_TOKEN_REVOKED":
|
|
1824
|
+
this.#revoked = true;
|
|
1825
|
+
this.#changed(keys);
|
|
1826
|
+
return "retry";
|
|
1827
|
+
case "MOSTRA_SUBMIT_VIEW_INVALID":
|
|
1828
|
+
if (this.view && mutation.viewId !== this.view.viewId) {
|
|
1829
|
+
const moved = {
|
|
1830
|
+
...mutation,
|
|
1831
|
+
viewId: this.view.viewId
|
|
1832
|
+
};
|
|
1833
|
+
this.#queue[0] = moved;
|
|
1834
|
+
this.#options.journal.promote(moved, []).catch(() => void 0);
|
|
1835
|
+
return this.#send(moved, false);
|
|
1836
|
+
}
|
|
1837
|
+
break;
|
|
1838
|
+
case "MOSTRA_SUBMIT_ALREADY":
|
|
1839
|
+
this.#closed = true;
|
|
1840
|
+
forget();
|
|
1841
|
+
this.#changed(this.#allKeys());
|
|
1842
|
+
return "done";
|
|
1843
|
+
case "MOSTRA_HOME_DISK_FULL":
|
|
1844
|
+
case "MOSTRA_HOME_GONE":
|
|
1845
|
+
case "MOSTRA_HOST_INTERNAL_ERROR":
|
|
1846
|
+
this.#online = false;
|
|
1847
|
+
this.#changed(keys);
|
|
1848
|
+
return "retry";
|
|
1849
|
+
}
|
|
1850
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1851
|
+
for (const op of mutation.ops) {
|
|
1852
|
+
const key = fieldKey(mutation.scope, op.id);
|
|
1853
|
+
if (!this.#pending.has(key)) {
|
|
1854
|
+
const held = {
|
|
1855
|
+
kind: "pending",
|
|
1856
|
+
viewerId: mutation.viewerId,
|
|
1857
|
+
tab: this.#options.tab,
|
|
1858
|
+
viewId: mutation.viewId,
|
|
1859
|
+
scope: mutation.scope,
|
|
1860
|
+
op,
|
|
1861
|
+
base: mutation.baseRevision,
|
|
1862
|
+
at: Date.now()
|
|
1863
|
+
};
|
|
1864
|
+
this.#pending.set(key, held);
|
|
1865
|
+
this.#options.journal.putPending(held).catch(() => void 0);
|
|
1866
|
+
}
|
|
1867
|
+
this.#refused.set(key, {
|
|
1868
|
+
code,
|
|
1869
|
+
message
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
forget();
|
|
1873
|
+
this.#changed(keys);
|
|
1874
|
+
return "done";
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Replays what earlier pages left: this tab's entries (none on a fresh load) and those of tabs
|
|
1878
|
+
* that are gone, mutations first in their order, then pending edits (IN-02, IN-04).
|
|
1879
|
+
*/
|
|
1880
|
+
#recoverJournal() {
|
|
1881
|
+
return (this.#options.exclusive ?? ((fn) => fn()))(() => this.#adoptJournal()).catch(() => void 0);
|
|
1882
|
+
}
|
|
1883
|
+
async #adoptJournal() {
|
|
1884
|
+
const viewerId = this.#viewer?.id;
|
|
1885
|
+
if (!viewerId) return;
|
|
1886
|
+
let contents;
|
|
1887
|
+
try {
|
|
1888
|
+
contents = await this.#options.journal.load(viewerId);
|
|
1889
|
+
} catch {
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
const tabs = [...new Set([...contents.pending, ...contents.mutations].map((entry) => entry.tab))].filter((tab) => tab !== this.#options.tab);
|
|
1893
|
+
const gone = this.#options.goneTabs ? await this.#options.goneTabs(tabs) : new Set(tabs);
|
|
1894
|
+
const mutations = contents.mutations.filter((entry) => gone.has(entry.tab));
|
|
1895
|
+
const pending = [];
|
|
1896
|
+
const newest = /* @__PURE__ */ new Map();
|
|
1897
|
+
for (const entry of contents.pending.filter((candidate) => gone.has(candidate.tab)).sort((a, b) => a.at - b.at)) {
|
|
1898
|
+
const key = fieldKey(entry.scope, entry.op.id);
|
|
1899
|
+
const earlier = newest.get(key);
|
|
1900
|
+
if (earlier) {
|
|
1901
|
+
const promoted = {
|
|
1902
|
+
kind: "mutation",
|
|
1903
|
+
viewerId: earlier.viewerId,
|
|
1904
|
+
tab: earlier.tab,
|
|
1905
|
+
viewId: earlier.viewId,
|
|
1906
|
+
scope: earlier.scope,
|
|
1907
|
+
mutationId: MutationId.create(),
|
|
1908
|
+
baseRevision: earlier.base ?? this.#server.get(key)?.revision ?? this.#seen[earlier.scope],
|
|
1909
|
+
ops: [earlier.op],
|
|
1910
|
+
seq: earlier.at * 1e3
|
|
1911
|
+
};
|
|
1912
|
+
try {
|
|
1913
|
+
await this.#options.journal.promote(promoted, [earlier]);
|
|
1914
|
+
} catch {
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
mutations.push(promoted);
|
|
1918
|
+
pending.splice(pending.indexOf(earlier), 1);
|
|
1919
|
+
}
|
|
1920
|
+
newest.set(key, entry);
|
|
1921
|
+
pending.push(entry);
|
|
1922
|
+
}
|
|
1923
|
+
const adopted = [...mutations.sort((a, b) => a.seq - b.seq), ...pending];
|
|
1924
|
+
if (adopted.length === 0) return;
|
|
1925
|
+
try {
|
|
1926
|
+
await this.#options.journal.adopt(adopted, this.#options.tab);
|
|
1927
|
+
} catch {
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const touched = [];
|
|
1931
|
+
for (const entry of adopted) {
|
|
1932
|
+
if (entry.kind !== "mutation") continue;
|
|
1933
|
+
const mutation = {
|
|
1934
|
+
...entry,
|
|
1935
|
+
tab: this.#options.tab
|
|
1936
|
+
};
|
|
1937
|
+
for (const op of mutation.ops) {
|
|
1938
|
+
const key = fieldKey(mutation.scope, op.id);
|
|
1939
|
+
touched.push(key);
|
|
1940
|
+
this.#sending.set(key, {
|
|
1941
|
+
value: op.delete ? void 0 : op.value,
|
|
1942
|
+
mutationId: mutation.mutationId
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
this.#queue.push(mutation);
|
|
1946
|
+
}
|
|
1947
|
+
for (const entry of adopted) {
|
|
1948
|
+
if (entry.kind !== "pending") continue;
|
|
1949
|
+
const key = fieldKey(entry.scope, entry.op.id);
|
|
1950
|
+
touched.push(key);
|
|
1951
|
+
if (this.#pending.has(key)) continue;
|
|
1952
|
+
const base = entry.base ?? (this.#sending.has(key) ? null : this.#baseFor(key));
|
|
1953
|
+
this.#pending.set(key, {
|
|
1954
|
+
...entry,
|
|
1955
|
+
tab: this.#options.tab,
|
|
1956
|
+
base
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
this.#changed(touched);
|
|
1960
|
+
this.flush();
|
|
1961
|
+
}
|
|
1962
|
+
#openChannel() {
|
|
1963
|
+
const viewerId = this.#viewer?.id;
|
|
1964
|
+
const artifactId = this.view?.artifactId;
|
|
1965
|
+
if (!viewerId || !artifactId || !this.#options.channel) return;
|
|
1966
|
+
this.#broadcast = this.#options.channel(`mostra:${artifactId}:${viewerId}`);
|
|
1967
|
+
if (!this.#broadcast) return;
|
|
1968
|
+
this.#broadcast.onmessage = (event) => {
|
|
1969
|
+
const data = event.data;
|
|
1970
|
+
if (data.kind !== "applied" || !data.scope || typeof data.revision !== "number") return;
|
|
1971
|
+
const touched = [];
|
|
1972
|
+
for (const change of data.changes ?? []) {
|
|
1973
|
+
const key = fieldKey(data.scope, change.id);
|
|
1974
|
+
const current = this.#server.get(key);
|
|
1975
|
+
if (current && current.revision >= data.revision) continue;
|
|
1976
|
+
this.#server.set(key, {
|
|
1977
|
+
value: change.value,
|
|
1978
|
+
revision: data.revision,
|
|
1979
|
+
deleted: change.value === void 0,
|
|
1980
|
+
...current?.updatedBy ? { updatedBy: current.updatedBy } : {}
|
|
1981
|
+
});
|
|
1982
|
+
touched.push(key);
|
|
1983
|
+
}
|
|
1984
|
+
if (touched.length > 0) this.#changed(touched);
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
/** The submission state (INTERFACES §2.3). */
|
|
1988
|
+
submitState() {
|
|
1989
|
+
return this.#submitState;
|
|
1990
|
+
}
|
|
1991
|
+
/** The viewer's submission, once made or known. */
|
|
1992
|
+
submission() {
|
|
1993
|
+
return this.#submission;
|
|
1994
|
+
}
|
|
1995
|
+
/** Whether a submission closed the viewer's draft. */
|
|
1996
|
+
get closed() {
|
|
1997
|
+
return this.#closed;
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Gets the draft ready for a submit (D110, IN-02): writes the initial values of mounted viewer
|
|
2001
|
+
* fields the gateway never stored, saves everything, waits until the event stream reached the
|
|
2002
|
+
* page's own last save, and reports what still blocks.
|
|
2003
|
+
*/
|
|
2004
|
+
/**
|
|
2005
|
+
* Adds a check that runs before every submit, either channel's: an adapter whose library
|
|
2006
|
+
* validates on its own schedule (react-hook-form validates on submit by default) runs its
|
|
2007
|
+
* validation here and updates its registrations, so the blockers are current. Returns the
|
|
2008
|
+
* function that removes it.
|
|
2009
|
+
*/
|
|
2010
|
+
beforeSubmit(check) {
|
|
2011
|
+
this.#beforeSubmit.add(check);
|
|
2012
|
+
return () => this.#beforeSubmit.delete(check);
|
|
2013
|
+
}
|
|
2014
|
+
async prepareSubmit() {
|
|
2015
|
+
if (this.mode === "local") throw targetUnavailable("Submitting");
|
|
2016
|
+
await this.whenReady(CATCH_UP_MS);
|
|
2017
|
+
await Promise.allSettled([...this.#beforeSubmit].map((check) => check()));
|
|
2018
|
+
for (const [key, { registration }] of this.#registrations) {
|
|
2019
|
+
if (registration.scope !== "viewer" || !this.writable(key)) continue;
|
|
2020
|
+
if (this.stored(key) || this.#pending.has(key) || this.#sending.has(key)) continue;
|
|
2021
|
+
if (registration.initial === void 0) continue;
|
|
2022
|
+
this.set("viewer", registration.id, registration.initial, {
|
|
2023
|
+
...registration.label !== void 0 ? { label: registration.label } : {},
|
|
2024
|
+
...registration.required !== void 0 ? { required: registration.required } : {},
|
|
2025
|
+
...registration.sensitive !== void 0 ? { sensitive: registration.sensitive } : {}
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
const flushed = await this.flush();
|
|
2029
|
+
await this.#caughtUp(flushed.revision);
|
|
2030
|
+
if (this.#unknown.size > 0) await this.#resync();
|
|
2031
|
+
if (this.#seen.viewer < flushed.revision) {
|
|
2032
|
+
this.#resync();
|
|
2033
|
+
return {
|
|
2034
|
+
revision: this.#seen.viewer,
|
|
2035
|
+
unsaved: this.unsaved(),
|
|
2036
|
+
blockers: this.blockers()
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
return {
|
|
2040
|
+
revision: Math.max(this.#seen.viewer, flushed.revision),
|
|
2041
|
+
unsaved: this.unsaved() || this.#unknown.size > 0,
|
|
2042
|
+
blockers: this.blockers()
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
/** Waits until the event stream reached `revision` of the draft, or a while. */
|
|
2046
|
+
#caughtUp(revision) {
|
|
2047
|
+
if (this.#seen.viewer >= revision) return Promise.resolve();
|
|
2048
|
+
return new Promise((resolve) => {
|
|
2049
|
+
const check = () => {
|
|
2050
|
+
if (this.#seen.viewer >= revision) resolve();
|
|
2051
|
+
else this.#catchUp.push(check);
|
|
2052
|
+
};
|
|
2053
|
+
this.#catchUp.push(check);
|
|
2054
|
+
this.#timers.setTimeout(resolve, CATCH_UP_MS);
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
#resolveCatchUp() {
|
|
2058
|
+
for (const check of this.#catchUp.splice(0)) check();
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Submits through the artifact channel (D40), where the view allows it: prepares the draft,
|
|
2062
|
+
* refuses with `MOSTRA_SUBMIT_BLOCKED` while a field blocks, and submits the revision the
|
|
2063
|
+
* viewer saw.
|
|
2064
|
+
*/
|
|
2065
|
+
async submit() {
|
|
2066
|
+
if (this.mode === "local") throw targetUnavailable("Submitting");
|
|
2067
|
+
const view = this.view;
|
|
2068
|
+
if (view && !view.submitChannels.includes("artifact")) throw new MostraError("MOSTRA_SUBMIT_CHANNEL_DENIED", "This host takes submissions from the shell's trusted Submit only.");
|
|
2069
|
+
if (view?.policy.policy === "none") throw new MostraError("MOSTRA_SUBMIT_POLICY_NONE", "This artifact's submission policy is none: it takes no submissions.");
|
|
2070
|
+
if (!this.canSubmit()) throw new MostraError("MOSTRA_HYGIENE_SCOPE_DENIED", "This link doesn't carry the submit scope.");
|
|
2071
|
+
const previous = this.#submitState;
|
|
2072
|
+
this.#submitState = "submitting";
|
|
2073
|
+
this.#changed();
|
|
2074
|
+
try {
|
|
2075
|
+
const prepared = await this.prepareSubmit();
|
|
2076
|
+
if (prepared.blockers.length > 0) throw new MostraError("MOSTRA_SUBMIT_BLOCKED", `Not submitted: ${prepared.blockers.map((b) => b.label ?? b.fieldId).join(", ")} ${prepared.blockers.length === 1 ? "needs" : "need"} attention.`, { body: { blockers: prepared.blockers } });
|
|
2077
|
+
if (prepared.unsaved) throw new MostraError("MOSTRA_SUBMIT_BLOCKED", "Not submitted: some answers aren't saved yet. Try again when the page is back online.", { body: { blockers: [] } });
|
|
2078
|
+
const submission = await this.#transport().submit({
|
|
2079
|
+
viewId: view?.viewId ?? "",
|
|
2080
|
+
expectedRevision: String(prepared.revision),
|
|
2081
|
+
channel: "artifact"
|
|
2082
|
+
});
|
|
2083
|
+
this.#adoptSubmission(submission, false);
|
|
2084
|
+
this.#changed(this.#allKeys());
|
|
2085
|
+
return submission;
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
if (error instanceof MostraError && error.code === "MOSTRA_SUBMIT_ALREADY") {
|
|
2088
|
+
const existing = error.body?.["submission"];
|
|
2089
|
+
if (existing) this.#adoptSubmission(existing, false);
|
|
2090
|
+
else this.#submitState = "closed";
|
|
2091
|
+
} else {
|
|
2092
|
+
this.#submitState = previous === "submitting" ? "open" : previous;
|
|
2093
|
+
if (error instanceof MostraError && error.code === "MOSTRA_SUBMIT_REVISION_MOVED") await this.#resync();
|
|
2094
|
+
}
|
|
2095
|
+
this.#changed();
|
|
2096
|
+
throw error;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
/** Raises a signal for the host's attention (IN-09). */
|
|
2100
|
+
async signal(name, payload) {
|
|
2101
|
+
if (this.mode === "local") throw targetUnavailable("Sending a signal");
|
|
2102
|
+
if (!SIGNAL_NAME_PATTERN.test(name) || name === "submit") throw new MostraError("MOSTRA_SUBMIT_SIGNAL_INVALID", `${JSON.stringify(name)} isn't a signal name: use letters, digits, ".", "_" and "-"; "submit" is the shell's.`);
|
|
2103
|
+
await this.#transport().signal({
|
|
2104
|
+
viewId: this.view?.viewId ?? "",
|
|
2105
|
+
name,
|
|
2106
|
+
...payload !== void 0 ? { payload } : {}
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
/** Takes a submission the page made, or learned of, as the viewer's. */
|
|
2110
|
+
adoptSubmission(submission) {
|
|
2111
|
+
this.#adoptSubmission(submission, false);
|
|
2112
|
+
this.#changed();
|
|
2113
|
+
}
|
|
2114
|
+
#adoptSubmission(incoming, fromSnapshot) {
|
|
2115
|
+
const submission = mergedSubmission(this.#submission, incoming);
|
|
2116
|
+
this.#submission = submission;
|
|
2117
|
+
const early = this.#earlyDeliveries.filter((event) => event.payload.submissionId === submission.id);
|
|
2118
|
+
this.#earlyDeliveries = this.#earlyDeliveries.filter((event) => !early.includes(event));
|
|
2119
|
+
const policy = this.view?.policy;
|
|
2120
|
+
if (policy?.policy !== "epochs" || submission.epoch === this.#epoch) {
|
|
2121
|
+
if (policy?.policy === "once" || policy === void 0) this.#closed = true;
|
|
2122
|
+
}
|
|
2123
|
+
if (policy?.policy === "epochs" && fromSnapshot && submission.epoch < this.#epoch) {
|
|
2124
|
+
this.#submitState = "open";
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
this.#submitState = deliveryState(submission);
|
|
2128
|
+
for (const event of early) this.#deliveryEvent(event);
|
|
2129
|
+
}
|
|
2130
|
+
#deliveryEvent(event) {
|
|
2131
|
+
const submission = this.#submission;
|
|
2132
|
+
const payload = event.payload;
|
|
2133
|
+
if (!submission || payload.submissionId !== submission.id) {
|
|
2134
|
+
this.#earlyDeliveries = [...this.#earlyDeliveries, event].slice(-64);
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
const kind = event.type.slice(11);
|
|
2138
|
+
const state = kind === "delivered" ? "delivered" : kind === "rejected" ? "rejected" : kind === "undeliverable" ? "undeliverable" : "pending";
|
|
2139
|
+
const deliveries = (submission.deliveries ?? []).map((delivery) => delivery.deliveryId === payload.deliveryId ? {
|
|
2140
|
+
...delivery,
|
|
2141
|
+
state,
|
|
2142
|
+
...kind === "retrying" ? { waiting: true } : {},
|
|
2143
|
+
...payload.reason !== void 0 && kind !== "retrying" ? { reason: payload.reason } : {},
|
|
2144
|
+
updatedAt: event.at
|
|
2145
|
+
} : delivery);
|
|
2146
|
+
this.#submission = {
|
|
2147
|
+
...submission,
|
|
2148
|
+
deliveries,
|
|
2149
|
+
delivery: worst(deliveries)
|
|
2150
|
+
};
|
|
2151
|
+
this.#submitState = deliveryState(this.#submission);
|
|
2152
|
+
this.#changed();
|
|
2153
|
+
}
|
|
2154
|
+
#localStorage() {
|
|
2155
|
+
if (this.#options.local) return this.#options.local;
|
|
2156
|
+
try {
|
|
2157
|
+
return typeof localStorage === "undefined" ? void 0 : localStorage;
|
|
2158
|
+
} catch {
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
#localRead(key) {
|
|
2163
|
+
const text = this.#localStorage()?.getItem(`mostra.field.${key}`);
|
|
2164
|
+
if (text === null || text === void 0) return void 0;
|
|
2165
|
+
try {
|
|
2166
|
+
return JSON.parse(text);
|
|
2167
|
+
} catch {
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
#localWrite(key, value) {
|
|
2172
|
+
const storage = this.#localStorage();
|
|
2173
|
+
try {
|
|
2174
|
+
if (value === void 0) storage?.removeItem(`mostra.field.${key}`);
|
|
2175
|
+
else storage?.setItem(`mostra.field.${key}`, JSON.stringify(value));
|
|
2176
|
+
} catch {
|
|
2177
|
+
this.#pending.set(key, {
|
|
2178
|
+
kind: "pending",
|
|
2179
|
+
viewerId: "",
|
|
2180
|
+
tab: this.#options.tab,
|
|
2181
|
+
viewId: "",
|
|
2182
|
+
scope: keyParts(key).scope,
|
|
2183
|
+
op: value === void 0 ? {
|
|
2184
|
+
id: keyParts(key).id,
|
|
2185
|
+
delete: true
|
|
2186
|
+
} : {
|
|
2187
|
+
id: keyParts(key).id,
|
|
2188
|
+
value
|
|
2189
|
+
},
|
|
2190
|
+
base: 0,
|
|
2191
|
+
at: Date.now()
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
#localIds(scope) {
|
|
2196
|
+
const storage = this.#localStorage();
|
|
2197
|
+
if (!storage || typeof storage.length !== "number") return [];
|
|
2198
|
+
const prefix = `mostra.field.${scope}:`;
|
|
2199
|
+
const ids = [];
|
|
2200
|
+
for (let index = 0; index < storage.length; index += 1) {
|
|
2201
|
+
const name = storage.key(index);
|
|
2202
|
+
if (name?.startsWith(prefix)) ids.push(name.slice(prefix.length));
|
|
2203
|
+
}
|
|
2204
|
+
return ids;
|
|
2205
|
+
}
|
|
2206
|
+
};
|
|
2207
|
+
const SEVERITY = {
|
|
2208
|
+
delivered: 0,
|
|
2209
|
+
pending: 1,
|
|
2210
|
+
rejected: 2,
|
|
2211
|
+
undeliverable: 3
|
|
2212
|
+
};
|
|
2213
|
+
function worst(deliveries) {
|
|
2214
|
+
let result = "delivered";
|
|
2215
|
+
for (const delivery of deliveries) if (SEVERITY[delivery.state] > SEVERITY[result]) result = delivery.state;
|
|
2216
|
+
return result;
|
|
2217
|
+
}
|
|
2218
|
+
/** The submission's state as the viewer sees it (IN-13, D47). */
|
|
2219
|
+
function deliveryState(submission) {
|
|
2220
|
+
const deliveries = submission.deliveries ?? [];
|
|
2221
|
+
if (submission.delivery === "undeliverable" || submission.delivery === "rejected") return "undeliverable";
|
|
2222
|
+
if (submission.delivery === "delivered") return "delivered";
|
|
2223
|
+
return deliveries.some((delivery) => delivery.state === "pending" && delivery.waiting) ? "pending-delivery" : "submitted";
|
|
2224
|
+
}
|
|
2225
|
+
//#endregion
|
|
2226
|
+
//#region src/shell-bridge.ts
|
|
2227
|
+
/**
|
|
2228
|
+
* The artifact's end of the shell bridge (INTERFACES §5, SH-04). The client runtime starts it:
|
|
2229
|
+
* when the page runs inside Mostra's shell, it learns the shell's exact origin and its link's
|
|
2230
|
+
* scopes from its own session (`GET /_mostra/session`, readable only by this origin), introduces
|
|
2231
|
+
* itself with MCP Apps' `ui/initialize`, whose answer names the view this frame load is (D104),
|
|
2232
|
+
* and reports `mostra/ready` once the page has rendered. Messages from any other window or origin
|
|
2233
|
+
* are dropped. Outside a shell (a static export, the page opened on its own) it does nothing.
|
|
2234
|
+
*/
|
|
2235
|
+
/**
|
|
2236
|
+
* The version this page's build belongs to: its `<meta name="mostra-version">`, which Mostra's
|
|
2237
|
+
* builder writes (WP-07). `undefined` for a build that has none. @internal
|
|
2238
|
+
*/
|
|
2239
|
+
function builtVersion() {
|
|
2240
|
+
if (typeof document === "undefined") return void 0;
|
|
2241
|
+
const content = document.querySelector("meta[name=\"mostra-version\"]")?.getAttribute("content");
|
|
2242
|
+
return content ? content : void 0;
|
|
2243
|
+
}
|
|
2244
|
+
/** `ui/initialize`'s `mostra` params: the page's version, when the build names it. */
|
|
2245
|
+
function versionParams() {
|
|
2246
|
+
const versionId = builtVersion();
|
|
2247
|
+
return versionId === void 0 ? {} : { mostra: { versionId } };
|
|
2248
|
+
}
|
|
2249
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2250
|
+
let connecting;
|
|
2251
|
+
/**
|
|
2252
|
+
* Listens to the shell's requests and notifications on this page's bridge; returns the function
|
|
2253
|
+
* that stops. The client runtime answers `mostra/flush` and hears `mostra/version.available`.
|
|
2254
|
+
*/
|
|
2255
|
+
function listenShell(listener) {
|
|
2256
|
+
listeners.add(listener);
|
|
2257
|
+
return () => listeners.delete(listener);
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Connects this page to the shell that frames it, once; later calls return the same connection.
|
|
2261
|
+
* Resolves `undefined` when the page isn't framed by a Mostra shell. The client runtime calls it;
|
|
2262
|
+
* artifact code doesn't need to.
|
|
2263
|
+
*/
|
|
2264
|
+
function connectShell() {
|
|
2265
|
+
connecting ??= (async () => {
|
|
2266
|
+
if (typeof window === "undefined" || window.parent === window) return void 0;
|
|
2267
|
+
let session;
|
|
2268
|
+
try {
|
|
2269
|
+
const response = await fetch("/_mostra/session", {
|
|
2270
|
+
credentials: "same-origin",
|
|
2271
|
+
cache: "no-store",
|
|
2272
|
+
signal: AbortSignal.timeout(HANDSHAKE_TIMEOUT_MS)
|
|
2273
|
+
});
|
|
2274
|
+
if (!response.ok) return void 0;
|
|
2275
|
+
session = await response.json();
|
|
2276
|
+
} catch {
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
if (typeof session.shellOrigin !== "string") return void 0;
|
|
2280
|
+
const shellOrigin = session.shellOrigin;
|
|
2281
|
+
const bridge = connectBridge({
|
|
2282
|
+
side: "artifact",
|
|
2283
|
+
receiver: window,
|
|
2284
|
+
peer: () => window.parent,
|
|
2285
|
+
peerOrigin: shellOrigin,
|
|
2286
|
+
onRequest: async (message, params) => {
|
|
2287
|
+
for (const listener of listeners) {
|
|
2288
|
+
const answer = await listener.request?.(message, params);
|
|
2289
|
+
if (answer !== void 0) return answer;
|
|
2290
|
+
}
|
|
2291
|
+
throw new Error(`The page doesn't answer ${message.method}.`);
|
|
2292
|
+
},
|
|
2293
|
+
onNotification: (message, params) => {
|
|
2294
|
+
for (const listener of listeners) listener.notification?.(message, params);
|
|
2295
|
+
}
|
|
2296
|
+
});
|
|
2297
|
+
let host;
|
|
2298
|
+
try {
|
|
2299
|
+
host = await bridge.call(uiInitialize, {
|
|
2300
|
+
appInfo: {
|
|
2301
|
+
name: "mostra-artifact",
|
|
2302
|
+
version: "0"
|
|
2303
|
+
},
|
|
2304
|
+
appCapabilities: {},
|
|
2305
|
+
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
|
|
2306
|
+
...versionParams()
|
|
2307
|
+
}, { timeoutMs: HANDSHAKE_TIMEOUT_MS });
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
bridge.close();
|
|
2310
|
+
throw error;
|
|
2311
|
+
}
|
|
2312
|
+
bridge.notify(uiInitialized, {});
|
|
2313
|
+
return {
|
|
2314
|
+
shellOrigin,
|
|
2315
|
+
host,
|
|
2316
|
+
bridge,
|
|
2317
|
+
session: {
|
|
2318
|
+
csrf: typeof session.csrf === "string" ? session.csrf : "",
|
|
2319
|
+
scopes: Array.isArray(session.scopes) ? session.scopes.filter((scope) => typeof scope === "string") : [],
|
|
2320
|
+
...isViewer(session.viewer) ? { viewer: session.viewer } : {}
|
|
2321
|
+
}
|
|
2322
|
+
};
|
|
2323
|
+
})().then((connection) => {
|
|
2324
|
+
if (connection === void 0) connecting = void 0;
|
|
2325
|
+
return connection;
|
|
2326
|
+
}, () => {
|
|
2327
|
+
connecting = void 0;
|
|
2328
|
+
});
|
|
2329
|
+
return connecting;
|
|
2330
|
+
}
|
|
2331
|
+
/** Whether the session's `viewer` looks like a viewer (a light check: the gateway sends it). */
|
|
2332
|
+
function isViewer(value) {
|
|
2333
|
+
const viewer = value;
|
|
2334
|
+
return typeof viewer?.id === "string" && typeof viewer.kind === "string";
|
|
2335
|
+
}
|
|
2336
|
+
/** How long one attempt at the shell's handshake may take. */
|
|
2337
|
+
const HANDSHAKE_TIMEOUT_MS = 1e4;
|
|
2338
|
+
/**
|
|
2339
|
+
* Tells the shell the page has rendered (`mostra/ready`), after the next frame paints. The
|
|
2340
|
+
* client runtime calls it right after mounting the page.
|
|
2341
|
+
*/
|
|
2342
|
+
function reportReady() {
|
|
2343
|
+
connectShell().then((connection) => {
|
|
2344
|
+
if (!connection) return;
|
|
2345
|
+
requestAnimationFrame(() => connection.bridge.notify(mostraReady, {}));
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
/**
|
|
2349
|
+
* Opens `url` for the viewer in a new tab. Inside Mostra's shell the frame can't navigate the
|
|
2350
|
+
* viewer's tab (D98 item 7), so the shell opens it (`ui/open-link`, MCP Apps); on a page of its
|
|
2351
|
+
* own, the browser does. Only `http:`, `https:`, `mailto:` and `tel:` URLs open. Resolves `true`
|
|
2352
|
+
* when the URL was handed to the browser.
|
|
2353
|
+
*/
|
|
2354
|
+
async function openLink(url) {
|
|
2355
|
+
const connection = await connectShell();
|
|
2356
|
+
if (connection) try {
|
|
2357
|
+
return (await connection.bridge.call(uiOpenLink, { url })).isError !== true;
|
|
2358
|
+
} catch {
|
|
2359
|
+
return false;
|
|
2360
|
+
}
|
|
2361
|
+
let parsed;
|
|
2362
|
+
try {
|
|
2363
|
+
parsed = new URL(url, window.location.href);
|
|
2364
|
+
} catch {
|
|
2365
|
+
return false;
|
|
2366
|
+
}
|
|
2367
|
+
if (!OPEN_LINK_SCHEMES.includes(parsed.protocol)) return false;
|
|
2368
|
+
window.open(parsed.href, "_blank", "noopener,noreferrer");
|
|
2369
|
+
return true;
|
|
2370
|
+
}
|
|
2371
|
+
//#endregion
|
|
2372
|
+
//#region src/hooks.ts
|
|
2373
|
+
/**
|
|
2374
|
+
* The client runtime's hooks (INTERFACES §2.3, D52, D53, D113): fields that behave like
|
|
2375
|
+
* `useState` with a stable ID, the artifact channel's submit, the shell's trusted Submit label,
|
|
2376
|
+
* signals, choices, the viewer and the artifact. Every hook works in every delivery target: where
|
|
2377
|
+
* there is no Mostra server (a static export), fields keep their values in this browser, and
|
|
2378
|
+
* submit and signals reject with `MOSTRA_TARGET_UNAVAILABLE`.
|
|
2379
|
+
*/
|
|
2380
|
+
const RuntimeContext = createContext(void 0);
|
|
2381
|
+
let fallback;
|
|
2382
|
+
/** The runtime of a tree rendered outside `mountArtifact` (a test, a component gallery): local. */
|
|
2383
|
+
function fallbackRuntime() {
|
|
2384
|
+
if (!fallback) {
|
|
2385
|
+
const store = new Store({
|
|
2386
|
+
mode: "local",
|
|
2387
|
+
journal: new MemoryJournal(),
|
|
2388
|
+
tab: "fallback"
|
|
2389
|
+
});
|
|
2390
|
+
store.start();
|
|
2391
|
+
fallback = {
|
|
2392
|
+
store,
|
|
2393
|
+
mode: "static"
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
return fallback;
|
|
2397
|
+
}
|
|
2398
|
+
/** Provides a runtime to a tree; `mountArtifact` renders the page inside it. */
|
|
2399
|
+
function RuntimeProvider(props) {
|
|
2400
|
+
const value = useMemo(() => ({
|
|
2401
|
+
store: props.store,
|
|
2402
|
+
mode: props.mode
|
|
2403
|
+
}), [props.store, props.mode]);
|
|
2404
|
+
return createElement(RuntimeContext.Provider, { value }, props.children);
|
|
2405
|
+
}
|
|
2406
|
+
function useRuntime() {
|
|
2407
|
+
return useContext(RuntimeContext) ?? fallbackRuntime();
|
|
2408
|
+
}
|
|
2409
|
+
/** The page's store, for the package's own adapters (`@mostra/react/form`). @internal */
|
|
2410
|
+
function useStore() {
|
|
2411
|
+
return useRuntime().store;
|
|
2412
|
+
}
|
|
2413
|
+
/** Re-renders on every change of the store; returns its version. */
|
|
2414
|
+
function useStoreVersion(store) {
|
|
2415
|
+
return useSyncExternalStore(store.subscribe, store.version, store.version);
|
|
2416
|
+
}
|
|
2417
|
+
/** The message shown while an asynchronous check runs; it blocks a submit until it settles. */
|
|
2418
|
+
const CHECKING = "Checking this answer…";
|
|
2419
|
+
function messageOf(result) {
|
|
2420
|
+
const issues = result?.issues;
|
|
2421
|
+
return issues && issues.length > 0 ? issues[0]?.message ?? "Invalid value." : void 0;
|
|
2422
|
+
}
|
|
2423
|
+
function validationOf(validate, value) {
|
|
2424
|
+
if (validate === void 0) return {
|
|
2425
|
+
kind: "now",
|
|
2426
|
+
error: void 0
|
|
2427
|
+
};
|
|
2428
|
+
if (typeof validate === "function") return {
|
|
2429
|
+
kind: "now",
|
|
2430
|
+
error: validate(value) ?? void 0
|
|
2431
|
+
};
|
|
2432
|
+
const result = validate["~standard"].validate(value);
|
|
2433
|
+
if (result instanceof Promise) return {
|
|
2434
|
+
kind: "later",
|
|
2435
|
+
settled: result.then(messageOf, () => "This answer couldn't be checked.")
|
|
2436
|
+
};
|
|
2437
|
+
return {
|
|
2438
|
+
kind: "now",
|
|
2439
|
+
error: messageOf(result)
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
function updatedByOf(actor) {
|
|
2443
|
+
if (!actor || actor.kind === "mostra") return void 0;
|
|
2444
|
+
return {
|
|
2445
|
+
kind: actor.kind,
|
|
2446
|
+
...actor.name !== void 0 ? { name: actor.name } : {}
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
const warned = /* @__PURE__ */ new Set();
|
|
2450
|
+
/**
|
|
2451
|
+
* A persisted value with a stable field ID (D52). It behaves like `useState`: the value the viewer
|
|
2452
|
+
* sees, a setter, and what the field is doing. Edits save to Mostra as the viewer works, survive
|
|
2453
|
+
* reloads, closed tabs and other devices, reach other tabs, and carry over to newer versions by
|
|
2454
|
+
* ID. Dotted IDs (`reply.m1`) nest in results.
|
|
2455
|
+
*
|
|
2456
|
+
* ```tsx
|
|
2457
|
+
* const [layout, setLayout, meta] = useField("layout", "a", { label: "Layout", required: true });
|
|
2458
|
+
* ```
|
|
2459
|
+
*/
|
|
2460
|
+
function useField(id, initial, options = {}) {
|
|
2461
|
+
const { store } = useRuntime();
|
|
2462
|
+
const scope = options.scope ?? "viewer";
|
|
2463
|
+
const key = fieldKey(scope, id);
|
|
2464
|
+
const [first] = useState(() => initial);
|
|
2465
|
+
useSyncExternalStore(store.subscribe, () => store.keyVersion(key), () => store.keyVersion(key));
|
|
2466
|
+
const value = store.value(key, first);
|
|
2467
|
+
const { validate } = options;
|
|
2468
|
+
const validation = useMemo(() => validationOf(validate, value), [validate, value]);
|
|
2469
|
+
const [settled, setSettled] = useState();
|
|
2470
|
+
useEffect(() => {
|
|
2471
|
+
if (validation.kind !== "later") return;
|
|
2472
|
+
let current = true;
|
|
2473
|
+
validation.settled.then((message) => {
|
|
2474
|
+
if (current) setSettled({
|
|
2475
|
+
of: validation,
|
|
2476
|
+
error: message
|
|
2477
|
+
});
|
|
2478
|
+
});
|
|
2479
|
+
return () => {
|
|
2480
|
+
current = false;
|
|
2481
|
+
};
|
|
2482
|
+
}, [validation]);
|
|
2483
|
+
const error = validation.kind === "now" ? validation.error : settled?.of === validation ? settled.error : CHECKING;
|
|
2484
|
+
const { label, required, sensitive } = options;
|
|
2485
|
+
useEffect(() => store.register({
|
|
2486
|
+
id,
|
|
2487
|
+
scope,
|
|
2488
|
+
initial: first,
|
|
2489
|
+
...label !== void 0 ? { label } : {},
|
|
2490
|
+
...required !== void 0 ? { required } : {},
|
|
2491
|
+
...sensitive !== void 0 ? { sensitive } : {}
|
|
2492
|
+
}), [
|
|
2493
|
+
store,
|
|
2494
|
+
id,
|
|
2495
|
+
scope,
|
|
2496
|
+
first
|
|
2497
|
+
]);
|
|
2498
|
+
useEffect(() => {
|
|
2499
|
+
store.updateRegistration({
|
|
2500
|
+
id,
|
|
2501
|
+
scope,
|
|
2502
|
+
initial: first,
|
|
2503
|
+
...label !== void 0 ? { label } : {},
|
|
2504
|
+
...required !== void 0 ? { required } : {},
|
|
2505
|
+
...sensitive !== void 0 ? { sensitive } : {},
|
|
2506
|
+
...error !== void 0 ? { error } : {}
|
|
2507
|
+
});
|
|
2508
|
+
});
|
|
2509
|
+
useEffect(() => {
|
|
2510
|
+
if (validation.kind !== "later") return;
|
|
2511
|
+
return store.beforeSubmit(async () => {
|
|
2512
|
+
const message = await validation.settled;
|
|
2513
|
+
store.updateRegistration({
|
|
2514
|
+
id,
|
|
2515
|
+
scope,
|
|
2516
|
+
initial: first,
|
|
2517
|
+
...label !== void 0 ? { label } : {},
|
|
2518
|
+
...required !== void 0 ? { required } : {},
|
|
2519
|
+
...sensitive !== void 0 ? { sensitive } : {},
|
|
2520
|
+
...message !== void 0 ? { error: message } : {}
|
|
2521
|
+
});
|
|
2522
|
+
});
|
|
2523
|
+
}, [
|
|
2524
|
+
store,
|
|
2525
|
+
validation,
|
|
2526
|
+
id,
|
|
2527
|
+
scope,
|
|
2528
|
+
first,
|
|
2529
|
+
label,
|
|
2530
|
+
required,
|
|
2531
|
+
sensitive
|
|
2532
|
+
]);
|
|
2533
|
+
const set = useCallback((next) => {
|
|
2534
|
+
if (!store.writable(key)) {
|
|
2535
|
+
if (!warned.has(key)) {
|
|
2536
|
+
warned.add(key);
|
|
2537
|
+
console.warn(`Mostra: the field ${id} is read-only here (submitted, or a view-only link); the change was not kept.`);
|
|
2538
|
+
}
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
const previous = store.value(key, first);
|
|
2542
|
+
const resolved = typeof next === "function" ? next(previous) : next;
|
|
2543
|
+
store.set(scope, id, resolved, {
|
|
2544
|
+
...label !== void 0 ? { label } : {},
|
|
2545
|
+
...required !== void 0 ? { required } : {},
|
|
2546
|
+
...sensitive !== void 0 ? { sensitive } : {}
|
|
2547
|
+
});
|
|
2548
|
+
}, [
|
|
2549
|
+
store,
|
|
2550
|
+
key,
|
|
2551
|
+
scope,
|
|
2552
|
+
id,
|
|
2553
|
+
label,
|
|
2554
|
+
required,
|
|
2555
|
+
sensitive,
|
|
2556
|
+
first
|
|
2557
|
+
]);
|
|
2558
|
+
const conflict = store.conflict(key);
|
|
2559
|
+
const refusal = store.refusal(key);
|
|
2560
|
+
const save = store.save(key);
|
|
2561
|
+
const writer = updatedByOf(store.updatedBy(key));
|
|
2562
|
+
const writerKind = writer?.kind;
|
|
2563
|
+
const writerName = writer?.name;
|
|
2564
|
+
return [
|
|
2565
|
+
value,
|
|
2566
|
+
set,
|
|
2567
|
+
useMemo(() => {
|
|
2568
|
+
const message = error ?? refusal?.message;
|
|
2569
|
+
const updatedBy = writerKind === void 0 ? void 0 : {
|
|
2570
|
+
kind: writerKind,
|
|
2571
|
+
...writerName !== void 0 ? { name: writerName } : {}
|
|
2572
|
+
};
|
|
2573
|
+
return {
|
|
2574
|
+
save,
|
|
2575
|
+
...message !== void 0 ? { error: message } : {},
|
|
2576
|
+
...conflict ? { conflict: {
|
|
2577
|
+
theirs: conflict.deleted ? first : conflict.theirs,
|
|
2578
|
+
keepMine: () => store.keepMine(key),
|
|
2579
|
+
takeTheirs: () => store.takeTheirs(key)
|
|
2580
|
+
} } : {},
|
|
2581
|
+
...scope === "artifact" && updatedBy ? { updatedBy } : {}
|
|
2582
|
+
};
|
|
2583
|
+
}, [
|
|
2584
|
+
save,
|
|
2585
|
+
error,
|
|
2586
|
+
refusal,
|
|
2587
|
+
conflict,
|
|
2588
|
+
writerKind,
|
|
2589
|
+
writerName,
|
|
2590
|
+
store,
|
|
2591
|
+
key,
|
|
2592
|
+
scope,
|
|
2593
|
+
first
|
|
2594
|
+
])
|
|
2595
|
+
];
|
|
2596
|
+
}
|
|
2597
|
+
/** The artifact channel's submit, the blockers and the submission's state (D40, IN-13). */
|
|
2598
|
+
function useSubmit() {
|
|
2599
|
+
const { store } = useRuntime();
|
|
2600
|
+
const version = useStoreVersion(store);
|
|
2601
|
+
return useMemo(() => {
|
|
2602
|
+
const view = store.view;
|
|
2603
|
+
const state = store.submitState();
|
|
2604
|
+
const submission = store.submission();
|
|
2605
|
+
const openState = state === "open" || view?.policy.policy === "epochs" && state !== "submitting";
|
|
2606
|
+
return {
|
|
2607
|
+
submit: () => store.submit(),
|
|
2608
|
+
allowed: store.mode === "live" && view !== void 0 && view.submitChannels.includes("artifact") && view.policy.policy !== "none" && store.canSubmit() && !store.closed && openState,
|
|
2609
|
+
blockers: store.blockers(),
|
|
2610
|
+
state,
|
|
2611
|
+
...submission ? { submission: {
|
|
2612
|
+
id: submission.id,
|
|
2613
|
+
at: submission.at
|
|
2614
|
+
} } : {}
|
|
2615
|
+
};
|
|
2616
|
+
}, [store, version]);
|
|
2617
|
+
}
|
|
2618
|
+
/** Asks the shell for a trusted control while mounted, and withdraws it on unmount. */
|
|
2619
|
+
function useControl(request, release) {
|
|
2620
|
+
const text = request === void 0 ? void 0 : JSON.stringify(request);
|
|
2621
|
+
const releaseText = JSON.stringify(release);
|
|
2622
|
+
useEffect(() => {
|
|
2623
|
+
if (text === void 0) return;
|
|
2624
|
+
let active = true;
|
|
2625
|
+
connectShell().then((connection) => {
|
|
2626
|
+
if (!active || !connection) return;
|
|
2627
|
+
connection.bridge.call(mostraControlsRequest, JSON.parse(text)).catch(() => void 0);
|
|
2628
|
+
});
|
|
2629
|
+
return () => {
|
|
2630
|
+
active = false;
|
|
2631
|
+
connectShell().then((connection) => {
|
|
2632
|
+
connection?.bridge.call(mostraControlsRequest, JSON.parse(releaseText)).catch(() => void 0);
|
|
2633
|
+
});
|
|
2634
|
+
};
|
|
2635
|
+
}, [text, releaseText]);
|
|
2636
|
+
}
|
|
2637
|
+
/**
|
|
2638
|
+
* Relabels the shell's trusted Submit while mounted, and asks the shell to confirm with `confirm`
|
|
2639
|
+
* first (D06, SH-02). The artifact asks; the shell renders the control in its own origin, where
|
|
2640
|
+
* the artifact can't reach it.
|
|
2641
|
+
*/
|
|
2642
|
+
function useSubmitControl(options) {
|
|
2643
|
+
useControl({
|
|
2644
|
+
control: "submit",
|
|
2645
|
+
...options.label !== void 0 ? { label: options.label } : {},
|
|
2646
|
+
...options.confirm !== void 0 ? { confirm: options.confirm } : {}
|
|
2647
|
+
}, {
|
|
2648
|
+
control: "release",
|
|
2649
|
+
submit: true
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
/** A named signal for the host's attention (IN-09), such as "Ping the agent". */
|
|
2653
|
+
function useSignal(name) {
|
|
2654
|
+
const { store } = useRuntime();
|
|
2655
|
+
return useMemo(() => ({ send: (payload) => store.signal(name, payload) }), [store, name]);
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* A review set's choice (D65, D113): the field `review.<reviewSetId>.choice`. With `candidates`,
|
|
2659
|
+
* the shell renders a trusted Choose for the candidate the page shows (`current`); `choose()`
|
|
2660
|
+
* chooses from inside the page where the host allows the artifact channel (D40), else rejects
|
|
2661
|
+
* with `MOSTRA_SUBMIT_CHANNEL_DENIED`.
|
|
2662
|
+
*/
|
|
2663
|
+
function useChoice(reviewSetId, options) {
|
|
2664
|
+
const { store } = useRuntime();
|
|
2665
|
+
const id = `review.${reviewSetId}.choice`;
|
|
2666
|
+
const key = fieldKey("viewer", id);
|
|
2667
|
+
useSyncExternalStore(store.subscribe, () => store.keyVersion(key), () => store.keyVersion(key));
|
|
2668
|
+
const choice = store.value(key, void 0);
|
|
2669
|
+
useEffect(() => store.announceMount({
|
|
2670
|
+
id,
|
|
2671
|
+
scope: "viewer",
|
|
2672
|
+
initial: void 0,
|
|
2673
|
+
hook: "useChoice"
|
|
2674
|
+
}), [store, id]);
|
|
2675
|
+
const candidates = options?.candidates;
|
|
2676
|
+
useControl(candidates && candidates.length > 0 ? {
|
|
2677
|
+
control: "choose",
|
|
2678
|
+
reviewSetId,
|
|
2679
|
+
candidates: candidates.map((candidate) => ({
|
|
2680
|
+
id: candidate.id,
|
|
2681
|
+
...candidate.label !== void 0 ? { label: candidate.label } : {}
|
|
2682
|
+
})),
|
|
2683
|
+
...options?.current !== void 0 ? { current: options.current } : {}
|
|
2684
|
+
} : void 0, {
|
|
2685
|
+
control: "release",
|
|
2686
|
+
reviewSetId
|
|
2687
|
+
});
|
|
2688
|
+
const labels = useMemo(() => new Map((candidates ?? []).map((candidate) => [candidate.id, candidate.label])), [candidates]);
|
|
2689
|
+
const choose = useCallback(async (candidateId) => {
|
|
2690
|
+
const view = store.view;
|
|
2691
|
+
if (store.mode === "live" && view && !view.submitChannels.includes("artifact")) throw new MostraError("MOSTRA_SUBMIT_CHANNEL_DENIED", "This host takes choices from the shell's trusted Choose only.");
|
|
2692
|
+
const label = labels.get(candidateId);
|
|
2693
|
+
store.set("viewer", id, candidateId, {
|
|
2694
|
+
...label !== void 0 ? { label } : {},
|
|
2695
|
+
immediate: true
|
|
2696
|
+
});
|
|
2697
|
+
await store.flush();
|
|
2698
|
+
}, [
|
|
2699
|
+
store,
|
|
2700
|
+
id,
|
|
2701
|
+
labels
|
|
2702
|
+
]);
|
|
2703
|
+
return {
|
|
2704
|
+
...typeof choice === "string" ? { choice } : {},
|
|
2705
|
+
choose
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
/** Who the page acts as (D104): the home's local user, a tailnet user, or a link's person. */
|
|
2709
|
+
function useViewer() {
|
|
2710
|
+
const { store } = useRuntime();
|
|
2711
|
+
useStoreVersion(store);
|
|
2712
|
+
const viewer = store.viewer();
|
|
2713
|
+
return viewer ? {
|
|
2714
|
+
id: viewer.id,
|
|
2715
|
+
...viewer.name !== void 0 ? { name: viewer.name } : {},
|
|
2716
|
+
source: viewer.kind
|
|
2717
|
+
} : {
|
|
2718
|
+
id: "",
|
|
2719
|
+
source: "local"
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
/** The artifact, the version and the view the page shows, and where it runs (D104, D111). */
|
|
2723
|
+
function useArtifact() {
|
|
2724
|
+
const { store, mode } = useRuntime();
|
|
2725
|
+
const view = store.view;
|
|
2726
|
+
return {
|
|
2727
|
+
id: view?.artifactId ?? "",
|
|
2728
|
+
versionId: view?.version.id ?? "",
|
|
2729
|
+
version: view?.version.number ?? 0,
|
|
2730
|
+
viewId: view?.viewId ?? "",
|
|
2731
|
+
mode
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
//#endregion
|
|
2735
|
+
export { serverFunctionId as _, useSignal as a, MemoryJournal as b, useSubmitControl as c, connectShell as d, listenShell as f, fieldKey as g, Store as h, useField as i, useViewer as l, reportReady as m, useArtifact as n, useStore as o, openLink as p, useChoice as r, useSubmit as s, RuntimeProvider as t, builtVersion as u, HttpTransport as v, MostraError as x, IndexedDbJournal as y };
|
|
2736
|
+
|
|
2737
|
+
//# sourceMappingURL=hooks-Dh_l8KqJ.js.map
|