@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.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * How the client runtime reaches Mostra (INTERFACES §4): the browser protocol over the artifact's
3
+ * own origin, with its session cookie and CSRF token. A delivery target without a gateway (a
4
+ * static export, an MCP App before its transport, D111) gets another implementation of the same
5
+ * interface, so hooks never know which they talk to.
6
+ */
7
+ import {
8
+ BROWSER_PROTOCOL_REVISION,
9
+ type CallLine,
10
+ type CallRequest,
11
+ type EphemeralMessage,
12
+ type EventRecord,
13
+ type FieldsRequest,
14
+ type LiveRequest,
15
+ type LiveState,
16
+ type LiveUpdate,
17
+ type MutationResult,
18
+ type PollResult,
19
+ type RunAttachRequest,
20
+ type RunDelta,
21
+ type RunRequest,
22
+ type RunState,
23
+ type SignalRequest,
24
+ type StreamSnapshot,
25
+ type SubmissionSummary,
26
+ type SubmitRequest,
27
+ } from "@mostra/protocol";
28
+ import { type DiagnosticLike, MostraError } from "./errors.ts";
29
+
30
+ /** The connection to the gateway failed before it answered: the page is offline. */
31
+ export class NetworkError extends Error {
32
+ constructor(message: string) {
33
+ super(message);
34
+ this.name = "NetworkError";
35
+ }
36
+ }
37
+
38
+ /** What a subscription hears. */
39
+ export interface StreamHandlers {
40
+ /** Where the page stands, first, and again after a compacted cursor. */
41
+ onSnapshot(snapshot: StreamSnapshot): void;
42
+ /** Durable events after the last cursor, in order. */
43
+ onEvents(events: readonly EventRecord[]): void;
44
+ /** The stream's health: `live` while connected, `offline` while it retries, `revoked` at the end. */
45
+ onStatus(status: "live" | "offline" | "revoked"): void;
46
+ /** Run and live updates (WP-08): lossy, never events, so a page reads again what it missed. */
47
+ onEphemeral?(message: EphemeralMessage): void;
48
+ }
49
+
50
+ /** The client runtime's way to Mostra. */
51
+ export interface Transport {
52
+ /** Where the page stands: its viewer, draft, the artifact state and their cursor. */
53
+ snapshot(): Promise<StreamSnapshot>;
54
+ /** One mutation (`fields`); `keepalive` lets it outlive the page (`pagehide`). */
55
+ mutate(
56
+ request: FieldsRequest,
57
+ options?: { readonly keepalive?: boolean },
58
+ ): Promise<MutationResult>;
59
+ /** A submit through the artifact channel. */
60
+ submit(request: SubmitRequest): Promise<SubmissionSummary>;
61
+ /** A signal. */
62
+ signal(request: SignalRequest): Promise<{ readonly cursor: string }>;
63
+ /** Hears the page's events after `cursor`; returns the function that stops. */
64
+ subscribe(cursor: string | undefined, handlers: StreamHandlers): () => void;
65
+ /** A plain server-function call (D54): its result's lines as they come. */
66
+ call?(fnId: string, request: CallRequest, signal?: AbortSignal): AsyncIterable<CallLine>;
67
+ /** Starts or joins an action run (AC-02, AC-03). */
68
+ startRun?(request: RunRequest): Promise<RunState>;
69
+ /** Reads runs and follows them for a minute. */
70
+ attachRuns?(request: RunAttachRequest): Promise<{ readonly runs: readonly RunState[] }>;
71
+ /** Cancels a run (AC-04). */
72
+ cancelRun?(runId: string, viewId: string): Promise<RunState>;
73
+ /** Reads live sources and keeps them produced for a minute (D56). */
74
+ live?(request: LiveRequest): Promise<{ readonly sources: readonly LiveState[] }>;
75
+ }
76
+
77
+ /** The longest body `fetch` sends with `keepalive` (the Fetch standard's 64 KiB budget). */
78
+ export const KEEPALIVE_BODY_MAX: number = 64 * 1024 - 1024;
79
+
80
+ /** After this many stream failures in a row, the client long-polls instead (FAILURE_MODEL §8). */
81
+ const STREAM_FAILURES_BEFORE_POLL = 2;
82
+
83
+ /**
84
+ * The browser protocol over HTTP, on the artifact's origin. The CSRF token comes from
85
+ * `/_mostra/session`, once; a refusal is a {@link MostraError} with the gateway's diagnostic, and
86
+ * an unreachable gateway a {@link NetworkError}.
87
+ */
88
+ export class HttpTransport implements Transport {
89
+ readonly #viewId: string;
90
+ readonly #base: string;
91
+ #csrf: Promise<string> | undefined;
92
+
93
+ /**
94
+ * `csrf` is the session's token when the caller already read it (the bridge does): a save made
95
+ * as the page closes must not wait for a request the closing page would cancel (IN-02).
96
+ */
97
+ constructor(viewId: string, options: { readonly csrf?: string; readonly base?: string } = {}) {
98
+ this.#viewId = viewId;
99
+ this.#base = options.base ?? `/_mostra/v/${BROWSER_PROTOCOL_REVISION}`;
100
+ if (options.csrf) this.#csrf = Promise.resolve(options.csrf);
101
+ }
102
+
103
+ #token(): Promise<string> {
104
+ this.#csrf ??= (async () => {
105
+ const session = (await this.#request("/_mostra/session", { method: "GET" })) as {
106
+ csrf: string;
107
+ };
108
+ return session.csrf;
109
+ })().catch((error: unknown) => {
110
+ this.#csrf = undefined;
111
+ throw error;
112
+ });
113
+ return this.#csrf;
114
+ }
115
+
116
+ async #request(path: string, init: RequestInit): Promise<unknown> {
117
+ let response: Response;
118
+ try {
119
+ response = await fetch(path, { credentials: "same-origin", cache: "no-store", ...init });
120
+ } catch (error) {
121
+ throw new NetworkError((error as Error).message);
122
+ }
123
+ let body: unknown;
124
+ try {
125
+ body = await response.json();
126
+ } catch {
127
+ body = undefined;
128
+ }
129
+ if (!response.ok) {
130
+ const diagnostic = (body ?? {}) as Partial<DiagnosticLike> & Record<string, unknown>;
131
+ // A proxy or a restarting gateway answers without a diagnostic: that is a network failure.
132
+ if (typeof diagnostic.code !== "string") {
133
+ throw new NetworkError(`The gateway answered ${response.status}.`);
134
+ }
135
+ throw new MostraError(diagnostic.code, diagnostic.message ?? diagnostic.code, {
136
+ status: response.status,
137
+ diagnostic: diagnostic as DiagnosticLike,
138
+ body: diagnostic,
139
+ });
140
+ }
141
+ return body;
142
+ }
143
+
144
+ async #post(path: string, body: unknown, keepalive = false): Promise<unknown> {
145
+ const text = JSON.stringify(body);
146
+ const csrf = await this.#token();
147
+ return this.#request(`${this.#base}${path}`, {
148
+ method: "POST",
149
+ headers: { "content-type": "application/json", "x-mostra-csrf": csrf },
150
+ body: text,
151
+ keepalive: keepalive && text.length < KEEPALIVE_BODY_MAX,
152
+ });
153
+ }
154
+
155
+ async snapshot(): Promise<StreamSnapshot> {
156
+ const result = (await this.#request(`${this.#base}/events/poll?view=${this.#viewId}&wait=0`, {
157
+ method: "GET",
158
+ })) as PollResult;
159
+ if (!result.snapshot) throw new NetworkError("The gateway sent no snapshot.");
160
+ return result.snapshot;
161
+ }
162
+
163
+ mutate(
164
+ request: FieldsRequest,
165
+ options?: { readonly keepalive?: boolean },
166
+ ): Promise<MutationResult> {
167
+ return this.#post("/fields", request, options?.keepalive === true) as Promise<MutationResult>;
168
+ }
169
+
170
+ submit(request: SubmitRequest): Promise<SubmissionSummary> {
171
+ return this.#post("/submit", request) as Promise<SubmissionSummary>;
172
+ }
173
+
174
+ signal(request: SignalRequest): Promise<{ readonly cursor: string }> {
175
+ return this.#post("/signal", request) as Promise<{ cursor: string }>;
176
+ }
177
+
178
+ async *call(fnId: string, request: CallRequest, signal?: AbortSignal): AsyncIterable<CallLine> {
179
+ const csrf = await this.#token();
180
+ let response: Response;
181
+ try {
182
+ response = await fetch(`${this.#base}/call/${encodeURIComponent(fnId)}`, {
183
+ method: "POST",
184
+ credentials: "same-origin",
185
+ cache: "no-store",
186
+ headers: { "content-type": "application/json", "x-mostra-csrf": csrf },
187
+ body: JSON.stringify(request),
188
+ ...(signal ? { signal } : {}),
189
+ });
190
+ } catch (error) {
191
+ throw new NetworkError((error as Error).message);
192
+ }
193
+ if (!response.ok || !response.body) {
194
+ let body: unknown;
195
+ try {
196
+ body = await response.json();
197
+ } catch {
198
+ body = undefined;
199
+ }
200
+ const diagnostic = (body ?? {}) as Partial<DiagnosticLike>;
201
+ if (typeof diagnostic.code !== "string") {
202
+ throw new NetworkError(`The gateway answered ${response.status}.`);
203
+ }
204
+ throw new MostraError(diagnostic.code, diagnostic.message ?? diagnostic.code, {
205
+ status: response.status,
206
+ diagnostic: diagnostic as DiagnosticLike,
207
+ });
208
+ }
209
+ // NDJSON: one line per batch, then the end.
210
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
211
+ let buffer = "";
212
+ try {
213
+ for (;;) {
214
+ let chunk: ReadableStreamReadResult<string>;
215
+ try {
216
+ chunk = await reader.read();
217
+ } catch (error) {
218
+ throw new NetworkError((error as Error).message);
219
+ }
220
+ if (chunk.done) break;
221
+ buffer += chunk.value;
222
+ let newline = buffer.indexOf("\n");
223
+ while (newline !== -1) {
224
+ const line = buffer.slice(0, newline);
225
+ buffer = buffer.slice(newline + 1);
226
+ if (line.trim() !== "") yield JSON.parse(line) as CallLine;
227
+ newline = buffer.indexOf("\n");
228
+ }
229
+ }
230
+ } finally {
231
+ reader.releaseLock();
232
+ }
233
+ if (buffer.trim() !== "") yield JSON.parse(buffer) as CallLine;
234
+ }
235
+
236
+ startRun(request: RunRequest): Promise<RunState> {
237
+ return this.#post("/runs", request) as Promise<RunState>;
238
+ }
239
+
240
+ attachRuns(request: RunAttachRequest): Promise<{ readonly runs: readonly RunState[] }> {
241
+ return this.#post("/runs/attach", request) as Promise<{ runs: RunState[] }>;
242
+ }
243
+
244
+ cancelRun(runId: string, viewId: string): Promise<RunState> {
245
+ return this.#post(`/runs/${encodeURIComponent(runId)}/cancel`, { viewId }) as Promise<RunState>;
246
+ }
247
+
248
+ live(request: LiveRequest): Promise<{ readonly sources: readonly LiveState[] }> {
249
+ return this.#post("/live", request) as Promise<{ sources: LiveState[] }>;
250
+ }
251
+
252
+ subscribe(cursor: string | undefined, handlers: StreamHandlers): () => void {
253
+ let closed = false;
254
+ let last = cursor;
255
+ let source: EventSource | undefined;
256
+ let failures = 0;
257
+ let polling = false;
258
+ let retry: ReturnType<typeof setTimeout> | undefined;
259
+ const query = (): string =>
260
+ `view=${this.#viewId}${last === undefined ? "" : `&cursor=${last}`}`;
261
+
262
+ const poll = async (): Promise<void> => {
263
+ polling = true;
264
+ let backoff = 1_000;
265
+ while (!closed) {
266
+ try {
267
+ const result = (await this.#request(`${this.#base}/events/poll?${query()}&wait=25`, {
268
+ method: "GET",
269
+ })) as PollResult;
270
+ backoff = 1_000;
271
+ handlers.onStatus("live");
272
+ if (result.snapshot) handlers.onSnapshot(result.snapshot);
273
+ if (result.events.length > 0) handlers.onEvents(result.events);
274
+ for (const message of result.ephemeral ?? []) handlers.onEphemeral?.(message);
275
+ last = result.cursor;
276
+ } catch (error) {
277
+ if (error instanceof MostraError && error.code === "MOSTRA_HYGIENE_TOKEN_REVOKED") {
278
+ handlers.onStatus("revoked");
279
+ return;
280
+ }
281
+ handlers.onStatus("offline");
282
+ await new Promise((resolve) => {
283
+ retry = setTimeout(resolve, backoff);
284
+ });
285
+ backoff = Math.min(backoff * 2, 30_000);
286
+ }
287
+ }
288
+ };
289
+
290
+ const open = (): void => {
291
+ if (closed) return;
292
+ if (typeof EventSource === "undefined") {
293
+ void poll();
294
+ return;
295
+ }
296
+ source = new EventSource(`${this.#base}/events?${query()}`);
297
+ source.addEventListener("open", () => {
298
+ failures = 0;
299
+ handlers.onStatus("live");
300
+ });
301
+ source.addEventListener("snapshot", (event) => {
302
+ failures = 0;
303
+ const snapshot = JSON.parse((event as MessageEvent<string>).data) as StreamSnapshot;
304
+ last = snapshot.cursor;
305
+ handlers.onSnapshot(snapshot);
306
+ });
307
+ source.addEventListener("mostra", (event) => {
308
+ failures = 0;
309
+ const record = JSON.parse((event as MessageEvent<string>).data) as EventRecord;
310
+ last = record.cursor;
311
+ handlers.onEvents([record]);
312
+ });
313
+ // Run and live updates carry no `id`, so they never move the stream's cursor.
314
+ source.addEventListener("run", (event) => {
315
+ const run = JSON.parse((event as MessageEvent<string>).data) as RunDelta;
316
+ handlers.onEphemeral?.({ kind: "run", run });
317
+ });
318
+ source.addEventListener("live", (event) => {
319
+ const live = JSON.parse((event as MessageEvent<string>).data) as LiveUpdate;
320
+ handlers.onEphemeral?.({ kind: "live", live });
321
+ });
322
+ source.addEventListener("error", () => {
323
+ if (closed) return;
324
+ failures += 1;
325
+ handlers.onStatus("offline");
326
+ // A refused stream (revoked link, gateway gone) is CLOSED; a dropped one reconnects by
327
+ // itself. Either way, after two failures in a row the page long-polls on the same cursor.
328
+ if (source?.readyState === EventSource.CLOSED || failures >= STREAM_FAILURES_BEFORE_POLL) {
329
+ source?.close();
330
+ source = undefined;
331
+ if (!polling) void poll();
332
+ }
333
+ });
334
+ };
335
+ open();
336
+ return () => {
337
+ closed = true;
338
+ source?.close();
339
+ if (retry !== undefined) clearTimeout(retry);
340
+ };
341
+ }
342
+ }
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `verify`'s field probe (D138 item 3): in the `verify` target only, the page keeps a record of
5
+ * every field it mounts (the hook, the scope and the type of the initial value) and how much
6
+ * server work it waits on, and publishes both on `globalThis[VERIFY_PROBE]`, where the verify
7
+ * runner reads them. Viewers' pages never install it.
8
+ */
9
+ import { type ProbedField, VERIFY_PROBE } from "@mostra/protocol";
10
+ import type { Registration, Store } from "./store.ts";
11
+
12
+ /** `typeof`, with arrays and `null` named as such. */
13
+ function typeName(value: unknown): string {
14
+ if (value === null) return "null";
15
+ if (Array.isArray(value)) return "array";
16
+ return typeof value;
17
+ }
18
+
19
+ interface Entry {
20
+ readonly first: { readonly hook: string; readonly scope: string; readonly initialType: string };
21
+ mounts: number;
22
+ readonly conflicts: Set<string>;
23
+ }
24
+
25
+ /** Records the store's field mounts on `globalThis[VERIFY_PROBE]`. */
26
+ export function installVerifyProbe(store: Store): void {
27
+ const entries = new Map<string, Entry>();
28
+ store.observeMounts((registration: Registration, mounted: boolean) => {
29
+ const shape = {
30
+ hook: registration.hook ?? "useField",
31
+ scope: registration.scope,
32
+ initialType: typeName(registration.initial),
33
+ };
34
+ let entry = entries.get(registration.id);
35
+ if (!entry) {
36
+ entry = { first: shape, mounts: 0, conflicts: new Set() };
37
+ entries.set(registration.id, entry);
38
+ }
39
+ if (mounted) {
40
+ entry.mounts += 1;
41
+ if (shape.hook !== entry.first.hook)
42
+ entry.conflicts.add(`${entry.first.hook} and ${shape.hook}`);
43
+ if (shape.scope !== entry.first.scope)
44
+ entry.conflicts.add(`scopes ${entry.first.scope} and ${shape.scope}`);
45
+ if (shape.initialType !== entry.first.initialType)
46
+ entry.conflicts.add(
47
+ `initial values of types ${entry.first.initialType} and ${shape.initialType}`,
48
+ );
49
+ } else entry.mounts = Math.max(0, entry.mounts - 1);
50
+ });
51
+ const fields = (): ProbedField[] =>
52
+ [...entries].map(([id, entry]) => ({
53
+ id,
54
+ scope: entry.first.scope as ProbedField["scope"],
55
+ hook: entry.first.hook,
56
+ initialType: entry.first.initialType,
57
+ mounts: entry.mounts,
58
+ conflicts: [...entry.conflicts],
59
+ }));
60
+ // `pending`: the server work the page waits on (calls, runs, first live values), which the
61
+ // runner's quiescence wait includes (Opus review finding 1).
62
+ const pending = (): number => store.server.pending();
63
+ Object.defineProperty(globalThis, VERIFY_PROBE, {
64
+ value: { fields, pending },
65
+ configurable: true,
66
+ });
67
+ }