@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,291 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server functions and live data in the page (INTERFACES §2.3, D54, D56): `useAction` turns calls
|
|
5
|
+
* of a `"use server"` function into durable action runs, and `useLive` reads a live source. Both
|
|
6
|
+
* share the page's runtime, its view and its event stream, so a run started in one tab or before a
|
|
7
|
+
* reload is followed the same way, and every component showing one source shares one value.
|
|
8
|
+
*/
|
|
9
|
+
import { isFinalRunStatus, type RunProgress, type RunStatus } from "@mostra/protocol";
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
11
|
+
import { useStore } from "./hooks.ts";
|
|
12
|
+
import { MostraError } from "./runtime/errors.ts";
|
|
13
|
+
import { serverFunctionId } from "./runtime/server-client.ts";
|
|
14
|
+
|
|
15
|
+
// ── useAction ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** What `useAction` shows of its run (INTERFACES §2.3). */
|
|
18
|
+
export interface ActionHandle<A extends unknown[], R> {
|
|
19
|
+
/**
|
|
20
|
+
* Runs the action: a durable run (D24) whose result this resolves with, or whose failure it
|
|
21
|
+
* rejects with as a `MostraError`. While this hook's run is in flight, another call joins it; with
|
|
22
|
+
* a `key`, every call anywhere joins the one run of that key, so it happens at most once.
|
|
23
|
+
*/
|
|
24
|
+
run(...args: A): Promise<R>;
|
|
25
|
+
/**
|
|
26
|
+
* `idle` before any run, then the run's state. `waiting` is reserved for a run waiting for its
|
|
27
|
+
* owner's instance (a hosted artifact, WP-28).
|
|
28
|
+
*/
|
|
29
|
+
readonly status: "idle" | RunStatus;
|
|
30
|
+
/** Whether a run is starting or in flight. */
|
|
31
|
+
readonly isPending: boolean;
|
|
32
|
+
/** The result, once the run succeeded. */
|
|
33
|
+
readonly data?: R;
|
|
34
|
+
/** Why the run failed, was cancelled or couldn't start. */
|
|
35
|
+
readonly error?: { readonly code: string; readonly message: string };
|
|
36
|
+
/** The run's latest progress (`progress()` in the action). */
|
|
37
|
+
readonly progress?: RunProgress;
|
|
38
|
+
/** The run's log lines (`log()` and the console in the action), capped at 1 MB. */
|
|
39
|
+
readonly log: readonly string[];
|
|
40
|
+
/** For an action that returns an async iterable: the items it yielded so far. */
|
|
41
|
+
readonly partial: readonly (R extends AsyncIterable<infer T> ? T : unknown)[];
|
|
42
|
+
/** Cancels the run: the action's `abortSignal` fires (AC-04). */
|
|
43
|
+
cancel(): void;
|
|
44
|
+
/** The run's `run_` ID, once it has one. */
|
|
45
|
+
readonly runId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const NO_LOG: readonly string[] = [];
|
|
49
|
+
|
|
50
|
+
/** What `useAction` knows of one function and key. */
|
|
51
|
+
interface IdentityState {
|
|
52
|
+
readonly runId?: string | undefined;
|
|
53
|
+
readonly starting: boolean;
|
|
54
|
+
readonly startError?: { code: string; message: string } | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const IDLE: IdentityState = { starting: false };
|
|
58
|
+
const NO_STATES: ReadonlyMap<string, IdentityState> = new Map();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Runs a `"use server"` function as durable action runs (D24, D54): each call is recorded before
|
|
62
|
+
* it runs, streams progress, log lines and partial results, survives the tab closing, and is
|
|
63
|
+
* found again after a reload. Without a `key`, a call while this hook's run is in flight joins it,
|
|
64
|
+
* so a double click runs once; with a `key`, the run of that key happens at most once across
|
|
65
|
+
* clicks, tabs, reloads and devices, and a page that opens later shows it.
|
|
66
|
+
*
|
|
67
|
+
* ```tsx
|
|
68
|
+
* const send = useAction(sendReply, { key: `send.${message.id}` });
|
|
69
|
+
* <button disabled={send.isPending || send.status === "succeeded"} onClick={() => send.run(message.id, reply)}>
|
|
70
|
+
* {send.status === "succeeded" ? "Sent" : (send.progress?.message ?? "Send")}
|
|
71
|
+
* </button>
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function useAction<A extends unknown[], R>(
|
|
75
|
+
fn: (...args: A) => Promise<R>,
|
|
76
|
+
options?: { readonly key?: string },
|
|
77
|
+
): ActionHandle<A, Awaited<R>> {
|
|
78
|
+
const store = useStore();
|
|
79
|
+
const server = store.server;
|
|
80
|
+
const fnId = serverFunctionId(fn);
|
|
81
|
+
const key = options?.key;
|
|
82
|
+
// Everything the hook knows belongs to one function and key: a component that changes either
|
|
83
|
+
// shows the new one's run, never the old one's, and a late answer for the old one lands under
|
|
84
|
+
// the old identity, where the hook no longer looks.
|
|
85
|
+
const identity = `${fnId ?? ""}\u0000${key ?? ""}`;
|
|
86
|
+
const [states, setStates] = useState<ReadonlyMap<string, IdentityState>>(NO_STATES);
|
|
87
|
+
const current = states.get(identity) ?? IDLE;
|
|
88
|
+
const { runId, starting, startError } = current;
|
|
89
|
+
const update = useCallback((of: string, patch: Partial<IdentityState>): void => {
|
|
90
|
+
setStates((previous) => {
|
|
91
|
+
const next = new Map(previous);
|
|
92
|
+
next.set(of, { ...(previous.get(of) ?? IDLE), ...patch });
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
}, []);
|
|
96
|
+
const inFlight = useRef(new Map<string, Promise<unknown>>());
|
|
97
|
+
useSyncExternalStore(server.subscribe, server.version, server.version);
|
|
98
|
+
|
|
99
|
+
// A keyed run started earlier, in another tab or before a reload, is shown again (AC-02).
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (key === undefined || fnId === undefined || store.mode !== "live") return;
|
|
102
|
+
let active = true;
|
|
103
|
+
server.attach({ keyed: [{ fnId, key }] }).then(
|
|
104
|
+
(runs) => {
|
|
105
|
+
const [found] = runs;
|
|
106
|
+
if (active && found) update(identity, { runId: found.runId });
|
|
107
|
+
},
|
|
108
|
+
() => {},
|
|
109
|
+
);
|
|
110
|
+
return () => {
|
|
111
|
+
active = false;
|
|
112
|
+
};
|
|
113
|
+
}, [server, store.mode, fnId, key, identity, update]);
|
|
114
|
+
|
|
115
|
+
const view = runId === undefined ? undefined : server.run(runId);
|
|
116
|
+
|
|
117
|
+
const run = useCallback(
|
|
118
|
+
(...args: A): Promise<Awaited<R>> => {
|
|
119
|
+
if (fnId === undefined) {
|
|
120
|
+
return Promise.reject(
|
|
121
|
+
new TypeError(
|
|
122
|
+
'useAction needs a server function: import it from a module that starts with "use server".',
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
// One run in flight per hook instance and function and key (D54): a second click joins it.
|
|
127
|
+
const joined = inFlight.current.get(identity);
|
|
128
|
+
if (joined) return joined as Promise<Awaited<R>>;
|
|
129
|
+
update(identity, { starting: true, startError: undefined });
|
|
130
|
+
const started = (async () => {
|
|
131
|
+
let state: Awaited<ReturnType<typeof server.startRun>>;
|
|
132
|
+
try {
|
|
133
|
+
state = await server.startRun(fnId, args, key);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// The run never started (a refused link, a parked worker, the network): say why.
|
|
136
|
+
update(identity, {
|
|
137
|
+
starting: false,
|
|
138
|
+
startError:
|
|
139
|
+
error instanceof MostraError
|
|
140
|
+
? { code: error.code, message: error.message }
|
|
141
|
+
: { code: "MOSTRA_ACTION_THREW", message: (error as Error).message },
|
|
142
|
+
});
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
update(identity, { starting: false, runId: state.runId });
|
|
146
|
+
return server.settled(state.runId);
|
|
147
|
+
})();
|
|
148
|
+
inFlight.current.set(identity, started);
|
|
149
|
+
const settle = (): void => {
|
|
150
|
+
if (inFlight.current.get(identity) === started) inFlight.current.delete(identity);
|
|
151
|
+
};
|
|
152
|
+
started.then(settle, settle);
|
|
153
|
+
return started as Promise<Awaited<R>>;
|
|
154
|
+
},
|
|
155
|
+
[server, fnId, key, identity, update],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const cancel = useCallback(() => {
|
|
159
|
+
if (runId === undefined) return;
|
|
160
|
+
const current = server.run(runId);
|
|
161
|
+
if (current && !isFinalRunStatus(current.status)) void server.cancel(runId).catch(() => {});
|
|
162
|
+
}, [server, runId]);
|
|
163
|
+
|
|
164
|
+
return useMemo(() => {
|
|
165
|
+
const status: ActionHandle<A, Awaited<R>>["status"] = view?.status ?? "idle";
|
|
166
|
+
const error =
|
|
167
|
+
view?.error && (view.status === "failed" || view.status === "cancelled")
|
|
168
|
+
? { code: view.error.code ?? "MOSTRA_ACTION_THREW", message: view.error.message }
|
|
169
|
+
: view?.status === "cancelled"
|
|
170
|
+
? { code: "MOSTRA_ACTION_CANCELLED", message: "The run was cancelled." }
|
|
171
|
+
: startError;
|
|
172
|
+
return {
|
|
173
|
+
run,
|
|
174
|
+
cancel,
|
|
175
|
+
status,
|
|
176
|
+
isPending: starting || status === "queued" || status === "running" || status === "waiting",
|
|
177
|
+
...(view?.status === "succeeded" && view.data !== undefined
|
|
178
|
+
? { data: view.data as Awaited<R> }
|
|
179
|
+
: {}),
|
|
180
|
+
...(error ? { error } : {}),
|
|
181
|
+
...(view?.progress ? { progress: view.progress } : {}),
|
|
182
|
+
log: view?.log ?? NO_LOG,
|
|
183
|
+
partial: (view?.partial ?? []) as ActionHandle<A, Awaited<R>>["partial"],
|
|
184
|
+
...(runId !== undefined ? { runId } : {}),
|
|
185
|
+
};
|
|
186
|
+
}, [run, cancel, view, starting, startError, runId]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── useLive ───────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The live sources of the artifact, by key, with their value types. A generated declaration
|
|
193
|
+
* (`mostra-env.d.ts`, written by `mostra new`) fills it from `live.ts`:
|
|
194
|
+
*
|
|
195
|
+
* ```ts
|
|
196
|
+
* declare module "@mostra/react" {
|
|
197
|
+
* interface LiveSources extends LiveValuesOf<typeof import("./live")> {}
|
|
198
|
+
* }
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
// biome-ignore lint/suspicious/noEmptyInterface: augmented by each artifact's declaration.
|
|
202
|
+
export interface LiveSources {}
|
|
203
|
+
|
|
204
|
+
/** The value types of a `live.ts` module's sources, by export name (D56). */
|
|
205
|
+
export type LiveValuesOf<M> = {
|
|
206
|
+
[K in keyof M as M[K] extends { readonly "~mostra.live": unknown } ? K : never]: M[K] extends {
|
|
207
|
+
readonly __value?: infer T;
|
|
208
|
+
}
|
|
209
|
+
? T
|
|
210
|
+
: never;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** A live source's key: one of `LiveSources`, or any string before the declaration exists. */
|
|
214
|
+
export type LiveKey = [keyof LiveSources] extends [never] ? string : keyof LiveSources & string;
|
|
215
|
+
|
|
216
|
+
/** A live source's value type. */
|
|
217
|
+
export type LiveValue<K extends string> = K extends keyof LiveSources ? LiveSources[K] : unknown;
|
|
218
|
+
|
|
219
|
+
/** What `useLive` shows (INTERFACES §2.3). */
|
|
220
|
+
export interface LiveResult<T> {
|
|
221
|
+
/** The latest value; in append mode, the values kept, oldest first. */
|
|
222
|
+
readonly data: T | undefined;
|
|
223
|
+
/** Whether no value has arrived yet. */
|
|
224
|
+
readonly isLoading: boolean;
|
|
225
|
+
/** The producer's last failure; the last good value stays in `data` (FAILURE_MODEL §6). */
|
|
226
|
+
readonly error?: Error;
|
|
227
|
+
/** Asks the producer to load again now. */
|
|
228
|
+
refresh(): void;
|
|
229
|
+
/** The value's revision, which only grows. */
|
|
230
|
+
readonly revision?: string;
|
|
231
|
+
/** When the value was produced. */
|
|
232
|
+
readonly at?: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** How long append mode keeps values without a `window`. */
|
|
236
|
+
const DEFAULT_WINDOW = 1024;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Reads a live source of `live.ts`, or one an agent or host pushes to (D56): the latest value, or
|
|
240
|
+
* in append mode the recent values for a high-rate chart. The value is shared by every component
|
|
241
|
+
* that shows it and updates without a reload; nothing of it is a durable event (D63).
|
|
242
|
+
*
|
|
243
|
+
* ```tsx
|
|
244
|
+
* const { data, isLoading, error } = useLive("inbox");
|
|
245
|
+
* const { data: points } = useLive("cpu", { mode: "append", window: 600 });
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
export function useLive<K extends LiveKey>(
|
|
249
|
+
key: K,
|
|
250
|
+
options: { readonly mode: "append"; readonly window?: number },
|
|
251
|
+
): LiveResult<LiveValue<K>[]>;
|
|
252
|
+
/** Reads a live source's latest value (D56). See the first signature. */
|
|
253
|
+
export function useLive<K extends LiveKey>(
|
|
254
|
+
key: K,
|
|
255
|
+
options?: { readonly mode?: "latest"; readonly window?: number },
|
|
256
|
+
): LiveResult<LiveValue<K>>;
|
|
257
|
+
export function useLive<K extends LiveKey>(
|
|
258
|
+
key: K,
|
|
259
|
+
options?: { readonly mode?: "latest" | "append"; readonly window?: number },
|
|
260
|
+
): LiveResult<unknown> {
|
|
261
|
+
const store = useStore();
|
|
262
|
+
const server = store.server;
|
|
263
|
+
const append = options?.mode === "append";
|
|
264
|
+
const window = append ? (options?.window ?? DEFAULT_WINDOW) : undefined;
|
|
265
|
+
useSyncExternalStore(server.subscribe, server.version, server.version);
|
|
266
|
+
useEffect(() => server.watchLive(key, window), [server, key, window]);
|
|
267
|
+
const view = server.liveView(key);
|
|
268
|
+
const refresh = useCallback(() => server.refreshLive(key), [server, key]);
|
|
269
|
+
return useMemo(() => {
|
|
270
|
+
const data = append
|
|
271
|
+
? view?.ring.length
|
|
272
|
+
? view.ring.slice(-(window ?? 0))
|
|
273
|
+
: undefined
|
|
274
|
+
: view?.data;
|
|
275
|
+
return {
|
|
276
|
+
data,
|
|
277
|
+
isLoading: data === undefined && (view?.loading ?? true),
|
|
278
|
+
...(view?.error
|
|
279
|
+
? {
|
|
280
|
+
error: new MostraError(
|
|
281
|
+
view.error.code ?? "MOSTRA_LIVE_SOURCE_FAILED",
|
|
282
|
+
view.error.message,
|
|
283
|
+
),
|
|
284
|
+
}
|
|
285
|
+
: {}),
|
|
286
|
+
refresh,
|
|
287
|
+
...(view?.revision !== undefined ? { revision: view.revision } : {}),
|
|
288
|
+
...(view?.at !== undefined ? { at: view.at } : {}),
|
|
289
|
+
};
|
|
290
|
+
}, [view, append, window, refresh]);
|
|
291
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The client side of `"use server"` functions (D19 seam 2, D54). The builder compiles every export
|
|
5
|
+
* of a `"use server"` module in client code into a server reference: a function that carries the
|
|
6
|
+
* server function's ID and hands the call to the page's runtime, as React's own server references
|
|
7
|
+
* hand theirs to the callback `setServerCallback` installs. The runtime calls Mostra (a plain
|
|
8
|
+
* call: no record, the result streamed back); where there is no Mostra server, such as a static
|
|
9
|
+
* export, the call rejects with `MOSTRA_TARGET_UNAVAILABLE`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A `"use server"` function as client code sees it. */
|
|
13
|
+
export interface ServerReference<A extends unknown[] = unknown[], R = unknown> {
|
|
14
|
+
/** Calls the server function with `args`; resolves with its result. */
|
|
15
|
+
(...args: A): Promise<R>;
|
|
16
|
+
/** The server function's ID: its module path, `#` and its export name (`actions.ts#sendReply`). */
|
|
17
|
+
readonly mostraServerFunctionId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** How the page's runtime calls a server function. */
|
|
21
|
+
export type CallServer = (id: string, args: unknown[]) => Promise<unknown>;
|
|
22
|
+
|
|
23
|
+
const unavailable: CallServer = async (id) => {
|
|
24
|
+
const error = new Error(
|
|
25
|
+
`The server function ${id} can't run here: this delivery target has no Mostra server. Open the artifact through its Mostra link to use it.`,
|
|
26
|
+
) as Error & { code: string };
|
|
27
|
+
error.code = "MOSTRA_TARGET_UNAVAILABLE";
|
|
28
|
+
throw error;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
let callServer: CallServer = unavailable;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Installs how server references call the server: the runtime does it when the page starts (a
|
|
35
|
+
* live page calls Mostra; a static export keeps the default, which rejects). Returns the function
|
|
36
|
+
* that restores the previous one.
|
|
37
|
+
*/
|
|
38
|
+
export function setServerCallback(callback: CallServer): () => void {
|
|
39
|
+
const previous = callServer;
|
|
40
|
+
callServer = callback;
|
|
41
|
+
return () => {
|
|
42
|
+
callServer = previous;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates the client stub of one server function. The builder emits these calls; artifact code
|
|
48
|
+
* imports the server module as usual and never calls this itself.
|
|
49
|
+
*/
|
|
50
|
+
export function createServerReference(id: string): ServerReference {
|
|
51
|
+
const reference = (...args: unknown[]): Promise<unknown> => callServer(id, args);
|
|
52
|
+
Object.defineProperty(reference, "mostraServerFunctionId", { value: id, enumerable: true });
|
|
53
|
+
return reference as unknown as ServerReference;
|
|
54
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The artifact's end of the shell bridge (INTERFACES §5, SH-04). The client runtime starts it:
|
|
5
|
+
* when the page runs inside Mostra's shell, it learns the shell's exact origin and its link's
|
|
6
|
+
* scopes from its own session (`GET /_mostra/session`, readable only by this origin), introduces
|
|
7
|
+
* itself with MCP Apps' `ui/initialize`, whose answer names the view this frame load is (D104),
|
|
8
|
+
* and reports `mostra/ready` once the page has rendered. Messages from any other window or origin
|
|
9
|
+
* are dropped. Outside a shell (a static export, the page opened on its own) it does nothing.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
type AnyBridgeMessage,
|
|
13
|
+
type Bridge,
|
|
14
|
+
connectBridge,
|
|
15
|
+
MCP_APPS_PROTOCOL_VERSION,
|
|
16
|
+
mostraReady,
|
|
17
|
+
OPEN_LINK_SCHEMES,
|
|
18
|
+
type UiInitializeResult,
|
|
19
|
+
uiInitialize,
|
|
20
|
+
uiInitialized,
|
|
21
|
+
uiOpenLink,
|
|
22
|
+
type Viewer,
|
|
23
|
+
} from "@mostra/protocol";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The version this page's build belongs to: its `<meta name="mostra-version">`, which Mostra's
|
|
27
|
+
* builder writes (WP-07). `undefined` for a build that has none. @internal
|
|
28
|
+
*/
|
|
29
|
+
export function builtVersion(): string | undefined {
|
|
30
|
+
if (typeof document === "undefined") return undefined;
|
|
31
|
+
const content = document.querySelector('meta[name="mostra-version"]')?.getAttribute("content");
|
|
32
|
+
return content ? content : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `ui/initialize`'s `mostra` params: the page's version, when the build names it. */
|
|
36
|
+
function versionParams(): { mostra?: { versionId: string } } {
|
|
37
|
+
const versionId = builtVersion();
|
|
38
|
+
return versionId === undefined ? {} : { mostra: { versionId } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The shell connection of this page, once it is established. */
|
|
42
|
+
export interface ShellConnection {
|
|
43
|
+
/** The shell's origin. */
|
|
44
|
+
readonly shellOrigin: string;
|
|
45
|
+
/** What the shell said about where the page is shown, and the view (`host.mostra`). */
|
|
46
|
+
readonly host: UiInitializeResult;
|
|
47
|
+
/** The bridge, for the client runtime's messages. */
|
|
48
|
+
readonly bridge: Bridge;
|
|
49
|
+
/**
|
|
50
|
+
* The frame session's facts: its CSRF token, the link's scopes (D97, D98) and, from gateways
|
|
51
|
+
* since WP-07, the viewer it acts as.
|
|
52
|
+
*/
|
|
53
|
+
readonly session: {
|
|
54
|
+
readonly csrf: string;
|
|
55
|
+
readonly scopes: readonly string[];
|
|
56
|
+
readonly viewer?: Viewer;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface FrameSession {
|
|
61
|
+
readonly shellOrigin?: unknown;
|
|
62
|
+
readonly csrf?: unknown;
|
|
63
|
+
readonly scopes?: unknown;
|
|
64
|
+
readonly viewer?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Hears what the shell sends: requests answered by the first handler that returns a value. */
|
|
68
|
+
export interface ShellListener {
|
|
69
|
+
readonly request?: (message: AnyBridgeMessage, params: unknown) => unknown;
|
|
70
|
+
readonly notification?: (message: AnyBridgeMessage, params: unknown) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const listeners = new Set<ShellListener>();
|
|
74
|
+
let connecting: Promise<ShellConnection | undefined> | undefined;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Listens to the shell's requests and notifications on this page's bridge; returns the function
|
|
78
|
+
* that stops. The client runtime answers `mostra/flush` and hears `mostra/version.available`.
|
|
79
|
+
*/
|
|
80
|
+
export function listenShell(listener: ShellListener): () => void {
|
|
81
|
+
listeners.add(listener);
|
|
82
|
+
return () => listeners.delete(listener);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Connects this page to the shell that frames it, once; later calls return the same connection.
|
|
87
|
+
* Resolves `undefined` when the page isn't framed by a Mostra shell. The client runtime calls it;
|
|
88
|
+
* artifact code doesn't need to.
|
|
89
|
+
*/
|
|
90
|
+
export function connectShell(): Promise<ShellConnection | undefined> {
|
|
91
|
+
connecting ??= (async () => {
|
|
92
|
+
if (typeof window === "undefined" || window.parent === window) return undefined;
|
|
93
|
+
let session: FrameSession;
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch("/_mostra/session", {
|
|
96
|
+
credentials: "same-origin",
|
|
97
|
+
cache: "no-store",
|
|
98
|
+
signal: AbortSignal.timeout(HANDSHAKE_TIMEOUT_MS),
|
|
99
|
+
});
|
|
100
|
+
if (!response.ok) return undefined;
|
|
101
|
+
session = (await response.json()) as FrameSession;
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
if (typeof session.shellOrigin !== "string") return undefined;
|
|
106
|
+
const shellOrigin = session.shellOrigin;
|
|
107
|
+
const bridge = connectBridge({
|
|
108
|
+
side: "artifact",
|
|
109
|
+
receiver: window,
|
|
110
|
+
peer: () => window.parent,
|
|
111
|
+
peerOrigin: shellOrigin,
|
|
112
|
+
onRequest: async (message, params) => {
|
|
113
|
+
for (const listener of listeners) {
|
|
114
|
+
const answer = await listener.request?.(message, params);
|
|
115
|
+
if (answer !== undefined) return answer;
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`The page doesn't answer ${message.method}.`);
|
|
118
|
+
},
|
|
119
|
+
onNotification: (message, params) => {
|
|
120
|
+
for (const listener of listeners) listener.notification?.(message, params);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
let host: UiInitializeResult;
|
|
124
|
+
try {
|
|
125
|
+
host = await bridge.call(
|
|
126
|
+
uiInitialize,
|
|
127
|
+
{
|
|
128
|
+
appInfo: { name: "mostra-artifact", version: "0" },
|
|
129
|
+
appCapabilities: {},
|
|
130
|
+
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
|
|
131
|
+
...versionParams(),
|
|
132
|
+
},
|
|
133
|
+
{ timeoutMs: HANDSHAKE_TIMEOUT_MS },
|
|
134
|
+
);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
bridge.close();
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
bridge.notify(uiInitialized, {});
|
|
140
|
+
return {
|
|
141
|
+
shellOrigin,
|
|
142
|
+
host,
|
|
143
|
+
bridge,
|
|
144
|
+
session: {
|
|
145
|
+
csrf: typeof session.csrf === "string" ? session.csrf : "",
|
|
146
|
+
scopes: Array.isArray(session.scopes)
|
|
147
|
+
? session.scopes.filter((scope): scope is string => typeof scope === "string")
|
|
148
|
+
: [],
|
|
149
|
+
...(isViewer(session.viewer) ? { viewer: session.viewer } : {}),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
})().then(
|
|
153
|
+
(connection) => {
|
|
154
|
+
// A framed page that didn't connect may try again (the daemon restarting during the
|
|
155
|
+
// frame's load, a slow handshake): only a connection is kept (Claude review finding 4).
|
|
156
|
+
if (connection === undefined) connecting = undefined;
|
|
157
|
+
return connection;
|
|
158
|
+
},
|
|
159
|
+
() => {
|
|
160
|
+
connecting = undefined;
|
|
161
|
+
return undefined;
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
return connecting;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether the session's `viewer` looks like a viewer (a light check: the gateway sends it). */
|
|
168
|
+
function isViewer(value: unknown): value is Viewer {
|
|
169
|
+
const viewer = value as { id?: unknown; kind?: unknown } | null | undefined;
|
|
170
|
+
return typeof viewer?.id === "string" && typeof viewer.kind === "string";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** How long one attempt at the shell's handshake may take. */
|
|
174
|
+
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Tells the shell the page has rendered (`mostra/ready`), after the next frame paints. The
|
|
178
|
+
* client runtime calls it right after mounting the page.
|
|
179
|
+
*/
|
|
180
|
+
export function reportReady(): void {
|
|
181
|
+
void connectShell().then((connection) => {
|
|
182
|
+
if (!connection) return;
|
|
183
|
+
requestAnimationFrame(() => connection.bridge.notify(mostraReady, {}));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Opens `url` for the viewer in a new tab. Inside Mostra's shell the frame can't navigate the
|
|
189
|
+
* viewer's tab (D98 item 7), so the shell opens it (`ui/open-link`, MCP Apps); on a page of its
|
|
190
|
+
* own, the browser does. Only `http:`, `https:`, `mailto:` and `tel:` URLs open. Resolves `true`
|
|
191
|
+
* when the URL was handed to the browser.
|
|
192
|
+
*/
|
|
193
|
+
export async function openLink(url: string): Promise<boolean> {
|
|
194
|
+
const connection = await connectShell();
|
|
195
|
+
if (connection) {
|
|
196
|
+
try {
|
|
197
|
+
const result = await connection.bridge.call(uiOpenLink, { url });
|
|
198
|
+
return result.isError !== true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
let parsed: URL;
|
|
204
|
+
try {
|
|
205
|
+
parsed = new URL(url, window.location.href);
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (!OPEN_LINK_SCHEMES.includes(parsed.protocol)) return false;
|
|
210
|
+
window.open(parsed.href, "_blank", "noopener,noreferrer");
|
|
211
|
+
return true;
|
|
212
|
+
}
|