Primate 0.41: Isolated client bundles, programmable navigation and production migrations
Today we're announcing the availability of the Primate 0.41 preview release.
Route-group client bundles
Primate applications can now split their browser code into isolated route groups. Each group gets its own JavaScript, CSS, dynamic imports, named entrypoints and file assets.
// config/app.ts
import config from "primate/config";
export default config({
client: {
groups: {
public: "/app/public/",
account: "/app/account/",
admin: "/admin/app/",
},
routes: {
"/": "public",
"/account": "account",
"/admin": "admin",
},
},
});Routes are matched by longest URL prefix. The / mapping is required and acts
as the fallback. Every route value must name a declared group, and Primate
validates prefixes when the app starts.
Independent build branches
Each route group is an independent build branch, compiled into its own output file tree. A public visitor does not need to download the client pages, styles or dynamic imports used, for example, by an admin area. An admin deployment can use a different asset prefix and a specific cache policy.
Dependencies used by more than one group are deliberately emitted into each
output tree. This makes the boundary complete: groups do not share generated
chunks or imported file assets. Put assets that are intentionally public and
shared in the static directory.
Same-document transitions
Cross-route-group navigation stays in the current document. Primate loads the destination assets and inactive styles, then waits for the destination frontend to become ready while the outgoing frontend and styles remain active. Once the destination is ready, Primate disposes the outgoing frontend, replaces its styles with the prepared destination styles, and mounts the destination.
Links, enhanced forms, redirects, Back and Forward all use the same transition path. React, Svelte, Solid, Vue, Angular and Marko can be used on either side of the boundary. A failed or denied destination leaves the current URL, frontend, styles and history state intact, so a login or network failure cannot produce a page whose address belongs to another app.
See Client groups for configuration details and deployment guidance.
Navigation as a public API
Browser code can now start Primate navigation directly with primate/client.
This is useful for notification clicks, command palettes and other interactions
that are not ordinary anchors.
import client from "primate/client";
await client.navigate("/projects/42?tab=activity");Same-origin URLs use Primate's client router. External URLs fall back to normal browser navigation, so application code does not need to branch first.
await client.navigate("https://example.com");Primate also supports navigating between pages rendered by different frontend modules. The outgoing adapter is disposed and the destination adapter mounts in the same document.
Refreshing the current route
When server data changes without a new destination, refresh the current route in place:
const refreshed = await client.refresh();Primate fetches and applies fresh route data without changing the URL, query,
hash, history or scroll position. The promise resolves to true after the
update, or false if the request fails, is superseded, redirects or does not
return a successful Primate route response. A failed refresh leaves the current
view and location intact.
Shallow query updates
Use shallow navigation when a query string represents local UI state and the current page does not need to refetch or remount.
await client.navigate("/search?q=primate", { shallow: true });The public request store updates immediately. Back and Forward restore the
query state without fetching while history remains within the same rendered
view. Add replace when filters should update the current history entry.
await client.navigate("/search?q=framework", {
replace: true,
shallow: true,
});See Client navigation for refresh, destination and event handling.
Typed query parameters and cancellation
Route clients now accept a typed query option. When a route declares a query
schema, its input type flows to every client call.
// routes/search.ts
import p from "pema";
import route from "primate/route";
export default route({
get: route.with({
query: p({ filter: p.string, page: p.loose.u32 }),
}, request => ({
filter: request.query.get("filter"),
page: request.query.get("page"),
})),
});
import search from "@/routes/search";
const controller = new AbortController();
const pending = search.get({
query: { filter: "active", page: "2" },
signal: controller.signal,
});
// Calling controller.abort() while pending cancels the request.
const response = await pending;Client stubs serialize objects or URLSearchParams and forward an optional
AbortSignal. The server validates the query before the handler runs.
Production migrations
primate build now emits a self-contained migration program beside the server:
build/
├── migrate.js
└── server.jsIt contains the exact database configuration, adapter and migration files from that build. Migrations can be applied as a standalone deployment step without the source tree, the Primate CLI or a package manager.
node build/migrate.js status --json
node build/migrate.js apply --json
node build/server.jsstatus exits with 0 when the database is current, 2 when migrations are
pending or the database is ahead, and 1 for invalid commands or operational
errors. This makes it suitable for release jobs, init containers and deployment
health checks.
Migration IDs are now checked as an append-only sequence. primate migrate
commands and the production artifact reject duplicate IDs and any unapplied
migration whose ID is below the highest already applied ID.
Autoapply is deprecated
Automatic migration application remains functional in 0.41 for compatibility,
but db.migrations.autoapply is deprecated and will be removed in 0.42. When it
is enabled, Primate emits one warning.
Apply migrations explicitly during development:
npx primate migrate:applyIn production, run the artifact before the server:
node build/migrate.js apply
node build/server.jsSee Migrations for the full workflow.
Pema string normalization and custom checks
Pema strings can normalize input before validating it. Transforms run in the order they are declared, followed by validators.
import p from "pema";
const Username = p.string
.trim()
.lowercase()
.regex(/^[a-z][a-z0-9]{2,31}$/);
Username.parse(" Primate41 "); // "primate41"Available string transforms are trim(), lowercase() and uppercase().
As the parsed value carries the normalized result, the same schema can be
used directly in route bodies, query parameters and stores.
Use check for a predicate that is clearer as code than as a built-in
validator.
const EvenCode = p.u32.check(
value => value % 2 === 0,
"Code must be even",
);String validators also accept custom messages.
const Email = p.string.email({ message: "Enter a valid email address" });Read more in Input validation.
Store query ergonomics
Use Store.first() when a query needs at most one record. It accepts the same
filtering, sorting, projection, offset and relation options as find, adds a
limit of one internally, and returns undefined when nothing matches.
const latest = await Post.first({
where: { published: true },
sort: { created: "desc" },
select: ["id", "title"],
});Stores also expose their inferred insertion type as Store.Insert.
import Post from "@/stores/Post";
type NewPost = typeof Post.Insert;
const draft: NewPost = {
title: "Primate 0.41",
body: "...",
};
await Post.insert(draft);Default and generated fields remain optional in the insertion type. See Stores for query and schema details.
Persistent session cookies
Session cookies can now declare maxAge in seconds. Without it, the cookie
lasts for the browser session; with it, Primate emits Max-Age for persistent
login or preference sessions.
// config/session.ts
import session from "primate/session";
import Session from "@/stores/Session";
export default session({
cookie: {
maxAge: 60 * 60 * 24 * 30,
},
store: Session,
});The browser lifetime and server-side record retention are separate policies, so configure the backing store accordingly. See Sessions.
Submitter-aware enhanced forms
Enhanced forms now include the button that initiated submission. Multiple submit buttons can therefore share one form and communicate their intent using ordinary HTML names and values.
<form method="post">
<input name="title" />
<button name="intent" value="draft">Save draft</button>
<button name="intent" value="publish">Publish</button>
</form>The clicked button is included in request.body.form() as intent. This works
for Primate's automatic enhanced forms and client.form in every reactive
frontend.
Server-controlled SSE completion
An SSE producer can now end its own response with source.close().
return response.sse(source => {
const timer = setInterval(() => {
source.send("progress", nextProgress());
if (complete()) source.close();
}, 1000);
return () => clearInterval(timer);
});Closing from the server completes the response cleanly and runs the setup function's cleanup exactly once, just like a client disconnect. See Server-sent events.
Fin
If you like Primate, consider joining our Discord server or starring us on GitHub.