Skip to content
Open
94 changes: 94 additions & 0 deletions packages/host/app/components/card-prerender.gts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
type RenderError,
type ModuleRenderResponse,
type FileExtractResponse,
type FileRenderResponse,
type FileRenderArgs,
type Prerenderer,
type Format,
type PrerenderMeta,
Expand Down Expand Up @@ -93,6 +95,7 @@ export default class CardPrerender extends Component {
prerenderCard: this.prerender.bind(this),
prerenderModule: this.prerenderModule.bind(this),
prerenderFileExtract: this.prerenderFileExtract.bind(this),
prerenderFileRender: this.prerenderFileRenderPublic.bind(this),
};
this.localIndexer.setup(this.#prerendererDelegate);
window.addEventListener('boxel-render-error', this.#handleRenderErrorEvent);
Expand Down Expand Up @@ -203,6 +206,24 @@ export default class CardPrerender extends Component {
});
}

private async prerenderFileRenderPublic(
args: FileRenderArgs,
): Promise<FileRenderResponse> {
return await withRenderContext(async () => {
try {
let run = () => this.fileRenderPrerenderTask.perform(args);
return isTesting() ? await run() : await withTimersBlocked(run);
} catch (e: any) {
if (!didCancel(e)) {
throw e;
}
}
throw new Error(
`card-prerender component is missing or being destroyed before file render prerender of url ${args.url} was completed`,
);
});
}

// This emulates the job of the Prerenderer that runs in the server
private prerenderTask = enqueueTask(
async ({
Expand Down Expand Up @@ -404,6 +425,79 @@ export default class CardPrerender extends Component {
},
);

private fileRenderPrerenderTask = enqueueTask(
async ({
url,
fileData,
renderOptions,
}: FileRenderArgs): Promise<FileRenderResponse> => {
this.#nonce++;
let shouldClearCache = this.#consumeClearCacheForRender(
Boolean(renderOptions?.clearCache),
);
let initialRenderOptions: RenderRouteOptions = {
...(renderOptions ?? {}),
fileRender: true,
fileDefCodeRef: fileData.fileDefCodeRef,
};
if (shouldClearCache) {
initialRenderOptions.clearCache = true;
this.loaderService.resetLoader({
clearFetchCache: true,
reason: 'card-prerender file render clearCache',
});
this.store.resetCache();
} else {
delete initialRenderOptions.clearCache;
}

// Stash file data for the render route to consume
(globalThis as any).__boxelFileRenderData = fileData;

let error: RenderError | undefined;
let isolatedHTML: string | null = null;

// Render isolated HTML only – additional formats (head, atom, icon,
// fitted, embedded) are deferred to keep boot-indexing fast.
try {
isolatedHTML = await this.renderHTML.perform(
url,
'isolated',
0,
initialRenderOptions,
);
} catch (e: any) {
try {
error = { ...JSON.parse(e.message), type: 'file-error' };
} catch (_err) {
let cardErr = new CardError(e.message);
cardErr.stack = e.stack;
error = {
error: {
...cardErr.toJSON(),
deps: [url],
additionalErrors: null,
},
type: 'file-error',
};
}
this.store.resetCache();
} finally {
delete (globalThis as any).__boxelFileRenderData;
}

return {
isolatedHTML,
headHTML: null,
atomHTML: null,
embeddedHTML: null,
fittedHTML: null,
iconHTML: null,
...(error ? { error } : {}),
};
},
);

#moduleModelContext(): ModuleModelContext {
return {
router: this.router,
Expand Down
44 changes: 44 additions & 0 deletions packages/host/app/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,50 @@ export default class RenderRoute extends Route<Model> {
this.currentTransition = undefined;
return model;
}
if (parsedOptions.fileRender) {
let fileRenderData = (globalThis as any).__boxelFileRenderData as
| { resource: any; fileDefCodeRef: { module: string; name: string } }
| undefined;
if (!fileRenderData) {
throw new Error('fileRender mode requires __boxelFileRenderData');
}
let { resource } = fileRenderData;
let cardApi = await this.loaderService.loader.import<typeof CardAPI>(
`${baseRealm.url}card-api`,
);
let doc = { data: resource };
let instance = (await cardApi.createFromSerialized(
resource,
doc,
resource.id ? new URL(resource.id) : undefined,
)) as unknown as CardDef;
Comment on lines +264 to +268

Choose a reason for hiding this comment

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

P1 Badge Use authenticated store for fileRender instances

The fileRender path instantiates the card via cardApi.createFromSerialized without passing a store, which means it falls back to FallbackCardStore (see packages/base/card-api.gts) that fetches related resources with the global fetch and no auth context. If a FileDef template touches linked/computed fields that need realm auth, those loads will 401 or be skipped, so prerendered HTML can be incomplete or incorrect. The normal render path wires instances through this.store.add/store.loaded() to ensure authenticated loading; this new branch should do the same or pass the render store into createFromSerialized.

Useful? React with 👍 / 👎.


let state = new TrackedMap<string, unknown>();
state.set('status', 'ready');
let readyDeferred = new Deferred<void>();
readyDeferred.fulfill();
let model: Model = {
instance,
nonce,
cardId: id,
renderOptions: parsedOptions,
get status(): RenderStatus {
return (state.get('status') as RenderStatus) ?? 'loading';
},
get ready(): boolean {
return (state.get('status') as RenderStatus) === 'ready';
},
readyPromise: readyDeferred.promise,
};
this.#modelStates.set(model, {
state,
readyDeferred,
isReady: true,
});
(globalThis as any).__renderModel = model;
this.currentTransition = undefined;
return model;
}
// This is for host tests
(globalThis as any).__renderModel = undefined;

Expand Down
3 changes: 3 additions & 0 deletions packages/realm-server/prerender/manager-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,9 @@ export function buildPrerenderManagerApp(options?: {
router.post('/prerender-file-extract', (ctxt) =>
proxyPrerenderRequest(ctxt, 'prerender-file-extract', 'file-extract'),
);
router.post('/prerender-file-render', (ctxt) =>
proxyPrerenderRequest(ctxt, 'prerender-file-render', 'file-render'),
);

let verboseManagerLogs =
process.env.PRERENDER_MANAGER_VERBOSE_LOGS === 'true';
Expand Down
170 changes: 170 additions & 0 deletions packages/realm-server/prerender/prerender-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type RenderResponse,
type ModuleRenderResponse,
type FileExtractResponse,
type FileRenderResponse,
} from '@cardstack/runtime-common';
import {
ecsMetadata,
Expand Down Expand Up @@ -336,6 +337,175 @@ export function buildPrerenderApp(options: {
},
});

// File render route needs additional attributes (fileData, types)
// beyond what registerPrerenderRoute handles, so we register it directly.
router.post('/prerender-file-render', async (ctxt: Koa.Context) => {
try {
let request = await fetchRequestFromContext(ctxt);
let raw = await request.text();
let body: any;
try {
body = raw ? JSON.parse(raw) : {};
} catch (e) {
ctxt.status = 400;
ctxt.body = {
errors: [{ status: 400, message: 'Invalid JSON body' }],
};
return;
}

let attrs = body?.data?.attributes ?? {};
let rawUrl = attrs.url;
let rawAuth = attrs.auth;
let rawRealm = attrs.realm;
let renderOptions: RenderRouteOptions =
attrs.renderOptions &&
typeof attrs.renderOptions === 'object' &&
!Array.isArray(attrs.renderOptions)
? (attrs.renderOptions as RenderRouteOptions)
: {};
let fileData = attrs.fileData;
let types = attrs.types;

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

let missing = [
{ value: rawUrl, name: 'url' },
{ value: rawRealm, name: 'realm' },
{ value: rawAuth, name: 'auth' },
]
.filter(({ value }) => !isNonEmptyString(value))
.map(({ name }) => name);

if (!fileData) {
missing.push('fileData');
}
if (!Array.isArray(types)) {
missing.push('types');
}

log.debug(
`received file render prerender request ${rawUrl}: realm=${rawRealm}`,
);
if (missing.length > 0) {
ctxt.status = 400;
ctxt.body = {
errors: [
{
status: 400,
message: `Missing or invalid required attributes: ${missing.join(', ')}`,
},
],
};
return;
}

let realm = rawRealm as string;
let url = rawUrl as string;
let auth = rawAuth as string;

let start = Date.now();
let execPromise = prerenderer
.prerenderFileRender({
realm,
url,
auth,
fileData,
types,
renderOptions,
})
.then((result) => ({ result }));
let drainPromise = options.drainingPromise
? options.drainingPromise.then(() => ({ draining: true as const }))
: null;
let raceResult = drainPromise
? await Promise.race([execPromise, drainPromise])
: await execPromise;
if ('draining' in raceResult) {
execPromise.catch((e) =>
log.debug(
'file render prerender execute settled after drain (ignored):',
e,
),
);
ctxt.status = PRERENDER_SERVER_DRAINING_STATUS_CODE;
ctxt.set(
PRERENDER_SERVER_STATUS_HEADER,
PRERENDER_SERVER_STATUS_DRAINING,
);
ctxt.body = {
errors: [
{
status: PRERENDER_SERVER_DRAINING_STATUS_CODE,
message: 'Prerender server draining',
},
],
};
return;
}
let { response, timings, pool } = raceResult.result;
let totalMs = Date.now() - start;
let poolFlags = Object.entries({
reused: pool.reused,
evicted: pool.evicted,
timedOut: pool.timedOut,
})
.filter(([, value]) => value === true)
.map(([key]) => key)
.join(', ');
let poolFlagSuffix = poolFlags.length > 0 ? ` flags=[${poolFlags}]` : '';
log.info(
'file render prerendered %s total=%dms launch=%dms render=%dms pageId=%s realm=%s%s',
url,
totalMs,
timings.launchMs,
timings.renderMs,
pool.pageId,
pool.realm,
poolFlagSuffix,
);
ctxt.status = 201;
ctxt.set('Content-Type', 'application/vnd.api+json');
ctxt.body = {
data: {
type: 'prerender-file-render-result',
id: url,
attributes: response,
},
meta: {
timing: {
launchMs: timings.launchMs,
renderMs: timings.renderMs,
totalMs,
},
pool,
},
};
if (pool.timedOut) {
log.warn(`file render of ${url} timed out`);
}
const fileResponse = response as FileRenderResponse;
if (fileResponse.error) {
log.debug(
`file render of ${url} resulted in error doc:\n${JSON.stringify(fileResponse.error, null, 2)}`,
);
}
} catch (err: any) {
Sentry.captureException(err);
log.error('Unhandled error in /prerender-file-render:', err);
ctxt.status = 500;
ctxt.body = {
errors: [
{
status: 500,
message: err?.message ?? 'Unknown error',
},
],
};
}
});

app
.use((ctxt: Koa.Context, next: Koa.Next) => {
if (
Expand Down
Loading