Skip to content
Merged
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
4 changes: 0 additions & 4 deletions clients/coordinator/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@ export const ROOT_ID = "root"
export const BUNDLED_PREFIX = "/static/poc"
export const RENDERER_CACHE_NAME = "renderer"

// Shared data schema version for coordinated payloads.
// Renderer builds and coordinator must agree on this at release time.
export const DATA_SCHEMA_VERSION = "1.0.0" as const
export const DATA_SCHEMA_MAJOR = 1

/**
* Data cache is separate from the renderer cache. Renderer cache holds code,
Expand Down
21 changes: 12 additions & 9 deletions clients/coordinator/src/data-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createBufferedLogger } from "@common/utilities/logger"
import { DATA_SCHEMA_VERSION, DATA_CACHE_NAME, DATA_TTL_MS, SCHEMA_CACHE_NAME } from "./constants"
import { DATA_CACHE_NAME, DATA_TTL_MS, SCHEMA_CACHE_NAME } from "./constants"
import * as merinoTransport from "./transports/merino"
import * as rsTransport from "./transports/rs"

Expand All @@ -26,11 +26,13 @@ export const logger = createBufferedLogger({
})

/**
* Builds the cache/network key for the coordinated data endpoint.
* Schema version is included so different shapes never share the same key.
* Builds the cache/network key for the coordinated SWR payload.
*
* schemaFile is the renderer-emitted hashed filename (data-schema.<hash>.json).
* Any descriptor change produces a new filename — no coordinator browser ship required.
*/
function coordinatedKey(): string {
return `/data/coordinated?schema=${encodeURIComponent(DATA_SCHEMA_VERSION)}`
function coordinatedKey(schemaFile: string): string {
return `/data/coordinated?schema=${encodeURIComponent(schemaFile)}`
}

/**
Expand Down Expand Up @@ -103,8 +105,8 @@ export function shouldDataUpdate(payload: CoordinatedPayload): boolean {
/**
* Reads the cached coordinated data payload, if present.
*/
export async function getDataPayload(): Promise<CoordinatedPayload | null> {
const key = coordinatedKey()
export async function getDataPayload(schemaFile: string): Promise<CoordinatedPayload | null> {
const key = coordinatedKey(schemaFile)
const cached = await getCachedJson<CoordinatedPayload>(DATA_CACHE_NAME, key)

if (!cached) {
Expand Down Expand Up @@ -322,8 +324,9 @@ export async function expireSourceCache(
export async function refreshCacheForNextSession(
schema: SourceDescriptor[],
browserCore: BrowserCoreAdapter,
schemaFile: string,
): Promise<void> {
const key = coordinatedKey()
const key = coordinatedKey(schemaFile)

try {
const results = await Promise.all(
Expand All @@ -345,7 +348,7 @@ export async function refreshCacheForNextSession(
}

const payload: CoordinatedPayload = {
schemaVersion: DATA_SCHEMA_VERSION,
schemaVersion: schemaFile,
updatedAt: new Date().toISOString(),
data: data as CoordinatedData,
}
Expand Down
109 changes: 52 additions & 57 deletions clients/coordinator/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { createBufferedLogger } from "@common/utilities/logger"
import { isJsModulePath } from "@common/utilities/values"
import { BASIS, inRange } from "@common/utilities/versions"
import { createDevBrowserCore } from "./adapters/browser-core"
import { createStorageAdapter } from "./adapters/storage"
import { createDevTelemetry } from "./adapters/telemetry"
import { REMOTE_PREFIX, DATA_SCHEMA_VERSION } from "./constants"
import { REMOTE_PREFIX } from "./constants"
import {
getDataPayload,
getOrFetchSchema,
Expand All @@ -28,6 +27,7 @@ import {
import { mountRendererFromUrl, validateRendererModule } from "./renderer-loader"

import type {
AppRenderManifest,
CoordinatedData,
DataSourceStatuses,
LocaleAvailability,
Expand Down Expand Up @@ -132,26 +132,37 @@ function buildLocaleFacet(
* remote renderer manifest is checked and pre-cached if it has changed.
*/
async function boot() {
// Phase 1: Resolve renderer candidates, then fetch schema from the bundle.
// Phase 1: Resolve renderer candidates and fetch the remote manifest in parallel.
// Schema is required before data assembly — it declares what to fetch.
const resolvedRenderers = await resolveRenderers()
const [resolvedRenderers, remoteManifest] = await Promise.all([
resolveRenderers(),
fetchRemoteManifest(),
])

logger.info("resolved renderers", resolvedRenderers)

// Soft schema sanity check: log + warn, but don't block usage.
const cachedRenderer = resolvedRenderers.cached
const bundledRenderer = resolvedRenderers.bundled
const remoteVersion = cachedRenderer?.manifest.dataSchemaVersion
const dataMatch = inRange(DATA_SCHEMA_VERSION, remoteVersion, BASIS.major)

const baseline =
cachedRenderer && dataMatch
? { isCached: true, ...cachedRenderer }
: { isCached: false, ...bundledRenderer }

if (!dataMatch) {
logger.warn(`schema mismatch — ${cachedRenderer?.manifest.dataSchemaVersion} not in range: ${DATA_SCHEMA_VERSION}`) // prettier-ignore
logger.warn(`falling back to baked in renderer — ${bundledRenderer.manifest.hash}`) // prettier-ignore
// Baseline selection: cached (browser Cache API) → remote (network) → bundled.
// Cached is a fast-path for subsequent loads, not a prerequisite.
// Remote is tried directly when no cached renderer exists, so the current
// session uses the latest published renderer without a session-hop.
// Bundled is the guaranteed fallback when remote is unavailable or fails.
// Published renderers are trusted — the publish gate already vetted them.
let baseline: { isCached: boolean; manifest: AppRenderManifest; jsUrl: string }

if (cachedRenderer) {
baseline = { isCached: true, ...cachedRenderer }
} else if (remoteManifest && isJsModulePath(remoteManifest.file)) {
baseline = {
isCached: false,
manifest: remoteManifest,
jsUrl: `${REMOTE_PREFIX}/${remoteManifest.file}`,
}
logger.log("no cached renderer — using remote directly", { hash: remoteManifest.hash })
} else {
baseline = { isCached: false, ...bundledRenderer }
}

// Derive the renderer base URL and fetch the schema artifact.
Expand All @@ -177,8 +188,9 @@ async function boot() {
const telemetry = createDevTelemetry()

// Phase 2: Check cached payload freshness and assemble blocking data in parallel.
const { schemaFile } = baseline.manifest
const [dataPayload, blocking] = await Promise.all([
getDataPayload(),
getDataPayload(schemaFile ?? ""),
assembleBlockingData(schema, browserCore),
])
const shouldRefreshCache = dataPayload ? shouldDataUpdate(dataPayload) : true
Expand Down Expand Up @@ -269,65 +281,48 @@ async function boot() {
// Does not push to the live renderer — the user's current session is not disrupted.
if (shouldRefreshCache) {
logger.info("data is old, refreshing cache for next session")
void refreshCacheForNextSession(schema, browserCore)
void refreshCacheForNextSession(schema, browserCore, schemaFile ?? "")
} else {
logger.info("data is fresh, no cache refresh needed")
}

// SWR: prepare a new renderer bundle for the next load.
const remote = await fetchRemoteManifest()
if (!remote) {
logger.log("no remote manifest; staying on current renderer")
// SWR: ensure the remote renderer is in the browser Cache API for the next-session fast-path.
// remoteManifest was fetched at boot start — no second network request needed here.
if (!remoteManifest || !isJsModulePath(remoteManifest.file)) {
logger.log("no valid remote manifest; skipping renderer cache update")
return
}

// !! NOTE — This doesn't account for an updated bundled
// !! We should check the build time, not just the hash diff
const currentHash = baseline.manifest.hash
if (currentHash === remote.hash) {
logger.log("remote hash matches current; no cache update")
const alreadyCached = cachedRenderer?.manifest.hash === remoteManifest.hash
if (alreadyCached) {
logger.log("remote renderer already cached; no update needed")
return
}

const onRemote = baseline.manifest.hash === remoteManifest.hash
if (!onRemote) {
// Running from bundled or a stale cached version — signal update is available.
if (update)
update({
manifest: baseline.manifest,
renderUpdate: false,
renderUpdate: true,
nextHash: remoteManifest.hash,
isCached: baseline.isCached,
dataSchema: schema,
})
return
}

if (!isJsModulePath(remote.file)) {
logger.warn("remote manifest.file is not JS; ignoring", remote.file)
return
}

const remoteUrl = `${REMOTE_PREFIX}/${remote.file}`

if (update)
update({
manifest: baseline.manifest,
renderUpdate: true,
nextHash: remote.hash,
isCached: baseline.isCached,
dataSchema: schema,
})

const remoteUrl = `${REMOTE_PREFIX}/${remoteManifest.file}`
try {
logger.log("validating new remote renderer", {
remoteUrl,
hash: remote.hash,
})
logger.log("validating remote renderer for cache", { remoteUrl, hash: remoteManifest.hash })
await validateRendererModule(remoteUrl)
await cacheRenderer(remoteManifest)
logger.log("cached remote renderer for next load", remoteManifest.hash)

await cacheRenderer(remote)
logger.log("cached new remote renderer for next load")

// Pre-warm the schema cache for the new renderer so the next boot
// serves the schema from cache rather than making a network request.
if (remote.schemaFile) {
const remoteSchemaUrl = `${REMOTE_PREFIX}/${remote.schemaFile}`
await getOrFetchSchema(remoteSchemaUrl, remote.hash)
logger.log("pre-cached schema for next renderer", remote.hash)
if (remoteManifest.schemaFile) {
const remoteSchemaUrl = `${REMOTE_PREFIX}/${remoteManifest.schemaFile}`
await getOrFetchSchema(remoteSchemaUrl, remoteManifest.hash)
logger.log("pre-cached schema for next renderer", remoteManifest.hash)
}
} catch (e) {
logger.error("validation/cache failed for remote renderer", e)
Expand Down
7 changes: 1 addition & 6 deletions clients/coordinator/src/transports/merino.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createBufferedLogger } from "@common/utilities/logger"
import { DATA_SCHEMA_VERSION } from "../constants"

import type { CoordinatedData } from "@common/types"
import type { CachedSourceResult, MerinoDescriptor } from "../data-schema"
Expand All @@ -13,12 +12,8 @@ const logger = createBufferedLogger({
},
})

/**
* Cache key for a merino source entry.
* Schema version is included so different payload shapes never share the same key.
*/
function cacheKey(entry: MerinoDescriptor): string {
return `/data/${entry.key}?schema=${encodeURIComponent(DATA_SCHEMA_VERSION)}`
return `/data/${entry.key}`
}

async function getCachedEntry(
Expand Down
51 changes: 42 additions & 9 deletions clients/renderer/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
extractMessageIds,
} from "@config/l10n-config"
import react from "@vitejs/plugin-react"
import { createHash } from "node:crypto"
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
import { resolve, dirname } from "node:path"
import { defineConfig } from "vite"
Expand Down Expand Up @@ -38,25 +39,56 @@ function exposeBuildHash(): Plugin {
}

/**
* Emit the renderer's data-schema.json as a build artifact.
* Emit the renderer's data-schema.json as a content-hashed build artifact.
*
* The coordinator fetches this file from the renderer bundle URL at boot to
* discover which data sources to fetch and how to cache them. Schema is a
* required renderer artifact — the coordinator has no fallback domain knowledge.
* The filename embeds a hash of the full normalized schema content, following
* the same pattern as JS and CSS artifacts. The coordinator uses the filename
* as the SWR cache key discriminator — any descriptor change (key, transport,
* TTL, endpoint, etc.) produces a new filename and automatically busts both
* the coordinator's Cache API and any HTTP-layer caches.
*
* The coordinator has no fallback domain knowledge; schema is required.
*/
function emitDataSchema(result: typeof schemaBuildResult): Plugin {
return {
name: "emit-data-schema",
async generateBundle() {
const schemaPath = resolve(__dirname, "src/data-schema.json")
const source = await readFile(schemaPath, "utf-8")
const schemaFile = "data-schema.json"
const hash = computeSchemaContentHash(source)
const schemaFile = `data-schema.${hash}.json`
this.emitFile({ type: "asset", fileName: schemaFile, source })
result.schemaFile = schemaFile
},
}
}

/**
* Derives a stable content hash from data-schema.json.
*
* Normalization: each descriptor's keys are sorted alphabetically, then
* descriptors are sorted by their primary key. This ensures the hash is
* stable regardless of source-file formatting or key ordering.
*/
function computeSchemaContentHash(source: string): string {
const entries = JSON.parse(source) as Array<Record<string, unknown>>
const normalized = entries
.map((entry) =>
Object.fromEntries(
Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)),
),
)
.sort((a, b) => {
const aKey = String(a["key"] ?? (a["keys"] as string[] | undefined)?.[0] ?? "")
const bKey = String(b["key"] ?? (b["keys"] as string[] | undefined)?.[0] ?? "")
return aKey.localeCompare(bKey)
})
return createHash("sha256")
.update(JSON.stringify(normalized))
.digest("hex")
.slice(0, 16)
}

/**
* Aggregate all colocated component.ftl files, compute l10nHash from their
* sorted message ID set, and emit the concatenated baseline FTL as an artifact.
Expand Down Expand Up @@ -134,7 +166,6 @@ function emitRendererManifest(l10n: typeof l10nBuildResult, schema: typeof schem
buildTime,
file: `index.${hash}.js`,
hash,
dataSchemaVersion: "1.2.1",
cssFile: cssFile ?? undefined,
l10nHash: l10n.l10nHash || undefined,
baselineFtlFile: l10n.baselineFtlFile || undefined,
Expand Down Expand Up @@ -205,14 +236,16 @@ function validateRendererSnapshot(l10n: typeof l10nBuildResult): Plugin {
})
}

// Structural: data schema emitted
const schemaArtifact = keys.find((k) => k === "data-schema.json")
// Structural: data schema emitted with content hash in filename
const schemaArtifact = keys.find(
(k) => k.startsWith("data-schema.") && k.endsWith(".json"),
)
if (!schemaArtifact) {
failures.push({
layer: "structural",
rule: "missing_artifact",
message:
"No data-schema.json found in bundle. The data schema is a universally required renderer artifact — the coordinator has no fallback domain knowledge.",
"No data-schema.<hash>.json found in bundle. The data schema is a universally required renderer artifact — the coordinator has no fallback domain knowledge.",
})
}

Expand Down
4 changes: 1 addition & 3 deletions common/types/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ export type AppRenderManifest = {
file: string
/** Content hash of the entry artifact, used for identity and caching. */
hash: string
/** Data schema version this renderer expects from the coordinator. */
dataSchemaVersion: string
/** Path to the CSS presentation artifact, if present. */
/** Path to the CSS presentation artifact, if present. */
cssFile?: string
/** Key-set hash of the baseline FTL. Feeds into snapshot identity and keys translations. */
l10nHash?: string
Expand Down
4 changes: 2 additions & 2 deletions ui/components/dev-panel-metrics/component.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ dev-panel-metrics-hash = Hash: { $hash }
dev-panel-metrics-next-hash = Next Hash: { $hash }

# Variables:
# $version (String) - Data schema version identifier
dev-panel-metrics-schema-version = Data Schema Version: { $version }
# $file (String) - Hashed schema filename (data-schema.<hash>.json)
dev-panel-metrics-schema-file = Schema: { $file }

# Variables:
# $time (String) - Human-readable build timestamp
Expand Down
2 changes: 1 addition & 1 deletion ui/components/dev-panel-metrics/component.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const mockManifest = {
buildTime: new Date().toISOString(),
file: "/renderer.js",
hash: "abc123def456",
dataSchemaVersion: "2",
schemaFile: "data-schema.b2e0739a7dd78a0e.json",
}

// Storybook Meta
Expand Down
2 changes: 1 addition & 1 deletion ui/components/dev-panel-metrics/component.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const mockProps = {
buildTime: "2026-01-01T00:00:00.000Z",
file: "/renderer.js",
hash: "abc123def456",
dataSchemaVersion: "2",
schemaFile: "data-schema.b2e0739a7dd78a0e.json",
},
renderUpdate: false,
isCached: false,
Expand Down
Loading
Loading