Skip to content

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.

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, or prefix;
  • 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.

Adding, deleting, or renaming a page, or changing its route identity, updates the live route table without restarting the development server. Warlock:

  1. atomically replaces the page-owned routes, restoring the previous table if registration fails;
  2. invalidates the browser page registry;
  3. 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.

Import global CSS directly from src/web/root.tsx:

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.

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.json with each page’s method, path, name, and source file;
  • rejects page graphs that violate the one-rendering-layout rule;
  • rejects .ts/.tsx sources under src/web that import a static asset or use import.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 in webConnector({ 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 start does 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.

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:

Terminal window
warlock routes:diff

The 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.

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 build writes each locale’s table to dist/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() and register()): 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.

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:

warlock.config.ts
web({
build: {
translations: { sharedGroups: ["app", "common"], prune: true },
},
});

The localization guide explains the keys the analysis resolves and how to declare the rest.

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.

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.

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:

src/app/shared/request-context.ts
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.

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.

  1. Confirm the root, current layout, or page directly imports the stylesheet.
  2. In development, inspect the rendered document for a link ending in ?direct and confirm it returns text/css.
  3. In production, confirm the matching source appears in client/.vite/manifest.json with CSS assets beneath the configured client prefix.
  4. 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.

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.

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:

warlock.config.ts
webConnector({ build: { chunks: "merged", precompress: false } });
OptionValuesDefaultEffect
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.
precompressbooleantruefalse 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.

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 .br and .gz siblings 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.

In production, the work that depends only on the build is done once:

WorkWhen it runs
Page route names for the browser route tableAt 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 tablesAt build. A page’s translation snapshot is built on its first request per locale and reused.
Sitemap and robots discoveryAt 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.

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.