@mostra/react 0.0.0-stage → 0.1.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
package/src/router.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `pages/` routing on React Router (D57): the generated entry hands the routes, and the entry's
|
|
5
|
+
* layout when it has one, to {@link mountRoutes}. The history follows the delivery target (D111):
|
|
6
|
+
* browser history behind Mostra's gateway (deep links work, D98), hash routing in a static export
|
|
7
|
+
* (any static host serves it), memory routing inside an MCP App (its frame has no URL of its own).
|
|
8
|
+
* Every navigation is reported to the shell, so comments record their page (WP-20).
|
|
9
|
+
*
|
|
10
|
+
* Artifact code navigates with React Router's own `<Link to>` and `useNavigate`.
|
|
11
|
+
*/
|
|
12
|
+
import type { DeliveryTarget } from "@mostra/protocol";
|
|
13
|
+
import { type ComponentType, createElement, type ReactNode } from "react";
|
|
14
|
+
import {
|
|
15
|
+
createBrowserRouter,
|
|
16
|
+
createHashRouter,
|
|
17
|
+
createMemoryRouter,
|
|
18
|
+
Outlet,
|
|
19
|
+
type RouteObject,
|
|
20
|
+
} from "react-router";
|
|
21
|
+
import { RouterProvider } from "react-router/dom";
|
|
22
|
+
import { prepareArtifact, renderArtifact, reportPage } from "./mount.ts";
|
|
23
|
+
|
|
24
|
+
/** One route of a `pages/` artifact: its React Router path and its page. */
|
|
25
|
+
export interface PageRouteModule {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly page: ComponentType;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** How a router keeps its location: in the address bar, in its hash, or in memory. */
|
|
31
|
+
export type HistoryMode = "browser" | "hash" | "memory";
|
|
32
|
+
|
|
33
|
+
/** The history mode for a delivery target (D57, D111). */
|
|
34
|
+
export function historyMode(target: DeliveryTarget): HistoryMode {
|
|
35
|
+
if (target === "static") return "hash";
|
|
36
|
+
if (target === "mcp-app") return "memory";
|
|
37
|
+
return "browser";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The route tree: the pages, inside the layout when there is one. */
|
|
41
|
+
export function routeTree(
|
|
42
|
+
routes: readonly PageRouteModule[],
|
|
43
|
+
layout?: ComponentType<{ children?: ReactNode }>,
|
|
44
|
+
): RouteObject[] {
|
|
45
|
+
const pages: RouteObject[] = routes.map((route) => ({
|
|
46
|
+
path: route.path,
|
|
47
|
+
element: createElement(route.page),
|
|
48
|
+
}));
|
|
49
|
+
if (!layout) return pages;
|
|
50
|
+
return [{ element: createElement(layout, null, createElement(Outlet)), children: pages }];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mounts a `pages/` artifact (D57). The generated entry calls it; artifact code doesn't need to.
|
|
55
|
+
*/
|
|
56
|
+
export async function mountRoutes(options: {
|
|
57
|
+
readonly routes: readonly PageRouteModule[];
|
|
58
|
+
readonly layout?: ComponentType<{ children?: ReactNode }>;
|
|
59
|
+
}): Promise<void> {
|
|
60
|
+
const runtime = await prepareArtifact();
|
|
61
|
+
const tree = routeTree(options.routes, options.layout);
|
|
62
|
+
const mode = historyMode(runtime.target);
|
|
63
|
+
const router =
|
|
64
|
+
mode === "hash"
|
|
65
|
+
? createHashRouter(tree)
|
|
66
|
+
: mode === "memory"
|
|
67
|
+
? createMemoryRouter(tree)
|
|
68
|
+
: createBrowserRouter(tree);
|
|
69
|
+
const pathOf = (): string => {
|
|
70
|
+
const location = router.state.location;
|
|
71
|
+
return `${location.pathname}${location.search}${location.hash}`;
|
|
72
|
+
};
|
|
73
|
+
let last = "";
|
|
74
|
+
const report = (): void => {
|
|
75
|
+
const path = pathOf();
|
|
76
|
+
if (path === last) return;
|
|
77
|
+
last = path;
|
|
78
|
+
reportPage(runtime, path, document.title);
|
|
79
|
+
};
|
|
80
|
+
router.subscribe(report);
|
|
81
|
+
renderArtifact(runtime, createElement(RouterProvider, { router }));
|
|
82
|
+
report();
|
|
83
|
+
document.documentElement.dataset["mostraHistory"] = mode;
|
|
84
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts the client runtime for the page (D111): reads the delivery target, connects to the shell
|
|
3
|
+
* and the gateway in live mode, opens the journal, and wires the page's lifecycle (blur,
|
|
4
|
+
* `visibilitychange`, `pagehide`) and the bridge (`mostra/fields`, `mostra/flush`,
|
|
5
|
+
* `mostra/field.focus`, `mostra/version.available`) to the store.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
type DeliveryTarget,
|
|
9
|
+
type FieldsParams,
|
|
10
|
+
type FlushResult,
|
|
11
|
+
mostraFieldFocus,
|
|
12
|
+
mostraFields,
|
|
13
|
+
mostraFlush,
|
|
14
|
+
mostraVersionAvailable,
|
|
15
|
+
readViewContext,
|
|
16
|
+
type VersionAvailableParams,
|
|
17
|
+
type ViewContext,
|
|
18
|
+
} from "@mostra/protocol";
|
|
19
|
+
import { builtVersion, connectShell, listenShell, type ShellConnection } from "../shell-bridge.ts";
|
|
20
|
+
import { blockersForShell, clip, firstThatMatter } from "./bridge-limits.ts";
|
|
21
|
+
import { IndexedDbJournal, type Journal, MemoryJournal } from "./journal.ts";
|
|
22
|
+
import { Store } from "./store.ts";
|
|
23
|
+
import { HttpTransport } from "./transport.ts";
|
|
24
|
+
import { installVerifyProbe } from "./verify-probe.ts";
|
|
25
|
+
|
|
26
|
+
/** The page's runtime: its store, where it runs, and its shell connection. */
|
|
27
|
+
export interface Runtime {
|
|
28
|
+
readonly store: Store;
|
|
29
|
+
/** The delivery target the page was built or exported for (D111): it chooses the history. */
|
|
30
|
+
readonly target: DeliveryTarget;
|
|
31
|
+
/**
|
|
32
|
+
* Where the page runs, as `useArtifact` reports it: the target, except that a live page opened
|
|
33
|
+
* outside Mostra's shell has no server features and reports `static`.
|
|
34
|
+
*/
|
|
35
|
+
readonly mode: DeliveryTarget;
|
|
36
|
+
/** The shell connection, inside Mostra's shell. */
|
|
37
|
+
readonly shell: ShellConnection | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The delivery target the page was built or exported for: its `mostra-target` meta tag (D111). */
|
|
41
|
+
export function deliveryTarget(): DeliveryTarget {
|
|
42
|
+
if (typeof document === "undefined") return "live";
|
|
43
|
+
const content = document
|
|
44
|
+
.querySelector('meta[name="mostra-target"]')
|
|
45
|
+
?.getAttribute("content")
|
|
46
|
+
?.trim();
|
|
47
|
+
return content === "static" || content === "mcp-app" || content === "verify" ? content : "live";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A random ID for this tab. */
|
|
51
|
+
function tabId(): string {
|
|
52
|
+
const bytes = new Uint8Array(12);
|
|
53
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
54
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Locks {
|
|
58
|
+
request(name: string, callback: () => Promise<unknown>): Promise<unknown>;
|
|
59
|
+
request(
|
|
60
|
+
name: string,
|
|
61
|
+
options: { mode?: "exclusive" | "shared" },
|
|
62
|
+
callback: () => Promise<unknown>,
|
|
63
|
+
): Promise<unknown>;
|
|
64
|
+
query(): Promise<{ held?: { name?: string }[] }>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function locks(): Locks | undefined {
|
|
68
|
+
const navigatorLocks = (globalThis.navigator as { locks?: Locks } | undefined)?.locks;
|
|
69
|
+
return navigatorLocks;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const TAB_LOCK = "mostra-tab:";
|
|
73
|
+
|
|
74
|
+
/** How long a tab waits for other tabs to say they are open, where Web Locks are missing. */
|
|
75
|
+
const PRESENCE_WAIT_MS = 300;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where Web Locks are missing (they need a secure context, and an artifact served over plain
|
|
79
|
+
* HTTP on a LAN address isn't one), open tabs answer a roll call on a BroadcastChannel, which
|
|
80
|
+
* works everywhere: a tab that doesn't answer within {@link PRESENCE_WAIT_MS} is gone.
|
|
81
|
+
*/
|
|
82
|
+
function rollCall(
|
|
83
|
+
tab: string,
|
|
84
|
+
): ((tabs: readonly string[]) => Promise<ReadonlySet<string>>) | undefined {
|
|
85
|
+
if (typeof BroadcastChannel === "undefined") return undefined;
|
|
86
|
+
const channel = new BroadcastChannel("mostra-tabs");
|
|
87
|
+
channel.onmessage = (event: MessageEvent<{ who?: true; here?: string }>) => {
|
|
88
|
+
if (event.data?.who) channel.postMessage({ here: tab });
|
|
89
|
+
};
|
|
90
|
+
return (tabs) =>
|
|
91
|
+
new Promise((resolve) => {
|
|
92
|
+
const open = new Set<string>();
|
|
93
|
+
const listen = new BroadcastChannel("mostra-tabs");
|
|
94
|
+
listen.onmessage = (event: MessageEvent<{ here?: string }>) => {
|
|
95
|
+
if (typeof event.data?.here === "string") open.add(event.data.here);
|
|
96
|
+
};
|
|
97
|
+
listen.postMessage({ who: true });
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
listen.close();
|
|
100
|
+
resolve(new Set(tabs.filter((other) => !open.has(other))));
|
|
101
|
+
}, PRESENCE_WAIT_MS);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Holds this tab's lock for as long as the page lives, so other pages know its journal entries
|
|
107
|
+
* belong to an open tab (Web Locks). Returns which of some tabs hold no lock: they are gone.
|
|
108
|
+
*/
|
|
109
|
+
function tabPresence(tab: string): (tabs: readonly string[]) => Promise<ReadonlySet<string>> {
|
|
110
|
+
const api = locks();
|
|
111
|
+
if (!api) return rollCall(tab) ?? (async (tabs) => new Set(tabs));
|
|
112
|
+
void api.request(`${TAB_LOCK}${tab}`, () => new Promise<never>(() => undefined));
|
|
113
|
+
return async (tabs) => {
|
|
114
|
+
const held = new Set(
|
|
115
|
+
((await api.query()).held ?? [])
|
|
116
|
+
.map((lock) => lock.name)
|
|
117
|
+
.filter((name): name is string => name?.startsWith(TAB_LOCK) === true)
|
|
118
|
+
.map((name) => name.slice(TAB_LOCK.length)),
|
|
119
|
+
);
|
|
120
|
+
return new Set(tabs.filter((other) => !held.has(other)));
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Runs `fn` while no other page of the artifact adopts journal entries. */
|
|
125
|
+
function exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
126
|
+
const api = locks();
|
|
127
|
+
if (!api) return fn();
|
|
128
|
+
return api.request("mostra-journal", fn) as Promise<T>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The longest pause between two attempts at the shell's handshake. */
|
|
132
|
+
const SHELL_RETRY_MAX_MS = 8_000;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The shell's connection for a framed page. A page inside a frame is inside Mostra's shell (its
|
|
136
|
+
* origin is framed by the shell only): if the handshake fails (the daemon restarting during the
|
|
137
|
+
* frame's load, a slow shell), it tries again rather than run on its own, where its answers
|
|
138
|
+
* would stay in this browser while the shell said "Saved" (FAILURE_MODEL §1 rule 4; Claude review
|
|
139
|
+
* finding 4). A page opened on its own has no shell and runs locally.
|
|
140
|
+
*/
|
|
141
|
+
async function framedShell(): Promise<ShellConnection | undefined> {
|
|
142
|
+
if (typeof window === "undefined" || window.parent === window) return undefined;
|
|
143
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
144
|
+
const shell = await connectShell();
|
|
145
|
+
if (shell) return shell;
|
|
146
|
+
if (attempt === 1)
|
|
147
|
+
console.warn("Mostra: waiting for the shell; the page connects when it can.");
|
|
148
|
+
await new Promise((resolve) =>
|
|
149
|
+
setTimeout(resolve, Math.min(500 * 2 ** attempt, SHELL_RETRY_MAX_MS)),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Starts the runtime: live behind the gateway inside Mostra's shell, local otherwise (a static
|
|
156
|
+
* export, an MCP App until its transport arrives, a page opened on its own).
|
|
157
|
+
*/
|
|
158
|
+
export async function startRuntime(): Promise<Runtime> {
|
|
159
|
+
const target = deliveryTarget();
|
|
160
|
+
const tab = tabId();
|
|
161
|
+
const shell = target === "live" || target === "verify" ? await framedShell() : undefined;
|
|
162
|
+
// Read the tolerant way: a later shell may send values this page doesn't know (Claude review
|
|
163
|
+
// finding 5), which never cost it its view.
|
|
164
|
+
const view: ViewContext | undefined = shell?.host.mostra
|
|
165
|
+
? readViewContext(shell.host.mostra)
|
|
166
|
+
: undefined;
|
|
167
|
+
const built = builtVersion();
|
|
168
|
+
if (shell && view && built !== undefined && built !== view.version.id) {
|
|
169
|
+
// This page is another version than the view the shell opened: another tab moved the frame
|
|
170
|
+
// session on (D98 item 5). Nothing it shows may be saved as this view, nor kept: the shell,
|
|
171
|
+
// told the page's version in `ui/initialize`, loads the frame again at its own version.
|
|
172
|
+
const scratch = new Map<string, string>();
|
|
173
|
+
const store = new Store({
|
|
174
|
+
mode: "local",
|
|
175
|
+
journal: new MemoryJournal(),
|
|
176
|
+
tab,
|
|
177
|
+
local: {
|
|
178
|
+
getItem: (key) => scratch.get(key) ?? null,
|
|
179
|
+
setItem: (key, value) => void scratch.set(key, value),
|
|
180
|
+
removeItem: (key) => void scratch.delete(key),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
store.start();
|
|
184
|
+
return { store, target, mode: view.target, shell };
|
|
185
|
+
}
|
|
186
|
+
if (!shell || !view) {
|
|
187
|
+
const store = new Store({ mode: "local", journal: new MemoryJournal(), tab });
|
|
188
|
+
store.start();
|
|
189
|
+
return { store, target, mode: target === "live" ? "static" : target, shell: undefined };
|
|
190
|
+
}
|
|
191
|
+
const journal: Journal = (await IndexedDbJournal.open()) ?? new MemoryJournal();
|
|
192
|
+
const store = new Store({
|
|
193
|
+
mode: "live",
|
|
194
|
+
transport: new HttpTransport(view.viewId, {
|
|
195
|
+
...(shell.session.csrf !== "" ? { csrf: shell.session.csrf } : {}),
|
|
196
|
+
}),
|
|
197
|
+
journal,
|
|
198
|
+
view,
|
|
199
|
+
scopes: shell.session.scopes,
|
|
200
|
+
...(shell.session.viewer ? { viewer: shell.session.viewer } : {}),
|
|
201
|
+
tab,
|
|
202
|
+
goneTabs: tabPresence(tab),
|
|
203
|
+
exclusive,
|
|
204
|
+
channel: (name) =>
|
|
205
|
+
typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(name),
|
|
206
|
+
});
|
|
207
|
+
// A verify run reads which fields the page mounts (D138); a viewer's page never records them.
|
|
208
|
+
if (target === "verify") installVerifyProbe(store);
|
|
209
|
+
store.start();
|
|
210
|
+
wireLifecycle(store);
|
|
211
|
+
wireShell(store, shell);
|
|
212
|
+
return { store, target, mode: view.target, shell };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Saves on blur, and when the page is hidden or closed (IN-02). */
|
|
216
|
+
function wireLifecycle(store: Store): void {
|
|
217
|
+
if (typeof document === "undefined") return;
|
|
218
|
+
document.addEventListener("focusout", () => void store.flush(), true);
|
|
219
|
+
document.addEventListener("visibilitychange", () => {
|
|
220
|
+
if (document.visibilityState === "hidden") void store.flush({ keepalive: true });
|
|
221
|
+
});
|
|
222
|
+
window.addEventListener("pagehide", () => void store.flush({ keepalive: true }));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Focuses the element a field is bound to, when the viewer picks it in the shell. */
|
|
226
|
+
function focusField(id: string): void {
|
|
227
|
+
const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : id;
|
|
228
|
+
const element = document.querySelector<HTMLElement>(
|
|
229
|
+
`[data-mostra-field="${escaped}"], [name="${escaped}"], #${escaped}`,
|
|
230
|
+
);
|
|
231
|
+
element?.scrollIntoView({ block: "center" });
|
|
232
|
+
element?.focus();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Reports fields and save state to the shell, and answers its requests. */
|
|
236
|
+
function wireShell(store: Store, shell: ShellConnection): void {
|
|
237
|
+
let last = "";
|
|
238
|
+
let scheduled = false;
|
|
239
|
+
const report = (): void => {
|
|
240
|
+
scheduled = false;
|
|
241
|
+
const fields = store.registrations().map((registration) => ({
|
|
242
|
+
id: registration.id,
|
|
243
|
+
scope: registration.scope,
|
|
244
|
+
...(registration.label !== undefined ? { label: clip(registration.label) } : {}),
|
|
245
|
+
...(registration.required !== undefined ? { required: registration.required } : {}),
|
|
246
|
+
...(registration.sensitive !== undefined ? { sensitive: registration.sensitive } : {}),
|
|
247
|
+
...(registration.error !== undefined ? { error: clip(registration.error) } : {}),
|
|
248
|
+
...(store.conflict(`${registration.scope}:${registration.id}`) ? { conflict: true } : {}),
|
|
249
|
+
}));
|
|
250
|
+
const params: FieldsParams = {
|
|
251
|
+
fields: firstThatMatter(fields, (field) =>
|
|
252
|
+
Boolean(field.required || field.error !== undefined || field.conflict),
|
|
253
|
+
),
|
|
254
|
+
save: store.pageSave(),
|
|
255
|
+
unsaved: store.unsaved(),
|
|
256
|
+
};
|
|
257
|
+
const text = JSON.stringify(params);
|
|
258
|
+
if (text === last) return;
|
|
259
|
+
last = text;
|
|
260
|
+
try {
|
|
261
|
+
shell.bridge.notify(mostraFields, params);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
// Never silently: the shell's rail would keep its last state (Claude review finding 11).
|
|
264
|
+
console.error("Mostra: the page's fields couldn't be reported to the shell.", error);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
store.subscribe(() => {
|
|
268
|
+
if (scheduled) return;
|
|
269
|
+
scheduled = true;
|
|
270
|
+
queueMicrotask(report);
|
|
271
|
+
});
|
|
272
|
+
report();
|
|
273
|
+
listenShell({
|
|
274
|
+
request: async (message, params) => {
|
|
275
|
+
if (message !== mostraFlush) return undefined;
|
|
276
|
+
const reason = (params as { reason: "submit" | "switch" }).reason;
|
|
277
|
+
if (reason === "submit") {
|
|
278
|
+
const prepared = await store.prepareSubmit();
|
|
279
|
+
const result: FlushResult = {
|
|
280
|
+
revision: String(prepared.revision),
|
|
281
|
+
unsaved: prepared.unsaved,
|
|
282
|
+
blockers: blockersForShell(prepared.blockers),
|
|
283
|
+
};
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
const flushed = await store.flush();
|
|
287
|
+
const result: FlushResult = {
|
|
288
|
+
revision: String(flushed.revision),
|
|
289
|
+
unsaved: flushed.unsaved,
|
|
290
|
+
blockers: blockersForShell(store.blockers()),
|
|
291
|
+
};
|
|
292
|
+
return result;
|
|
293
|
+
},
|
|
294
|
+
notification: (message, params) => {
|
|
295
|
+
if (message === mostraFieldFocus) focusField((params as { id: string }).id);
|
|
296
|
+
if (message === mostraVersionAvailable) {
|
|
297
|
+
// The page may show its own notice; the shell's trusted banner always appears (§5).
|
|
298
|
+
window.dispatchEvent(
|
|
299
|
+
new CustomEvent<VersionAvailableParams>("mostra:version-available", {
|
|
300
|
+
detail: params as VersionAvailableParams,
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page sends the shell, fitted to the bridge's limits (Claude review finding 11): a long
|
|
3
|
+
* label or validation message is shortened, and a page with more fields than one message lists
|
|
4
|
+
* sends those that matter first. A report the bridge refuses would leave the shell's rail and its
|
|
5
|
+
* blocker list stale, and a refused flush answer would stop every trusted Submit.
|
|
6
|
+
*/
|
|
7
|
+
import { BRIDGE_FIELDS_MAX, BRIDGE_TEXT_MAX, type SubmitBlocker } from "@mostra/protocol";
|
|
8
|
+
|
|
9
|
+
/** Text shortened to what the bridge carries. */
|
|
10
|
+
export function clip(text: string): string {
|
|
11
|
+
return text.length <= BRIDGE_TEXT_MAX ? text : `${text.slice(0, BRIDGE_TEXT_MAX - 1)}…`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** At most {@link BRIDGE_FIELDS_MAX} items, those that matter first, in their order otherwise. */
|
|
15
|
+
export function firstThatMatter<T>(items: readonly T[], matters: (item: T) => boolean): T[] {
|
|
16
|
+
if (items.length <= BRIDGE_FIELDS_MAX) return [...items];
|
|
17
|
+
return [...items.filter(matters), ...items.filter((item) => !matters(item))].slice(
|
|
18
|
+
0,
|
|
19
|
+
BRIDGE_FIELDS_MAX,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Blockers as the bridge carries them: labels shortened, at most {@link BRIDGE_FIELDS_MAX}. */
|
|
24
|
+
export function blockersForShell(blockers: readonly SubmitBlocker[]): SubmitBlocker[] {
|
|
25
|
+
return blockers
|
|
26
|
+
.slice(0, BRIDGE_FIELDS_MAX)
|
|
27
|
+
.map((blocker) =>
|
|
28
|
+
blocker.label === undefined ? blocker : { ...blocker, label: clip(blocker.label) },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors the client runtime throws: each carries the stable diagnostic code an artifact can switch
|
|
3
|
+
* on (INTERFACES §13), and the diagnostic itself when the gateway sent one.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A diagnostic as the gateway sends it; only the fields the runtime reads. */
|
|
7
|
+
export interface DiagnosticLike {
|
|
8
|
+
/** The stable diagnostic code, such as `MOSTRA_FIELD_TOO_LARGE`. */
|
|
9
|
+
readonly code: string;
|
|
10
|
+
/** What is wrong. */
|
|
11
|
+
readonly message: string;
|
|
12
|
+
/** What to do about it. */
|
|
13
|
+
readonly hint?: string;
|
|
14
|
+
/** The code's documentation page. */
|
|
15
|
+
readonly docs?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** An error with a Mostra diagnostic code, such as `MOSTRA_SUBMIT_CHANNEL_DENIED`. */
|
|
19
|
+
export class MostraError extends Error {
|
|
20
|
+
/** The stable diagnostic code. */
|
|
21
|
+
readonly code: string;
|
|
22
|
+
/** The HTTP status the gateway answered with, when the error came from it. */
|
|
23
|
+
readonly status: number | undefined;
|
|
24
|
+
/** The diagnostic, when the gateway sent one. */
|
|
25
|
+
readonly diagnostic: DiagnosticLike | undefined;
|
|
26
|
+
/** Extra data the refusal carried, such as the existing submission of `MOSTRA_SUBMIT_ALREADY`. */
|
|
27
|
+
readonly body: Readonly<Record<string, unknown>> | undefined;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
code: string,
|
|
31
|
+
message: string,
|
|
32
|
+
options: {
|
|
33
|
+
readonly status?: number;
|
|
34
|
+
readonly diagnostic?: DiagnosticLike;
|
|
35
|
+
readonly body?: Readonly<Record<string, unknown>>;
|
|
36
|
+
} = {},
|
|
37
|
+
) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "MostraError";
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.status = options.status;
|
|
42
|
+
this.diagnostic = options.diagnostic;
|
|
43
|
+
this.body = options.body;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `MOSTRA_TARGET_UNAVAILABLE`: what the delivery target can't do (a static export, D111). */
|
|
48
|
+
export function targetUnavailable(what: string): MostraError {
|
|
49
|
+
return new MostraError(
|
|
50
|
+
"MOSTRA_TARGET_UNAVAILABLE",
|
|
51
|
+
`${what} needs the artifact's Mostra link; this copy of the page has no Mostra server.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The offline journal (D22, IN-02, IN-04): every edit the gateway hasn't confirmed, kept in the
|
|
3
|
+
* artifact origin's IndexedDB so a closed tab, a crash or a lost connection loses nothing. Two
|
|
4
|
+
* kinds of entry: a field's pending edit (not sent yet; its newest value wins), and a mutation that
|
|
5
|
+
* was sent or is about to be, kept with its mutation ID so a replay is idempotent. Every entry
|
|
6
|
+
* names its viewer and the tab that wrote it: a page replays only its own viewer's entries, and
|
|
7
|
+
* adopts another tab's only once that tab is gone, so two open tabs never overwrite each other's
|
|
8
|
+
* unsent edits. Where IndexedDB can't be used the journal lives in memory and says so.
|
|
9
|
+
*/
|
|
10
|
+
import type { FieldOp } from "@mostra/protocol";
|
|
11
|
+
|
|
12
|
+
/** A field's edit, not sent yet. */
|
|
13
|
+
export interface PendingEntry {
|
|
14
|
+
readonly kind: "pending";
|
|
15
|
+
readonly viewerId: string;
|
|
16
|
+
/** The tab that made the edit. */
|
|
17
|
+
readonly tab: string;
|
|
18
|
+
readonly viewId: string;
|
|
19
|
+
readonly scope: "viewer" | "artifact";
|
|
20
|
+
readonly op: FieldOp;
|
|
21
|
+
/**
|
|
22
|
+
* The revision the edit was based on: the field was unchanged since it (D22). `null` while an
|
|
23
|
+
* earlier mutation of the same field is in flight; its answer sets it.
|
|
24
|
+
*/
|
|
25
|
+
readonly base: number | null;
|
|
26
|
+
/** When the edit was made (ms since the epoch). */
|
|
27
|
+
readonly at: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A mutation, sent or about to be, replayed with its own ID. */
|
|
31
|
+
export interface MutationEntry {
|
|
32
|
+
readonly kind: "mutation";
|
|
33
|
+
readonly viewerId: string;
|
|
34
|
+
readonly tab: string;
|
|
35
|
+
readonly viewId: string;
|
|
36
|
+
readonly scope: "viewer" | "artifact";
|
|
37
|
+
readonly mutationId: string;
|
|
38
|
+
readonly baseRevision: number;
|
|
39
|
+
readonly ops: readonly FieldOp[];
|
|
40
|
+
/** Its place in the journal: mutations replay in this order. */
|
|
41
|
+
readonly seq: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One entry. */
|
|
45
|
+
export type JournalEntry = PendingEntry | MutationEntry;
|
|
46
|
+
|
|
47
|
+
/** A viewer's entries, mutations in journal order. */
|
|
48
|
+
export interface JournalContents {
|
|
49
|
+
readonly pending: PendingEntry[];
|
|
50
|
+
readonly mutations: MutationEntry[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The journal's storage. */
|
|
54
|
+
export interface Journal {
|
|
55
|
+
/** Whether entries survive the page (IndexedDB), or live only in memory. */
|
|
56
|
+
readonly durable: boolean;
|
|
57
|
+
/** Every entry of a viewer, mutations in order. */
|
|
58
|
+
load(viewerId: string): Promise<JournalContents>;
|
|
59
|
+
/** Records a pending edit, replacing the same tab's earlier one for the field. */
|
|
60
|
+
putPending(entry: PendingEntry): Promise<void>;
|
|
61
|
+
/** Turns pending edits into a mutation in one transaction. */
|
|
62
|
+
promote(mutation: MutationEntry, consumed: readonly PendingEntry[]): Promise<void>;
|
|
63
|
+
/** Forgets a mutation the gateway answered. */
|
|
64
|
+
remove(mutationId: string): Promise<void>;
|
|
65
|
+
/** Forgets a field's pending edit (taken back, or refused for good). */
|
|
66
|
+
dropPending(entry: PendingEntry): Promise<void>;
|
|
67
|
+
/** Moves entries of a tab that is gone to `tab`, in one transaction. */
|
|
68
|
+
adopt(entries: readonly JournalEntry[], tab: string): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The key of a pending edit: one per viewer, tab, scope and field. */
|
|
72
|
+
export const pendingKey = (
|
|
73
|
+
entry: Pick<PendingEntry, "viewerId" | "tab" | "scope" | "op">,
|
|
74
|
+
): string => `p\u0000${entry.viewerId}\u0000${entry.tab}\u0000${entry.scope}\u0000${entry.op.id}`;
|
|
75
|
+
|
|
76
|
+
const mutationKey = (mutationId: string): string => `m\u0000${mutationId}`;
|
|
77
|
+
|
|
78
|
+
const keyOf = (entry: JournalEntry): string =>
|
|
79
|
+
entry.kind === "pending" ? pendingKey(entry) : mutationKey(entry.mutationId);
|
|
80
|
+
|
|
81
|
+
function split(entries: readonly JournalEntry[], viewerId: string): JournalContents {
|
|
82
|
+
const mine = entries.filter((entry) => entry.viewerId === viewerId);
|
|
83
|
+
return {
|
|
84
|
+
pending: mine.filter((entry): entry is PendingEntry => entry.kind === "pending"),
|
|
85
|
+
mutations: mine
|
|
86
|
+
.filter((entry): entry is MutationEntry => entry.kind === "mutation")
|
|
87
|
+
.sort((a, b) => a.seq - b.seq),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The journal in memory: for targets without IndexedDB, and for tests. */
|
|
92
|
+
export class MemoryJournal implements Journal {
|
|
93
|
+
readonly durable: boolean = false;
|
|
94
|
+
readonly #entries = new Map<string, JournalEntry>();
|
|
95
|
+
|
|
96
|
+
async load(viewerId: string): Promise<JournalContents> {
|
|
97
|
+
return split([...this.#entries.values()], viewerId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async putPending(entry: PendingEntry): Promise<void> {
|
|
101
|
+
this.#entries.set(pendingKey(entry), entry);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async promote(mutation: MutationEntry, consumed: readonly PendingEntry[]): Promise<void> {
|
|
105
|
+
for (const entry of consumed) this.#entries.delete(pendingKey(entry));
|
|
106
|
+
this.#entries.set(mutationKey(mutation.mutationId), mutation);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async remove(mutationId: string): Promise<void> {
|
|
110
|
+
this.#entries.delete(mutationKey(mutationId));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async dropPending(entry: PendingEntry): Promise<void> {
|
|
114
|
+
this.#entries.delete(pendingKey(entry));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async adopt(entries: readonly JournalEntry[], tab: string): Promise<void> {
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
this.#entries.delete(keyOf(entry));
|
|
120
|
+
const moved = { ...entry, tab };
|
|
121
|
+
const key = keyOf(moved);
|
|
122
|
+
// A tab's own newer edit of the same field wins over an adopted one.
|
|
123
|
+
if (!this.#entries.has(key)) this.#entries.set(key, moved);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const DB_NAME = "mostra";
|
|
129
|
+
const STORE = "journal";
|
|
130
|
+
|
|
131
|
+
function request<T>(req: IDBRequest<T>): Promise<T> {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
req.onsuccess = () => resolve(req.result);
|
|
134
|
+
req.onerror = () => reject(req.error);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function done(transaction: IDBTransaction): Promise<void> {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
transaction.oncomplete = () => resolve();
|
|
141
|
+
transaction.onerror = () => reject(transaction.error);
|
|
142
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("The transaction aborted."));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The journal in the artifact origin's IndexedDB: one database per origin, so per artifact (D38).
|
|
148
|
+
* Each write starts its transaction at once, so an edit made just before the page closes is in it.
|
|
149
|
+
*/
|
|
150
|
+
export class IndexedDbJournal implements Journal {
|
|
151
|
+
readonly durable: boolean = true;
|
|
152
|
+
readonly #db: IDBDatabase;
|
|
153
|
+
|
|
154
|
+
private constructor(db: IDBDatabase) {
|
|
155
|
+
this.#db = db;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Opens the journal; `undefined` where IndexedDB can't be used (a private mode, a sandbox). */
|
|
159
|
+
static async open(): Promise<IndexedDbJournal | undefined> {
|
|
160
|
+
if (typeof indexedDB === "undefined") return undefined;
|
|
161
|
+
try {
|
|
162
|
+
const open = indexedDB.open(DB_NAME, 1);
|
|
163
|
+
open.onupgradeneeded = () => {
|
|
164
|
+
if (!open.result.objectStoreNames.contains(STORE)) open.result.createObjectStore(STORE);
|
|
165
|
+
};
|
|
166
|
+
return new IndexedDbJournal(await request(open));
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#write(change: (store: IDBObjectStore) => void): Promise<void> {
|
|
173
|
+
const transaction = this.#db.transaction(STORE, "readwrite");
|
|
174
|
+
change(transaction.objectStore(STORE));
|
|
175
|
+
return done(transaction);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async load(viewerId: string): Promise<JournalContents> {
|
|
179
|
+
const all = (await request(
|
|
180
|
+
this.#db.transaction(STORE, "readonly").objectStore(STORE).getAll(),
|
|
181
|
+
)) as JournalEntry[];
|
|
182
|
+
return split(all, viewerId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
putPending(entry: PendingEntry): Promise<void> {
|
|
186
|
+
return this.#write((store) => store.put(entry, pendingKey(entry)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
promote(mutation: MutationEntry, consumed: readonly PendingEntry[]): Promise<void> {
|
|
190
|
+
return this.#write((store) => {
|
|
191
|
+
for (const entry of consumed) store.delete(pendingKey(entry));
|
|
192
|
+
store.put(mutation, mutationKey(mutation.mutationId));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
remove(mutationId: string): Promise<void> {
|
|
197
|
+
return this.#write((store) => store.delete(mutationKey(mutationId)));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
dropPending(entry: PendingEntry): Promise<void> {
|
|
201
|
+
return this.#write((store) => store.delete(pendingKey(entry)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async adopt(entries: readonly JournalEntry[], tab: string): Promise<void> {
|
|
205
|
+
const transaction = this.#db.transaction(STORE, "readwrite");
|
|
206
|
+
const store = transaction.objectStore(STORE);
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
store.delete(keyOf(entry));
|
|
209
|
+
const moved = { ...entry, tab };
|
|
210
|
+
// `add` fails on an existing key: a tab's own newer edit wins over an adopted one.
|
|
211
|
+
const added = store.add(moved, keyOf(moved));
|
|
212
|
+
added.onerror = (event) => {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
event.stopPropagation();
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
await done(transaction);
|
|
218
|
+
}
|
|
219
|
+
}
|