Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -3703,8 +3703,9 @@ export class FileDef extends BaseDef {
}

// See CardDef.screenshotURLs — the same reserved, meta-derived getter for
// file-backed defs. The prerender pass captures only instance rows, so a
// file's declared names read `undefined` until file rows capture too.
// file-backed defs. The prerender pass captures a URL's file rendering
// alongside its instance rendering, so a file family's declared names
// resolve here just as a card's do.
get screenshotURLs(): Record<string, string | undefined> {
return composeScreenshotURLs(this);
}
Expand Down
85 changes: 84 additions & 1 deletion packages/host/app/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,12 +491,35 @@ export default class RenderRoute extends Route<Model> {
}
if (parsedOptions.fileRender) {
let fileRenderData = (globalThis as any).__boxelFileRenderData as
| { resource: any; fileDefCodeRef: { module: string; name: string } }
| {
resource: any;
fileDefCodeRef: { module: string; name: string };
// The visit's realm, stashed by the prerender server alongside
// the file data — a file render has no response header to learn
// its realm from the way the card branch does.
realmURL?: string;
}
| undefined;
if (!fileRenderData) {
throw new Error('fileRender mode requires __boxelFileRenderData');
}
let { resource } = fileRenderData;
// The file-half twin of the card branch's declaration-derived
// `meta.screenshots` injection below (`declarationScreenshotsMeta`):
// the FileDef family's declared roster asserts each slot's durable URL
// so the file's own prerendered formats can embed it on the very first
// pass, before that pass's captures persist.
let fileScreenshotsMeta = await this.fileDeclarationScreenshotsMeta(
fileRenderData.fileDefCodeRef,
resource,
fileRenderData.realmURL,
);
if (fileScreenshotsMeta) {
resource = {
...resource,
meta: { ...resource.meta, screenshots: fileScreenshotsMeta },
};
}
let doc = { data: resource };
let instance = (await this.store.addFileMeta(
resource,
Expand Down Expand Up @@ -686,6 +709,66 @@ export default class RenderRoute extends Route<Model> {
// (`getCard`), preserving `undefined` as the not-captured absence signal.
// Roster entries carry no `hash` for the same reason — no capture is being
// asserted.
// The file rendering's variant of `declarationScreenshotsMeta` below: the
// roster comes from the file's FileDef family class (resolved by
// extension), and the addressed path keeps its extension — a file row's
// captures are keyed by the file's own URL, only instance ids shed `.json`.
//
// Absent `realmURL` is a deliberate no-op, not a defensive default: the
// in-browser prerender twin stashes no realm on purpose — it never
// captures, so injection must stay off there, or the baked durable URLs
// would 404 with no capture ever landing to self-heal them.
private async fileDeclarationScreenshotsMeta(
fileDefCodeRef: { module: string; name: string },
resource: {
id?: string;
meta?: { realmURL?: string };
},
visitRealmURL: string | undefined,
): Promise<ScreenshotsMeta | undefined> {
try {
let id = resource.id;
// The stashed visit realm is the authority; an extract-built resource
// carries no meta.realmURL of its own.
let realmURL = visitRealmURL ?? resource.meta?.realmURL;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The silent no-op when realmURL is absent is load-bearing and nothing pins it. The in-browser prerender twin stashes __boxelFileRenderData without a realm (card-prerender.gts), so this helper skips injection there — which is the correct behavior: that twin never captures, so baked durable URLs would 404 with no later capture to self-heal them. But as written the fallback chain reads like a defensive default, and the natural "fix" for someone debugging missing URLs in-browser is to add realmURL to the twin's stash — reintroducing permanently-dead links.

Ask: one sentence in this comment naming the twin's omission as deliberate ("the in-browser twin stashes no realm on purpose: it never captures, so injection must stay off there"). Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in a1c6e0d — the helper's comment now names the in-browser twin's missing realm as a deliberate no-op and why (no captures there, so baked URLs would never self-heal). This resolves it.

if (!id || !realmURL) {
return undefined;
}
let api = await this.cardService.getAPI();
if (typeof api.serializeDeclaredScreenshots !== 'function') {
return undefined;
}
let resolvedId = this.network.virtualNetwork.toURL(id);
if (!resolvedId.href.startsWith(realmURL)) {
return undefined;
}
let Klass = await loadCardDef(
// The wire shape carries plain strings; loadCardDef wants the branded
// resource-identifier spelling of the same ref.
fileDefCodeRef as Parameters<typeof loadCardDef>[0],
{
loader: this.loaderService.loader,
relativeTo: resolvedId,
},
);
let roster = api.serializeDeclaredScreenshots(
Klass as typeof CardDef,
) as DeclaredScreenshotRoster;
if (Object.keys(roster).length === 0) {
return undefined;
}
return screenshotsMetaFromRoster(roster, {
realmURL,
instanceLocalPath: resolvedId.href.slice(realmURL.length),
});
} catch {
// Same posture as `declarationScreenshotsMeta`: a class that fails to
// load fails the render itself moments later; this auxiliary read must
// never be what surfaces it.
return undefined;
}
}

private async declarationScreenshotsMeta(
doc: LooseSingleCardDocument,
canonicalId: string,
Expand Down
1 change: 0 additions & 1 deletion packages/realm-server/prerender/prerender-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,7 +1024,6 @@ export function buildPrerenderApp(options: {
!Array.isArray(attrs.screenshots)
? (attrs.screenshots as DeclaredScreenshotVisitArgs)
: undefined;

let isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;

Expand Down
192 changes: 142 additions & 50 deletions packages/realm-server/prerender/render-runner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type DeclaredScreenshotVisitArgs,
type DeclaredScreenshotVisitResult,
type FusedIndexMeta,
type PrerenderMeta,
Expand Down Expand Up @@ -1626,59 +1627,30 @@ export class RenderRunner {
// format renders (the hydrated card and its images are already
// settled and cached). Only the prerender-html half captures — the
// caller opts in by sending `screenshots` when it has a MediaCache
// to persist into. Deliberately NOT a runTimedStep: that helper
// promotes a step failure into the card error, but a failed capture
// is an absent screenshot, not an errored row (the broken-links
// model) — only an evicted page or an auth failure escalates, since
// the page itself is then unusable for anyone.
// to persist into.
let cardScreenshots: DeclaredScreenshotVisitResult | undefined;
if (
!cardShortCircuit &&
runHtmlSteps &&
!runIndexSteps &&
screenshots
) {
let stepStart = Date.now();
let stepResult = await this.#step(
let { result, escalation } = await this.#declaredScreenshotsStep({
page,
kind: 'instance',
bucket: 'card',
screenshots,
captureOptions,
affinityKey,
'visit card declared screenshots',
() =>
withTimeout(
page,
() =>
captureDeclaredScreenshots(page, screenshots, captureOptions),
opts?.timeoutMs,
this.#profileContext(
affinityKey,
url,
'visit card declared screenshots',
jobId,
),
signal,
),
);
recordFormatMs('card', 'screenshots', Date.now() - stepStart);
let allSlotsErrored = (error: RenderError) => {
response.screenshots = {
entries: [],
errors: [
{
name: '*',
message:
error.error?.message ??
'declared screenshot capture failed',
captureMs: Date.now() - stepStart,
},
],
};
};
if (!stepResult.ok) {
if (stepResult.evicted || this.#isAuthError(stepResult.error)) {
applyStepError(stepResult.error, stepResult.evicted);
}
allSlotsErrored(stepResult.error);
} else {
response.screenshots =
stepResult.value as DeclaredScreenshotVisitResult;
url,
jobId,
timeoutMs: opts?.timeoutMs,
signal,
recordStepMs: (ms) => recordFormatMs('card', 'screenshots', ms),
});
cardScreenshots = result;
if (escalation) {
applyStepError(escalation.error, escalation.evicted);
}
if (!cardShortCircuit) {
// The settle-time deps snapshot read after the isolated render
Expand Down Expand Up @@ -1718,6 +1690,7 @@ export class RenderRunner {
...(meta as PrerenderMeta),
...(capturedDeps ? { deps: capturedDeps } : {}),
...(cardError ? { error: cardError } : {}),
...(cardScreenshots ? { screenshots: cardScreenshots } : {}),
iconHTML,
isolatedHTML,
headHTML,
Expand Down Expand Up @@ -1858,11 +1831,18 @@ export class RenderRunner {
: undefined;

if (memoizedIconHTML === undefined) {
// stash file data for the render route model hook to consume
// stash file data for the render route model hook to consume,
// with the visit's realm alongside — a file render has no
// response header to learn its realm from (the card branch reads
// x-boxel-realm-url off the card GET), and the route needs it to
// compose declaration-derived screenshot URLs.
await abortable(signal, () =>
page.evaluate((data) => {
(globalThis as any).__boxelFileRenderData = data;
}, effectiveFileData),
page.evaluate(
(data) => {
(globalThis as any).__boxelFileRenderData = data;
},
{ ...effectiveFileData, realmURL: realm },
),
);
didStashFileRenderData = true;
}
Expand Down Expand Up @@ -2104,8 +2084,42 @@ export class RenderRunner {
}
}

// The file rendering's declared screenshots, mirroring the card
// pass's capture step above: same warm tab, after the file's format
// renders, and only when the caller can persist the bytes. The
// render.screenshots roster and render.screenshot captures read the
// parent render model's instance, which the fileRender transitions
// above have set to the hydrated FileDef — so the roster here is
// the file family's `static screenshots`, not the card's.
let fileScreenshots: DeclaredScreenshotVisitResult | undefined;
if (
!fileShortCircuit &&
runHtmlSteps &&
!runIndexSteps &&
screenshots
) {
let { result, escalation } = await this.#declaredScreenshotsStep({
page,
kind: 'file',
bucket: 'file',
screenshots,
captureOptions,
affinityKey,
url,
jobId,
timeoutMs: opts?.timeoutMs,
signal,
recordStepMs: (ms) => recordFormatMs('file', 'screenshots', ms),
});
fileScreenshots = result;
if (escalation) {
applyStepError(escalation.error, escalation.evicted);
}
}

let fileResponse: FileRenderResponse = {
...(fileError ? { error: fileError } : {}),
...(fileScreenshots ? { screenshots: fileScreenshots } : {}),
iconHTML,
isolatedHTML,
headHTML,
Expand Down Expand Up @@ -2283,6 +2297,84 @@ export class RenderRunner {
return { ok: true, value: r as T };
}

// One rendering's declared-screenshot capture step, shared by the card and
// file passes: runs the capture against the pass's settled page and
// normalizes a step failure into the all-slots-errored result — a failed
// capture is an absent screenshot, never an errored row (the broken-links
// model). Only an eviction or an auth failure comes back as an escalation
// for the caller to fold into its pass error, since the page is then
// unusable for anyone.
async #declaredScreenshotsStep({
page,
kind,
bucket,
screenshots,
captureOptions,
affinityKey,
url,
jobId,
timeoutMs,
signal,
recordStepMs,
}: {
page: Page;
kind: 'instance' | 'file';
bucket: 'card' | 'file';
screenshots: DeclaredScreenshotVisitArgs;
captureOptions: CaptureOptions;
affinityKey: string;
url: string;
jobId?: string;
timeoutMs?: number;
signal?: AbortSignal;
recordStepMs: (ms: number) => void;
}): Promise<{
result: DeclaredScreenshotVisitResult;
escalation?: { error: RenderError; evicted: boolean };
}> {
let label = `visit ${bucket} declared screenshots`;
let stepStart = Date.now();
let stepResult = await this.#step(affinityKey, label, () =>
withTimeout(
page,
() =>
captureDeclaredScreenshots(page, screenshots, kind, captureOptions),
timeoutMs,
this.#profileContext(affinityKey, url, label, jobId),
signal,
),
);
let stepMs = Date.now() - stepStart;
recordStepMs(stepMs);
if (!stepResult.ok) {
return {
result: {
entries: [],
errors: [
{
name: '*',
message:
stepResult.error.error?.message ??
'declared screenshot capture failed',
// The step failed as a unit, so this is the whole step's
// elapsed time, not one slot's share.
captureMs: stepMs,
},
],
},
...(stepResult.evicted || this.#isAuthError(stepResult.error)
? {
escalation: {
error: stepResult.error,
evicted: stepResult.evicted,
},
}
: {}),
};
}
return { result: stepResult.value as DeclaredScreenshotVisitResult };
}

#captureToError(capture: RenderCapture): RenderError | undefined {
if (capture.status === 'error' || capture.status === 'unusable') {
let parsed: RenderError | undefined;
Expand Down
Loading
Loading