134a - Build Pipeline
Design Log #134a — Build Pipeline
Written for AI agents. See Log Methodology Note below for details.
Date: May 10, 2026 Status: Draft Parent: #134 (production build) Related: #94 (SSR streaming), #110 (slow render cache), #52 (code splitting), #118 (jay-html-compiler refactor)
Background
Design Log #134 establishes a two-server production architecture where a slow render server produces build artifacts and a main server consumes them. This child design log covers the build pipeline — the process of turning source code into production artifacts.
The pipeline must produce, per instance (route + param combination):
- Pre-rendered jay-html (slow data baked in)
- Compiled server element JS (for SSR at request time)
- Compiled + bundled client JS (hydration script)
- CSS
Plus shared artifacts built once per version:
- Jay framework shared chunks
- Plugin shared chunks
- Compiled server code (page.ts, actions, services, init.ts)
- Route manifest
Current State: What Exists Today
compiler-jay-html — Four Compilation Targets
The compiler produces four targets from a single .jay-html file (jay-html-compiler.ts, jay-html-compiler-server.ts):
| Target | Generator | Output | Used By |
|---|---|---|---|
| Element | generateElementFile() |
render() — creates DOM from scratch |
Client-only rendering (no SSR) |
| Hydrate | generateElementHydrateFile() |
hydrate() — adopts existing DOM |
Client after SSR |
| Server Element | generateServerElementFile() |
renderToStream(vs, ctx) — writes HTML strings |
SSR on server |
| Bridge | generateElementBridgeFile() |
Worker sandbox communication | Secure mode (not relevant here) |
compiler-jay-stack — Vite Plugin Composition
jayStackCompiler() returns three Vite plugins (compiler-jay-stack/lib/index.ts):
- jay-stack:code-split — strips server/client code based on
options.ssr- Server build: keeps
withSlowlyRender,withFastRender, stripswithInteractive - Client build: strips server phases, keeps
withInteractive
- Server build: keeps
- jay-stack:action-transform — replaces action imports with
createActionCaller()stubs (client only) - jay:runtime — the base jay-html compiler plugin
Plugin Packages — Already Pre-compiled
Plugins use the same jayStackCompiler for their builds (examples/jay-stack/mood-tracker-plugin/vite.config.ts):
- Server build:
vite build --ssr→dist/index.js(server code) - Client build:
vite build→dist/index.client.js(client code) - Jay framework packages are externalized — they become shared chunks in the consuming project
Dev Server — How SSR Works Today
generate-ssr-response.ts at request time:
1. parseJayFile(jayHtmlContent) — parse jay-html YAML
2. generateServerElementFile(jayFile) — compile to TS
3. fs.writeFile(serverElementPath) — write .server-element.ts to build/
4. vite.ssrLoadModule(serverElementPath) — Vite compiles TS → JS + loads
5. renderToStream(viewState, ctx) — execute to produce HTML
6. generateHydrationScript(...) — inline <script> with imports
The hydration script is an inline <script type="module"> that imports:
hydrateCompositeJayComponentfrom@jay-framework/stack-client-runtimehydratefrompage.jay-html?jay-hydrate(compiled on-demand by Vite plugin)- Each page part's client export
- Plugin/project init modules
Vite dev server resolves all these imports at request time.
Problem: Gaps for Production
Gap 1: Server elements compiled at request time via Vite
compileAndLoadServerElement() uses vite.ssrLoadModule() which requires a running Vite dev server. Production needs pre-compiled .js files loadable via import().
What to change: The compilation steps (parse → generate → write) are reusable. Replace vite.ssrLoadModule() with a pre-compilation step that produces standalone JS.
Gap 2: Hydration script is an inline module with bare imports
The hydration script uses bare specifiers (@jay-framework/stack-client-runtime, page.jay-html?jay-hydrate) that Vite resolves at request time. Production needs a bundled JS file with all dependencies resolved.
What to change: Generate a hydration entry point TS file per instance, then run Vite build on it.
Gap 3: No per-instance Vite build capability
The existing jayStackCompiler is designed for whole-project builds (all pages at once). We need per-instance builds where each instance has its own entry point referencing pre-rendered jay-html.
What to change: Create a Vite build configuration that takes a single instance entry point and produces a bundled JS file, referencing pre-built shared chunks.
Gap 4: Client script generation is runtime-only
buildScriptFragments() and generateHydrationScript() produce inline script text. Production needs them to produce importable TS/JS entry files.
What to change: Extract the logic that generates imports and wiring into a file-writing function instead of inline string generation.
Gap 5: CSS handling relies on Vite's /@fs/ URL scheme
compileAndLoadServerElement writes CSS to disk and serves it via /@fs/ prefix. Production needs CSS as a build artifact with content hashing.
What to change: CSS becomes part of the per-instance Vite build — imported by the hydration entry, extracted by Vite's CSS handling.
Gap 6: No shared chunk pre-building
In dev, shared dependencies are resolved on-demand. Production needs shared chunks built once and referenced by all instances.
What to change: A separate Vite build step that produces shared chunks from jay framework + plugin packages.
Design
Build Pipeline Overview
Version Build (jay-stack build)
==============================
Phase 0: Shared Artifacts
├── Compile server code (page.ts, actions, init.ts) → build/v{n}/server/
├── Build shared client chunks (jay framework, plugins) → build/v{n}/shared/
└── Scan routes + load params → route list
Phase 1: Per-Instance Pipeline (concurrent, bounded parallelism)
For each (route, params):
├── 1. Slow render → slowViewState + carryForward
├── 2. Pre-render jay-html → instance-specific jay-html
├── 3. Compile server element → .server-element.js
├── 4. Generate hydration entry → .hydrate-entry.ts
├── 5. Per-instance Vite build → instance-[hash].js + instance-[hash].css
└── 6. Write instance manifest entry
Phase 2: Finalize
└── Write route-manifest.json → build/v{n}/
Phase 0: Shared Artifacts
Server Code Compilation
Use Vite build in SSR mode to compile all server-side TypeScript:
await build({
plugins: [...jayStackCompiler(jayOptions)],
build: {
ssr: true,
outDir: `build/v${version}/server`,
rollupOptions: {
input: {
init: 'src/lib/init.ts',
...pageEntries, // { 'pages/home/page': 'src/pages/home/page.ts', ... }
...actionEntries, // { 'actions/addToCart': 'src/lib/actions/addToCart.actions.ts', ... }
},
external: [
'@jay-framework/fullstack-component',
'@jay-framework/stack-server-runtime',
// ... other server-side framework packages
],
},
},
});
This produces build/v{n}/server/pages/home/page.js, etc. — compiled JS with server-only code (slow + fast phases). These are what the main server loads via import() for fast-phase rendering.
Shared Client Chunks
Build jay framework + plugin client code into shared chunks:
await build({
plugins: [...jayStackCompiler(jayOptions)],
build: {
outDir: `build/v${version}/shared`,
lib: {
entry: {
'stack-client-runtime': '@jay-framework/stack-client-runtime',
component: '@jay-framework/component',
reactive: '@jay-framework/reactive',
runtime: '@jay-framework/runtime',
// plugin client entries
...pluginClientEntries,
},
formats: ['es'],
},
rollupOptions: {
output: {
entryFileNames: '[name]-[hash].js',
chunkFileNames: 'chunks/[name]-[hash].js',
},
},
},
});
Output: build/v{n}/shared/shared-manifest.json mapping module names to hashed filenames.
Question:
Q1: Should shared chunks use Vite's library mode or a custom chunk strategy?
Vite library mode externalizes dependencies and produces one file per entry. A custom chunk strategy (manualChunks) can deduplicate shared code between entries. For shared chunks that multiple instance bundles reference, library mode with external seems right — each shared module becomes a standalone file that instance bundles import.
However, instance bundles need to resolve these imports at runtime. Options:
- Import maps — browser-native,
<script type="importmap">maps bare specifiers to hashed URLs - Rollup
pathsoption — rewrite imports at bundle time to absolute URLs - Pre-bundled with externals — instance bundles include only page-specific code, shared code loaded via separate
<script>tags
Import maps are the cleanest solution — the SSR HTML includes an import map, and both shared and instance scripts use standard ES module imports.
Phase 1: Per-Instance Pipeline
Step 1-2: Slow Render + Pre-render
Reuse existing DevSlowlyChangingPhase.runSlowlyForPage() and slowRenderTransform() from stack-server-runtime. These are not Vite-dependent — they parse contracts, run component slow render functions, and transform jay-html.
The slow render needs compiled server code from Phase 0 to call slowlyRender(). Load page components via import() from build/v{n}/server/.
Step 3: Compile Server Element to JS
Today: generateServerElementFile() → TS → vite.ssrLoadModule().
Production: generateServerElementFile() → TS → compile to JS without Vite.
The server element TS is simple — it imports only from @jay-framework/ssr-runtime (which is a small package with escapeHtml, escapeAttr, ServerRenderContext). Compiling it requires:
- TypeScript → JS transpilation (no type checking needed, just strip types)
- Import resolution for
@jay-framework/ssr-runtime
Options:
- esbuild — fast TS-to-JS transpilation, can bundle the single import
- Vite build in SSR mode — heavier but consistent with the rest of the pipeline
tsc --noEmit false— standard TS compiler
esbuild is the best fit — it's already a Vite dependency, fast, and can produce a standalone JS file:
import { build } from 'esbuild';
await build({
entryPoints: [serverElementTsPath],
outfile: serverElementJsPath,
bundle: true,
format: 'esm',
platform: 'node',
external: [], // Bundle everything (ssr-runtime is tiny)
});
Output: build/v{n}/instances/{route}/page_{hash}.server-element.js — a standalone ESM file with no external dependencies, loadable via import().
Step 4: Generate Hydration Entry
Today: generateHydrationScript() in generate-ssr-response.ts produces an inline <script> with imports that Vite resolves.
Production: Generate a TS file per instance, which becomes the Vite build entry.
What's known at build time vs request time:
| Data | Known at | Embedded in |
|---|---|---|
| Slow ViewState | Build time (per instance) | Hydration entry (bundled) |
| Fast ViewState | Request time | SSR inline JSON |
| CarryForward | Request time | SSR inline JSON |
| TrackByMap | Build time | Hydration entry (bundled) |
| Hydrate function | Build time (per instance, depends on pre-rendered jay-html) | Hydration entry (bundled) |
| Page parts (interactive) | Build time | Hydration entry (bundled) |
The slow ViewState is different per instance — it's the output of slow rendering, which produced the pre-rendered jay-html that this instance is compiled from. It must be embedded in the hydration entry so the client can merge it with the fast ViewState for a complete picture.
// page_abc123.hydrate-entry.ts — per-instance
import { hydrateCompositeJayComponent } from '@jay-framework/stack-client-runtime';
import { hydrate } from './page_abc123.jay-html?jay-hydrate';
// Part imports (client-side only, code-split by jay-stack:code-split)
import pagePart from '../../../pages/products/[slug]/page';
// Slow ViewState — known at build time, baked into this instance's bundle
const slowViewState = {"title":"Blue Widget","inStock":true,"variants":[...]};
const trackByMap = {"variants": "id"};
export function init(fastViewState, fastCarryForward) {
const target = document.getElementById('target');
const rootElement = target.firstElementChild;
const parts = [pagePart].filter(p => p.comp).map(p => ({
comp: p.comp, contexts: p.contexts || []
}));
const pageComp = hydrateCompositeJayComponent(
hydrate, slowViewState, fastViewState, fastCarryForward,
parts, trackByMap, rootElement
);
return pageComp({});
}
The SSR response at request time only injects fast-phase data:
<script type="importmap">
{ "imports": { ... } }
</script>
<script type="module">
import { init } from '/assets/page_abc123-a1b2c3.js';
init(
${JSON.stringify(fastViewState)},
${JSON.stringify(fastCarryForward)}
);
</script>
This means the SSR inline script is minimal — just the init() call with fast-phase data. All build-time knowledge (slow ViewState, hydrate function, page parts, trackByMap) is bundled in the instance JS.
Step 5: Per-Instance Vite Build
Build the hydration entry into a bundled JS file:
await build({
plugins: [...jayStackCompiler(jayOptions)],
build: {
outDir: `build/v${version}/instances/${routeDir}`,
rollupOptions: {
input: { [instanceId]: hydrateEntryPath },
external: [
// Shared chunks — resolved via import map at runtime
'@jay-framework/stack-client-runtime',
'@jay-framework/component',
'@jay-framework/reactive',
'@jay-framework/runtime',
],
output: {
entryFileNames: '[name]-[hash].js',
assetFileNames: '[name]-[hash].[ext]', // CSS
},
},
},
});
The ?jay-hydrate import is resolved by the jay:runtime plugin, which compiles the pre-rendered jay-html into hydrate code. The jay-stack:code-split plugin strips server phases. The jay-stack:action-transform replaces action imports with client callers.
Output per instance:
page_abc123-[hash].js— bundled hydration entrypage_abc123-[hash].css— extracted CSS (if any)
Question:
Q2: Running a separate Vite build per instance could be slow for many instances. Can we batch multiple instance entries into a single Vite build?
Yes — Vite supports multiple entry points. We can batch instances within the same route (they share the same page.ts and plugins, only the jay-html differs):
await build({
rollupOptions: {
input: {
page_abc123: 'instances/products/[slug]/page_abc123.hydrate-entry.ts',
page_def456: 'instances/products/[slug]/page_def456.hydrate-entry.ts',
},
},
});
This allows Rollup to deduplicate code shared between instances of the same route. For data change re-renders, only the affected instance's entry is rebuilt.
Batching strategy:
- Full build: All instances in one Vite build (maximum deduplication, slower for incremental)
- Per-route build: Instances of the same route in one build (good balance)
- Per-instance build: One build per instance (maximum incrementality, more overhead)
Per-route seems like the right default — instances of the same route share most code.
Phase 2: Route Manifest
// Canonical definition — used by all DL#134 child logs
interface RouteManifest {
version: number;
buildTimestamp: string;
sourceHash: string;
publicBasePath: string; // CDN URL or "/" for self-hosting
sharedManifest: Record<string, string>; // module name → hashed filename
routes: RouteEntry[];
actions: ActionEntry[]; // from DL#134d
plugins: PluginEntry[]; // from DL#134d
}
interface RouteEntry {
pattern: string; // e.g., "/products/[slug]"
segments: RouteSegment[]; // parsed segments for matching
serverModule: string; // relative path to compiled page.js
trackByMap?: Record<string, string>; // for ViewState merging
instances: InstanceEntry[];
isPlugin?: boolean;
pluginName?: string;
}
interface InstanceEntry {
params: Record<string, string>; // e.g., { slug: "blue-widget" }
preRenderedPath: string; // relative path to pre-rendered jay-html
serverElementPath: string; // relative path to server-element.js
clientBundlePath: string; // relative path to instance-[hash].js
clientCssPath?: string; // relative path to instance-[hash].css
}
interface PreRenderedEntry {
content: string; // jay-html with cache tag stripped
slowViewState: object; // from <script type="application/jay-cache">
carryForward: object; // includes __instances for headless components
}
The main server reads this manifest to:
- Match incoming requests to routes
- Find the correct instance for a given set of params
- Load the right server element for SSR
- Reference the right client bundle in the HTML response
SSR Response in Production
The main server produces HTML using pre-built artifacts:
async function handleRequest(req, res) {
const route = matchRoute(manifest, req.path);
const instance = findInstance(route, req.params);
// Load pre-compiled server element
const { renderToStream } = await import(instance.serverElement);
// Load pre-compiled page component, run fast phase
const pageModule = await import(route.serverModule);
const carryForward = instance.carryForward; // from slow render, stored in manifest or cache file
const fastResult = await pageModule.default.fastRender(carryForward, ...services);
// Merge slow + fast ViewState for SSR rendering
const fullViewState = mergeViewStates(instance.slowViewState, fastResult.rendered);
// SSR render — server element needs the full merged ViewState
const htmlChunks = [];
renderToStream(fullViewState, {
write: (c) => htmlChunks.push(c),
onAsync: (promise, id, templates) => {
/* ... */
},
});
// Build response — init() only receives fast-phase data
// (slow ViewState + trackByMap are already embedded in the client bundle)
const importMap = buildImportMap(sharedManifest, publicBasePath);
res.write(`<!doctype html><html><head>
<script type="importmap">${JSON.stringify(importMap)}</script>
<link rel="stylesheet" href="${publicBasePath}/${instance.clientCss}" />
</head><body>
<div id="target">${htmlChunks.join('')}</div>
<script type="module">
import { init } from '${publicBasePath}/${instance.clientBundle}';
init(${JSON.stringify(fastResult.rendered)}, ${JSON.stringify(fastResult.carryForward)});
</script>
</body></html>`);
}
The SSR server element renders with the full merged ViewState (slow + fast) to produce correct HTML. But the client init() only receives fast-phase data — the slow ViewState is already baked into the client bundle at build time. The client merges them internally before hydrating.
Implementation Plan
Step 1: Server Code Compilation
Create a build function that compiles all server-side TypeScript using Vite SSR build:
- Input: scanned routes (page.ts paths), action files, init.ts
- Output:
build/v{n}/server/with compiled JS - Test: compiled page modules can be
import()-ed and theirfastRendercalled
Step 2: Shared Client Chunks
Create a build function that produces shared chunks:
- Input: list of framework packages + plugin client entries
- Output:
build/v{n}/shared/with hashed JS files +shared-manifest.json - Test: import map can be constructed from manifest, modules load correctly in browser
Step 3: Server Element Compilation (without Vite)
Extract compileAndLoadServerElement logic into a production variant:
- Input: pre-rendered jay-html content
- Output: standalone
.server-element.jsvia esbuild - Test:
renderToStreamproduces identical HTML to dev server for same ViewState
Step 4: Hydration Entry Generation
Create a function that generates a .hydrate-entry.ts file per instance:
- Input: pre-rendered jay-html path, page parts, plugin inits
- Output: TS file exporting
init(viewState, carryForward, trackByMap) - Test: generated file is valid TS, imports resolve
Step 5: Per-Instance Vite Build
Create a build function that compiles hydration entries:
- Input: one or more
.hydrate-entry.tsfiles - Output: bundled JS + CSS per entry
- Test: bundle loads in browser, hydration works
Step 6: Route Manifest Generation
Create a function that assembles the route manifest:
- Input: all instance build results
- Output:
route-manifest.json - Test: manifest correctly maps routes → instances → artifact paths
Step 7: Build Orchestrator
Wire all steps together into jay-stack build:
- Phase 0: steps 1-2 + route scanning (parallel)
- Phase 1: slow render + steps 3-5 per instance (concurrent with bounded parallelism)
- Phase 2: step 6
Questions
Q1: Import maps vs pre-bundled shared code?
Import maps are cleaner (standard ES modules, browser-native resolution) but require browser support (Chrome 89+, Firefox 108+, Safari 16.4+). Pre-bundling shared code into each instance bundle is simpler but produces larger bundles with duplicated framework code.
Q2: Per-route or per-instance Vite builds?
Per-route batching seems optimal — instances of the same route share page.ts code and plugins. Full-project batching maximizes dedup but makes incremental rebuilds slower.
Q3: Should esbuild or Vite compile server elements?
esbuild is simpler and faster for a single-file compilation with one small dependency. Vite is more consistent with the rest of the pipeline but heavier. The server element is self-contained (imports only ssr-runtime), so esbuild seems right.
Trade-offs
| Decision | Pro | Con |
|---|---|---|
| Import maps for shared chunks | Standard ES modules; browser-native; clean separation | Requires modern browsers; extra <script> tag in HTML |
| esbuild for server elements | Fast; already a dependency; simple for single-file compilation | Different tool than rest of pipeline; potential edge cases |
| Per-route Vite batching | Good dedup within route; reasonable incremental rebuild | Less dedup across routes; more builds than full-project |
init() export pattern |
Clean separation of build-time vs request-time data | Extra indirection; ViewState still inlined as JSON |
Verification Criteria
jay-stack buildproduces all artifacts in the versioned bucket- Server elements produce identical HTML to dev server for the same ViewState
- Hydration works correctly — client adopts server-rendered DOM without flicker
- Shared chunks are loaded once and shared across pages
- Per-instance CSS is correctly extracted and linked
- Incremental rebuild (single instance change) only rebuilds affected artifacts
- Build time scales linearly with instance count
Implementation Results
Package: @jay-framework/production-server
New package at packages/jay-stack/production-server/ with two modules:
lib/builder/— build pipeline (Phase 0-2)lib/serve/— production HTTP server
Shared Chunks Build Strategy
Final approach: Wrapper entries + resolve.dedupe
Each shared package gets a wrapper entry file (export * from '@jay-framework/...') that imports by package name. The Vite build uses resolve.dedupe to ensure all packages resolve to the same physical module.
// Wrapper entry (temporary, deleted after build)
export * from '@jay-framework/runtime';
await viteBuild({
rollupOptions: {
input: entries, // wrapper files
preserveEntrySignatures: 'exports-only',
},
resolve: {
dedupe: [...SHARED_PACKAGES, '@jay-framework/list-compare'],
},
});
Key requirements for correctness:
Package name imports, not source paths — using
resolve.aliasor source paths as Rollup entries creates module duplication. Each module's closure variables (likecurrentContextfor the context stack) get duplicated, breaking singleton patterns. Wrapper entries with package name imports +resolve.dedupeensure Rollup shares module instances.preserveEntrySignatures: 'exports-only'— without this, Rollup tree-shakes entry exports that aren't consumed within the bundle (e.g.,adoptTextfor hydration). This option preserves all exports from entry points.Import maps in SSR HTML —
<script type="importmap">maps bare specifiers (@jay-framework/runtime) to content-hashed URLs. Instance bundles use standard ES module imports that the browser resolves via the map.
Approaches tried and rejected:
| Approach | Problem |
|---|---|
| Copy pre-built dist files | Code duplication — each package's dist bundles its own deps inline |
resolve.alias to source paths |
Module duplication — separate closure variables for context state |
| Source paths as Rollup entries | Same duplication — each entry creates its own module tree |
manualChunks forcing single chunk |
Still duplicated — chunks share file but not closure state |
Single namespace entry (export * as pkg) |
Complex wrapper generation, fragile export enumeration |
Per-Instance Client Build
- Uses
jayStackCompiler()withpreserveEntrySignatures: 'exports-only'to keepinit()export - Shared packages externalized — resolved at runtime via import maps
jay-stack:code-splitskips.jay-htmlfiles (they contain builder names as HTML text)jay-stack:action-transformstrips inlinemakeJayQuery/makeJayAction/makeJayStreamstatements
Server Element Compilation
- Uses esbuild (not Vite) for standalone JS compilation
- Custom esbuild plugin handles
.jay-contractimports by extracting enum types from contract YAML - PascalCase conversion for contract names with hyphens
Action Builder Stripping
jay-stack:action-transform plugin extended with a transform hook that strips inline action builder statements (makeJayQuery, makeJayAction, makeJayStream) from component files in client builds. Uses transformJayStackBuilder with a stripBuilders parameter that removes entire statements rooted in action builders before the normal method-stripping transform runs.
slowForEach Instance Handling
After Pass 1 (slowRenderTransform), slowForEach templates are unrolled into static <jay:xxx> instances with coordinate-based keys (e.g., S3/0/0/product-widget:AR0). Pass 2 discovers these static instances via discoverHeadlessInstances and runs slowRenderInstances on ALL of them — including the ones from unrolled forEach. Results are merged into carryForward.__instances.
Key insight: Do NOT use the original source jay-html for forEach discovery at serve time. The pre-rendered jay-html has static instances with correct coordinate keys that match what the hydrate code expects. Using the original source would find forEach patterns and generate trackBy,suffix keys that don't match.
Keyed Headless Parts in Hydration Entry
loadProductionPageParts tracks keyedPartModules for keyed headless components (e.g., mood tracker with key="mt"). These are passed to generateHydrationEntry which includes them as additional composite parts with key and contextMarkers. Module paths: NPM packages use /client entry, local plugins use absolute source path.
Shared Chunks: Plugin Externalization (May 13)
Instance bundles were 780 KB each — mostly duplicated plugin code (Wix SDK, wix-stores, wix-cart) inlined into every instance. Framework packages (@jay-framework/runtime, etc.) were correctly externalized, but plugin packages were not.
Fix: Externalize all @jay-framework/* packages, not just framework core.
| Component | Change |
|---|---|
instance-client-build.ts |
external: (id) => id.startsWith('@jay-framework/') — function instead of hardcoded list |
shared-chunks-build.ts |
Accepts pluginClientPackages parameter, builds shared chunks for plugin /client entries alongside framework packages |
build-pipeline.ts |
discoverPluginClientPackages() walks project package.json → transitive @jay-framework/* deps → filters to packages with ./client export |
page-handler.ts |
<link rel="modulepreload"> tags for all shared chunks — eliminates import waterfall |
Discovery approach: Uses createRequire(projectRoot) to resolve packages through Node's module resolution (handles hoisted monorepo node_modules), then walks up from the resolved main entry to find package.json. Filters to packages that declare exports["./client"]. Recursive — collects transitive @jay-framework/* dependencies.
Result (store-light):
- Instance bundles: 780 KB → 7-17 KB (hydration + slowViewState only)
- Shared chunks: 126 KB → 482 KB (framework + plugins, loaded once, browser-cached)
- Total JS per session:
11.7 MB → **600 KB**
InstanceBuildResult as Discriminated Union (May 13)
Changed from returning InstanceEntry | undefined to a discriminated union:
export type InstanceBuildResult =
| { status: 'success'; instanceEntry: InstanceEntry; slowViewState: object; carryForward: object }
| { status: 'skipped'; reason: string };
Non-fatal slow render outcomes (ClientError, Redirect) return status: 'skipped' instead of throwing. The kind discriminant values are 'ClientError' and 'Redirect' (not 'ClientError4xx'/'Redirect3xx' — those are type names, not kind values).
Optional Route Parameter Matching (May 13)
findInstance() in route-matcher.ts compared URL params by key count, which failed for optional [[param]] segments. URL /kitan/products produces params: {} but the instance has params: { prefix: "kitan" } — the prefix is an inferred param not from URL segments.
Fix: match only on segment-derived param names (from route.segments where type !== 'static'), ignoring non-segment params like prefix.
Dynamic Routes with Inferred Params (May 13)
Routes like /kitan/products/[category]/[slug] have both inferredParams: { prefix: "kitan" } AND dynamic segments. The old code treated inferredParams as "fully specified" and skipped loadParams. Fix: only use inferredParams alone for routes without dynamic segments. When both exist, run loadParams and merge inferred params into each result.
Resolved Gaps
slowForEach— Fixed via Pass 2slowRenderInstancesfor all discovered instancesBuild folder conflict— Dev server moved tobuild/dev/, production usesbuild/v{n}/Minification—--no-minifyCLI flag, default enabledPlugin code duplication— All@jay-framework/*packages externalized into shared chunksOptional params— Route matcher uses segment-derived param names onlyInferred + dynamic params—loadParamsruns with inferred params merged inDev plugins in production build—discoverPluginClientPackages()now scans onlydependencies, notdevDependencies
Dev Plugins Excluded from Production Build (June 2026)
discoverPluginClientPackages() in production-server/lib/builder/build-pipeline.ts merged both dependencies and devDependencies when scanning for @jay-framework/* packages with ./client exports. This caused dev-only plugins (e.g. aiditor) to be bundled into production output as shared chunks.
Fix: only scan projectPkg.dependencies. Plugins in devDependencies are dev-time tools (validators, editor plugins) and should not be included in production builds. No --exclude-plugins flag needed — the devDependencies convention is sufficient signal.
Production Head Performance Fixes (June 2026)
File: production-server/lib/serve/fetch-page-handler.ts
Two issues in the production HTML <head>:
Missing client bundle modulepreload — The route's client entry JS (
route.client-*.js) was imported in the body<script>but not included in<link rel="modulepreload">hints. The browser only discovered it after parsing the body, causing a waterfall. Fix: includeclientBundleUrlin the preload list alongside shared chunks.Google Fonts preload pattern — External CSS
@importURLs (e.g. Google Fonts) were emitted as<link rel="preload" as="style">, which requires a matching<link rel="stylesheet">to actually apply. Fix: detectfonts.googleapis.comURLs and emit the recommended pattern:preconnectto bothfonts.googleapis.comandfonts.gstatic.com, plus<link rel="stylesheet">for the font CSS URL.
Bug fix: public/ folder not flattened into frontend root
The build copied public/ into frontend/public/ — preserving the subdirectory. This meant images at public/logo.png ended up at /public/logo.png instead of /logo.png, breaking root-relative paths like <img src="/logo.png">.
Fix in build-pipeline.ts: Changed fs.cp(publicFolder, path.join(frontendDir, 'public')) to fs.cp(publicFolder, frontendDir). Contents of public/ are now copied directly into frontend/, matching how Vite dev server serves them.
Cleanup in fetch-static-handler.ts: Removed the public/ fallback path from static file candidates — no longer needed since files are at the root level.
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.