Headfull Component Stripping In Pre-Rendered Jay-Html
Design Log #137 — Production Build Self-Containment
Written for AI agents. See Log Methodology Note below for details.
Background
When deploying to production (Docker/Cloud Run), only build/, config/, and node_modules/ are in the container — no source files (src/). The production server fails in multiple places because it tries to read source files that don't exist. The build output should be fully self-contained.
DL#134b states the main server's design principle: no compilation at runtime, all code is pre-compiled JS. The "What the Main Server Does NOT Do" section explicitly excludes dynamic contract materialization and compilation. Yet the current serve-time code violates this by re-parsing jay-html and loading contracts from source files.
Related Design Logs
- #134 — Production build (two-server architecture, artifact directory as contract)
- #134b — Main server (stateless request handling, pre-built artifacts only)
- #111 — Nested headfull full-stack components (template injection, headless pipeline)
- #123 — Deeply nested headfull and headless components (recursive hoisting)
- #94 — SSR streaming renderer (server element compilation)
Problem
The production server requires source files that don't exist in the container. Four gaps were discovered during Cloud Run deployment of the golf project.
Root Cause
All four gaps stem from one root cause: the production server re-parses jay-html at serve time. loadProductionPageParts calls parseJayFile and injectHeadfullFSTemplates to discover headless imports, load contracts, and resolve component modules — work that was already done at build time. This triggers file reads against source paths that don't exist in the container.
The fix is to pre-compute the page configuration at build time and load it directly at serve time, eliminating jay-html parsing entirely.
Gap 1: Headfull component source files (ENOENT)
Two serve-time operations try to read headfull component source files:
parseJayFile(load-production-parts.ts:61) encounters<script type="application/jay-headfull" src="../../components/header">and callsimportResolver.readJayHtml()— fails with ENOENTinjectHeadfullFSTemplates(load-production-parts.ts:154) also reads the same source file — fails with ENOENT
Subtlety: headfull components can declare children that are also needed at serve time:
- Nested headfull components (DL#123 Scenario B) — headfull A contains headfull B
- Keyed headless components — headfull component imports a plugin with a
keyattribute for page-level data binding - Instance headless components — headfull component imports a plugin used as
<jay:xxx>instances
All three types are hoisted into the page's headlessImports during parseHeadfullFSImports, but only in memory — not persisted in the pre-rendered HTML.
Gap 2: CSS files referenced by headfull components (ENOENT)
parseJayFile resolves CSS <link> tags from headfull components' jay-html against source paths. The CSS is already compiled into the build output. This gap goes away when we stop parsing jay-html at serve time.
Gap 3: Materialized contracts needed at serve time (ENOENT)
parseJayFile → parseHeadlessImports calls importResolver.loadPluginContract() to load contracts at serve time. For NPM plugins, contracts resolve from node_modules (fine). For materialized contracts, the resolver looks in agent-kit/materialized-contracts/ (doesn't exist in container). This gap goes away when we stop parsing jay-html at serve time — the contract data needed for fast rendering is pre-computed.
Gap 4: Absolute paths in route manifest
route-manifest.json bakes in the build machine's absolute path as projectRoot. The production server passes this to scanPlugins() and loadProductionPageParts. Inside the container, that path doesn't exist.
Questions and Answers
Q1: After slow rendering and template injection, is a headfull component semantically equivalent to a headless component?
Yes. At that point:
| Contribution | Where it lives after slow rendering |
|---|---|
| Template content | Inlined in <jay:Name> tags in the body |
| Slow-phase bindings | Resolved into static HTML |
| Component module | Referenced by compiled server-element/hydrate output |
What remains is a component with a contract, a module, and <jay:Name> instances — exactly what a headless component is. The <script type="application/jay-headfull"> tag is leftover metadata.
Q2: Why does the production server parse jay-html at serve time?
It shouldn't. loadProductionPageParts calls parseJayFile to produce ProductionPageParts:
interface ProductionPageParts {
parts: DevServerPagePart[]; // page + keyed headless component definitions
headlessContracts: HeadlessContractInfo[]; // build-time only (for slowRenderTransform)
headlessInstanceComponents: HeadlessInstanceComponent[]; // instance component definitions
discoveredInstances: DiscoveredHeadlessInstance[]; // build-time only (for slow render)
forEachInstances: ForEachHeadlessInstance[]; // forEach instance metadata
keyedPartModules: KeyedPartModule[]; // build-time only (for hydration entry)
serverTrackByMap?: Record<string, string>; // build-time only
clientTrackByMap?: Record<string, string>; // build-time only
}
At serve time, handlePageRequest only uses a subset: parts, headlessInstanceComponents, and forEachInstances. All of these are known at build time.
Q3: What does renderFastChangingData actually need from each component?
Traced through fast-changing-runner.ts:
From parts (page + keyed headless):
compDefinition.fastRender— the function to call (loaded from module)compDefinition.slowlyRender— checked to determine fastRender signaturecompDefinition.services— for service resolutionkey— optional namespace for ViewState mergingcontractInfo.contractName+contractInfo.metadata— passed as props to fastRender
From headlessInstanceComponents (instance headless):
contractName— to match against discovered instancescompDefinition.fastRender,.slowlyRender,.services— same as abovecontract.props— only for prop name normalization in forEach instances (line 154-156):
Just needsconst contractProps = comp.contract?.props ?? []; const normalizePropName = (key: string) => contractProps.find((p) => p.name.toLowerCase() === key.toLowerCase())?.name ?? key;Array<{ name: string }>.
From forEachInstances:
contractName,forEachPath,trackBy,propBindings,coordinateSuffix— all plain serializable data.
Q4: Can we pre-compute this config at build time?
Yes. Everything renderFastChangingData needs is either:
- A module to import (serialize as path + export name)
- Plain data (serialize as JSON)
The only non-serializable parts are compDefinition objects (functions). Replace with module references that the serve-time loader imports.
Q5: What about contracts — are they needed at serve time beyond prop name normalization?
The full Contract object is not needed. At serve time, the only contract usage is contract.props for forEach prop name normalization. The rest (type generation, ViewState schema, phase annotations) is build-time only.
For the automation/webMCP system: contract descriptions and structure are used client-side by the automation API to help AI agents understand the page. This comes from the compiled hydration entry, not from server-side contract resolution.
We should compile the "effective contract" — the minimal contract data each component needs at serve time — and embed it in the page config. This also makes contracts available to plugins that need them at serve time.
Q6: Is the page config per-route or per-instance?
Per-route. All instances of the same route share the same source jay-html and the same component structure. They differ only in slow ViewState (which is already in cache.json). The current page-handler.ts caches pageParts by route.pattern (line 29-31), confirming this assumption.
Q7: What about headfull component children — nested headfull, keyed headless, instance headless?
All three are handled at build time by parseHeadfullFSImports (recursive for nested headfull, lines 1076-1114). The build pipeline already discovers and resolves all children via loadProductionPageParts. The pre-computed config captures the complete resolved result — all children are flattened into the parts and headlessInstanceComponents arrays.
No special serve-time handling needed. The config simply records the final resolved state.
Q8: The config is written "once per route, first instance writes it." How do we know the first instance's result is correct for all instances?
All instances of a route share the same source jay-html — slow rendering changes data bindings, not template structure. The headless imports, forEach declarations, and component modules are structural. The config captures template structure, not data.
Note on ForEachHeadlessInstance vs DiscoveredHeadlessInstance: slow forEach expansion (Pass 1) unrolls items into static <jay:xxx> tags, which become DiscoveredHeadlessInstance entries stored in carryForward.__instances (per-instance, in cache.json). Interactive forEach blocks stay as ForEachHeadlessInstance — template-structural, same for all instances. The coordinateSuffix (e.g., product-card:AR0) is a position-based suffix determined by template structure, not by data. It is safe per-route.
Q9: Module paths in the config — what if the build directory moves between build and serve (e.g., different mount point in Docker)?
Paths must be relative to the build directory, not absolute. When serializing, store server/components/kitan-header/index.js (relative to buildDir), not /Users/yoav/work/jay/golf/build/v1/server/.... The serve-time loader resolves against its own buildDir. NPM module paths (e.g., @wix/stores-plugin) are package names, not filesystem paths — they resolve via node_modules.
Q10: Is there a race condition if instances of the same route build concurrently?
Currently buildInstance runs sequentially per route (for (const params of paramsList) in build-pipeline.ts:371). The first instance writes the config, subsequent ones skip. No race. If concurrent builds are added later, last-writer-wins is safe because the config is identical for all instances of a route.
Q11: The serve-time loader casts { props: [...] } as any to satisfy the Contract type. Is this safe?
No — as any is fragile. If future code accesses other contract fields at serve time, it silently gets undefined. Instead, narrow the type. Introduce ServeTimeContract:
interface ServeTimeContract {
props: Array<{ name: string }>;
}
Update HeadlessInstanceComponent.contract to accept Contract | ServeTimeContract, or use Pick<Contract, 'props'> if Contract.props has the right shape. This makes the contract boundary explicit and type-safe.
Q12: The serve-time loader uses modulePath.startsWith('server/') to distinguish local from NPM modules. Is this reliable?
The heuristic works for the current naming convention (local modules are compiled to server/...), but it's implicit. Better: add a source: 'npm' | 'local' field to the config. Explicit is safer than convention-based.
Q13: When the slow render server re-renders an instance on data change (DL#134c), does it need to regenerate page-parts.json?
No. Data changes only affect slow ViewState (stored in cache.json). The component structure (which modules, which contracts, which forEach declarations) is determined by the source jay-html, which doesn't change on data change — only on code deployment. The page-parts config is stable within a version.
Q14: trackByMap for deepMergeViewStates — is it populated in the route manifest?
No — this is a latent bug. buildRouteEntry in route-manifest.ts does not set trackByMap, but page-handler.ts:131 reads route.trackByMap || {} for deepMergeViewStates. The pageParts.clientTrackByMap from loadProductionPageParts is available at build time (passed to generateHydrationEntry) but not written to the manifest.
This affects the secure package's deep merge behavior. Since jay-stack does not yet support the secure package, this can be fixed separately. For now, note it as a known gap: the page-parts config or route manifest should include trackByMap when secure package support is added.
Design
Pre-computed page config
Replace serve-time jay-html parsing with a build-time config file. The build pipeline already runs loadProductionPageParts to discover all components. Instead of discarding that knowledge and re-deriving it at serve time, serialize it.
Config schema
interface PagePartsConfigEntry {
modulePath: string; // e.g., "server/pages/product/page.js" or "@wix/stores-plugin"
exportName: string; // e.g., "page" or "ProductCard"
source: 'npm' | 'local';
}
interface PagePartsConfig {
/** Page component + keyed headless components */
parts: Array<
PagePartsConfigEntry & {
key?: string; // keyed headless namespace
contractInfo?: {
contractName: string;
metadata?: Record<string, unknown>;
};
}
>;
/** Instance headless components (used as <jay:xxx> tags) */
instanceComponents: Array<
PagePartsConfigEntry & {
contractName: string;
propNames: string[]; // from contract.props, for forEach prop normalization
}
>;
/** forEach headless instances (interactive forEach only — slow forEach is in carryForward) */
forEachInstances: Array<{
contractName: string;
forEachPath: string;
trackBy: string;
propBindings: Record<string, string>;
coordinateSuffix: string;
}>;
}
All fields are plain JSON. No parsed contracts, no function references, no file handles.
For the contract type at serve time, introduce a narrow type instead of casting:
interface ServeTimeContract {
props: Array<{ name: string }>;
}
HeadlessInstanceComponent.contract should accept Contract | ServeTimeContract so the serve-time loader can construct it type-safely from propNames.
Build-time: write config
In instance-pipeline.ts, after loadProductionPageParts (line 100-107), serialize the config:
Build time (instance-pipeline.ts):
1. loadProductionPageParts() → pageParts [already done]
2. writePagePartsConfig(pageParts) → page-parts.json [NEW]
3. injectHeadfullFSTemplates() → templates inlined [already done]
4. slowRenderTransform() → slow bindings resolved [already done]
5. ... rest of pipeline unchanged ...
The config is written per-route (not per-instance). First instance of each route writes it; subsequent instances skip. Location: build/v1/pre-rendered/{routeDir}/page-parts.json.
Serve-time: load config
Replace loadProductionPageParts with a new function that:
- Reads
page-parts.json - Imports each module by path (using
import()for NPM packages, artifact store for local modules) - Assembles
ProductionPagePartsfrom config + loaded modules - Returns the same shape as today —
handlePageRequestdoesn't change
async function importModule(
entry: PagePartsConfigEntry,
artifacts: FilesystemArtifactStore,
): Promise<any> {
return entry.source === 'local'
? artifacts.loadPageModule(entry.modulePath)
: import(entry.modulePath);
}
async function loadPagePartsFromConfig(
configPath: string,
artifacts: FilesystemArtifactStore,
): Promise<ProductionPageParts> {
const config: PagePartsConfig = JSON.parse(await fs.readFile(configPath, 'utf-8'));
const parts: DevServerPagePart[] = [];
for (const entry of config.parts) {
const mod = await importModule(entry, artifacts);
parts.push({
compDefinition: mod[entry.exportName],
key: entry.key,
clientImport: '',
clientPart: '',
contractInfo: entry.contractInfo,
});
}
const headlessInstanceComponents: HeadlessInstanceComponent[] = [];
for (const entry of config.instanceComponents) {
const mod = await importModule(entry, artifacts);
const serveTimeContract: ServeTimeContract = {
props: entry.propNames.map((name) => ({ name })),
};
headlessInstanceComponents.push({
contractName: entry.contractName,
compDefinition: mod[entry.exportName],
contract: serveTimeContract,
});
}
return {
parts,
headlessContracts: [],
headlessInstanceComponents,
discoveredInstances: [],
forEachInstances: config.forEachInstances,
keyedPartModules: [],
};
}
No parseJayFile, no injectHeadfullFSTemplates, no importResolver, no source files, no contracts.
What changes in each file
| File | Change | Why |
|---|---|---|
instance-pipeline.ts |
After loadProductionPageParts, write page-parts.json |
Serialize discovered config at build time |
load-production-parts.ts |
Add loadPagePartsFromConfig() function |
Serve-time loader from pre-computed config |
page-handler.ts |
Call loadPagePartsFromConfig() instead of loadProductionPageParts() |
Eliminate serve-time jay-html parsing |
main-server.ts |
Use process.cwd() instead of manifest.projectRoot |
Fix Gap 4: absolute path in container |
page-handler.ts |
Use process.cwd() instead of manifest.projectRoot |
Fix Gap 4 for page parts loading |
types.ts |
Make projectRoot optional in RouteManifest |
It's no longer the source of truth |
artifact-store.ts |
Add readPagePartsConfig(routeDir) |
Read page-parts.json for a route |
What does NOT change
instance-pipeline.tsbuild flow (slow render, server element compile, hydration gen) — unchangedloadProductionPagePartsitself — still used at build time bybuildInstanceand param discovery- Pre-rendered jay-html format — still written to disk for server-element compilation and hydration entry gen (both build-time only)
renderFastChangingData— receives the sameProductionPagePartsshapehandlePageRequestresponse assembly — unchanged
Effective contract compilation
For components that need contract data at serve time (forEach prop normalization), embed only what's needed:
// At build time, extract from contract:
propNames: contract.props?.map((p) => p.name) ?? [];
If plugins need richer contract data at serve time (e.g., descriptions for automation), extend the config with an effectiveContract field per component. This is an additive change — start minimal.
Module path resolution
At build time, loadProductionPageParts already resolves module paths:
- NPM plugins: package name (e.g.,
"@wix/stores-plugin") — works withimport()at serve time - Local components: resolved to
build/v1/server/components/.../index.jsvia theserverBuildDirparameter (load-production-parts.ts:86-100)
The config stores these resolved paths. At serve time, NPM modules import by package name; local modules import from the build directory.
Implementation Plan
Phase 0: Expand test fixture and establish baseline
The existing basic-project fixture only tests two simple headfull pages — none of the configurations affected by DL#137. Expand it to cover all relevant component patterns, verify they work with the current build+serve pipeline, then verify they still work after implementation.
Fixture structure
basic-project/
├── src/
│ ├── lib/init.ts [keep]
│ ├── actions/cart.actions.ts [keep]
│ ├── components/
│ │ └── site-header/
│ │ ├── index.ts headfull FS component
│ │ ├── site-header.jay-html template — includes <jay:cart-badge>
│ │ └── site-header.jay-contract contract
│ ├── plugins/
│ │ └── cart-badge/
│ │ ├── index.ts headless component (local plugin)
│ │ └── cart-badge.jay-contract contract
│ └── pages/
│ ├── page.ts [NEW] index — simple page
│ ├── page.jay-html
│ ├── home/
│ │ ├── page.ts [MOVED from /] original home page
│ │ └── page.jay-html
│ ├── featured/
│ │ ├── page.ts [NEW] page with headfull FS component
│ │ ├── page.jay-html imports site-header (headfull with nested headless)
│ │ └── page.jay-contract
│ ├── catalog/
│ │ ├── page.ts [NEW] page with direct headless instance
│ │ ├── page.jay-html uses <jay:cart-badge> directly
│ │ └── page.jay-contract
│ └── items/[slug]/
│ ├── page.ts [keep] dynamic page with loadParams
│ └── page.jay-html [keep]
What each page tests
| Route | Configuration | DL#137 relevance |
|---|---|---|
/ |
Simple page, slow+fast, no headless/headfull | Baseline — no source file dependencies at serve time |
/home |
Moved home page (same logic as current /) |
Verifies non-root routes work |
/featured |
Headfull FS component (site-header) containing nested headless (cart-badge) | Gap 1 core case — headfull source + nested headless hoisting |
/catalog |
Direct headless instance <jay:cart-badge> on page |
Headless without headfull wrapping |
/items/[slug] |
Dynamic params + loadParams (2 slugs) | Per-instance builds, different slow ViewState |
Components
site-header (headfull FS):
- Contract:
siteName: string (slow), ref:menuButton - Template includes
<jay:cart-badge>— a nested headless instance - Has slow+interactive phases
cart-badge (headless, local plugin):
- Contract:
count: number (fast) - Fast phase returns cart count
- Used in two contexts: nested inside site-header (featured page) AND directly on catalog page
Test strategy
Build tests — for each new page:
- Pre-rendered jay-html exists and contains expected content
- Server element loads and produces HTML via
renderToStream - Client bundle exists
- Cache metadata has correct slow ViewState
Serve tests — for each new page:
- SSR response returns 200 with expected content
- Headless component data appears in rendered output
- Import map and hydration script present
Self-containment test (the DL#137 litmus test):
- Build the project
- Remove/rename
src/directory - Start the production server from build artifacts only
- Hit each route — should all return 200
This test should FAIL with current code (confirming the gap) and PASS after implementation.
Phase 1: Write page-parts.json at build time
- In
instance-pipeline.ts, afterloadProductionPageParts()returnspageParts, serialize aPagePartsConfigtopage-parts.json - Write once per route (skip if already written for a different instance of the same route)
- Extract module paths from the headless import resolution that
loadProductionPagePartsalready performed - Extract
propNamesfromcontract.propsfor each instance component - Extract
forEachInstancesfrom the discovery result
Phase 2: Serve-time loader from config
- Add
loadPagePartsFromConfig()toload-production-parts.ts - Update
page-handler.tsto call it instead ofloadProductionPageParts() - The function reads
page-parts.json, imports modules, assemblesProductionPageParts - Cache per-route (same as current
pagePartsCache)
Phase 3: Fix absolute paths (Gap 4)
- In
main-server.ts, replacemanifest.projectRootwithprocess.cwd()fordiscoverPluginsWithInit - In
page-handler.ts, replacemanifest.projectRootwithprocess.cwd()for page parts loading - Make
projectRootoptional inRouteManifesttype — keep it for debugging but don't depend on it
Phase 4: Clean up
- Remove
parseJayFile/injectHeadfullFSTemplates/JAY_IMPORT_RESOLVERimports from serve-time code paths - Remove
jayHtmlPathfromRouteEntry— not needed at serve time - The
loadProductionPagePartsfunction stays for build-time use
Trade-offs
| Decision | Pro | Con |
|---|---|---|
| Pre-compute config at build time | Eliminates all source file dependencies at serve time; solves Gaps 1-3 in one design | Adds a config file to the build output; build pipeline must keep it in sync |
| Minimal contract serialization (prop names only) | Tiny config, no complex serialization | If plugins need richer contract data at serve time, config must be extended |
| Per-route config (not per-instance) | Single file per route; matches current caching behavior | Assumes all instances of a route share the same component structure |
process.cwd() for plugin discovery |
Works in any deployment (Docker, local, CI) | Assumes project root == cwd; might need CLI flag for custom layouts |
Keep loadProductionPageParts for build time |
No refactor of the build pipeline; serve-time change is isolated | Two code paths for loading page parts (build-time vs serve-time) |
Verification
Automated (Phase 0 fixture)
- Build tests pass — all 5 routes build successfully with correct artifacts
- Serve tests pass — all routes return 200 with expected SSR content
- Self-containment test — build, remove
src/, serve, all routes return 200 - Routes covering all configurations: simple page, headfull FS, headfull+nested headless, direct headless instance, dynamic params
Manual (golf project)
- Build golf project with headfull FS components (kitan-header with cart-indicator)
- Run production server WITHOUT
src/, WITHOUTagent-kit/— all routes serve correctly - Verify Dockerfile needs only
COPY build/v1,COPY config— noCOPY src/...orCOPY agent-kit/... - Verify no
sedpath rewriting needed in Dockerfile
Implementation Results
Phase 0: Fixture expansion — DONE
Expanded basic-project test fixture from 2 routes to 5:
| Route | Configuration | Purpose |
|---|---|---|
/ |
Simple page (slow render only) | Baseline |
/home |
Moved original home (slow+fast, head tags, CSS) | Non-root route |
/featured |
Headfull FS (site-header) with nested headless (cart-badge) |
Gap 1 core case |
/catalog |
Direct headless instance <jay:cart-badge> |
Headless without headfull |
/items/[slug] |
Dynamic params + loadParams | Per-instance builds |
Added two local components:
src/components/site-header/— headfull FS with contract, template containing<jay:cart-badge>src/plugins/cart-badge/— headless plugin with plugin.yaml, contract, slow+fast phases
74 tests pass (22 build + 27 serve + 25 param-routing).
Phases 1–3: Pre-computed page config — DONE
Phase 1 — Build-time config (page-parts.json):
loadProductionPagePartsnow tracksheadlessModuleInfos— resolved module paths, export names, source type (npm/local), and contract prop names for each headless importbuildPagePartsConfig()serializes this into aPagePartsConfigJSON structure with three sections:parts(page + keyed headless),instanceComponents(instance headless),forEachInstancesinstance-pipeline.tswritespage-parts.jsonper route directory (first instance writes, subsequent skip viafs.accesscheck)- Module paths stored relative to buildDir for local modules, package name for NPM
Phase 2 — Serve-time config loader:
loadPagePartsFromConfig()readspage-parts.json, imports modules by path (import()for NPM, path join for local), assemblesProductionPagePartspage-handler.tsuses the config-based loader — no moreparseJayFile,injectHeadfullFSTemplates, orJAY_IMPORT_RESOLVERat serve time- Fallback: if
page-parts.jsonmissing (e.g., pages without jay-html), creates minimal parts from the page module directly
Phase 3 — Absolute path fix (Gap 4):
main-server.tsusesprocess.cwd()instead ofmanifest.projectRootfordiscoverPluginsWithInit
No deviations from design. The ServeTimeContract type is used via as any cast for now (the HeadlessInstanceComponent.contract type still expects full Contract). The narrower type should be introduced in stack-server-runtime when the secure package adds jay-stack support (Q14).
Phase 4: Clean up — PENDING
Serve-time code paths still import parseJayFile etc. but no longer call them. Cleanup deferred to avoid unnecessary churn — the imports are harmless and the serve path is fully config-based.
Fake-shop verification (May 18, 2026)
Built and served examples/jay-stack/fake-shop — a real project with 10 routes, 19 instances, local plugins, headless components (product-widget, stock-status, mood-tracker), keyed headless (product-rating), actions, and init.
yarn confirm: Passed. Full rebuild + type check + test + format across all 70 packages.
yarn build: 19/19 instances built successfully. page-parts.json generated for all 10 routes.
yarn serve route test results:
| Route | Status | Notes |
|---|---|---|
/ |
200 | Homepage with mood tracker, product widgets (static + slowForEach + interactive forEach) |
/products |
200 | Product listing page |
/products/gaming-laptop |
200 | Dynamic product page with keyed headless (product-rating) |
/cart |
200 | Cart page |
/checkout |
200 | Checkout page |
/ui-demo |
200 | UI kit demo (popover, carousel, clipboard, etc.) |
/mood-stats |
200 | Plugin route (mood-tracker-plugin, external package) |
/upload |
— | Not tested (file upload) |
/inventory-check |
— | Not tested |
/thankyou |
— | Not tested |
All 7 tested routes return 200. Verified in browser — pages render correctly with interactive features.
Bugs found and fixed during verification:
Bracket escaping in module paths —
page-parts.jsonfor dynamic routes (e.g.,/products/[slug]) wroteserver/pages/products/[slug]/page.jsbut the build pipeline escapes brackets to underscores (server/pages/products/_slug_/page.js). Fixed by adding.replace(/\[/g, '_').replace(/\]/g, '_')to matchbuild-pipeline.ts:239. Symptom:/products/gaming-laptoprendered SSR HTML but client hydration crashed (keyed headless data missing from fast ViewState because the page module import failed silently).Plugin route source type — Plugin-provided pages (e.g., mood-stats from mood-tracker-plugin) had their module path stored as
source: 'local'with an absolute filesystem path. The serve-time loader joined this with buildDir, creating a nonsensical path. Fixed by detecting plugin routes (route.componentExportset) and usingsource: 'npm'so the path is imported directly. Symptom:/mood-statsreturned 500 ("Cannot read properties of undefined (reading 'fastRender')").
Remaining source file references in build output:
route-manifest.json—jayHtmlPathfields contain absolute source paths (e.g.,.../src/pages/cart/page.jay-html). No longer used at serve time after our changes —getPagePartsreads frompage-parts.jsoninstead.projectRootalso absolute — no longer used after Phase 3 (process.cwd()replaces it). Both are harmless metadata.cache.json—sourcePathfield per instance points to source jay-html. Informational only, not used at serve time for file resolution.page-parts.json— clean. Local module paths relative to buildDir (server/pages/...,server/plugins/...). Plugin module paths are absolute (to the NPM package dist). No source file references.
Local plugin init in production — FIXED
Problem: main-server.ts has its own inline plugin init loop (lines 40-51) that does import(pluginInit.packageName). For NPM plugins, packageName is the npm package name (resolved via node_modules). For local plugins, scanPlugins sets packageName: pluginPath = the source directory path (e.g., /Users/.../src/plugins/product-rating). Node ESM rejects directory imports.
The dev server avoids this because executePluginServerInits (plugin-init-discovery.ts:217) constructs modulePath = path.join(plugin.pluginPath, plugin.initModule) and loads via viteServer.ssrLoadModule(). The production server's inline loop skips this logic.
Root cause: Same class of problem as DL#137's page parts — serve-time code accesses source files instead of compiled build output.
Fix: Two changes:
main-server.ts— for local plugins, redirect the import from the source directory to the compiled init module inbuild/v1/server/plugins/{name}/{initModule}.jsserver-code-build.ts— removed thefile !== 'init.ts'exclusion soinit.tsin plugin directories is compiled to the build output
After fix: all plugin inits succeed — [product-rating] Initializing ratings service... appears in the server startup log.
Cleanup — DONE
- Removed
sourcePathfromcache.jsonmetadata (instance-pipeline.ts) - Removed
jayHtmlPathfromRouteEntrytype and route manifest output - Serve-time code has no dead imports (no
parseJayFile/JAY_IMPORT_RESOLVERinlib/serve/)
Summary:
The core DL#137 goal is achieved: the serve-time code path no longer parses jay-html or reads source files. All component discovery, contract resolution, and module path mapping happens at build time and is serialized to page-parts.json. The production server loads this config and imports the listed modules — no compiler, no import resolver, no source files needed.
Post-implementation fixes (2026-08-02)
Three bugs found during jay-website production build/serve:
1. Stale page-parts.json on incremental rebuilds (instance-pipeline.ts)
page-parts.json was guarded by fs.access — only written if the file didn't exist. On incremental rebuilds, changes to headfull FS imports were silently lost because the stale file was reused. Headfull FS components (SiteHeader, SiteFooter) were missing from instanceComponents, causing __headlessInstances hydration errors in production serve.
Fix: Always overwrite page-parts.json during build.
2. Server-only components in keyedPartModules (load-production-parts.ts)
loadProductionPageParts unconditionally pushed keyed components to keyedPartModules, generating client-side import {markdownPages} from '@jay-framework/markdown/client'. Server-only components (no fastRender or hasInteractive) don't have client exports, causing SyntaxError: does not provide an export named at runtime.
Fix: Added hasClientComp guard matching the dev server's loadPageParts logic.
3. loadParams cache keyed by function reference (build-pipeline.ts)
The loadParamsCache used the loadParams function reference as cache key. When multiple routes share the same plugin component (e.g., markdownPages) with different headlessProps (different contentDir), only the first route's params were used. Routes like /design-log/jay/[slug] and /docs/designer/[slug] both use markdownPages but scan different directories — the shared cache caused slug cross-contamination.
Fix: Changed cache key to key:propsJSON so routes with different headlessProps get separate loadParams results.
All 7 tested routes return 200 in fake-shop. All plugin inits succeed. 74 unit tests pass.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.