Development and production
Warlock keeps one page graph for development SSR, browser hydration, and the production build. The same discovery rules decide which pages and layouts exist in every environment.
Component edits and Fast Refresh
Section titled “Component edits and Fast Refresh”A component-body-only edit is left to React Fast Refresh. It updates the component without a document reload and preserves local state.
Anything outside the component body changes the module’s server-visible skeleton and forces a full reload. That includes:
- imports and module-level declarations;
route,middleware,validation,loader,metadata, orprefix;- a module-level helper even when only the component calls it.
Reloading is deliberately conservative: it re-runs SSR and refreshes the document head instead of risking stale metadata.
Live route-table changes
Section titled “Live route-table changes”Adding, deleting, or renaming a page, or changing its route identity, updates the live route table without restarting the development server. Warlock:
- atomically replaces the page-owned routes, restoring the previous table if registration fails;
- invalidates the browser page registry;
- sends a full document reload so the new server route and hydrated client graph agree.
When checking this manually, verify hydration with an interactive state change
or intercepted <Link> navigation. A visible 200 proves only the server half.
If a request 404s while a matching *.page.tsx exists under src/web/ but is
not registered, the development server logs a one-time warning naming that
file.
Global and page-local CSS
Section titled “Global and page-local CSS”Import global CSS directly from src/web/root.tsx:
import "./app.css";A page or matched layout may import its own stylesheet. Warlock creates one ordered chain for the response: root, ancestor layouts, then page. Only the matched chain contributes render-blocking CSS.
In development, Warlock emits a source stylesheet link ending in ?direct, so
Vite serves text/css instead of a JavaScript CSS module. The rendered root
must contain a closing </head> because stylesheet links are inserted
immediately before it.
Production build
Section titled “Production build”Web 5.32 uses the esbuild 0.28.2 line for build tooling, including the Windows development-server fix in GHSA-g7r4-m6w7-qqqr. Existing build and CSS Modules configuration continues to work without changes.
warlock build uses the same discoverPages() scan and:
- emits
page-routes.manifest.jsonwith each page’s method, path, name, and source file; - rejects page graphs that violate the one-rendering-layout rule;
- rejects
.ts/.tsxsources undersrc/webthat import a static asset or useimport.meta.url, since the server bundle cannot honour either. Test code is skipped:*.spec.*and*.test.*files,__tests__and__mocks__folders, and any app-root-relative path listed inwebConnector({ build: { testOnly: ["src/web/shared/testing"] } }). A page, layout or root inside such a folder is still checked; - builds the hydration client and a Vite manifest;
- resolves root, matched-layout, and page CSS into ordered, deduplicated, render-blocking asset links;
- stores the client asset directory in the page manifest so
warlock startdoes not need to load the TypeScript build config.
At production boot, the Web connector refuses to start without the page manifest instead of reporting healthy while serving only 404s.
Assets and the rate limiter
Section titled “Assets and the rate limiter”Core’s global rate limiter (http.rateLimit, 60 requests per minute per IP by
default) counts page and API requests. The client bundle under /assets/* and
the public files copied by the build are static files, so they are exempt by
default: a page load’s scripts, stylesheets, fonts and images do not spend the
visitor’s quota. Set http.rateLimit.staticFiles: true in src/config/http.ts
to count them as well. In development Vite answers asset requests before any
route runs, so they are never counted. See core’s
rate-limiting recipe.
Use warlock routes:diff to compare the current diagnostic route table with
the last successful build:
warlock routes:diffThe manifest records derived page names. A clean diff does not prove that no two routes collided during registration; discovery itself remains the source of that failure.
Translation tables
Section titled “Translation tables”Each locale’s shared translation groups (default app and common) are served
under a content-hashed name, and every other group is pruned per page to the
keys that page’s client code can reach. The two translation modes differ only
in where the shared table comes from:
- Route dictionaries (
locales.json):warlock buildwrites each locale’s table todist/client/assets/translations/<locale>.<hash>.json, served from the bundle’s assets directory with the one-year immutable cache headers. The build also records each page’s usage in the production page barrel. - Global registry (
extend()andregister()): when the web connector installs its routes at boot, it copies each locale’s shared groups once. That copy and its hash are fixed for the life of the process, and the server serves it at/__warlock/translations/<locale>.<hash>.json.
Both modes also serve /__warlock/translations/<locale>.json, the newest table
for the locale, with no-cache. A browser that receives a page whose hashed
table this process does not hold (a 404) fetches that unhashed table, and only
then retries the hashed one. The localization guide
explains the order in full.
Running more than one process
Section titled “Running more than one process”A deployment can run several processes, for example with pm2 cluster mode or a rolling restart. Each process copies the shared groups at boot, so processes started from the same build and the same registrations publish the same table under the same hash. A document from one process then names a table another process also serves.
During a rolling deploy, a document rendered by the old build names the old table. A new process does not hold it, answers 404, and the browser fetches the new process’s latest table instead. The keys then come from the new build, which can differ from the markup the old build rendered. Keep the shared groups stable across a release, or deploy the processes together.
Tune the groups and pruning with the connector’s build option, which development also honours:
web({ build: { translations: { sharedGroups: ["app", "common"], prune: true }, },});The localization guide explains the keys the analysis resolves and how to declare the rest.
Run warlock start from the app root
Section titled “Run warlock start from the app root”The page manifest stores clientDir as an app-root-relative path such as
dist/client. At boot the Web connector resolves it against the process
working directory (process.cwd()), not against the location of dist. A
built dist is therefore not relocatable: run warlock start with the app
root as the working directory. Copying dist elsewhere without the app root
means the client assets, Vite manifest, and copied public files are not found.
Serving pages on Bun and Deno
Section titled “Serving pages on Bun and Deno”A build made with Node serves pages on Bun and Deno too (bun dist/app.js,
deno run -A dist/app.js, or warlock start run with either runtime). Both
runtimes resolve a bare react-dom/server import to a build without
renderToPipeableStream (Bun’s "bun" export condition and Deno’s "deno"
condition), so the page renderer loads React’s Node server build,
react-dom/server.node, when that happens. On Node nothing changes. The
verified versions and commands are on core’s
Supported runtimes page.
Dev: app modules can load twice
Section titled “Dev: app modules can load twice”In warlock dev, page loaders import app modules through Vite SSR, while HTTP
handlers load them natively. A module-level singleton — an AsyncLocalStorage,
a cache, or a registry — can therefore exist twice, and a value set through one
copy is invisible to the other. Until a single module graph lands (planned for
5.26), keep such singletons on globalThis under a Symbol.for key:
import { AsyncLocalStorage } from "node:async_hooks";
const key = Symbol.for("my-app.requestContext");const store = globalThis as typeof globalThis & { [key]?: AsyncLocalStorage<{ userId: string }>;};
export const requestContext = (store[key] ??= new AsyncLocalStorage());Both copies of the module now read the same instance. warlock start has one
module graph and is unaffected.
Web’s locale, document and session React contexts already use a shared registry. Vite can reevaluate a context-owning module after an edit while a retained root still holds its previous provider. The refreshed hooks reuse that provider’s context, keeping locale and translations available without restarting the server. The registry shares context identity; each provider still supplies its own data. A real dev-server regression checks repeated watched edits and includes a control that reproduces the missing-provider HTTP error when context identity is split.
Dev SSR shares the app’s framework package instances
Section titled “Dev SSR shares the app’s framework package instances”warlock dev runs SSR inside Vite’s module graph. Before 5.13, an app
resolveAlias entry naming a framework package (@warlock.js/cache,
core, logger, context, cascade) made Vite bypass ssr.external and
load a second, never-booted copy of that package — a route with
cache: { serverCache: true } then failed with
CacheDriverNotInitializedError because the module graph’s copy never saw
the connector’s initialization. Dev SSR now always imports those packages
from the instance the app booted, as production does, and drops an app alias
that would re-inline an SSR-external package.
Failed page requests are logged
Section titled “Failed page requests are logged”A page request that fails outside the page pipeline itself — a cache
failure, a module that fails to load — is now logged to stderr as
[warlock:web] page request <METHOD> <path> failed: <error>. Before, the
error reached only the app’s error.page.tsx, and the server log stayed
empty.
Diagnose missing CSS
Section titled “Diagnose missing CSS”- Confirm the root, current layout, or page directly imports the stylesheet.
- In development, inspect the rendered document for a link ending in
?directand confirm it returnstext/css. - In production, confirm the matching source appears in
client/.vite/manifest.jsonwith CSS assets beneath the configured client prefix. - Do not hand-author links to hashed production assets; the manifest owns their filenames.
See warlock routes:diff and
connector lifecycle for
the surrounding Core APIs.
Client asset budgets
Section titled “Client asset budgets”After the client build, npx warlock build measures first-visit gzip JS and CSS
for every discovered page, including hydration, layouts, roots and static
imports. Deferred assets are reported separately. A small Rollup plugin records
the largest module contributors in private build metadata. Configure
build.budgets in warlock.config.ts to warn or fail;
client performance budgets
describes route overrides, deltas and npx warlock build --json.
Client bundle files
Section titled “Client bundle files”By default the client bundle has one lazy chunk per page plus shared chunks, a vendor-react chunk, and a .br and .gz copy of every asset. Two connector build options change that:
webConnector({ build: { chunks: "merged", precompress: false } });| Option | Values | Default | Effect |
|---|---|---|---|
chunks | "split", "merged", "single" | "split" | "merged" packs the small shared chunks together by size and keeps each page lazy. "single" builds one JavaScript entry and one stylesheet with dynamic imports inlined; it needs a single-site app. |
precompress | boolean | true | false skips the .br and .gz copies. Turn it off when the files are served from local disk. |
Every mode keeps the final-bytes file names, the merge of byte-identical chunks, the integrity check, the preload lists and the payload budgets. build.singleBundle in the core config builds the server as one file and does not change these options. For a desktop or offline app set both; see the desktop and offline app recipe.
Client file names and the integrity check
Section titled “Client file names and the integrity check”Every file under client/assets is served with a one-year immutable cache, so a
name must always mean the same bytes. After the bundler finishes, the build
renames each JavaScript chunk after its final bytes, that is, after the
import specifiers inside it point at the final names of the chunks they import:
- A chunk’s name changes whenever its bytes or the bytes of anything it imports change, and it stays the same when nothing changed.
- Chunks that import each other (a cycle) are named together: a change inside the cycle renames every member.
- Byte-identical chunks are merged into one file and their importers point at it. Entry chunks are never merged.
client/.vite/manifest.json, the module preload lists inside the chunks and the budget report all use the final names, and the precompressed.brand.gzsiblings are written after the rename.
Each build also writes client/.vite/warlock-integrity.json, a digest for every
file in client/assets. The next warlock build reads the previous record before it
starts and fails when a file name it emits maps to different bytes than the
previous build used for the same name. The error names every such file. Because
warlock build replaces build.outdir as a whole, a failed build restores the
previous output.
To compare with a build that is not in build.outdir (for example the release
currently deployed), point the web build option previousBuild at its
client/.vite/warlock-integrity.json or at its output directory:
webConnector({ build: { previousBuild: "../releases/current/dist" } });Delete the record (or drop the option) when you intentionally stop serving the previous build’s assets.
What runs per request
Section titled “What runs per request”In production, the work that depends only on the build is done once:
| Work | When it runs |
|---|---|
| Page route names for the browser route table | At build, baked into the page manifest. The table is resolved once per page, site and viewer kind, then reused. |
| Per-page translation usage and shared tables | At build. A page’s translation snapshot is built on its first request per locale and reused. |
| Sitemap and robots discovery | At boot (and on the configured refresh or model events). Requests only serve the generated artifacts. |
In development the same work reruns once per file change, not per request. The
translations and named-api-routes tracing phases (see
request tracing) show what
each request spent on it.
Browser regression checks
Section titled “Browser regression checks”Use the browser harness to start an owned
development fixture. Repeatedly edit components and add/change/remove setup files,
waiting for a visible revision after each write before requesting the route again.
Exercise a hydrated control as well as SSR. Imported shared must stay connected
to the request store, and parallel requests must keep separate values. Framework
module re-evaluations share the resolver and request-keyed scope map; this does
not create a process-wide shared payload.
Run await page.assertAccessible() and await page.assertViewport() on visible
content at phone and desktop widths. Accessibility evidence records axe rule IDs,
selectors, allowances and incomplete checks for manual review.
For forms rendered beneath layouts, compare SSR IDs with values captured by hydrated handlers, and inspect repeated instances, label/ARIA references and navigation. Checking only the DOM ID can miss a hydration mismatch because React may keep the server attribute. Keep hydration errors visible in run evidence.