Skip to content
Closed
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
46 changes: 46 additions & 0 deletions packages/host/app/lib/prerender-fetch-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
DURING_PRERENDER_HEADER,
X_BOXEL_CONSUMING_REALM_HEADER,
X_BOXEL_JOB_ID_HEADER,
} from '@cardstack/runtime-common';

// Set by the prerender server's `evaluateOnNewDocument` before the
// SPA boots — `__boxelDuringPrerender = true`. Read here so the
// realm-server fetch wrappers can attach the marker header on
// search calls only, narrowly scoping the signal to the endpoints
// that need it. See realm.ts:DURING_PRERENDER_HEADER for the full
// chain.
export function duringPrerenderHeaders(): Record<string, string> {
let flag = (globalThis as unknown as { __boxelDuringPrerender?: boolean })
.__boxelDuringPrerender;
return flag ? { [DURING_PRERENDER_HEADER]: '1' } : {};
}

// While rendering inside a prerender tab the render route writes
// `__boxelConsumingRealm` with the URL of the realm whose card is
// being rendered. Attach it to outbound search requests so the
// realm-server's job-scoped cache layer can gate caching on the
// indexer-traffic shape. Read each fetch (not cached at module
// scope) so a tab that renders cards from multiple realms in
// sequence sends the correct header per request. Returns an empty
// object when the global is not set so non-prerender (live SPA)
// fetches behave exactly as before.
export function consumingRealmHeader(): Record<string, string> {
let r = (globalThis as unknown as { __boxelConsumingRealm?: string })
.__boxelConsumingRealm;
return r ? { [X_BOXEL_CONSUMING_REALM_HEADER]: r } : {};
}

// Companion to `consumingRealmHeader()`. The prerender server's
// `prerenderVisitAttempt` injects `__boxelJobId` onto the page
// before transitioning into the render route — see
// `packages/realm-server/prerender/render-runner.ts`. Read it on
// each fetch (not module-scope-cached) so a page reused across
// multiple visits picks up the current visit's job id. Outside a
// prerender tab the global is undefined and we send no header, so
// user / API callers continue to bypass the realm-server's
// job-scoped cache.
export function jobIdHeader(): Record<string, string> {
let j = (globalThis as unknown as { __boxelJobId?: string }).__boxelJobId;
return j ? { [X_BOXEL_JOB_ID_HEADER]: j } : {};
}
13 changes: 13 additions & 0 deletions packages/host/app/resources/prerendered-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import { isPrerenderedCardCollectionDocument } from '@cardstack/runtime-common/d
import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event';

import { PrerenderedCard } from '../components/prerendered-card-search';
import {
consumingRealmHeader,
duringPrerenderHeaders,
jobIdHeader,
} from '../lib/prerender-fetch-headers';
import { normalizeRealms, resolveCardRealmUrl } from '../lib/realm-utils';

import type LoaderService from '../services/loader-service';
Expand Down Expand Up @@ -274,6 +279,14 @@ export class PrerenderedSearchResource extends Resource<Args> {
headers: {
Accept: SupportedMimeType.CardJson,
'Content-Type': 'application/json',
// Plumb the prerender-context headers so a worker-driven
// render hits the realm-server's job-scoped search cache
// (the same instance shared with `_federated-search`).
// Live (non-prerender) SPA fetches send no values for any of
// these globals and continue to bypass the cache.
...duringPrerenderHeaders(),
...consumingRealmHeader(),
...jobIdHeader(),
},
body: JSON.stringify({
...query,
Expand Down
46 changes: 5 additions & 41 deletions packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
baseFileRef,
CardError,
cardIdToURL,
DURING_PRERENDER_HEADER,
isRegisteredPrefix,
hasExecutableExtension,
isCardError,
Expand All @@ -33,8 +32,6 @@ import {
isSingleCardDocument,
isLinkableCollectionDocument,
resolveFileDefCodeRef,
X_BOXEL_CONSUMING_REALM_HEADER,
X_BOXEL_JOB_ID_HEADER,
Deferred,
delay,
mergeRelationships,
Expand Down Expand Up @@ -86,6 +83,11 @@ import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event'

import CardStore, { getDeps, type ReferenceCount } from '../lib/gc-card-store';

import {
consumingRealmHeader,
duringPrerenderHeaders,
jobIdHeader,
} from '../lib/prerender-fetch-headers';
import { errorJsonApiToErrorEntry } from '../lib/window-error-handler';
import { getSearch } from '../resources/search';
import {
Expand Down Expand Up @@ -121,44 +123,6 @@ let waiter = buildWaiter('store-service');
const realmEventsLogger = logger('realm:events');
const storeLogger = logger('store');

// Set by the prerender server's `evaluateOnNewDocument` before the
// SPA boots — `__boxelDuringPrerender = true`. Read here so the
// federated-search fetch wrapper can attach the marker header on
// realm-server-bound calls only, narrowly scoping the signal to the
// endpoint that needs it. See realm.ts:DURING_PRERENDER_HEADER for
// the full chain.
function duringPrerenderHeaders(): Record<string, string> {
let flag = (globalThis as unknown as { __boxelDuringPrerender?: boolean })
.__boxelDuringPrerender;
return flag ? { [DURING_PRERENDER_HEADER]: '1' } : {};
}

// While rendering inside a prerender tab the render route writes
// `__boxelConsumingRealm` with the URL of the realm whose card is being
// rendered. Attach it to outbound `_federated-search` requests so the
// realm-server's job-scoped cache layer can gate same-realm-only
// caching. Read each fetch (not cached at module scope) so a tab that
// renders cards from multiple realms in sequence sends the correct
// header per request. Returns an empty object when the global is not
// set so non-prerender (live SPA) fetches behave exactly as before.
function consumingRealmHeader(): Record<string, string> {
let r = (globalThis as unknown as { __boxelConsumingRealm?: string })
.__boxelConsumingRealm;
return r ? { [X_BOXEL_CONSUMING_REALM_HEADER]: r } : {};
}

// Companion to `consumingRealmHeader()`. The prerender server's
// `prerenderVisitAttempt` injects `__boxelJobId` onto the page before
// transitioning into the render route — see
// `packages/realm-server/prerender/render-runner.ts`. Read it on each
// fetch (not module-scope-cached) so a page reused across multiple
// visits picks up the current visit's job id. Outside a prerender
// tab the global is undefined and we send no header, so user / API
// callers continue to bypass the realm-server's job-scoped cache.
function jobIdHeader(): Record<string, string> {
let j = (globalThis as unknown as { __boxelJobId?: string }).__boxelJobId;
return j ? { [X_BOXEL_JOB_ID_HEADER]: j } : {};
}
const queryFieldSeedFromSearchSymbol = Symbol.for(
'cardstack-query-field-seed-from-search',
);
Expand Down
73 changes: 61 additions & 12 deletions packages/realm-server/handlers/handle-search-prerendered.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import type Koa from 'koa';
import {
buildSearchErrorResponse,
SupportedMimeType,
X_BOXEL_CONSUMING_REALM_HEADER,
parsePrerenderedSearchRequestFromPayload,
parsePrerenderedSearchRequestFromRequest,
sanitizeConsumingRealmHeader,
SearchRequestError,
searchPrerenderedRealms,
} from '@cardstack/runtime-common';
Expand All @@ -16,10 +18,16 @@ import {
getMultiRealmAuthorization,
getSearchRequestPayload,
} from '../middleware/multi-realm-authorization';
import type { JobScopedSearchCache } from '../job-scoped-search-cache';
import {
PRERENDER_JOB_ID_HEADER,
sanitizePrerenderJobId,
} from '../prerender/prerender-constants';

export default function handleSearchPrerendered(): (
ctxt: Koa.Context,
) => Promise<void> {
export default function handleSearchPrerendered(opts?: {
searchCache?: JobScopedSearchCache;
}): (ctxt: Koa.Context) => Promise<void> {
let searchCache = opts?.searchCache;
return async function (ctxt: Koa.Context) {
let { realmList, realmByURL } = getMultiRealmAuthorization(ctxt);

Expand All @@ -43,15 +51,56 @@ export default function handleSearchPrerendered(): (
throw e;
}

let combined = await searchPrerenderedRealms(
realmList.map((realmURL) => realmByURL.get(realmURL)),
parsed.cardsQuery,
{
htmlFormat: parsed.htmlFormat,
cardUrls: parsed.cardUrls,
renderType: parsed.renderType,
},
);
let searchOpts = {
htmlFormat: parsed.htmlFormat,
cardUrls: parsed.cardUrls,
renderType: parsed.renderType,
};
let runSearch = () =>
searchPrerenderedRealms(
realmList.map((realmURL) => realmByURL.get(realmURL)),
parsed.cardsQuery,
searchOpts,
);

// Symmetric to `_federated-search`'s gating. Cache is consulted
// only when both indexer-traffic headers are present and well-
// formed:
// (a) `x-boxel-job-id` — only the indexer worker stamps this,
// (b) `x-boxel-consuming-realm` — the host's render route only
// sets it during prerender.
// User-facing API callers never carry both, so they always
// bypass the cache and observe live SQL state.
//
// The prerendered handler's request shape carries
// `htmlFormat` / `cardUrls` / `renderType` which materially
// change the response body. These are passed through `opts` so
// the cache's `sortKeysDeep`-canonicalised inner key segregates
// entries that differ on any of them. `cardsQuery` is whatever
// remains after those three keys are stripped by
// `parsePrerenderedSearchRequest…` — so the cache key reflects
// the full request shape, not just the query body.
//
// `multiRealmAuthorization` has already validated read access
// for every entry of `realmList`, so the cache cannot surface
// results across an authorization boundary.
let jobId = searchCache
? sanitizePrerenderJobId(ctxt.get(PRERENDER_JOB_ID_HEADER))
: null;
let consumingRealm = searchCache
? sanitizeConsumingRealmHeader(ctxt.get(X_BOXEL_CONSUMING_REALM_HEADER))
: null;
let cacheable = searchCache && jobId && consumingRealm;

let combined = cacheable
? await searchCache!.getOrPopulate({
jobId: jobId!,
realms: realmList,
query: parsed.cardsQuery,
opts: searchOpts,
populate: runSearch,
})
: await runSearch();

await setContextResponse(
ctxt,
Expand Down
32 changes: 22 additions & 10 deletions packages/realm-server/job-scoped-search-cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
normalizeQueryForSignature,
sortKeysDeep,
type LinkableCollectionDocument,
type Query,
} from '@cardstack/runtime-common';

Expand All @@ -28,7 +27,14 @@ const DEFAULT_TTL_MS = 10 * 60 * 1000;
const DEFAULT_MAX_ENTRIES = 5000;

type CachedEntry = {
result: LinkableCollectionDocument;
// Result is stored opaquely so both `_federated-search`'s
// `LinkableCollectionDocument` and `_federated-search-prerendered`'s
// `PrerenderedCardCollectionDocument` can share the same cache
// instance. Inner-key canonicalisation already includes the
// endpoint-distinguishing params (htmlFormat / cardUrls / renderType
// are passed through `opts`), so two endpoints' entries cannot
// collide on a key they don't both fully share.
result: unknown;
timer: ReturnType<typeof setTimeout>;
// Position in the FIFO eviction ring. Stored on the entry so a
// cache hit doesn't need a separate map lookup to know its slot.
Expand All @@ -37,10 +43,16 @@ type CachedEntry = {

// Per-batch read cache used during indexing. Each entry is keyed by
// `(jobId, normalizedRealms, normalizedQuery, normalizedOpts)` and
// represents one `_federated-search` populate computed during the
// lifetime of one indexing job. The job-id boundary scopes the cache
// to a single batch; a subsequent job hashes to different keys and
// never reuses a stale value.
// represents one search populate computed during the lifetime of one
// indexing job. The cache is shared across both `_federated-search`
// (`LinkableCollectionDocument` results) and
// `_federated-search-prerendered` (`PrerenderedCardCollectionDocument`
// results) — the endpoint-specific request shape (`htmlFormat`,
// `cardUrls`, `renderType` for the prerendered handler) is folded into
// `opts` before the call here, so the canonicalised inner key already
// segregates the two endpoints' entries. The job-id boundary scopes
// the cache to a single batch; a subsequent job hashes to different
// keys and never reuses a stale value.
//
// Same-realm reads are safe by construction: within an indexing batch
// the writer touches `boxel_index_working`, not `boxel_index`, so
Expand Down Expand Up @@ -94,18 +106,18 @@ export class JobScopedSearchCache {
this.#maxEntries = opts?.maxEntries ?? DEFAULT_MAX_ENTRIES;
}

async getOrPopulate(args: {
async getOrPopulate<T>(args: {
jobId: string;
realms: string[];
query: Query;
opts: unknown | undefined;
populate: () => Promise<LinkableCollectionDocument>;
}): Promise<LinkableCollectionDocument> {
populate: () => Promise<T>;
}): Promise<T> {
let innerKey = buildInnerKey(args.realms, args.query, args.opts);
let jobMap = this.#byJob.get(args.jobId);
let existing = jobMap?.get(innerKey);
if (existing) {
return existing.result;
return existing.result as T;
}

let result = await args.populate();
Expand Down
2 changes: 1 addition & 1 deletion packages/realm-server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export function createRoutes(args: CreateRoutesArgs) {
router.all(
'/_federated-search-prerendered',
multiRealmAuthorization(args),
handleSearchPrerendered(),
handleSearchPrerendered({ searchCache }),
);
router.post(
'/_prerender-card',
Expand Down
Loading
Loading