zitejs 0.10.0-start.10 → 0.10.0-start.13

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,417 @@
1
+ /**
2
+ * Every fetch an app makes on the server, wrapped as classic's runtime wraps
3
+ * it: logged, every secret it knows redacted, with the connection whose token
4
+ * it carries and what an org's own AI key used; a 401 on a connection's token
5
+ * refreshed and sent once more; and a call on Zite's own AI key sent to the
6
+ * host over the app's platform binding.
7
+ */
8
+ import { AI_USAGE_HOSTS, meterAiResponse, withStreamUsage } from "./aiUsage.js";
9
+ import { sessionCredentials } from "./credentials.js";
10
+ import { REDACTED, redactKnown, secretForms } from "./redactSecrets.js";
11
+ import { getHost } from "./scope.js";
12
+ const MAX_FETCHES = 500;
13
+ // Shorter values (a region, a flag) aren't tokens, and could match by chance.
14
+ const MIN_TOKEN_LENGTH = 8;
15
+ // As classic: Workers send no User-Agent, and some APIs (GitHub) refuse a request without one.
16
+ const DEFAULT_USER_AGENT = "zite";
17
+ // The backend's stand-in for Zite's own AI key, which only the host answers.
18
+ const AI_MARKER = /^zite_ai_gateway:[a-z]+:(.+)$/;
19
+ // Where a provider SDK given the connection's `baseUrl` sends it; it never resolves.
20
+ const AI_HOST = "zite-ai.invalid";
21
+ // A provider's own base URL at the host: `/v1` is OpenAI's, Anthropic's SDK sends it in the path.
22
+ const AI_PROVIDER_HOSTS = {
23
+ "api.openai.com": { provider: "openai", strip: "/v1" },
24
+ "api.anthropic.com": { provider: "anthropic", strip: "" },
25
+ };
26
+ // As classic's run log: a body up to 50 KB, a request's fetch bodies up to 200 KB in all.
27
+ const MAX_BODY_CHARS = 50 * 1024;
28
+ const MAX_LOGGED_CHARS = 200 * 1024;
29
+ const MAX_URL_CHARS = 2_000;
30
+ const MAX_ERROR_CHARS = 1_000;
31
+ const SECRET_HEADER = /auth|key|token|secret|signature|password|cookie|session/i;
32
+ // Usage still being read when the request's log is flushed isn't waited on for longer.
33
+ const USAGE_WAIT_MS = 10_000;
34
+ // Tokens refreshed in this isolate, which a later request's props may not name yet.
35
+ const MAX_REFRESHED = 50;
36
+ const refreshedTokens = new Map();
37
+ const replacements = new Map();
38
+ // Moves with each refresh, so a request's list of secrets takes in a token another one refreshed.
39
+ let refreshes = 0;
40
+ const remember = (map, key, value) => {
41
+ map.delete(key);
42
+ map.set(key, value);
43
+ if (map.size > MAX_REFRESHED)
44
+ map.delete(map.keys().next().value);
45
+ };
46
+ const tokensOf = (host) => {
47
+ if (host.connectionTokens)
48
+ return host.connectionTokens;
49
+ const tokens = new Map(refreshedTokens);
50
+ for (const [key, name] of Object.entries(host.envConnections ?? {})) {
51
+ const value = host.env?.[key];
52
+ if (value && value.length >= MIN_TOKEN_LENGTH)
53
+ tokens.set(value, name);
54
+ }
55
+ host.connectionTokens = tokens;
56
+ return tokens;
57
+ };
58
+ /** An `integrations.get` answer, whose values name the calls that carry them. */
59
+ export const rememberConnection = (host, name, connection) => {
60
+ (host.connectionAnswers ??= new Map()).set(name, connection);
61
+ host.secretValues = undefined;
62
+ const tokens = tokensOf(host);
63
+ for (const [field, value] of Object.entries((connection ?? {}))) {
64
+ if (field !== "status" && field !== "baseUrl" && typeof value === "string" && value.length >= MIN_TOKEN_LENGTH) {
65
+ tokens.set(value, name);
66
+ }
67
+ }
68
+ };
69
+ const bare = (value) => value.replace(/^(?:Bearer|Token)\s+/i, "");
70
+ const isUrl = (value) => /^https?:\/\//i.test(value);
71
+ /** Every value this request knows is secret, longest first. */
72
+ const secretsOf = (host) => {
73
+ if (host.secretValues && host.secretsAt === refreshes)
74
+ return host.secretValues;
75
+ const values = new Set([...refreshedTokens.keys(), ...replacements.values()]);
76
+ // A connection's own URL (an instance, an account) says which call failed; a user's secret may be a URL.
77
+ const add = (value, urls) => {
78
+ if (typeof value !== "string" || value.length < MIN_TOKEN_LENGTH || AI_MARKER.test(value))
79
+ return;
80
+ if (urls || !isUrl(value))
81
+ values.add(value);
82
+ };
83
+ for (const answer of host.connectionAnswers?.values() ?? []) {
84
+ for (const [field, value] of Object.entries((answer ?? {}))) {
85
+ if (field !== "status" && field !== "baseUrl")
86
+ add(value, false);
87
+ }
88
+ }
89
+ for (const [key, value] of Object.entries(host.env ?? {}))
90
+ add(value, !host.envConnections?.[key]);
91
+ host.secretValues = [...values].sort((a, b) => b.length - a.length);
92
+ host.secretsAt = refreshes;
93
+ return host.secretValues;
94
+ };
95
+ /** The values a request's own credential headers carry, which the log redacts too. */
96
+ const headerSecrets = (headers) => [...headers].flatMap(([name, value]) => {
97
+ const secret = bare(value);
98
+ return SECRET_HEADER.test(name) && secret.length >= MIN_TOKEN_LENGTH && !AI_MARKER.test(secret) ? [secret] : [];
99
+ });
100
+ /** `text` cut to `max`, every secret in it redacted, none cut in half first, however it was written. */
101
+ const loggable = (text, secrets, max) => {
102
+ const reach = secrets.reduce((longest, secret) => Math.max(longest, secretForms(secret)[0].length), 0);
103
+ return redactKnown(text.slice(0, max + reach), secrets).slice(0, max);
104
+ };
105
+ const decodings = (raw) => {
106
+ const decoded = [raw];
107
+ for (const text of [raw, raw.replace(/\+/g, " ")]) {
108
+ try {
109
+ decoded.push(decodeURIComponent(text));
110
+ }
111
+ catch {
112
+ // Not encoded as a whole; matched as written.
113
+ }
114
+ }
115
+ return decoded;
116
+ };
117
+ /** A URL with each path segment or query value that holds a secret, however it was encoded, redacted whole. */
118
+ const redactedUrl = (url, secrets) => {
119
+ const holds = (raw) => decodings(raw).some((text) => secrets.some((secret) => text.includes(secret)));
120
+ const path = url.pathname
121
+ .split("/")
122
+ .map((segment) => (holds(segment) ? REDACTED : segment))
123
+ .join("/");
124
+ const query = url.search
125
+ ? `?${url.search
126
+ .slice(1)
127
+ .split("&")
128
+ .map((pair) => {
129
+ const at = pair.indexOf("=");
130
+ if (at < 0)
131
+ return holds(pair) ? REDACTED : pair;
132
+ return holds(pair.slice(at + 1)) ? `${pair.slice(0, at)}=${REDACTED}` : pair;
133
+ })
134
+ .join("&")}`
135
+ : "";
136
+ return `${url.protocol}//${url.host}${path}${query}`;
137
+ };
138
+ /** A request body as classic's run log shows it. */
139
+ const bodyText = (body) => {
140
+ if (body === undefined || body === null)
141
+ return undefined;
142
+ if (typeof body === "string")
143
+ return body;
144
+ if (body instanceof URLSearchParams)
145
+ return body.toString();
146
+ if (body instanceof ReadableStream)
147
+ return "[stream]";
148
+ if (body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body))
149
+ return "[binary data]";
150
+ if (typeof FormData !== "undefined" && body instanceof FormData)
151
+ return "[binary data]";
152
+ try {
153
+ return JSON.stringify(body);
154
+ }
155
+ catch {
156
+ return "[non-serializable body]";
157
+ }
158
+ };
159
+ const settledWithin = (promise, ms) => new Promise((resolve) => {
160
+ const timer = setTimeout(resolve, ms);
161
+ promise.then(() => {
162
+ clearTimeout(timer);
163
+ resolve();
164
+ }, () => {
165
+ clearTimeout(timer);
166
+ resolve();
167
+ });
168
+ });
169
+ /** The connection token a request carries, in a header or its query. */
170
+ const carried = (headers, url, tokens) => {
171
+ for (const [, value] of headers) {
172
+ const connection = tokens.get(bare(value));
173
+ if (connection)
174
+ return { token: bare(value), connection };
175
+ }
176
+ for (const [, value] of url.searchParams) {
177
+ const connection = tokens.get(value);
178
+ if (connection)
179
+ return { token: value, connection };
180
+ }
181
+ return undefined;
182
+ };
183
+ /** `url` with each query value equal to `from` set to `to`, every other pair left as it was written. */
184
+ const withQueryValue = (url, from, to) => {
185
+ if (!url.search)
186
+ return url;
187
+ const pairs = url.search
188
+ .slice(1)
189
+ .split("&")
190
+ .map((pair) => {
191
+ const at = pair.indexOf("=");
192
+ if (at < 0)
193
+ return pair;
194
+ let value;
195
+ try {
196
+ value = decodeURIComponent(pair.slice(at + 1).replace(/\+/g, " "));
197
+ }
198
+ catch {
199
+ return pair;
200
+ }
201
+ return value === from ? `${pair.slice(0, at)}=${encodeURIComponent(to)}` : pair;
202
+ });
203
+ const swapped = new URL(url);
204
+ swapped.search = `?${pairs.join("&")}`;
205
+ return swapped;
206
+ };
207
+ const swapHeaders = (headers, from, to) => {
208
+ for (const [name, value] of [...headers]) {
209
+ if (value.includes(from))
210
+ headers.set(name, value.split(from).join(to));
211
+ }
212
+ };
213
+ /** Where a call on Zite's AI key goes at the host, whether the app named the provider or the `baseUrl` it was given. */
214
+ const aiTarget = (url, headers) => {
215
+ const marker = [...headers].map(([, value]) => AI_MARKER.exec(bare(value))).find(Boolean);
216
+ if (url.hostname === AI_HOST)
217
+ return { target: url, connection: marker?.[1] };
218
+ const route = AI_PROVIDER_HOSTS[url.hostname];
219
+ if (!route || !marker)
220
+ return undefined;
221
+ const path = url.pathname.startsWith(route.strip) ? url.pathname.slice(route.strip.length) : url.pathname;
222
+ return { target: new URL(`https://${AI_HOST}/${route.provider}${path}${url.search}`), connection: marker[1] };
223
+ };
224
+ const sessionOf = async (host) => {
225
+ if (!host.platform)
226
+ return undefined;
227
+ host.session ??= Promise.resolve(host.watch ? host.platform.session(host.watch) : host.platform.session());
228
+ return host.session;
229
+ };
230
+ const credentialsOf = async (host) => {
231
+ if (host.credentials)
232
+ return host.credentials;
233
+ const session = await sessionOf(host);
234
+ return session && sessionCredentials(session, "anyone");
235
+ };
236
+ /** The token to send instead of `token`, refreshed once per connection per request. */
237
+ const freshToken = async (host, token, connection) => {
238
+ const known = replacements.get(token);
239
+ if (known)
240
+ return known;
241
+ const refreshing = (host.refreshing ??= new Map());
242
+ let pending = refreshing.get(connection);
243
+ if (!pending) {
244
+ pending = (async () => {
245
+ const source = await credentialsOf(host);
246
+ const result = source?.refresh ? await source.refresh(connection) : undefined;
247
+ if (!result || !("value" in result))
248
+ return undefined;
249
+ const fresh = result.value;
250
+ const previous = (host.connectionAnswers?.get(connection) ?? {});
251
+ const field = Object.keys(previous).find((key) => previous[key] === token) ?? "accessToken";
252
+ const value = fresh[field];
253
+ rememberConnection(host, connection, fresh);
254
+ return typeof value === "string" ? value : undefined;
255
+ })();
256
+ refreshing.set(connection, pending);
257
+ }
258
+ const value = await pending;
259
+ if (!value || value === token)
260
+ return undefined;
261
+ remember(replacements, token, value);
262
+ remember(refreshedTokens, value, connection);
263
+ refreshes += 1;
264
+ // As classic: the env vars that held the old token hold the new one.
265
+ const env = globalThis.process?.env;
266
+ for (const [key, held] of Object.entries(env ?? {})) {
267
+ if (held === token)
268
+ env[key] = value;
269
+ }
270
+ return value;
271
+ };
272
+ const hostOf = (input) => {
273
+ try {
274
+ return new URL(input instanceof Request ? input.url : input).host;
275
+ }
276
+ catch {
277
+ return "?";
278
+ }
279
+ };
280
+ const aiUnavailable = () => Response.json({ error: { type: "unavailable", message: "Zite's AI key works only in an app Zite hosts" } }, { status: 503 });
281
+ const cfOf = (request) => request.cf;
282
+ export const createOutboundFetch = (nativeFetch) => async (input, init) => {
283
+ const host = getHost();
284
+ if (!host)
285
+ return nativeFetch(input, init);
286
+ // A page that fetched depends on more than the reads the host saw, so the
287
+ // host must hear of it before the fetch goes out.
288
+ if (host.watch)
289
+ await host.watch.uncacheable(`fetch ${hostOf(input)}`);
290
+ let url;
291
+ try {
292
+ url = new URL(input instanceof Request ? input.url : input);
293
+ }
294
+ catch {
295
+ return nativeFetch(input, init);
296
+ }
297
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
298
+ if (!headers.has("user-agent"))
299
+ headers.set("user-agent", DEFAULT_USER_AGENT);
300
+ const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
301
+ const ai = aiTarget(url, headers);
302
+ const found = ai ? undefined : carried(headers, url, tokensOf(host));
303
+ const startedAt = Date.now();
304
+ const sentBody = bodyText(init?.body);
305
+ const sentHeaderSecrets = headerSecrets(headers);
306
+ const record = (outcome) => {
307
+ const fetches = (host.fetches ??= []);
308
+ if (fetches.length >= MAX_FETCHES)
309
+ return undefined;
310
+ const connection = ai?.connection ?? found?.connection;
311
+ const secrets = [...sentHeaderSecrets, ...secretsOf(host)].sort((a, b) => b.length - a.length);
312
+ const bodyRoom = Math.min(MAX_BODY_CHARS, MAX_LOGGED_CHARS - (host.fetchLogChars ?? 0));
313
+ const requestBody = sentBody !== undefined && bodyRoom > 0 ? loggable(sentBody, secrets, bodyRoom) : undefined;
314
+ if (requestBody)
315
+ host.fetchLogChars = (host.fetchLogChars ?? 0) + requestBody.length;
316
+ const entry = {
317
+ at: startedAt,
318
+ method,
319
+ url: loggable(redactedUrl(url, secrets), secrets, MAX_URL_CHARS),
320
+ durationMs: Date.now() - startedAt,
321
+ ...(requestBody !== undefined && { requestBody }),
322
+ ...(outcome.status !== undefined && { status: outcome.status }),
323
+ ...(outcome.error !== undefined && {
324
+ error: loggable(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), secrets, MAX_ERROR_CHARS),
325
+ }),
326
+ ...(connection && { connection }),
327
+ ...(ai && { ziteAi: true }),
328
+ ...(outcome.retried && { retried: true }),
329
+ };
330
+ fetches.push(entry);
331
+ return entry;
332
+ };
333
+ if (!ai && !found) {
334
+ try {
335
+ // The app's own call, `cf` and all; only the headers are ours.
336
+ const response = await nativeFetch(input, { ...init, headers });
337
+ record({ status: response.status });
338
+ return response;
339
+ }
340
+ catch (error) {
341
+ record({ error });
342
+ throw error;
343
+ }
344
+ }
345
+ // An org's own AI key, which classic reads usage from for analytics.
346
+ const usageProvider = found ? AI_USAGE_HOSTS[url.hostname] : undefined;
347
+ const usageBody = usageProvider ? withStreamUsage(url, method, init?.body) : undefined;
348
+ if (usageBody !== undefined)
349
+ headers.delete("content-length");
350
+ const base = new Request(input, usageBody === undefined ? init : { ...init, body: usageBody });
351
+ if (ai) {
352
+ try {
353
+ const session = await sessionOf(host);
354
+ if (!session?.aiFetch) {
355
+ const refused = aiUnavailable();
356
+ record({ status: refused.status });
357
+ return refused;
358
+ }
359
+ // Whole, so it crosses RPC as bytes whatever stream the app built it from.
360
+ const body = base.body ? await base.arrayBuffer() : undefined;
361
+ const response = await session.aiFetch(new Request(ai.target, { method: base.method, headers, body, redirect: "manual" }));
362
+ record({ status: response.status });
363
+ return response;
364
+ }
365
+ catch (error) {
366
+ record({ error });
367
+ throw error;
368
+ }
369
+ }
370
+ // As classic: Node SDKs ask for gzip and inflate it themselves, which fails on the body Workers already inflated.
371
+ if (found)
372
+ headers.delete("accept-encoding");
373
+ const outgoing = new Request(base, { headers });
374
+ const retry = found ? outgoing.clone() : undefined;
375
+ try {
376
+ let response = await nativeFetch(outgoing);
377
+ let retried = false;
378
+ if (response.status === 401 && retry && found) {
379
+ const fresh = await freshToken(host, found.token, found.connection);
380
+ if (fresh) {
381
+ const retryHeaders = new Headers(retry.headers);
382
+ swapHeaders(retryHeaders, found.token, fresh);
383
+ await response.body?.cancel();
384
+ response = await nativeFetch(new Request(withQueryValue(url, found.token, fresh), {
385
+ method: retry.method,
386
+ headers: retryHeaders,
387
+ body: retry.body,
388
+ redirect: retry.redirect,
389
+ signal: retry.signal,
390
+ ...(retry.body && { duplex: "half" }),
391
+ ...(cfOf(retry) !== undefined && { cf: cfOf(retry) }),
392
+ }));
393
+ retried = true;
394
+ }
395
+ }
396
+ if (!retried)
397
+ await retry?.body?.cancel();
398
+ if (usageProvider) {
399
+ const metered = meterAiResponse(usageProvider, response);
400
+ response = metered.response;
401
+ const entry = record({ status: response.status, retried });
402
+ const used = metered.usage.then((usage) => {
403
+ if (usage && entry)
404
+ entry.ai = usage;
405
+ });
406
+ (host.pending ??= []).push(settledWithin(used, USAGE_WAIT_MS));
407
+ return response;
408
+ }
409
+ record({ status: response.status, retried });
410
+ return response;
411
+ }
412
+ catch (error) {
413
+ await retry?.body?.cancel().catch(() => { });
414
+ record({ error });
415
+ throw error;
416
+ }
417
+ };
@@ -0,0 +1,2 @@
1
+ /** Runs `fn` with this request's console dropped; the isolate's other requests log as before. */
2
+ export declare const withoutConsole: <T>(fn: () => T) => T;
@@ -0,0 +1,27 @@
1
+ // Server-only. A trigger registration's console stays out of the platform's
2
+ // logs, as classic's does: the provider can answer it with the signing secret.
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+ const LEVELS = ["debug", "dir", "error", "info", "log", "table", "trace", "warn"];
5
+ // Keyed on globalThis so two copies of zitejs in one bundle wrap console once.
6
+ const stores = globalThis;
7
+ const QUIET_KEY = Symbol.for("zitejs:quiet-console");
8
+ const quietStorage = () => {
9
+ const held = stores[QUIET_KEY];
10
+ if (held)
11
+ return held;
12
+ const storage = new AsyncLocalStorage();
13
+ stores[QUIET_KEY] = storage;
14
+ const methods = console;
15
+ for (const level of LEVELS) {
16
+ const original = methods[level];
17
+ if (typeof original !== "function")
18
+ continue;
19
+ methods[level] = (...args) => {
20
+ if (!storage.getStore())
21
+ original.apply(console, args);
22
+ };
23
+ }
24
+ return storage;
25
+ };
26
+ /** Runs `fn` with this request's console dropped; the isolate's other requests log as before. */
27
+ export const withoutConsole = (fn) => quietStorage().run(true, fn);
@@ -5,3 +5,11 @@ export declare const REDACTED = "REDACTED";
5
5
  * callers that pass clean text see byte-identical output.
6
6
  */
7
7
  export declare function redactSecrets(text: string): string;
8
+ /** The ways a secret is written into text, longest first: as it is, URL-encoded in either case, form-encoded, JSON-escaped. */
9
+ export declare function secretForms(value: string): string[];
10
+ /**
11
+ * `text` with each of `values`, however it was written, replaced, then
12
+ * anything the patterns above find. `values` longest first, so none is left
13
+ * half-done.
14
+ */
15
+ export declare function redactKnown(text: string, values: readonly string[]): string;
@@ -129,3 +129,26 @@ export function redactSecrets(text) {
129
129
  }
130
130
  return result;
131
131
  }
132
+ /** The ways a secret is written into text, longest first: as it is, URL-encoded in either case, form-encoded, JSON-escaped. */
133
+ export function secretForms(value) {
134
+ const lower = (text) => text.replace(/%[0-9A-F]{2}/g, (hex) => hex.toLowerCase());
135
+ const encoded = encodeURIComponent(value);
136
+ const form = new URLSearchParams({ v: value }).toString().slice(2);
137
+ const json = JSON.stringify(value).slice(1, -1);
138
+ return [...new Set([value, encoded, lower(encoded), form, lower(form), json])].sort((a, b) => b.length - a.length);
139
+ }
140
+ /**
141
+ * `text` with each of `values`, however it was written, replaced, then
142
+ * anything the patterns above find. `values` longest first, so none is left
143
+ * half-done.
144
+ */
145
+ export function redactKnown(text, values) {
146
+ let result = text;
147
+ for (const value of values) {
148
+ for (const form of secretForms(value)) {
149
+ if (result.includes(form))
150
+ result = result.split(form).join(REDACTED);
151
+ }
152
+ }
153
+ return redactSecrets(result);
154
+ }
@@ -11,6 +11,11 @@ type Dehydrated = {
11
11
  user: User | null;
12
12
  };
13
13
  type ZiteRouterOptions<TRouteTree extends AnyRoute> = Omit<RouterConstructorOptions<TRouteTree, "never", false, RouterHistory, Dehydrated>, "context" | "dehydrate" | "hydrate">;
14
+ /**
15
+ * @internal Tells the editor's preview the page crashed, as classic's runtime reports an emptied #root: a Start page
16
+ * has none, and an error a route's boundary catches is the crash. Being signed out isn't one.
17
+ */
18
+ export declare const reportCrash: (error: unknown) => void;
14
19
  /**
15
20
  * The router for `src/router.tsx`. The signed-in user is in `context.user`: on the server from the platform, in the
16
21
  * browser from the page it rendered, so no request asks for it again. `context.queryClient` is new for each request
@@ -9,12 +9,21 @@ import { currentHost } from "./host.js";
9
9
  export const QUERY_STALE_TIME_MS = 30_000;
10
10
  const ziteWindow = () => (typeof window === "undefined" ? undefined : window);
11
11
  const box = { maxWidth: 480, margin: "15vh auto", padding: 24, fontFamily: "system-ui, sans-serif", textAlign: "center" };
12
+ /**
13
+ * @internal Tells the editor's preview the page crashed, as classic's runtime reports an emptied #root: a Start page
14
+ * has none, and an error a route's boundary catches is the crash. Being signed out isn't one.
15
+ */
16
+ export const reportCrash = (error) => {
17
+ if (!isSignInError(error))
18
+ ziteWindow()?._ziteOnCrash?.(error);
19
+ };
12
20
  // As classic's crash screen: the message, "Fix it for me" in the editor, and a reload. The server renders from the
13
21
  // app's own error and the browser from the one that crossed, already mapped, so both show the same text.
14
22
  function ZiteErrorComponent({ error }) {
15
23
  const router = useRouter();
16
24
  const [allowAiFix, setAllowAiFix] = useState(false);
17
25
  useEffect(() => setAllowAiFix(ziteWindow()?.__ziteAllowAiFix === true), []);
26
+ useEffect(() => markReady("error"), []);
18
27
  const message = typeof window === "undefined" ? toClientError(error).message : error instanceof Error ? error.message : String(error);
19
28
  const reload = () => {
20
29
  const onReload = ziteWindow()?._ziteOnReload;
@@ -27,6 +36,20 @@ function ZiteErrorComponent({ error }) {
27
36
  !isSignInError(error) &&
28
37
  h("button", { type: "button", onClick: () => ziteWindow()?._ziteOnFixError?.(error) }, "Fix it for me"), h("button", { type: "button", onClick: reload }, "Try again"));
29
38
  }
39
+ /**
40
+ * `<html data-zite-ready>`: what the platform's screenshots wait for. `1` once
41
+ * the page has rendered with nothing loading, `error` when the error screen did.
42
+ */
43
+ const markReady = (state) => {
44
+ if (typeof document !== "undefined")
45
+ document.documentElement.dataset.ziteReady = state;
46
+ };
47
+ const watchReady = (router) => () => {
48
+ const { status, isLoading, matches } = router.state;
49
+ if (status !== "idle" || isLoading || matches.some((match) => match.status === "pending"))
50
+ return;
51
+ markReady(matches.some((match) => match.status === "error") ? "error" : "1");
52
+ };
30
53
  function ZiteNotFound() {
31
54
  return h("div", { style: box }, h("p", { style: { fontWeight: 600 } }, "Page not found"), h(Link, { to: "/" }, "Go home"));
32
55
  }
@@ -67,6 +90,12 @@ export function createZiteRouter(options) {
67
90
  defaultPendingComponent: ZitePending,
68
91
  ...options,
69
92
  ...(claim && { rewrite: keepPreviewClaim(claim, options.rewrite) }),
93
+ // Whichever screen shows it: a route may have its own.
94
+ defaultOnCatch: (error, info) => {
95
+ markReady("error");
96
+ reportCrash(error);
97
+ options.defaultOnCatch?.(error, info);
98
+ },
70
99
  context,
71
100
  dehydrate: () => ({ user: context.user }),
72
101
  hydrate: (dehydrated) => {
@@ -74,6 +103,9 @@ export function createZiteRouter(options) {
74
103
  },
75
104
  });
76
105
  setupRouterSsrQueryIntegration({ router, queryClient });
106
+ const mark = watchReady(router);
107
+ router.subscribe("onResolved", mark);
108
+ router.subscribe("onRendered", mark);
77
109
  const host = currentHost();
78
110
  if (host)
79
111
  host.router = router;
@@ -1,3 +1,4 @@
1
+ import { type CredentialSource } from "./credentials.js";
1
2
  import type { HostProps, PlatformBinding, RenderWatch, ZiteRequestContext } from "./types.js";
2
3
  export { runWithHost } from "./scope.js";
3
4
  export { describeApp } from "./describe.js";
@@ -35,6 +36,12 @@ export type ZiteServerOptions = {
35
36
  };
36
37
  getRouter: () => unknown;
37
38
  platform?: PlatformBinding;
39
+ /** The app's start instance, which a test run of a server function runs under. Entries before 0.10.0-start.11 don't pass it. */
40
+ startInstance?: {
41
+ getOptions: () => unknown;
42
+ };
43
+ /** Where `integrations.get` reads outside Zite; the host's requests always read the host. */
44
+ credentials?: CredentialSource;
38
45
  };
39
46
  type HostedContext = WorkerContext & {
40
47
  props?: Partial<HostProps>;