@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/hooks.ts
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The client runtime's hooks (INTERFACES §2.3, D52, D53, D113): fields that behave like
|
|
5
|
+
* `useState` with a stable ID, the artifact channel's submit, the shell's trusted Submit label,
|
|
6
|
+
* signals, choices, the viewer and the artifact. Every hook works in every delivery target: where
|
|
7
|
+
* there is no Mostra server (a static export), fields keep their values in this browser, and
|
|
8
|
+
* submit and signals reject with `MOSTRA_TARGET_UNAVAILABLE`.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
type Actor,
|
|
12
|
+
type ControlRequest,
|
|
13
|
+
type FieldScope,
|
|
14
|
+
mostraControlsRequest,
|
|
15
|
+
type SubmissionSummary,
|
|
16
|
+
type SubmitBlocker,
|
|
17
|
+
} from "@mostra/protocol";
|
|
18
|
+
import {
|
|
19
|
+
createContext,
|
|
20
|
+
createElement,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
useCallback,
|
|
23
|
+
useContext,
|
|
24
|
+
useEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
useState,
|
|
27
|
+
useSyncExternalStore,
|
|
28
|
+
} from "react";
|
|
29
|
+
import { MostraError } from "./runtime/errors.ts";
|
|
30
|
+
import { MemoryJournal } from "./runtime/journal.ts";
|
|
31
|
+
import { type FieldKey, fieldKey, Store, type SubmitState } from "./runtime/store.ts";
|
|
32
|
+
import { connectShell } from "./shell-bridge.ts";
|
|
33
|
+
|
|
34
|
+
/** Where the artifact runs (D111): Mostra's gateway, a static export, an MCP App, or `verify`. */
|
|
35
|
+
export type ArtifactMode = "live" | "static" | "mcp-app" | "verify";
|
|
36
|
+
|
|
37
|
+
interface RuntimeValue {
|
|
38
|
+
readonly store: Store;
|
|
39
|
+
readonly mode: ArtifactMode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const RuntimeContext = createContext<RuntimeValue | undefined>(undefined);
|
|
43
|
+
|
|
44
|
+
let fallback: RuntimeValue | undefined;
|
|
45
|
+
|
|
46
|
+
/** The runtime of a tree rendered outside `mountArtifact` (a test, a component gallery): local. */
|
|
47
|
+
function fallbackRuntime(): RuntimeValue {
|
|
48
|
+
if (!fallback) {
|
|
49
|
+
const store = new Store({ mode: "local", journal: new MemoryJournal(), tab: "fallback" });
|
|
50
|
+
store.start();
|
|
51
|
+
fallback = { store, mode: "static" };
|
|
52
|
+
}
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Provides a runtime to a tree; `mountArtifact` renders the page inside it. */
|
|
57
|
+
export function RuntimeProvider(props: {
|
|
58
|
+
readonly store: Store;
|
|
59
|
+
readonly mode: ArtifactMode;
|
|
60
|
+
readonly children?: ReactNode;
|
|
61
|
+
}): ReactNode {
|
|
62
|
+
const value = useMemo(
|
|
63
|
+
() => ({ store: props.store, mode: props.mode }),
|
|
64
|
+
[props.store, props.mode],
|
|
65
|
+
);
|
|
66
|
+
return createElement(RuntimeContext.Provider, { value }, props.children);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function useRuntime(): RuntimeValue {
|
|
70
|
+
return useContext(RuntimeContext) ?? fallbackRuntime();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The page's store, for the package's own adapters (`@mostra/react/form`). @internal */
|
|
74
|
+
export function useStore(): Store {
|
|
75
|
+
return useRuntime().store;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Re-renders on every change of the store; returns its version. */
|
|
79
|
+
function useStoreVersion(store: Store): number {
|
|
80
|
+
return useSyncExternalStore(store.subscribe, store.version, store.version);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── useField ────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A Standard Schema validator (`~standard`), such as a Zod 4 schema: what `validate` takes besides
|
|
87
|
+
* a function.
|
|
88
|
+
*/
|
|
89
|
+
export interface StandardSchema<T> {
|
|
90
|
+
/** The Standard Schema interface (version 1). */
|
|
91
|
+
readonly "~standard": {
|
|
92
|
+
/** Checks a value: issues when it is invalid. */
|
|
93
|
+
validate(
|
|
94
|
+
value: unknown,
|
|
95
|
+
): { readonly issues?: readonly { readonly message: string }[] } | Promise<unknown>;
|
|
96
|
+
/** The type the schema accepts. */
|
|
97
|
+
readonly types?: { readonly input: T; readonly output: T } | undefined;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Options of {@link useField} (D52). */
|
|
102
|
+
export interface FieldOptions<T> {
|
|
103
|
+
/** The label people see in results and the shell's list of missing answers. */
|
|
104
|
+
readonly label?: string;
|
|
105
|
+
/** A submit needs a value: the shell and `useSubmit` list it until it has one. */
|
|
106
|
+
readonly required?: boolean;
|
|
107
|
+
/** Kept in the submission, left out of events and logs. */
|
|
108
|
+
readonly sensitive?: boolean;
|
|
109
|
+
/** A Zod (or any Standard Schema) schema, or a function returning a message for a bad value. */
|
|
110
|
+
readonly validate?: StandardSchema<T> | ((value: T) => string | undefined);
|
|
111
|
+
/** `viewer` (default): the viewer's draft. `artifact`: shared state every viewer sees (D63). */
|
|
112
|
+
readonly scope?: FieldScope;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What a field is doing (INTERFACES §2.3). */
|
|
116
|
+
export interface FieldMeta<T> {
|
|
117
|
+
/**
|
|
118
|
+
* `saved`; `saving`; `offline` (not saved yet: no connection, or the gateway refused it, with
|
|
119
|
+
* `error`); `conflict` (another writer changed it: see `conflict`); `readonly` (submitted under
|
|
120
|
+
* `once`, a view-only link, or a copy of the page without a Mostra server).
|
|
121
|
+
*/
|
|
122
|
+
readonly save: "saved" | "saving" | "offline" | "conflict" | "readonly";
|
|
123
|
+
/** The validation message for the current value, or why the gateway refused it. */
|
|
124
|
+
readonly error?: string;
|
|
125
|
+
/** Another writer changed the field since the viewer's edit: keep theirs or mine. */
|
|
126
|
+
readonly conflict?: { readonly theirs: T; keepMine(): void; takeTheirs(): void };
|
|
127
|
+
/** Who last wrote the field (artifact scope, D63). */
|
|
128
|
+
readonly updatedBy?: { readonly kind: "viewer" | "agent" | "host"; readonly name?: string };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What a field's validator says of a value: now, or once its asynchronous check settles. */
|
|
132
|
+
type Validation =
|
|
133
|
+
| { readonly kind: "now"; readonly error: string | undefined }
|
|
134
|
+
| { readonly kind: "later"; readonly settled: Promise<string | undefined> };
|
|
135
|
+
|
|
136
|
+
/** The message shown while an asynchronous check runs; it blocks a submit until it settles. */
|
|
137
|
+
const CHECKING = "Checking this answer…";
|
|
138
|
+
|
|
139
|
+
function messageOf(result: unknown): string | undefined {
|
|
140
|
+
const issues = (result as { issues?: readonly { message: string }[] } | undefined)?.issues;
|
|
141
|
+
return issues && issues.length > 0 ? (issues[0]?.message ?? "Invalid value.") : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validationOf<T>(validate: FieldOptions<T>["validate"], value: T): Validation {
|
|
145
|
+
if (validate === undefined) return { kind: "now", error: undefined };
|
|
146
|
+
if (typeof validate === "function") return { kind: "now", error: validate(value) ?? undefined };
|
|
147
|
+
const result = validate["~standard"].validate(value);
|
|
148
|
+
if (result instanceof Promise) {
|
|
149
|
+
// A Standard Schema may check asynchronously (Zod's async refinements): the answer counts
|
|
150
|
+
// only once the check settles (GPT review finding 14).
|
|
151
|
+
return {
|
|
152
|
+
kind: "later",
|
|
153
|
+
settled: result.then(messageOf, () => "This answer couldn't be checked."),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { kind: "now", error: messageOf(result) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function updatedByOf(actor: Actor | undefined): FieldMeta<unknown>["updatedBy"] {
|
|
160
|
+
if (!actor || actor.kind === "mostra") return undefined;
|
|
161
|
+
return { kind: actor.kind, ...(actor.name !== undefined ? { name: actor.name } : {}) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const warned = new Set<string>();
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A persisted value with a stable field ID (D52). It behaves like `useState`: the value the viewer
|
|
168
|
+
* sees, a setter, and what the field is doing. Edits save to Mostra as the viewer works, survive
|
|
169
|
+
* reloads, closed tabs and other devices, reach other tabs, and carry over to newer versions by
|
|
170
|
+
* ID. Dotted IDs (`reply.m1`) nest in results.
|
|
171
|
+
*
|
|
172
|
+
* ```tsx
|
|
173
|
+
* const [layout, setLayout, meta] = useField("layout", "a", { label: "Layout", required: true });
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
export function useField<T>(
|
|
177
|
+
id: string,
|
|
178
|
+
initial: T,
|
|
179
|
+
// `NoInfer`: the type comes from `initial`, as `useState`'s does; a validator annotated
|
|
180
|
+
// `(value: number)` must not narrow `useField("count", 1)` to the literal type `1`.
|
|
181
|
+
options: FieldOptions<NoInfer<T>> = {},
|
|
182
|
+
): [T, (next: T | ((previous: T) => T)) => void, FieldMeta<T>] {
|
|
183
|
+
const { store } = useRuntime();
|
|
184
|
+
const scope = options.scope ?? "viewer";
|
|
185
|
+
const key = fieldKey(scope, id);
|
|
186
|
+
// The first render's initial value, as `useState` keeps it.
|
|
187
|
+
const [first] = useState(() => initial);
|
|
188
|
+
useSyncExternalStore(
|
|
189
|
+
store.subscribe,
|
|
190
|
+
() => store.keyVersion(key),
|
|
191
|
+
() => store.keyVersion(key),
|
|
192
|
+
);
|
|
193
|
+
const value = store.value(key, first) as T;
|
|
194
|
+
const { validate } = options;
|
|
195
|
+
const validation = useMemo(() => validationOf(validate, value), [validate, value]);
|
|
196
|
+
const [settled, setSettled] = useState<{ of: Validation; error: string | undefined }>();
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (validation.kind !== "later") return;
|
|
199
|
+
let current = true;
|
|
200
|
+
void validation.settled.then((message) => {
|
|
201
|
+
if (current) setSettled({ of: validation, error: message });
|
|
202
|
+
});
|
|
203
|
+
return () => {
|
|
204
|
+
current = false;
|
|
205
|
+
};
|
|
206
|
+
}, [validation]);
|
|
207
|
+
const error =
|
|
208
|
+
validation.kind === "now"
|
|
209
|
+
? validation.error
|
|
210
|
+
: settled?.of === validation
|
|
211
|
+
? settled.error
|
|
212
|
+
: CHECKING;
|
|
213
|
+
const { label, required, sensitive } = options;
|
|
214
|
+
|
|
215
|
+
// Registered once per mount and ID; the next effect keeps its options current.
|
|
216
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: options change through updateRegistration.
|
|
217
|
+
useEffect(
|
|
218
|
+
() =>
|
|
219
|
+
store.register({
|
|
220
|
+
id,
|
|
221
|
+
scope,
|
|
222
|
+
initial: first,
|
|
223
|
+
...(label !== undefined ? { label } : {}),
|
|
224
|
+
...(required !== undefined ? { required } : {}),
|
|
225
|
+
...(sensitive !== undefined ? { sensitive } : {}),
|
|
226
|
+
}),
|
|
227
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- the next effect keeps the options current.
|
|
228
|
+
[store, id, scope, first],
|
|
229
|
+
);
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
store.updateRegistration({
|
|
232
|
+
id,
|
|
233
|
+
scope,
|
|
234
|
+
initial: first,
|
|
235
|
+
...(label !== undefined ? { label } : {}),
|
|
236
|
+
...(required !== undefined ? { required } : {}),
|
|
237
|
+
...(sensitive !== undefined ? { sensitive } : {}),
|
|
238
|
+
...(error !== undefined ? { error } : {}),
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
// A submit waits for an asynchronous check of the value it would send, then counts its answer.
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (validation.kind !== "later") return;
|
|
244
|
+
return store.beforeSubmit(async () => {
|
|
245
|
+
const message = await validation.settled;
|
|
246
|
+
store.updateRegistration({
|
|
247
|
+
id,
|
|
248
|
+
scope,
|
|
249
|
+
initial: first,
|
|
250
|
+
...(label !== undefined ? { label } : {}),
|
|
251
|
+
...(required !== undefined ? { required } : {}),
|
|
252
|
+
...(sensitive !== undefined ? { sensitive } : {}),
|
|
253
|
+
...(message !== undefined ? { error: message } : {}),
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}, [store, validation, id, scope, first, label, required, sensitive]);
|
|
257
|
+
|
|
258
|
+
const set = useCallback(
|
|
259
|
+
(next: T | ((previous: T) => T)) => {
|
|
260
|
+
if (!store.writable(key)) {
|
|
261
|
+
if (!warned.has(key)) {
|
|
262
|
+
warned.add(key);
|
|
263
|
+
console.warn(
|
|
264
|
+
`Mostra: the field ${id} is read-only here (submitted, or a view-only link); the change was not kept.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const previous = store.value(key, first) as T;
|
|
270
|
+
const resolved =
|
|
271
|
+
typeof next === "function" ? (next as (previous: T) => T)(previous) : (next as T);
|
|
272
|
+
store.set(scope, id, resolved, {
|
|
273
|
+
...(label !== undefined ? { label } : {}),
|
|
274
|
+
...(required !== undefined ? { required } : {}),
|
|
275
|
+
...(sensitive !== undefined ? { sensitive } : {}),
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
[store, key, scope, id, label, required, sensitive, first],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const conflict = store.conflict(key);
|
|
282
|
+
const refusal = store.refusal(key);
|
|
283
|
+
const save = store.save(key);
|
|
284
|
+
const writer = updatedByOf(store.updatedBy(key));
|
|
285
|
+
const writerKind = writer?.kind;
|
|
286
|
+
const writerName = writer?.name;
|
|
287
|
+
const meta = useMemo((): FieldMeta<T> => {
|
|
288
|
+
const message = error ?? refusal?.message;
|
|
289
|
+
const updatedBy =
|
|
290
|
+
writerKind === undefined
|
|
291
|
+
? undefined
|
|
292
|
+
: { kind: writerKind, ...(writerName !== undefined ? { name: writerName } : {}) };
|
|
293
|
+
return {
|
|
294
|
+
save,
|
|
295
|
+
...(message !== undefined ? { error: message } : {}),
|
|
296
|
+
...(conflict
|
|
297
|
+
? {
|
|
298
|
+
conflict: {
|
|
299
|
+
theirs: (conflict.deleted ? first : conflict.theirs) as T,
|
|
300
|
+
keepMine: () => store.keepMine(key),
|
|
301
|
+
takeTheirs: () => store.takeTheirs(key),
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
: {}),
|
|
305
|
+
...(scope === "artifact" && updatedBy ? { updatedBy } : {}),
|
|
306
|
+
};
|
|
307
|
+
}, [save, error, refusal, conflict, writerKind, writerName, store, key, scope, first]);
|
|
308
|
+
return [value, set, meta];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── useSubmit ───────────────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/** What `useSubmit` returns (INTERFACES §2.3). */
|
|
314
|
+
export interface SubmitHandle {
|
|
315
|
+
/**
|
|
316
|
+
* Submits the viewer's draft through the artifact's own code (D40). Rejects with
|
|
317
|
+
* `MOSTRA_SUBMIT_CHANNEL_DENIED` where the host takes the shell's trusted Submit only,
|
|
318
|
+
* `MOSTRA_SUBMIT_BLOCKED` while a field blocks, `MOSTRA_SUBMIT_ALREADY` under a closed `once`.
|
|
319
|
+
*/
|
|
320
|
+
submit(): Promise<SubmissionSummary>;
|
|
321
|
+
/** Whether `submit()` can work here: the host allows the artifact channel and the draft is open. */
|
|
322
|
+
readonly allowed: boolean;
|
|
323
|
+
/** What blocks a submit now. */
|
|
324
|
+
readonly blockers: readonly SubmitBlocker[];
|
|
325
|
+
/** Where the submission stands (IN-13). */
|
|
326
|
+
readonly state: SubmitState;
|
|
327
|
+
/** The viewer's submission, once made. */
|
|
328
|
+
readonly submission?: { readonly id: string; readonly at: string };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The artifact channel's submit, the blockers and the submission's state (D40, IN-13). */
|
|
332
|
+
export function useSubmit(): SubmitHandle {
|
|
333
|
+
const { store } = useRuntime();
|
|
334
|
+
const version = useStoreVersion(store);
|
|
335
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the version stands for the store's state.
|
|
336
|
+
return useMemo((): SubmitHandle => {
|
|
337
|
+
const view = store.view;
|
|
338
|
+
const state = store.submitState();
|
|
339
|
+
const submission = store.submission();
|
|
340
|
+
const openState =
|
|
341
|
+
state === "open" || (view?.policy.policy === "epochs" && state !== "submitting");
|
|
342
|
+
const allowed =
|
|
343
|
+
store.mode === "live" &&
|
|
344
|
+
view !== undefined &&
|
|
345
|
+
view.submitChannels.includes("artifact") &&
|
|
346
|
+
view.policy.policy !== "none" &&
|
|
347
|
+
store.canSubmit() &&
|
|
348
|
+
!store.closed &&
|
|
349
|
+
openState;
|
|
350
|
+
return {
|
|
351
|
+
submit: () => store.submit(),
|
|
352
|
+
allowed,
|
|
353
|
+
blockers: store.blockers(),
|
|
354
|
+
state,
|
|
355
|
+
...(submission ? { submission: { id: submission.id, at: submission.at } } : {}),
|
|
356
|
+
};
|
|
357
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `version` stands for the store's state.
|
|
358
|
+
}, [store, version]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Asks the shell for a trusted control while mounted, and withdraws it on unmount. */
|
|
362
|
+
function useControl(request: ControlRequest | undefined, release: ControlRequest): void {
|
|
363
|
+
const text = request === undefined ? undefined : JSON.stringify(request);
|
|
364
|
+
const releaseText = JSON.stringify(release);
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
if (text === undefined) return;
|
|
367
|
+
let active = true;
|
|
368
|
+
void connectShell().then((connection) => {
|
|
369
|
+
if (!active || !connection) return;
|
|
370
|
+
void connection.bridge
|
|
371
|
+
.call(mostraControlsRequest, JSON.parse(text) as ControlRequest)
|
|
372
|
+
.catch(() => undefined);
|
|
373
|
+
});
|
|
374
|
+
return () => {
|
|
375
|
+
active = false;
|
|
376
|
+
void connectShell().then((connection) => {
|
|
377
|
+
void connection?.bridge
|
|
378
|
+
.call(mostraControlsRequest, JSON.parse(releaseText) as ControlRequest)
|
|
379
|
+
.catch(() => undefined);
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
}, [text, releaseText]);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Relabels the shell's trusted Submit while mounted, and asks the shell to confirm with `confirm`
|
|
387
|
+
* first (D06, SH-02). The artifact asks; the shell renders the control in its own origin, where
|
|
388
|
+
* the artifact can't reach it.
|
|
389
|
+
*/
|
|
390
|
+
export function useSubmitControl(options: {
|
|
391
|
+
readonly label?: string;
|
|
392
|
+
readonly confirm?: string;
|
|
393
|
+
}): void {
|
|
394
|
+
useControl(
|
|
395
|
+
{
|
|
396
|
+
control: "submit",
|
|
397
|
+
...(options.label !== undefined ? { label: options.label } : {}),
|
|
398
|
+
...(options.confirm !== undefined ? { confirm: options.confirm } : {}),
|
|
399
|
+
},
|
|
400
|
+
{ control: "release", submit: true },
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── useSignal ───────────────────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
/** A named signal for the host's attention (IN-09), such as "Ping the agent". */
|
|
407
|
+
export function useSignal(name: string): { send(payload?: unknown): Promise<void> } {
|
|
408
|
+
const { store } = useRuntime();
|
|
409
|
+
return useMemo(
|
|
410
|
+
() => ({ send: (payload?: unknown) => store.signal(name, payload) }),
|
|
411
|
+
[store, name],
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── useChoice ───────────────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
/** A review set's candidate, as the trusted Choose names it. */
|
|
418
|
+
export interface ChoiceCandidate {
|
|
419
|
+
/** The candidate's ID: the choice's value. */
|
|
420
|
+
readonly id: string;
|
|
421
|
+
/** The name the trusted Choose shows ("Choose Dense"), and results label it with. */
|
|
422
|
+
readonly label?: string;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A review set's choice (D65, D113): the field `review.<reviewSetId>.choice`. With `candidates`,
|
|
427
|
+
* the shell renders a trusted Choose for the candidate the page shows (`current`); `choose()`
|
|
428
|
+
* chooses from inside the page where the host allows the artifact channel (D40), else rejects
|
|
429
|
+
* with `MOSTRA_SUBMIT_CHANNEL_DENIED`.
|
|
430
|
+
*/
|
|
431
|
+
export function useChoice(
|
|
432
|
+
reviewSetId: string,
|
|
433
|
+
options?: { readonly candidates?: readonly ChoiceCandidate[]; readonly current?: string },
|
|
434
|
+
): { readonly choice?: string; choose(candidateId: string): Promise<void> } {
|
|
435
|
+
const { store } = useRuntime();
|
|
436
|
+
const id = `review.${reviewSetId}.choice`;
|
|
437
|
+
const key: FieldKey = fieldKey("viewer", id);
|
|
438
|
+
useSyncExternalStore(
|
|
439
|
+
store.subscribe,
|
|
440
|
+
() => store.keyVersion(key),
|
|
441
|
+
() => store.keyVersion(key),
|
|
442
|
+
);
|
|
443
|
+
const choice = store.value(key, undefined);
|
|
444
|
+
// verify's field probe hears the choice field too (GPT review finding 6); a viewer's page has
|
|
445
|
+
// no probe, and this does nothing there.
|
|
446
|
+
useEffect(
|
|
447
|
+
() => store.announceMount({ id, scope: "viewer", initial: undefined, hook: "useChoice" }),
|
|
448
|
+
[store, id],
|
|
449
|
+
);
|
|
450
|
+
const candidates = options?.candidates;
|
|
451
|
+
useControl(
|
|
452
|
+
candidates && candidates.length > 0
|
|
453
|
+
? {
|
|
454
|
+
control: "choose",
|
|
455
|
+
reviewSetId,
|
|
456
|
+
candidates: candidates.map((candidate) => ({
|
|
457
|
+
id: candidate.id,
|
|
458
|
+
...(candidate.label !== undefined ? { label: candidate.label } : {}),
|
|
459
|
+
})),
|
|
460
|
+
...(options?.current !== undefined ? { current: options.current } : {}),
|
|
461
|
+
}
|
|
462
|
+
: undefined,
|
|
463
|
+
{ control: "release", reviewSetId },
|
|
464
|
+
);
|
|
465
|
+
const labels = useMemo(
|
|
466
|
+
() => new Map((candidates ?? []).map((candidate) => [candidate.id, candidate.label])),
|
|
467
|
+
[candidates],
|
|
468
|
+
);
|
|
469
|
+
const choose = useCallback(
|
|
470
|
+
async (candidateId: string) => {
|
|
471
|
+
const view = store.view;
|
|
472
|
+
if (store.mode === "live" && view && !view.submitChannels.includes("artifact")) {
|
|
473
|
+
throw new MostraError(
|
|
474
|
+
"MOSTRA_SUBMIT_CHANNEL_DENIED",
|
|
475
|
+
"This host takes choices from the shell's trusted Choose only.",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
const label = labels.get(candidateId);
|
|
479
|
+
store.set("viewer", id, candidateId, {
|
|
480
|
+
...(label !== undefined ? { label } : {}),
|
|
481
|
+
immediate: true,
|
|
482
|
+
});
|
|
483
|
+
await store.flush();
|
|
484
|
+
},
|
|
485
|
+
[store, id, labels],
|
|
486
|
+
);
|
|
487
|
+
return {
|
|
488
|
+
...(typeof choice === "string" ? { choice } : {}),
|
|
489
|
+
choose,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── useViewer and useArtifact ───────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
/** Who the page acts as (D104): the home's local user, a tailnet user, or a link's person. */
|
|
496
|
+
export function useViewer(): {
|
|
497
|
+
readonly id: string;
|
|
498
|
+
readonly name?: string;
|
|
499
|
+
readonly source: "local" | "tailnet" | "token" | "host";
|
|
500
|
+
} {
|
|
501
|
+
const { store } = useRuntime();
|
|
502
|
+
useStoreVersion(store);
|
|
503
|
+
const viewer = store.viewer();
|
|
504
|
+
return viewer
|
|
505
|
+
? {
|
|
506
|
+
id: viewer.id,
|
|
507
|
+
...(viewer.name !== undefined ? { name: viewer.name } : {}),
|
|
508
|
+
source: viewer.kind,
|
|
509
|
+
}
|
|
510
|
+
: { id: "", source: "local" };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** The artifact, the version and the view the page shows, and where it runs (D104, D111). */
|
|
514
|
+
export function useArtifact(): {
|
|
515
|
+
readonly id: string;
|
|
516
|
+
readonly versionId: string;
|
|
517
|
+
readonly version: number;
|
|
518
|
+
readonly viewId: string;
|
|
519
|
+
readonly mode: ArtifactMode;
|
|
520
|
+
} {
|
|
521
|
+
const { store, mode } = useRuntime();
|
|
522
|
+
const view = store.view;
|
|
523
|
+
return {
|
|
524
|
+
id: view?.artifactId ?? "",
|
|
525
|
+
versionId: view?.version.id ?? "",
|
|
526
|
+
version: view?.version.number ?? 0,
|
|
527
|
+
viewId: view?.viewId ?? "",
|
|
528
|
+
mode,
|
|
529
|
+
};
|
|
530
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `@mostra/react`: the client runtime artifact authors import (INTERFACES §2.3). Fields that behave
|
|
5
|
+
* like `useState` with a stable ID and persist as the viewer works, the artifact channel's
|
|
6
|
+
* submit, the shell's trusted Submit label, signals, review-set choices, the viewer and the
|
|
7
|
+
* artifact, a Claude-compatible `window.storage`, and the manifest helper. `@mostra/react/form`
|
|
8
|
+
* adapts react-hook-form; `@mostra/react/router` mounts `pages/` artifacts.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* import { defineArtifact, useField, useSubmitControl } from "@mostra/react";
|
|
12
|
+
*
|
|
13
|
+
* export const artifact = defineArtifact({ title: "Sidebar options", family: "review" });
|
|
14
|
+
*
|
|
15
|
+
* export default function Page() {
|
|
16
|
+
* const [layout, setLayout] = useField("layout", "left", { label: "Layout", required: true });
|
|
17
|
+
* useSubmitControl({ label: "Send my choice" });
|
|
18
|
+
* return <select value={layout} onChange={(e) => setLayout(e.target.value)}>…</select>;
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @packageDocumentation
|
|
23
|
+
*/
|
|
24
|
+
import type { ArtifactManifest } from "@mostra/protocol";
|
|
25
|
+
|
|
26
|
+
export type { ArtifactManifest, SubmissionSummary, SubmitBlocker } from "@mostra/protocol";
|
|
27
|
+
export {
|
|
28
|
+
type ArtifactMode,
|
|
29
|
+
type ChoiceCandidate,
|
|
30
|
+
type FieldMeta,
|
|
31
|
+
type FieldOptions,
|
|
32
|
+
type StandardSchema,
|
|
33
|
+
type SubmitHandle,
|
|
34
|
+
useArtifact,
|
|
35
|
+
useChoice,
|
|
36
|
+
useField,
|
|
37
|
+
useSignal,
|
|
38
|
+
useSubmit,
|
|
39
|
+
useSubmitControl,
|
|
40
|
+
useViewer,
|
|
41
|
+
} from "./hooks.ts";
|
|
42
|
+
export { mountArtifact } from "./mount.ts";
|
|
43
|
+
export { type DiagnosticLike, MostraError } from "./runtime/errors.ts";
|
|
44
|
+
export type { SubmitState } from "./runtime/store.ts";
|
|
45
|
+
export {
|
|
46
|
+
type ActionHandle,
|
|
47
|
+
type LiveKey,
|
|
48
|
+
type LiveResult,
|
|
49
|
+
type LiveSources,
|
|
50
|
+
type LiveValue,
|
|
51
|
+
type LiveValuesOf,
|
|
52
|
+
useAction,
|
|
53
|
+
useLive,
|
|
54
|
+
} from "./server-hooks.ts";
|
|
55
|
+
export {
|
|
56
|
+
type CallServer,
|
|
57
|
+
createServerReference,
|
|
58
|
+
type ServerReference,
|
|
59
|
+
setServerCallback,
|
|
60
|
+
} from "./server-reference.ts";
|
|
61
|
+
export { connectShell, openLink, reportReady, type ShellConnection } from "./shell-bridge.ts";
|
|
62
|
+
export type { ClaudeCompatibleStorage, StorageEntry } from "./storage.ts";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Declares an artifact's manifest (INTERFACES §2.2, D51). Mostra reads the object statically
|
|
66
|
+
* without running the page, so write every value as a literal: strings, numbers, booleans, arrays
|
|
67
|
+
* and objects. At run time it returns its argument unchanged.
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* export const artifact = defineArtifact({ title: "Q3 plan", family: "proposal" });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function defineArtifact<const M extends ArtifactManifest>(manifest: M): M {
|
|
74
|
+
return manifest;
|
|
75
|
+
}
|
package/src/mount.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mounting an artifact (D57, D111): the generated client entry hands the page to
|
|
5
|
+
* {@link mountArtifact}, or its `pages/` routes to `mountRoutes` from `@mostra/react/router`. The
|
|
6
|
+
* client runtime starts first, so the page renders with the viewer's saved answers rather than
|
|
7
|
+
* flashing its defaults; after a short wait it renders anyway and catches up when the gateway
|
|
8
|
+
* answers.
|
|
9
|
+
*/
|
|
10
|
+
import { mostraPage, VERIFY_REPORT } from "@mostra/protocol";
|
|
11
|
+
import { type ComponentType, createElement, type ReactNode } from "react";
|
|
12
|
+
import { createRoot, type RootOptions } from "react-dom/client";
|
|
13
|
+
import { type ArtifactMode, RuntimeProvider } from "./hooks.ts";
|
|
14
|
+
import { type Runtime, startRuntime } from "./runtime/boot.ts";
|
|
15
|
+
import { setServerCallback } from "./server-reference.ts";
|
|
16
|
+
import { reportReady } from "./shell-bridge.ts";
|
|
17
|
+
import { installStorage } from "./storage.ts";
|
|
18
|
+
|
|
19
|
+
/** How long the first render waits for the viewer's saved answers. */
|
|
20
|
+
const FIRST_RENDER_WAIT_MS = 3_000;
|
|
21
|
+
|
|
22
|
+
/** Starts the runtime, installs `window.storage`, and resolves once the page can render. */
|
|
23
|
+
export async function prepareArtifact(): Promise<Runtime> {
|
|
24
|
+
const runtime = await startRuntime();
|
|
25
|
+
installStorage(runtime.store);
|
|
26
|
+
// Server references call through this page's runtime in live mode (D54); elsewhere (a static
|
|
27
|
+
// export, a page opened outside its shell) they keep refusing with `MOSTRA_TARGET_UNAVAILABLE`,
|
|
28
|
+
// naming the function.
|
|
29
|
+
if (runtime.store.mode === "live") {
|
|
30
|
+
setServerCallback((id, args) => runtime.store.server.call(id, args));
|
|
31
|
+
}
|
|
32
|
+
await runtime.store.whenReady(FIRST_RENDER_WAIT_MS);
|
|
33
|
+
return runtime;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The root's error handlers: in the `verify` target, an error a boundary caught goes to verify's
|
|
38
|
+
* recorder with its `cause` chain (React's default logs it with `console.error`, which verify
|
|
39
|
+
* couldn't tell apart from the page's own logging); everywhere else, React's defaults.
|
|
40
|
+
*/
|
|
41
|
+
function rootOptions(mode: string): RootOptions | undefined {
|
|
42
|
+
if (mode !== "verify") return undefined;
|
|
43
|
+
const report = (globalThis as unknown as Record<string, unknown>)[VERIFY_REPORT];
|
|
44
|
+
if (typeof report !== "function") return undefined;
|
|
45
|
+
return { onCaughtError: (error: unknown) => (report as (error: unknown) => void)(error) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Renders `node` inside the runtime into `#root`, and reports `mostra/ready`. */
|
|
49
|
+
export function renderArtifact(runtime: Runtime, node: ReactNode): void {
|
|
50
|
+
const root = document.getElementById("root");
|
|
51
|
+
if (!root) throw new Error("The page has no #root element.");
|
|
52
|
+
createRoot(root, rootOptions(runtime.mode)).render(
|
|
53
|
+
createElement(
|
|
54
|
+
RuntimeProvider,
|
|
55
|
+
{ store: runtime.store, mode: runtime.mode as ArtifactMode },
|
|
56
|
+
node,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
reportReady();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Tells the shell which page the viewer is on (D57), so comments record it. */
|
|
63
|
+
export function reportPage(runtime: Runtime, path: string, title?: string): void {
|
|
64
|
+
runtime.shell?.bridge.notify(mostraPage, {
|
|
65
|
+
path,
|
|
66
|
+
...(title !== undefined && title !== "" ? { title } : {}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mounts a single-page artifact: the generated entry calls it with the entry's default export.
|
|
72
|
+
* Artifact code doesn't need to.
|
|
73
|
+
*/
|
|
74
|
+
export async function mountArtifact(options: { readonly page: ComponentType }): Promise<void> {
|
|
75
|
+
const runtime = await prepareArtifact();
|
|
76
|
+
renderArtifact(runtime, createElement(options.page));
|
|
77
|
+
reportPage(runtime, "/", document.title);
|
|
78
|
+
}
|