Skip to content

Upgrade to v5

Warlock v5 introduces an optional Web layer: server-rendered React pages, hydration, and client navigation served by the same Warlock HTTP server as your API. Adopting Web is separate from upgrading an API-only v4 application; the API migration does not add a page layer by itself.

The current 5.2 model is compact but specific:

  • Page files live under src/web. With no route export, directories contribute path segments, index.page.tsx claims its directory, (group) directories contribute nothing, and a whole [id] segment becomes dynamic; an explicit route always wins. Catch-all ([...slug]) and composite dynamic ([lat]-[lng]) filenames are unsupported and remain literal. See filesystem routing.
  • Layouts on the page’s ancestry compose outer-to-inner. At most one may render, while non-rendering layouts may still contribute middleware, loaders, and prefixes; a layout prefix replaces its own directory segment. See layouts and the prefix override.
  • Adding, removing, renaming, or changing a page’s route identity in development atomically re-registers the page routes without restarting the server. Component-body-only edits stay on React Fast Refresh. See live dev re-registration.
  • Keep universal register() setup in a sidecar and named-re-export it (export { register } from "./index.register";). Declaring the non-component hook inline breaks the page’s Fast Refresh boundary. See register().

For the upgrade itself, move the @warlock.js/* packages you use to v5 together, then make the edits below.

5.12.0 ships four changes worth reading before you bump the version, one of them breaking. This section is scoped to that one minor bump; the rest of this page (below) covers the original v4 → v5.0 migration and still applies if you’re coming from further back.

request.user moved to request.locals.user (BREAKING)

Section titled “request.user moved to request.locals.user (BREAKING)”

The authenticated user no longer lives at request.user. It’s now request.locals.user, written by @warlock.js/auth’s middleware — RequestUser, the augmentable interface your app narrows to its own model, moved from @warlock.js/core into @warlock.js/auth alongside it. In development, reading request.user throws a new RequestUserMovedError naming the new location (kept for one release as a migration diagnostic — not silent, but not present forever either); request.clearCurrentUser() is removed with no direct replacement.

Before (removed)

declare module "@warlock.js/core" {
interface RequestUser {
id: string | number;
}
}
const user = request.user;

After

declare module "@warlock.js/auth" {
interface RequestUser {
id: string | number;
}
}
const user = request.locals.user;

useCurrentUser() / requestContext.getUser() still work, but now read request.locals.user and return unknown (or your generic) instead of the removed RequestUser type. @warlock.js/auth also exports a typed currentUser<UserType extends Auth>() wrapper for callers that want Auth-derived typing without repeating the cast at every call site.

Search your codebase for request.user (careful: not request.userAgent) and update every hit to request.locals.user, and move any declare module "@warlock.js/core" { interface RequestUser ... } augmentation to declare module "@warlock.js/auth".

container.get() now throws for a missing key

Section titled “container.get() now throws for a missing key”

container.get(key) throws a ContainerKeyMissingError when key was never registered, instead of silently returning undefined typed as the real value. If you relied on the old undefined-on-miss behavior anywhere, switch that call to container.tryGet(key), which keeps returning undefined. See Container & runtime accessors.

Page data is now devalue, not plain JSON (@warlock.js/web)

Section titled “Page data is now devalue, not plain JSON (@warlock.js/web)”

appData/layoutData/pageData (and a defer()red value’s settlement) now travel the browser wire serialized with devalue instead of plain JSON. Concretely, Date, Map, Set, BigInt, undefined inside an object, repeated references, and cyclic structures now arrive on the client exactly as the loader returned them — they previously arrived flattened to strings or silently dropped.

Two things to check before you deploy this:

  • Deploy the client and server bundles together. The wire format changed, so a server on 5.12 paired with a stale, pre-5.12 client bundle (or vice versa) can fail to deserialize page data. Don’t roll this out as a server-only or client-only partial deploy.
  • A loader value devalue cannot serialize now fails loudly instead of silently. A class instance devalue doesn’t recognize, a function, or a symbol throws PageDataSerializationError at build/render time, naming the loader level (app/layout/page), the key path, and the page route. If a loader you own returns something like that, give it a resource or a toJSON() method — this was already the standing rule for what a loader is allowed to return; devalue just enforces it loudly where JSON silently dropped or mangled the value before. shared is unaffected — it keeps its own, stricter gate.
Section titled “CSRF Origin check for cookie auth (@warlock.js/auth)”

If any route uses authMiddleware(userType, "cookie:<name>"), an unsafe-method request (POST/PUT/PATCH/DELETE) on it is now also checked against Origin/Referer: it must name the request’s own origin or an entry in the new auth.csrf.allowedOrigins config (default []), and a request with neither header is rejected. A mismatch is a new 403 (AuthErrorCodes.CsrfOriginMismatch, EC006) instead of reaching your handler.

Header-token auth and safe methods (GET/HEAD/OPTIONS) are completely unaffected — this only changes behavior for apps already using the cookie token source. If your cross-origin frontend legitimately calls a cookie-authenticated write endpoint, add its origin to auth.csrf.allowedOrigins:

src/config/auth.ts
export default {
// ...
csrf: {
allowedOrigins: ["https://admin.example.com"],
},
};

See Protect routes → CSRF Origin check for the full mechanics.

Also new in 5.12, none of it requiring a migration: authService.setAuthCookie/clearAuthCookie (the write side of cookie-sourced sessions), the opt-in http.csp header, opt-in http.tracing request-tracing hooks, devServer.timings, and a warlock build preflight that fails fast when esbuild’s native binary isn’t linked.

Route handlers and middleware receive one context object

Section titled “Route handlers and middleware receive one context object”

v4 called handlers with positional request and response arguments. v5 passes one HttpContext object. A v4 handler still registers, but its second parameter is undefined and the first request that uses it fails. The same calling convention applies to middleware.

Before (v4)

import type { Middleware, RequestHandler } from "@warlock.js/core";
export const listProducts: RequestHandler = async (request, response) => {
return response.success({ products: await productsRepository.all() });
};
export const loadTenant: Middleware = async (request, response) => {
// ...
};

After (v5)

import type { Middleware, RequestHandler } from "@warlock.js/core";
export const listProducts: RequestHandler = async ({ request, response }) => {
return response.success({ products: await productsRepository.all() });
};
export const loadTenant: Middleware = async ({ request, response }) => {
// ...
};

Run warlock doctor after upgrading. Its handler-signature check reports handlers that still look like the v4 two-parameter form. Also search validation callbacks and application middleware, not only controllers.

Arbitrary properties can no longer be attached to Request

Section titled “Arbitrary properties can no longer be attached to Request”

Request no longer has a [key: string]: any index signature. Code such as request.post = post no longer compiles. Data written by middleware belongs in request.locals; augment RequestLocals where your application owns the key.

Before (v4)

request.post = await Post.find(request.int("id"));
// Later in the same request:
await request.post.publish();

After (v5)

import type { Post } from "../models/post.model";
declare module "@warlock.js/core" {
interface RequestLocals {
post?: Post;
}
}
request.locals.post = await Post.find(request.int("id"));
// Later in the same request:
await request.locals.post?.publish();

Choose the replacement per attachment:

  • Middleware-owned, server-only request data: request.locals plus RequestLocals augmentation.
  • A computed value that should run once per request: requestMemo().
  • A genuine new typed Request member with its own runtime implementation: module augmentation of Request itself.

Do not use request.set() as a substitute for locals. It writes into the input payload read by all(), input(), and validated().

fromRequest(key, callback) depended on dynamic Request properties. Replace it with the root export requestMemo(key, fn). The lifetime is still one request, concurrent callers share the same promise, and a rejected computation is evicted so a later call may retry.

Before (v4)

import { fromRequest } from "@warlock.js/core";
const tenant = await fromRequest("currentTenant", () =>
tenantsRepository.find(request.header("x-tenant-id")),
);

After (v5)

import { requestMemo } from "@warlock.js/core";
const tenant = await requestMemo("currentTenant", () =>
tenantsRepository.find(request.header("x-tenant-id")),
);

requestMemo() must run inside the HTTP request pipeline. Use a process-level cache for data that should be shared across requests.

The v4 request.localized getter is gone. request.getLocaleCode() remains as a deprecated compatibility alias for one version, but its legacy fallback argument is ignored; the default now belongs to application configuration.

Before (v4)

const locale = request.localized;
const localeWithFallback = request.getLocaleCode("ar");

After (v5)

src/config/app.ts
export default {
localeCode: "ar",
localeCodes: ["ar", "en"],
};
// In request code:
const locale = request.locale;

When app.localeCodes is configured, a client-supplied locale or translation-locale-code header, or a locale query value, outside that list is treated as absent and falls back to app.localeCode. In v4, the client value won as-is and could steer translations and serialized Resources to an unsupported locale. The default locale must itself appear in localeCodes.

Before (v4)

src/config/app.ts
export default {
localeCode: "en",
};
// A client-supplied `locale: xx` was accepted as "xx".

After (v5)

src/config/app.ts
export default {
localeCode: "en",
localeCodes: ["en", "ar"],
};
// A client-supplied `locale: xx` now resolves to "en".

If your application deliberately accepts arbitrary locale codes, leave app.localeCodes undefined; v5 preserves the old pass-through behavior when no list is declared. Test the locale query/header paths your clients use after choosing the policy.

request.t() follows locale changes made during the request

Section titled “request.t() follows locale changes made during the request”

In v4, request.t() and request.trans() captured the locale when the request was constructed. Calling setLocaleCode() later changed request.locale but not those translators. v5 resolves the locale when each translation is called.

Before (v4 workaround)

request.setLocaleCode(user.preferredLocale);
// Pin the locale explicitly because request.t() may still use the old one.
const message = request.transFrom(user.preferredLocale, "account.welcome");

After (v5)

request.setLocaleCode(user.preferredLocale);
const message = request.t("account.welcome");

This is a behavior correction. Remove workarounds only where they existed to compensate for the captured v4 locale; keep transFrom() when you intentionally want a locale different from the request’s locale.

Access no longer chooses the type of request.user

Section titled “Access no longer chooses the type of request.user”

@warlock.js/access no longer globally augments RequestUser to the generic Auth model. That declaration prevented an application from extending the interface with its own auth model. An app using gates must now make the contract explicit in the module that authenticates and writes request.user.

Before (v4)

import { gate } from "@warlock.js/access";
// Installing access supplied a blanket Auth type for request.user.
router.get("/orders", listOrders, {
middleware: [authMiddleware("user"), gate("orders.list")],
});

After (v5)

import type { User } from "app/users/models/user";
declare module "@warlock.js/core" {
interface RequestUser extends User {}
}
router.get("/orders", listOrders, {
middleware: [authMiddleware("user"), gate("orders.list")],
});

Use your own Auth-derived model in the augmentation. The route and gate APIs do not otherwise change.

Optional Seal fields no longer skip validation for present empty values

Section titled “Optional Seal fields no longer skip validation for present empty values”

An optional validator now skips value rules only when the value is absent. A present empty value such as "" is validated. This closes the v4 case where v.int().optional() could succeed with a string even though its output type is number | undefined.

Before (v4)

const schema = v.object({ page: v.int().optional() });
const result = await v.validate(schema, { page: "" });
// result.isValid === true; result.data.page === ""

After (v5)

const schema = v.object({ page: v.int().optional() });
const rawPage = request.input("page");
const result = await v.validate(schema, {
// Normalize at the input boundary only if your API defines empty as absent.
page: rawPage === "" ? undefined : rawPage,
});
// Without normalization, { page: "" } is invalid.

If an empty value should be rejected, make no schema change and update the affected test expectation. The IP validators’ move away from Node’s net module preserves the IPv4/IPv6 rules and needs no migration.

Review these changes; most apps need no edit

Section titled “Review these changes; most apps need no edit”

authMiddleware() still reads the authorization header by default. v5 additionally lets one middleware instance read a named cookie, and auth.canAuthenticate can reject an otherwise valid user:

authMiddleware("user", "cookie:accessToken");
export default {
userType: { user: User },
canAuthenticate: user => accountPolicy.canAuthenticate(user),
};

Invalid, malformed, expired, or wrong-type credentials still produce 401. Verification, storage, and configuration failures now propagate through the normal server error path instead of every verification exception being mislabeled as an invalid credential. Review tests or clients only if they assumed every auth failure was a 401; operational failures should be observed as server errors.

Core adds request.requireUser(). It returns the authenticated user non-optionally or throws UnAuthorizedError when no user is attached. It is a safer replacement for assertions behind an auth guard, but existing assertions continue to compile:

// v4-compatible assertion
const user = request.user!;
// v5 guard assertion
const user = request.requireUser();

The project creator now offers the web feature, emits the v5 context-object handler shape shown above, and honors CACHE_DRIVER in generated cache configuration. It also stops and reports dependency-install, Git, feature-addition, and cache-warmup failures instead of printing later success output. These changes affect newly generated projects; they do not rewrite an existing v4 app.

The Warlock family contains 28 lockstep-versioned packages. Only core, web, auth, access, seal, and create-warlock changed at all for 5.0.0, and web is a first release rather than an upgrade. The other 22 packages received the v5 family version only. There are no package-specific migrations to hunt for in them.

  1. Upgrade the @warlock.js/* packages you use together.
  2. Change (request, response) handlers and middleware to ({ request, response }); run warlock doctor to catch likely leftovers.
  3. Search for dynamic request.<name> =, fromRequest, request.localized, and fallback arguments passed to getLocaleCode().
  4. Declare app.localeCodes if your application has a closed set of supported locales, and test invalid client locale input.
  5. Add your application’s RequestUser augmentation if you use @warlock.js/access gates.
  6. Test optional numeric, date, boolean, and other non-string inputs when clients may send "".
  7. Review auth failure tests for the new credential-versus-server-error distinction.
  8. If you adopt Web, audit its current page model separately; it is not part of an API-only upgrade.