Fullstack web framework with SSR, hydration, and file-based conventions.
- Server: Hono (web framework) + Preact SSR
- Client: Preact + @preact/signals + wouter-preact
- Build: Vite with custom plugin (Babel AST transforms)
npx @mauroandre/velojs init my-app
cd my-app
npm install
npx velojs devmy-app/
├── app/
│ ├── routes.tsx # Route definitions (export default)
│ ├── server.tsx # Server init (DB connections, custom routes, etc)
│ ├── client.tsx # Client init (global CSS, etc)
│ ├── client-root.tsx # Root component (<html>, <head>, <body>)
│ └── pages/ # Pages, layouts, modules
├── vite.config.ts
├── tsconfig.json
└── package.json
import { defineConfig } from "vite";
import { veloPlugin } from "@mauroandre/velojs/vite";
export default defineConfig({
plugins: [veloPlugin()],
});{
"scripts": {
"dev": "velojs dev",
"build": "velojs build",
"build:static": "velojs build --static",
"start": "velojs start"
}
}The root component renders the HTML shell. It must accept children and include <Scripts />.
import type { ComponentChildren } from "preact";
import { Scripts } from "@mauroandre/velojs";
export const Component = ({ children }: { children?: ComponentChildren }) => (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
<Scripts />
</head>
<body>{children}</body>
</html>
);Runs on the client only. Use it to import global CSS, initialize client-side libraries, or set up global components like toasts.
// Import global styles
import "./styles/global.css";
// Optional: set up global client-side features
// import { initAnalytics } from "./modules/analytics.js";
// initAnalytics();Runs on the server only. Use it to connect to databases, create indexes, register custom API routes, start background jobs, and set up WebSocket handlers.
import type { Hono } from "hono";
import { addRoutes, onServer } from "@mauroandre/velojs/server";
// Connect to database
import { connectDB } from "../db/engine.js";
await connectDB();
// Create indexes
import { getDB } from "../db/engine.js";
const db = getDB();
await db.collection("users").createIndex({ email: 1 }, { unique: true });
// Register custom API routes
addRoutes((app: Hono) => {
app.get("/api/health", (c) => c.json({ ok: true }));
});
// Start background jobs
const { runCleanup } = await import("./modules/cleanup.js");
setInterval(() => runCleanup().catch(console.error), 60_000);import type { AppRoutes } from "@mauroandre/velojs";
import * as Root from "./client-root.js";
import * as Home from "./pages/Home.js";
export default [
{
module: Root,
isRoot: true,
children: [
{ path: "/", module: Home },
],
},
] satisfies AppRoutes;import type { LoaderArgs } from "@mauroandre/velojs";
import { useLoader } from "@mauroandre/velojs/hooks";
export const loader = async ({ c }: LoaderArgs) => {
return { message: "Hello, VeloJS!" };
};
export const Component = () => {
const { data } = useLoader<{ message: string }>();
return <h1>{data.value?.message}</h1>;
};npm run dev # http://localhost:3000To expose the dev server beyond loopback — a VPS behind a reverse proxy, an app reached by hostname — set HOST=0.0.0.0, in the shell or in the project's .env; no --host flag by hand. The security trade-off: a broad bind in dev (0.0.0.0, ::, or --host with no value) turns Vite's Host check off, so a request whose Host header carries that domain is served instead of 403'd — the DNS-rebinding protection you get on loopback is consciously given up. Stay on localhost unless you are behind a proxy; a server.allowedHosts declared in vite.config.ts still pins the allowed list.
veloPlugin({
appDirectory: "./app", // default
routesFile: "routes.tsx", // default
serverInit: "server.tsx", // default
clientInit: "client.tsx", // default
hostname: "127.0.0.1", // bind interface, dev and production (the HOST env wins)
serverOnly: ["server/**"], // declares server-only modules to the leak diagnostic
});Routes are defined in app/routes.tsx as a tree structure. Each node can have a module (component + loader + actions), children (nested routes), and middlewares.
// app/routes.tsx
import type { AppRoutes } from "@mauroandre/velojs";
import * as Root from "./client-root.js";
import * as AuthLayout from "./auth/Layout.js";
import * as Login from "./auth/Login.js";
import * as AdminLayout from "./admin/Layout.js";
import * as Dashboard from "./admin/Dashboard.js";
import * as Users from "./admin/Users.js";
import * as UserDetail from "./admin/UserDetail.js";
import { authMiddleware } from "./modules/auth/auth.middleware.js";
export default [
{
module: Root,
isRoot: true,
children: [
// Public routes
{
module: AuthLayout,
children: [
{ path: "/login", module: Login },
],
},
// Authenticated routes
{
module: AdminLayout,
middlewares: [authMiddleware],
children: [
{ path: "/", module: Dashboard },
{ path: "/users", module: Users },
{ path: "/users/:id", module: UserDetail },
],
},
],
},
] satisfies AppRoutes;Routes with children act as layouts. Their Component receives children and wraps nested routes. VeloJS renders the full hierarchy from root to leaf:
GET /users/123 renders:
Root (isRoot — <html>, <head>, <body>)
└─ AdminLayout (sidebar, nav)
└─ UserDetail (page content)
// app/client-root.tsx — Root component
import { Scripts } from "@mauroandre/velojs";
export const Component = ({ children }: { children: any }) => (
<html>
<head><Scripts /></head>
<body>{children}</body>
</html>
);
// app/admin/Layout.tsx — Layout component
export const Component = ({ children }: { children: any }) => (
<div class={css.layout}>
<nav class={css.sidebar}>...</nav>
<main class={css.content}>{children}</main>
</div>
);
// app/admin/UserDetail.tsx — Page component (leaf, no children)
export const Component = () => {
const { data } = useLoader<User>();
return <div>{data.value?.name}</div>;
};Every layout and page can have its own loader. On a request, all loaders in the hierarchy run in parallel — Root loader + AdminLayout loader + UserDetail loader all execute at the same time.
| Property | Type | Description |
|---|---|---|
path |
string |
URL path segment. Supports :params (e.g., /users/:id). |
module |
RouteModule |
Module with Component, loader, action_* |
children |
RouteNode[] |
Nested routes (module acts as layout) |
middlewares |
MiddlewareHandler[] |
Hono middlewares (server-only, inherited by children) |
isRoot |
boolean |
Marks the root node (renders <html>, <head>, <body>) |
Paths are relative segments that concatenate with parent paths:
Root (no path)
└─ AdminLayout (no path)
├─ Dashboard → path: "/" → fullPath: "/"
├─ Users → path: "/users" → fullPath: "/users"
└─ UserDetail → path: "/users/:id" → fullPath: "/users/:id"
Nodes without path don't add a segment — they're pure layout wrappers. The Vite plugin parses routes.tsx at build-time and calculates both fullPath (absolute) and path (relative segment), injecting them into each module's metadata export.
You can reuse the same layout for different route groups:
export default [
{
module: Root,
isRoot: true,
children: [
// Public pages — same layout, no auth
{
module: PublicLayout,
children: [
{ path: "/", module: Home },
{ path: "/about", module: About },
],
},
// Dashboard — same root, different layout + auth
{
path: "/dashboard",
module: DashboardLayout,
middlewares: [authMiddleware],
children: [
{ path: "/", module: Overview },
{ path: "/settings", module: Settings },
],
},
],
},
] satisfies AppRoutes;| Export | Purpose |
|---|---|
export const Component |
Preact component (required) |
export const loader |
Server-side data loader |
export const action_* |
Server-side actions (RPC) |
// app/admin/Users.tsx
import type { LoaderArgs, ActionArgs } from "@mauroandre/velojs";
import { useLoader } from "@mauroandre/velojs/hooks";
interface User { id: string; name: string; }
export const loader = async ({ params, query, c }: LoaderArgs) => {
const { getUsers } = await import("./user.service.js");
return getUsers();
};
export const action_delete = async ({
body,
c,
}: ActionArgs<{ id: string }>) => {
const { deleteUser } = await import("./user.service.js");
await deleteUser(body.id);
return { ok: true };
};
export const Component = () => {
const { data, loading, refetch } = useLoader<User[]>();
if (loading.value) return <div>Loading...</div>;
return (
<ul>
{data.value?.map((u) => (
<li key={u.id}>
{u.name}
<button onClick={async () => {
await action_delete({ body: { id: u.id } });
refetch();
}}>Delete</button>
</li>
))}
</ul>
);
};Loaders and actions run on the server, but the file itself is also bundled for the client (the Vite plugin strips the loader body and transforms actions into fetch stubs). This means top-level imports are included in the client bundle.
Always use await import() inside loaders and actions for server-only code (database access, file system, secrets, etc.):
// BAD — leaks server code into client bundle
import { getUsers } from "./user.service.js";
import { db } from "../db/engine.js";
export const loader = async () => {
return db.collection("users").find().toArray();
};
// GOOD — dynamic import, only runs on server
export const loader = async () => {
const { getUsers } = await import("./user.service.js");
return getUsers();
};This is the most important convention in VeloJS. If you top-level import a module that uses Node.js APIs (fs, crypto, database drivers), the client build will fail or include unnecessary code.
Two patterns for consuming loader data:
Use for page-specific data. Supports SSR hydration and SPA navigation (auto-fetches on navigation).
export const Component = () => {
const { data, loading, refetch } = useLoader<MyType>();
// data: Signal<T | null>
// loading: Signal<boolean>
// refetch: () => void — manually re-fetch data
};With dependencies (re-fetch when deps change):
const params = useParams<{ id: string }>();
const { data } = useLoader<User>([params.id]);Use for global/shared data loaded in a Layout and exported to child modules. Runs once on import — does not re-fetch on SPA navigation.
// app/admin/Layout.tsx
import { Loader } from "@mauroandre/velojs/hooks";
export const { data: globalData } = Loader<GlobalType>();
export const Component = ({ children }) => (
<div>
<header>Hello, {globalData.value?.user.name}</header>
{children}
</div>
);
// app/admin/Home.tsx — import from Layout
import { globalData } from "./Layout.js";
export const Component = () => (
<div>Permissions: {globalData.value?.permissions.join(", ")}</div>
);SSR:
loader() → server runs all loaders in parallel
→ injects window.__PAGE_DATA__ = { moduleId: data, ... }
→ Loader()/useLoader() hydrate from __PAGE_DATA__
SPA navigation:
useLoader() → fetch(currentPath?_data=1) → JSON { moduleId: data }
Loader() → returns null (no re-fetch)
A page value that must track server state over time — a total another
person's request changes, data a background scheduler mutates — declares the
channels that keep it fresh. No extra component, no manual subscription: the
module exports channels next to its loader.
The one-sentence model: the live loader keeps a page's state replica faithful to the server — the first payload arrives through the usual path (SSR, __PAGE_DATA__, the ?_data=1 refetch), and from then on every change is addressed, by the app's own rules, to the group of clients it concerns, by duty or by interest, each one receiving the state computed for its own scope.
// app/expenses/Expenses.tsx
export const loader = async ({ c }: LoaderArgs) => {
const user = c.get("user");
return {
teamTotal: await expensesService.teamTotal(user.teamId),
expenses: await expensesService.byTeam(user.teamId),
};
};
export const channels = ["teamExpenses"];
export const Component = () => {
const { data, freshness } = useLoader();
return <h2 class={freshness.value}>Team: ${data.value?.teamTotal}</h2>;
};// app/channels.ts — the app's channel map: name → partition resolver
export const channels = {
teamExpenses: {
// On subscribe the ctx is { user, params }: the principal materialized
// in c.get("user") and the route params the connection declared. On
// emit it is the object the producer built, with the fields this scope
// consumes. Returns the partition key.
scope: ({ user }) => `team:${user.teamId}`,
},
};// anywhere server-side: an action, server.tsx, a scheduler, a webhook
import { emit } from "@mauroandre/velojs/server";
await emit("teamExpenses", { user: { teamId: 7 } });- The framework derives one internal SSE route per declared channel —
GET /_channel/{moduleId}/{channel}, the same family as/_actionand/_event— inheriting themiddlewaresof the module's route node. - After hydration the client opens one connection per (module, channel) of the
rendered hierarchy (layout and page alike) and closes it on unmount. A
channel declared by both a layout and a page produces one connection per
module, each feeding its own module's data; a single
emitreaches both. - Every new connection immediately receives the current state, computed by re-executing the loader with that connection's own principal and route params — so the pane never shows the SSR value while the server has already moved on.
emit(channel, ctx)resolves the partition with the samescope— the producer builds the same{ user, params }shape with the fields its scope consumes — finds the connections in that partition and pushes a fresh snapshot to each one: its loader re-executed with its own principal and route params. Connections of other partitions receive nothing.- The snapshot replaces the module's loader data; the component re-renders
by itself.
useLoader()andLoader()also exposefreshness—"live","stale"(connection closed/reconnecting/silent),"error"(the last re-execution failed) — aggregated per page, plusfreshnessByChannel, the same states keyed by each of the module's channel names. They are data for CSS to react to.
A channel declared in a module whose route has :params (/projeto/:id, a page
or a layout) partitions per resource, and the loader re-executes with
params.id filled — the connect snapshot and every emit.
// app/projeto/Projeto.tsx — the route /projeto/:id
export const loader = async ({ params }: LoaderArgs) => ({
arquivos: await projetoService.arquivos(params.id),
});
export const channels = ["projetoArquivos"];// app/channels.ts
export const channels = {
projetoArquivos: {
scope: ({ user, params }) => {
if (!params?.id) return null;
if (user && !podeVer(user, params.id)) return null; // 403
return `projeto:${params.id}`;
},
},
};- The client declares the address it is already seeing: it extracts the
params the module's
fullPathdeclares, matching as a prefix of the pathname (a layout/projeto/:idcovers/projeto/7/salaand extractsid=7; the child's suffix takes no part) and sends them in?_route=<json>. A catch-all (/docs/*) declares no:key — the connection travels by principal. - The server decides: it validates the keys against the module's declared
path — a key the path does not declare rejects the subscription with 400 —
and the
scopevalidates the policy (possession/permission), deriving the key.paramsare never an authorization input: they are the declared route, validated by shape and then by policy. - The internal
_routekey never reaches the loader'squery: it is transport, not page data. - The connection is keyed by the extracted params, not by the pathname:
/projeto/7→/projeto/9closes the connection and opens a new one (with a snapshot on connect);/projeto/7→/projeto/7/saladoes not reconnect.
A source with a cost of life — a filesystem watcher, a process, a subscription to an external service — must not run forever for an empty group. The first connection of a group (channel + partition) arms it and the last one disarms it, by any cause (disconnect, idle, revalidation):
export const channels = {
projetoArquivos: {
scope: ({ params }) => (params?.id ? `projeto:${params.id}` : null),
onGroupOpen: ({ params }) =>
watchers.armar(params.id, () => emit("projetoArquivos", { params: { id: params.id } })),
onGroupClose: ({ partition }) => watchers.desarmar(),
},
};- A second connection of the same group fires nothing.
- Both hooks may be async and are awaited: on open, before the connect snapshot (the source is armed before the first data arrives); on close, before the group is discarded. A hook that throws (or rejects) is logged loudly, naming channel and partition, and the group continues — the cold-source snapshot still arrives, only the live data waits for the fix.
- The hook is infrastructure, not policy: it does not receive
user. - The
paramsofonGroupOpenbelong to the connection that opened the group and are identical across it only when the partition derives from them (a watcher per resource). A channel partitioned by principal, declared in a module with:params, mixes resources in one group: do not consume the hook's params there.
Invalidation — emit(channel, ctx) says this partition changed, without
knowing the shape of the data: the runtime re-executes the loader per connection
and pushes the snapshot. Use it when only the server knows the new value (a
background job, another request).
Slice — emit(module, channel, ctx, slice) says here is the new value: the
producer already holds it, so it travels as-is to the connections of that
(module, channel) pair in the partition, and no loader runs. The slice is
typed as a Partial of what that module's loader returns, so tsc rejects an
unknown key or a wrong value type.
import { emit } from "@mauroandre/velojs/server";
import * as Expenses from "../app/expenses/Expenses.js";
// invalidation: the runtime re-executes Expenses.loader per connection
await emit("teamExpenses", { user: { teamId: 7 } });
// slice: the value is already in hand — delivered as-is, no re-execution
await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });Why the module, and not just the channel name? Because the same channel can be
declared by a layout and by a page, each with its own loader and its own
Data — a slice is the shape of exactly one of them, and it reaches only that
module's connections. emit(channel, ctx) keeps reaching every module of the
channel.
- Snapshot replaces; slice merges. A snapshot (connect or invalidation) is the whole value of the module's data; a slice applies a shallow merge by key on top of the current value.
- The key sent is replaced by the whole value sent — lists travel whole inside their key; there is no list diff. Keys not sent stay intact.
- Removal is explicit: removing an item is re-sending the key with the new whole list (without the item). An absent key never means "remove".
- The merge happens on arrival, never at render: slices that land in the same frame are applied together before the next render — none is lost — and for the same key the last one in arrival order wins. A malformed payload keeps the previous value.
- The runtime merges blindly (it is JavaScript); shape safety comes from the
emit typing. A key that does not exist in the loader's
Dataonly gets in through a cast, and that is a usage error.
-
Coalescing. Invalidation emits are folded per (channel, partition): the first opens a window, the emits inside it join, and closing the window fires one re-execution round per connection — no invalidation is lost. The default window is
50ms;registerChannels(map, { coalesceMs: 0 })turns it off (immediate), and a slice never waits for it (a slice is pushed directly).The window decides what
await emit(…)waits for: with it off (0, and the test toolkit's default) the await resolves after the round was delivered; with it open it resolves as soon as the gesture is registered — the log line is out, the delivery comes later. Never assert a screen update right afterawait emit(…)under the default window: await the arrival (next()/nextEvent()) or wait for the effect. -
Emit log. Every
emit, in either mode, writes oneconsole.logline at the moment of the gesture:[velojs] emit kind=invalidate channel="teamExpenses" partition="team:7" connections=2 at=2026-09-30T12:00:00.000Zconnectionsis the size of the group at that instant — before coalescing and before revalidation — and the consolidated delivery writes no second line. An emit whosescoperesolves to nothing logspartition=null connections=0: reaching nobody is exactly what the log is for.registerChannels(map, { logEmits: false })silences it. In tests,createTestAppregisters with the window off and takes the same options inchannelOptions. -
Partition revalidation. Before delivering anything, the runtime re-derives each connection's partition by running the
scopeagain with the ctx captured at subscribe — the principal and the validated route params of that connection. A connection whose partition moved — or whose scope now returnsnull— is removed from the group and closed: the client sees the connection closed (freshnessgoes"stale") and receives none of that emit's data. Ascopethat throws on re-derivation is treated the same way — fail-closed, with the failure logged naming the channel — so a scope that does I/O must handle its own failures. Only the partition is revalidated; session expiry stays with the normal request pipeline.
An SSE connection crosses infrastructure that was not built for it — proxies, load balancers, browsers with sleeping tabs. The channel answer is built for that traffic, and its transport has exactly two clocks:
- Heartbeat — an SSE comment (
: ping) written through the write chain everyheartbeatMs. It keeps proxies and browsers from buffering or closing an idle stream, and it is pure transport: it generates no event in the client and does not count as a delivery — it touches neither freshness nor idle. The default is20000(20s), the sameDEFAULT_HEARTBEAT_MSthestream_*SSE surface uses — one transport ruler in the house.0disables it. - Idle timeout — a connection with no deliveries (snapshot or slice) for
idleMsis closed by the server. The heartbeat does not reset it: idle is about data, heartbeat is transport. A live but quiet page cycles by design — close,EventSourcereconnects by itself, snapshot on connect repairs the state — and an abandoned page's resources are freed for good. The default is300000(5 min) in any registration without options;0disables it.
// app/channels.ts registration with explicit windows
registerChannels(channels, { idleMs: 60000, heartbeatMs: 15000 });The answer also carries Cache-Control: no-store: a proxy or the browser must
never serve a cached channel snapshot — a stale snapshot is exactly the illusion
the live loader exists to kill.
Behind a reverse proxy (the house recipe, Caddy). SSE needs the proxy to
flush immediately and never buffer the response, or the page goes silent even
with everything alive. In the Caddyfile, the reverse_proxy for the app needs
flush_interval -1 (flush as soon as data arrives — a negative value means
"immediately", which is what SSE requires; a positive interval would batch
chunks and delay every snapshot). Caddy passes the request through with
streaming by default and closes dead upstreams on its own probes — the
heartbeat then does its part at 20s, and the idle timeout closes what nobody is
reading anymore. If you put another proxy in front (nginx, a cloud LB), the
same two rules apply: no response buffering, and X-Accel-Buffering: no for
nginx or the equivalent for the LB.
When the incident happens, the server answers "what is alive?":
// anywhere server-side (an action, a guarded endpoint of your own)
import { inspectChannels } from "@mauroandre/velojs/server";
const report = inspectChannels();
// {
// channels: [{
// channel: "teamExpenses",
// groups: [
// { moduleId: "expenses/Layout", partition: "team:7",
// connections: 1, lastDeliveryAt: "2026-09-30T12:00:00.000Z" },
// { moduleId: "expenses/Expenses", partition: "team:7",
// connections: 1, lastDeliveryAt: "2026-09-30T12:00:01.104Z" },
// ],
// connections: 2,
// }],
// openCoalesceWindows: [],
// totalConnections: 2,
// }Per channel, the groups are identified by the pair (moduleId, partition key) —
in the incident you know which page/module holds every connection — each with
its connection count and the timestamp of the last delivery; the coalescing
windows still open; the grand totals. inspectChannels() is always available:
the app decides how (and with what guard) to expose it.
In development there is a ready-made lens: GET /_channel-inspect answers the
same JSON in the browser. It exists only in dev — the map of channels and
partitions is internal information, and it never ships in a production build.
Addressed, not broadcast. A partition is not a filter applied after a broadcast. The emit is addressed: the runtime resolves the partition key and writes only to the connections of that group — a client outside the group never hears about the change, not even a byte. Two reasons make a partition matter, and they compose: by duty (authorization — the data belongs to that team, that family, that project; others must not see it) and by interest (relevance — the tab watching project 9 has nothing to wake up for when project 7 changes). The same scope serves both; the app decides which, or both at once.
On subscribe the scope receives { user, params }. user is c.get("user")
— the house key, the same one the stream/socket channel resolvers use (a
middleware that materializes the principal elsewhere should set it in "user"
too) — and params are the route params the connection declared, already
validated. Without an authenticated principal user is undefined: a public
channel ignores it (() => "all"), an authenticated one returns null, which
denies the subscription with 403; an app that does not materialize a principal
at all (a network guard, e.g. loopback) writes ({ params }) => …. On emit the
same scope receives the object the producer built, with the fields it consumes
({ params: … } or { user: … }). A scope that throws on subscribe denies
with 403 and logs loudly, naming the channel.
The partition never derives from the query string or from any other client
input: params are the declared route — validated by shape — not a choice of
subject.
Two questions decide, every time:
| Question | Answer | Tool |
|---|---|---|
| Is this a value or a sequence? | value — "what is it now?", replacement | loader + channels |
| sequence — "what happened?", accumulation (chat, log, progress) | stream_* / socket_* |
|
| Does the change come from someone else or from me? | someone else / a background job | loader + channels |
| me, through my own action | refetch() after the action |
channelsin a module without aloader: explicit error in the dev server and in the build ("nothing to synchronize").- A channel without an entry in
app/channels.ts: explicit error in the dev server and in the build, naming the channel. emit()on a channel with no entry in the map: throws immediately, naming the channel.emit()on a valid channel with no subscribers is a no-op.- Route params with a key the module's path does not declare: the subscription is rejected with 400, naming the key — the client may only declare what the route declares.
- A
scopethat throws on subscribe: denied with 403 and logged loudly, naming the channel. emit(module, channel, …)on a module that does not declare that channel, or that has noloader: throws immediately, naming both. A slice that is not an object throws too.velojs build --staticwith a module declaringchannels: the channels are inert (no server for SSE) — the client opens no connection,freshnessstays"live"and the build warns, naming the module.- A module outside the conventions (
.jsx/.js, or outsideapp/) is the one genuinely silent line: no transform, so the channel is simply inert.
import { createTestApp } from "@mauroandre/velojs/testing";
import { emit } from "@mauroandre/velojs/server";
import { channels } from "../app/channels.js";
import * as Expenses from "../app/expenses/Expenses.js";
const app = await createTestApp({
routes,
channels,
getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
});
const sub = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
await sub.next({ timeoutMs: 1000 }); // snapshot on connect
await emit("teamExpenses", { user: { teamId: 7 } });
const snapshot = await sub.next({ timeoutMs: 1000 });
await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
const slice = await sub.next({ timeoutMs: 1000 });
// A route with `:params`: the connection declares them (validated for real).
const project = await app.channel(Project, "projetoArquivos", { params: { id: "7" } });
expect(await project.next({ timeoutMs: 1000 })).toEqual({ id: "7", files: ["a.txt"] });next() returns the payload exactly as it travelled (a whole snapshot or a raw
slice); nextEvent() returns the same arrivals discriminated —
{ type: "snapshot" | "slice", data }. The toolkit registers the channels with
the coalescing window off, so await emit(…) is the delivery; a test that
exercises the window passes it in channelOptions — a sibling of channels,
which stays the map — and drives it with fake timers:
const app = await createTestApp({
routes,
channels, // the map
channelOptions: { coalesceMs: 20 }, // the registration options
getSessionCookie,
});The same channelOptions accepts idleMs and heartbeatMs — both default to
0 in the toolkit (deterministic: no connection dies, no comment is written
unless the test asks for it and advances fake timers through the window it
meant). inspectChannels() is process-wide and sees the test app's real
channels: subscribe, emit, inspect, close, inspect again — all without network.
A test that exercises the idle timeout or the heartbeat arms the window
explicitly and drives it with fake timers; never assert freshness (in either
surface) without advancing the fake timers through the window.
The live loader keeps a page value faithful to server state. The newer
apps are tab UIs (the VSCode shape): the page is the hull and every content
opens in a tab — client state, not a route — loaded at open time by an action
with its own arguments. When that content is a value that must track server
state over time (an expense sheet that grows line by line, a card whose
phase changes on its own), the action declares interest in living: the
tab-facing twin of the live loader, addressed by the call's own args — the
same channels map, the same emit(), zero polling.
// app/expenses/Expenses.tsx — the page module (the tab hull)
export const action_abrirExpense = async ({ body }: ActionArgs<{ id: number }>) => {
const { expenseSheet } = await import("./expenses.service.js");
return expenseSheet(body.id); // a parameterized read
};
// The opt-in: key = action name without `action_`, value = channel names.
export const liveActions = { abrirExpense: ["expenseSheet"] };// the tab component — one per open tab
import { useLiveAction } from "@mauroandre/velojs/hooks";
import { action_abrirExpense } from "../expenses/Expenses.js";
function ExpenseTab({ id }: { id: number }) {
const { data, loading, freshness } = useLiveAction(action_abrirExpense, { id });
return <article class={freshness.value}>{data.value?.total}</article>;
}// the same emit() — no second mechanism was born
import { emit } from "@mauroandre/velojs/server";
await emit("expenseSheet", { args: { id: 7 } });- The framework derives
GET /_live/{moduleId}/{actionName}(the same family as/_actionand/_channel), inheriting the route node'smiddlewares— the subscription is guarded exactly like the action call and the page are. useLiveAction(action, args)opens one SSE connection per subscription with the args in?_args=. The connect snapshot is the loading gesture: the action executed once with the declared args — there is no initial POST, and the tab never shows a value the server does not stand behind.- Every
emit()in any declared channel re-executes the action with the subscription's own args and pushes the fresh value, which replacesdatawhole (no merge). An action that throws marksfreshness"error"and keeps the last valid value — a failure never blanks the tab. - The subscription's life is the mount: the tab unmounts and the connection
closes (nothing arrives afterwards; being the last of the group,
onGroupClosedisarms the source). It remounts and is born again with a snapshot of the current state — reopening is indistinguishable from never having closed. Changing the args reconnects: they are the identity. freshnessis the subscription's own"live"/"stale"/"error"signal (the same 60s silence detector as the live loader's) and never touches the page aggregate — a stale tab does not stale the page, and the other way around. On SSR the hook does not connect (datanull,loadingtrue); in a static build the load really failed: no connection,dataundefined,freshness"error".
A live action is, by contract, a parameterized read — the server
re-executes it at every signal, so a mutation with a side effect in a live
action is a usage error (the re-execution would repeat the effect). The
mutation the user makes inside the tab keeps being a common one-shot action;
the tab updates through the channel. The re-execution receives the same
ActionArgs of the common call, with body carrying the declared args.
The scope ctx gains args, additively: a subscription resolves its partition
with { user, args }, a loader connection with { user, params } as before.
A channel declared by both ends has one scope and one partition — the
canonical form (carved so no reader invents their own):
// app/channels.ts — one emit("expenseSheet", { args: { id: 7 } }) updates
// the page (/expenses/:id, partitioned by params) and the tab (by args)
export const channels = {
expenseSheet: {
scope: ({ params, args }) => "expense:" + (args?.id ?? params?.id),
},
};null, undefined or a throw from the scope denies the subscription with
403, loudly; onGroupOpen/onGroupClose receive the args of the connection
that opened the group; and the transport rules are the live loader's,
inherited (heartbeat comment, idle timeout, coalescing, partition
revalidation, no-store) — no periodic request exists anywhere.
liveActionsnaming an action the module does not export, or a channel with no entry inapp/channels.ts: error in the dev server and in the build, naming module, action and channel.- Serialized args above 4096 bytes: rejected with 400, naming the action and the limit — the args are the tab's address, not a payload.
velojs build --staticwith a module declaringliveActions: the build warns naming module and action.
const sub = await app
.as({ id: 1, teamId: 7 })
.liveAction(Expenses, "abrirExpense", { args: { id: 7 } });
expect(await sub.next({ timeoutMs: 1000 })).toEqual({ total: 880 }); // connect
await emit("expenseSheet", { args: { id: 7 } });
expect(await sub.next({ timeoutMs: 1000 })).toEqual({ total: 940 });
await sub.close(); // the tab closed: the group empties, onGroupClose firesnext() delivers the payloads in arrival order (the connect snapshot first);
nextEvent() discriminates { type: "snapshot" | "error", data }; status
reads the guards (403 denied, 400 oversized args). The subscription and every
delivery are traced as their own telemetry boundary (live-action-connect,
ranked by velojs telemetry in By live action), and inspectChannels()
lists the live subscriptions next to the loader groups.
The framework records its own bottlenecks — pages, ?_data=1 refetches,
actions, endpoints, stream connects, socket setups, channel connects — plus
every emit() gesture that runs outside the request cycle, as JSONL lines with
duration, status, response bytes and child spans. Collection is transparent to
the app code: no import, no wrapper, no change in a route module.
There is no on/off flag. VELO_TELEMETRY_FILE and/or VELO_TELEMETRY_SINK_URL
present and non-empty turn the collection on; none of them — absent or empty —
leaves the whole thing inert (no file, no event, no cost) in any environment,
dev included. The framework never picks a path: there is no default file, and
the values expand ~ and $VAR/${VAR}.
| Variable | Default | Description |
|---|---|---|
VELO_TELEMETRY_FILE |
— | Destination file (append-only JSONL). Present and non-empty activates the file collection. Expands ~/$VAR; no default path |
VELO_TELEMETRY_SINK_URL |
— | Destination sink. Present and non-empty activates the HTTP collection: one POST per event |
VELO_TELEMETRY_MAX_MB |
10 |
Rotation limit of the file, in MB. The only pruning mechanism |
# .env — the operator's destination, in production or in dev
VELO_TELEMETRY_FILE=$DATA_DIR/telemetry.jsonl
VELO_TELEMETRY_MAX_MB=10One JSON object per line. route is always the path pattern (/:mes),
never the materialized URL; bytes is the response size where there is a body
(pages, data, actions, endpoints) — time and weight travel together. Every line
carries the trace id and its own timestamp.
{
"trace": "m3k9x1-4-f8a2q7",
"ts": "2026-10-07T18:22:03.482Z",
"type": "page",
"route": "/:mes",
"module": "MesPage",
"status": "ok",
"duration": 45.2,
"bytes": 122880,
"spans": [
{ "name": "loader:AppLayout", "start": 0.8, "duration": 3.1, "status": "ok" },
{ "name": "loader:MesPage", "start": 1.2, "duration": 43.9, "status": "ok" },
{ "name": "render", "start": 44.3, "duration": 3.5, "status": "ok" }
]
}Types: page, data, action, endpoint, stream-connect, socket-connect,
channel-connect, emit, delivery, close and boot. The emit trace has
no request parent — in the invalidation mode each re-executed loader is a child
span. A long connection (channel, stream, socket) writes one light delivery
line per frame, with the cumulative ordinal and the frame's bytes, and a final
close line with the total and the lifetime.
The first line of every active destination is the boot record: the app version,
the environment, the effective destination and the rotation limit in force.
The effective file carries an instance suffix — telemetry.jsonl becomes
telemetry.8123-host.jsonl — so replicas pointing the same path write distinct
files. The file rotates by size (VELO_TELEMETRY_MAX_MB, default 10):
<effective>.<n> keeps the past, the effective path keeps the newest lines.
velojs telemetry # resolves VELO_TELEMETRY_FILE (env or .env), instance files included
velojs telemetry --last 60m # the last hour of the file
velojs telemetry ./t.prod.jsonlRanks the JSONL by route, action and channel — count, errors, p50/p95 and volume in bytes — and prints the timeline. The full reference lives in site/docs/22-telemetry.md.
Server-side functions callable from the client via RPC.
export const action_login = async ({
body,
c,
}: ActionArgs<{ email: string; password: string }>) => {
const { authenticate } = await import("./auth.service.js");
const token = await authenticate(body.email, body.password);
const { setCookie } = await import("@mauroandre/velojs/cookie");
setCookie(c!, "session", token, { path: "/" });
return { ok: true };
};The Vite plugin transforms action bodies into fetch stubs at build time:
// Original (server)
export const action_login = async ({ body, c }: ActionArgs<LoginBody>) => {
// ... server logic
};
// Transformed (client)
export const action_login = async ({ body }: { body: LoginBody }) => {
return fetch("/_action/auth/Login/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(r => r.json());
};Error handling: Actions do NOT throw on server errors. They resolve with { error: "message" }. Always check result.error explicitly.
Actions are tied to the module where they're declared — the route is always /_action/{moduleId}/{actionName}. Declare an action in a Layout and import it from any child module to share it across multiple pages.
// app/admin/Layout.tsx — action declared once in the layout
import type { ActionArgs } from "@mauroandre/velojs";
export const action_logout = async ({ c }: ActionArgs) => {
const { deleteCookie } = await import("@mauroandre/velojs/cookie");
deleteCookie(c!, "session");
return { ok: true };
};
export const Component = ({ children }) => (/* layout with children */);// app/admin/Dashboard.tsx — imports and uses the action from Layout
import { action_logout } from "./Layout.js";
export const Component = () => (
<button onClick={async () => {
await action_logout({});
window.location.href = "/login";
}}>Logout</button>
);The client-side transform rewrites the import to fetch /_action/admin/Layout/logout, so the action always points to the correct URL regardless of where it's imported. Middlewares on the Layout apply automatically.
Declarative HTTP endpoints for anything that isn't a page — webhooks, email-verification redirects, OAuth callbacks, health checks.
Declare them directly in routes.tsx:
import type { AppRoutes, EndpointHandler } from "@mauroandre/velojs";
import * as Home from "./pages/Home.js";
import { githubWebhook } from "./webhooks/github.handler.js";
export default [
{ path: "/", module: Home },
{ path: "/api/github/webhook", method: "POST", handler: githubWebhook },
] satisfies AppRoutes;The handler gets { c, params, query } and must return a Response:
import type { EndpointHandler } from "@mauroandre/velojs";
export const githubWebhook: EndpointHandler = async ({ c }) => {
const body = await c.req.json();
// ...verify signature, process event...
return c.json({ ok: true });
};Endpoints inherit parent middlewares and can be nested inside grouping nodes. The Vite plugin strips endpoint objects (and their handler imports) from the client bundle, so server-only code never ships to the browser.
Because endpoints live in routes.tsx, the testing toolkit sees them automatically:
const res = await app.post("/api/github/webhook", { body: {...}, headers: {...} });Full documentation: site/docs/06-endpoints.md.
Push real-time updates from server to client via Server-Sent Events (SSE). Live progress, notifications, metrics, log streaming, AI tokens — anything server → client.
Three verbs: emit, close, useEventStream. The framework handles routing, types, listener management, snapshots, lifecycle, reconnection, and cleanup.
// app/admin/Provision.tsx
import { createEventStream } from "@mauroandre/velojs";
import { useEventStream } from "@mauroandre/velojs/hooks";
export const stream_logs = createEventStream<string>();
export const Component = () => {
const { snapshot, data, closed } = useEventStream(stream_logs, { channel: sessionId });
const lines = [...(snapshot.value ?? []), ...(data.value ? [data.value] : [])];
return <pre>{lines.join("\n")}{closed.value && "\n[done]"}</pre>;
};// app/admin/provision.service.ts
import { stream_logs } from "./Provision.js";
export async function provision(sessionId: string) {
try {
stream_logs.emit(sessionId, "Connecting...", { snapshot: true });
// ... real work
stream_logs.emit(sessionId, "Worker ready.", { snapshot: true });
} finally {
stream_logs.close(sessionId);
}
}One line to declare, three verbs to use. Route at /_event/admin/Provision/logs is registered automatically. Middlewares inherited from parent route nodes.
Convention stream_* (recommended) — for streams logically tied to a page/layout. Path derived from module ID, middlewares inherited.
export const stream_progress = createEventStream<DeployState>();Standalone — for cross-cutting streams (global metrics, notifications). Pass path and (if needed) middlewares explicitly. Use broadcast: true for streams without channels.
export const containerMetrics = createEventStream<Metric[]>({
path: "/api/metrics/containers",
broadcast: true,
middlewares: [authMiddleware],
});Reactive — call emit() from anywhere when something happens. Most common:
stream_logs.emit(sessionId, "Line", { snapshot: true });Source-driven — pass a source function. Framework runs it only while subscribed (zero CPU when nobody is watching):
import { poll } from "@mauroandre/velojs";
export const stream_metrics = createEventStream<Metric[]>({
broadcast: true,
source: poll({
intervalMs: 3000,
tick: async (emit) => emit(await collectMetrics()),
}),
});Stateful snapshot — for state-machine patterns where each emit is the complete current state:
const deploys = new Map<string, DeployState>();
export const stream_deploy = createEventStream<DeployState>({
snapshot: (id) => deploys.get(id ?? ""),
});| Option | Type | Description |
|---|---|---|
path |
string |
(Standalone) Explicit URL path |
broadcast |
boolean |
If true, every emit goes to all subscribers (no channels). Default: false |
channel |
(c) => string | null | Promise<...> |
Resolve channel ID. Sync or async. Return null/undefined to reject (403). Default: ?channel=... |
snapshot |
(channel) => TSnapshot |
Returns current state on connect (state-machine pattern) |
closeOn |
(event) => boolean |
Closes SSE when matching event is sent (declarative) |
source |
(emit, { abortSignal }) => Promise<void> |
Stream-wide producer, only runs while subscribed |
perChannelSource |
(channelKey, emit, { abortSignal }) => Promise<void> |
Per-channel producer. Mutually exclusive with source |
bufferSize |
number |
Max entries kept in snapshot buffer per channel (FIFO). Default Infinity |
retainMs |
number |
Buffer retention after close(). Default 300000 (5 min) |
heartbeatMs |
number | false |
Heartbeat interval. Default 20000 (20s). false to disable |
middlewares |
MiddlewareHandler[] |
(Standalone) Hono middlewares for the SSE route |
Two snapshot patterns for two cases:
Per-emit { snapshot: true } — append pattern. Framework keeps a buffer per channel. Late subscribers receive the array.
stream_logs.emit(id, "line 1", { snapshot: true });
stream_logs.emit(id, "line 2", { snapshot: true });
// Late subscriber → snapshot.value = ["line 1", "line 2"]snapshot: callback config — replace pattern. You return the latest state from your own data structure.
createEventStream({ snapshot: (id) => deploys.get(id ?? "") });
// Late subscriber → snapshot.value = the latest DeployStateBoth survive after close() for retainMs (default 5 min) so refresh-after-finish still shows final state.
Two ways, choose either or both:
// Imperative — call from anywhere
stream.close(channelId);
// Declarative — predicate on event
createEventStream({ closeOn: (s) => s.status === "success" });After close, subsequent emit() to that channel is ignored with a warning.
const { data, snapshot, closed, error } = useEventStream(stream, {
channel: "deploy-123", // optional
enabled: true, // optional
});| Signal | Description |
|---|---|
data |
Latest event received |
snapshot |
Initial state on connect (buffer or callback) |
closed |
true when server closed the stream |
error |
Parse or connection error, if any |
Lifecycle is automatic: opens on mount, closes on unmount, re-opens when channel changes.
Reconnect is stale-while-revalidate. When the connection re-opens — channel, stream or enabled changed — data and snapshot keep their previous values until the first event of the new connection overwrites them, the same choice the loader store makes (a refresh must not blank the screen). A channel switch therefore never paints an empty state; only closed and error reset, because a re-open is a fresh attempt. First mount still starts at null and fills in when the snapshot arrives. An app that wants to blank the view on a new channel can clear the signals itself when the channel changes.
Channel coming from loader data? Pass enabled so the hook does not connect before the channel exists — the first render of a SPA navigation has data = null, and connecting with channel: undefined reaches the server without ?channel=, which a resolver answers with null → 403 → a flash of closed:
const { data } = useLoader<{ id: string }>();
const channel = data.value?.id;
// no connection until the loader resolves the channel
const { snapshot, closed } = useEventStream(stream_logs, {
// spread only when it exists — `exactOptionalPropertyTypes` (the repo's own
// tsconfig) rejects an explicit `undefined` in an optional property
...(channel != null && { channel }),
enabled: channel != null,
});For interval-based polling sources. Wraps your tick function in a loop that respects the AbortSignal. Errors in tick are logged but don't stop the loop.
poll({ intervalMs: 3000, tick: async (emit) => emit(await collect()) })Use perChannelSource for resources that scale per channel (SSH connections, DB cursors, pub/sub topics). Invoked once per channel on first subscriber, aborted when the last subscriber of that channel leaves.
// The client sends the composite key: useEventStream(stream_logs, { channel: `${worker}:${container}` })
export const stream_logs = createEventStream<string>({
channel: (c) => c.req.query("channel") ?? "",
bufferSize: 500,
perChannelSource: async (key, emit, { abortSignal }) => {
const [worker, container] = key.split(":");
const conn = await ssh.connect(worker);
const stream = conn.exec(`podman logs -f ${container}`);
stream.on("data", (d) => emit(d.toString(), { snapshot: true }));
abortSignal.addEventListener("abort", () => { stream.close(); conn.end(); });
},
});
c.req.param()does not work in astream_*resolver — convention streams mount at a static path (/_event/{moduleId}/{name}), so the page's:paramsare not in scope and it always returns empty. The channel travels as?channel=. (Params do work in a standalone stream given an explicitpathcontaining:segments.)
Mutually exclusive with source.
The channel resolver can be async and reject the connection by returning null/undefined:
createEventStream({
channel: async (c) => {
const user = c.get("user");
const appId = c.req.query("channel");
const app = await getApp({ id: appId });
if (app?.owner !== user.id) return null; // → 403
return appId;
},
});Combines auth and channel extraction in one place.
Cap memory usage of long-running log streams via FIFO ring:
createEventStream<string>({ bufferSize: 500 }); // keep last 500 entries per channelOnly affects emits with { snapshot: true }.
Declarative WebSocket handlers, co-located with the page that needs bidirectional communication. Use for interactive terminals, collaborative editing, live cursors — anything where the client also sends messages.
Export socket_<name> from a page or layout, and VeloJS registers a WebSocket route at /_socket/{moduleId}/{name} with middleware inheritance.
// app/workers/WorkerTerminal.tsx
import type { SocketHandler } from "@mauroandre/velojs";
import { parseJson } from "@mauroandre/velojs/sockets";
import { useSocket } from "@mauroandre/velojs/hooks";
export const socket_terminal: SocketHandler = async ({
incoming, send, keepOpen, abortSignal, c, params,
}) => {
const session = await createTerminal(params.workerId);
session.on("data", (chunk) => send({ type: "data", data: chunk }));
abortSignal.addEventListener("abort", () => session.destroy());
keepOpen();
for await (const msg of parseJson<{ type: string; data?: string; cols?: number; rows?: number }>(incoming)) {
if (msg.type === "data" && msg.data) session.write(msg.data);
if (msg.type === "resize") session.resize(msg.cols!, msg.rows!);
}
};
export const Component = () => {
const { send, status } = useSocket(socket_terminal, { channel: workerId });
// ...
};| Arg | Description |
|---|---|
incoming |
AsyncIterable<string | Uint8Array> — raw frames. Wrap with parseJson<T>() for JSON auto-parse. |
send(msg) |
Send a frame. string / Uint8Array pass through; object → JSON.stringify. |
close(code?, reason?) |
Server-initiated close. |
keepOpen() |
Required for long-lived handlers. If you return without it, the socket closes. for await (const m of incoming) implicitly holds the handler. |
abortSignal |
Fires on disconnect / app.close(). Register all cleanup here. |
c, params, query |
Hono context, URL params, query. Auth via c.get("user") from middleware. |
Middleware runs before the WebSocket upgrade — an authMiddleware that rejects returns an HTTP error and the client never sees an open socket. Same middlewares array on parent route nodes as pages/actions/streams.
const { send, status, lastMessage, close } = useSocket(socket_terminal, {
channel: workerId,
onMessage: (msg) => { /* ... */ },
});status: Signal<"connecting" | "open" | "closed">— reactive.lastMessage: Signal<string | Uint8Array | null>— last frame received.- Reconnect preserves
lastMessage— changingstub.__path,channelorenabledopens a new socket, but the previous frame stays until the first frame of the new one arrives (stale-while-revalidate, same asuseEventStream);statusrestarts at"connecting"anderrorresets. Swapping the stub object with the same__pathdoes not reconnect. ClearlastMessageyourself inonOpenif a channel switch must blank the view. - Channel coming from loader data? Pass
enabled: channel != nullso the socket only opens once the channel resolves — same reason asuseEventStream. - No auto-reconnect — sockets are usually stateful (pty sessions, collaborative state); blind reconnect loses state silently. Re-mount or toggle
enabledto reconnect.
const ws = await app.socket(socket_terminal, {
user: { id: "alice" },
params: { workerId: "w42" },
});
ws.send({ type: "data", data: "ls\n" });
const reply = await ws.next({ timeoutMs: 500 });
await ws.close();app.socket() invokes the handler in-memory and aborts on app.close(). Middleware does NOT run — pass user via options to shortcut c.get("user"); test middleware via regular endpoint tests that use the same middleware.
Full reference: site/docs/12-sockets.md.
All hooks work in both SSR and client (via AsyncLocalStorage on server, wouter/DOM on client).
| Hook | Description |
|---|---|
useLoader<T>(deps?) |
Loader data with SSR + SPA support. Returns { data, loading, refetch } |
Loader<T>() |
Module-level SSR-only loader. Returns { data, loading } |
useEventStream<T, S>(stream, opts?) |
Subscribe to a server-sent event stream. Returns { data, snapshot, closed, error } |
useParams<T>() |
Route parameters (e.g., :id) |
useQuery<T>() |
Query string parameters |
useNavigate() |
Programmatic navigation. Returns navigate(path) function |
usePathname() |
Absolute pathname (unlike wouter's useLocation which is relative to nest context) |
touch(signal) |
Force signal notification after nested property mutation |
const items = useSignal<Item[]>([]);
// Mutating nested properties doesn't trigger signal updates
items.value[0].checked = true;
// touch() forces the update
touch(items);Navigation with type-safe module references or string paths.
import { Link } from "@mauroandre/velojs";
import * as UserPage from "./users/UserDetail.js";
import * as LoginPage from "./auth/Login.js";
// With route module (relative — uses metadata.path, works with wouter nest context)
<Link to={UserPage} params={{ id: "123" }}>View</Link>
// With route module (absolute — uses metadata.fullPath)
<Link to={LoginPage} absolute>Login</Link>
// With query string
<Link to={UserPage} params={{ id: "123" }} search={{ tab: "settings" }}>
Settings
</Link>
// String path (relative to current nest context)
<Link to="/users">Users</Link>
// String path with ~/ prefix (absolute — escapes nest context)
<Link to="~/stacks">Stacks</Link>
<Link to={`~/stacks/apps/${appId}/edit`}>Edit App</Link>VeloJS uses wouter-preact for routing. When routes are nested (layouts wrapping children), wouter creates a nest context — relative paths resolve within the current layout's scope.
The ~/ prefix escapes the nest context and navigates from the root. Use it when navigating between sections:
// Inside /master/workers layout, these behave differently:
<Link to="/details"> → resolves to /master/workers/details (relative)
<Link to="~/stacks"> → resolves to /stacks (absolute from root)When to use ~/: anytime you navigate to a route outside the current layout's scope. In practice, most cross-section links use ~/.
| Prop | Type | Description |
|---|---|---|
to |
string | RouteModule |
Destination path or module. String paths support ~/ prefix for absolute navigation |
params |
Record<string, string> |
URL parameter substitution (:id → value) |
search |
Record<string, string> |
Query string parameters |
absolute |
boolean |
When using module reference: use fullPath instead of path (default: false) |
Injects necessary scripts and styles in <head>.
import { Scripts } from "@mauroandre/velojs";
export const Component = ({ children }) => (
<html>
<head>
<Scripts />
</head>
<body>{children}</body>
</html>
);| Prop | Type | Default | Description |
|---|---|---|---|
basePath |
string |
process.env.STATIC_BASE_URL || "" |
Base path for static assets |
favicon |
string | false |
"/favicon.ico" |
Favicon path, or false to disable |
Development:
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<script type="module" src="/@vite/client"></script>
<script type="module" src="/__velo_client.js"></script>Production:
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="/client.a1b2c3.css" />
<script type="module" src="/client.x9y8z7.js"></script>Asset filenames include a content hash for cache busting. The hash changes only when the file content changes.
Server-side only. Removed from client bundle at build time.
Use createMiddleware from velojs/factory (wraps Hono's middleware):
// app/modules/auth/auth.middleware.ts
import { createMiddleware } from "@mauroandre/velojs/factory";
import { getCookie } from "@mauroandre/velojs/cookie";
export const authMiddleware = createMiddleware(async (c, next) => {
const token = getCookie(c, "session");
if (!token) {
if (c.req.method === "GET") return c.redirect("/login");
return c.json({ error: "unauthorized" }, 401);
}
// Set data on context — accessible in loaders and actions via c.get()
const user = await verifyToken(token);
c.set("user", user);
await next();
});Add middlewares to any route node. All children inherit the middleware:
// app/routes.tsx
import { authMiddleware } from "./modules/auth/auth.middleware.js";
import { masterMiddleware } from "./modules/auth/master.middleware.js";
export default [
{
module: Root,
isRoot: true,
children: [
// Public routes — no middleware
{ path: "/login", module: AuthLayout, children: [{ module: Login }] },
// Authenticated routes
{
module: AdminLayout,
middlewares: [authMiddleware],
children: [
{ path: "/", module: Dashboard }, // authMiddleware applies
{ path: "/stacks", module: Stacks }, // authMiddleware applies
// Admin-only routes — both middlewares apply
{
path: "/master",
module: MasterLayout,
middlewares: [masterMiddleware],
children: [
{ path: "/workers", module: Workers }, // auth + master
{ path: "/settings", module: Settings },// auth + master
],
},
],
},
],
},
] satisfies AppRoutes;Middlewares accumulate from parent to child and apply to every nested route, including:
- Page routes (GET) — when loading a page
- Action routes (POST
/_action/...) — when calling a server action - Data fetches (GET with
?_data=1) — during SPA navigation
In the example above, /master/workers runs authMiddleware first, then masterMiddleware. The same applies when calling action_* functions from any page under MasterLayout — both middlewares run before the action executes.
// This action, defined in app/master/Workers.tsx, is registered as
// POST /_action/master/Workers/delete
// with authMiddleware + masterMiddleware applied.
export const action_delete = async ({ body, c }: ActionArgs<{ id: string }>) => {
const user = c!.get("user"); // set by authMiddleware
// ...
};A middleware on a Layout guards everything underneath it — pages, loaders, and actions — with no extra wiring.
Use Hono's c.get() / c.set():
// Middleware sets data
c.set("user", { id: "123", name: "Mauro", role: "master" });
// Loader reads it
export const loader = async ({ c }: LoaderArgs) => {
const user = c.get("user");
return { greeting: `Hello, ${user.name}` };
};
// Action reads it
export const action_save = async ({ body, c }: ActionArgs<{ name: string }>) => {
const user = c!.get("user");
// ...
};Register custom Hono routes before page/action routes. Call in app/server.tsx. Use this for REST APIs, SSE streams, file uploads, webhooks, and any custom HTTP endpoints.
// app/server.tsx
import { addRoutes } from "@mauroandre/velojs/server";
import type { Hono } from "hono";
addRoutes((app: Hono) => {
// REST API
app.get("/api/health", (c) => c.json({ ok: true }));
app.post("/api/upload", async (c) => {
const body = await c.req.parseBody();
const file = body.file;
// ...
return c.json({ ok: true });
});
// Middleware for a group of routes
app.use("/api/admin/*", async (c, next) => {
const token = c.req.header("Authorization");
if (!token) return c.json({ error: "Unauthorized" }, 401);
await next();
});
});Use Hono's streamSSE for real-time server-to-client communication.
import { addRoutes } from "@mauroandre/velojs/server";
addRoutes((app) => {
app.get("/api/events", async (c) => {
const { streamSSE } = await import("hono/streaming");
return streamSSE(c, async (stream) => {
// Send snapshot on connect
await stream.writeSSE({ event: "snapshot", data: JSON.stringify({ count: 0 }) });
// Subscribe to updates
const unsubscribe = subscribe((data) => {
stream.writeSSE({ event: "update", data: JSON.stringify(data) });
});
// Cleanup on disconnect
stream.onAbort(() => { unsubscribe(); });
// Keep stream open
await new Promise<void>(() => {});
});
});
});Client-side consumption with EventSource:
useEffect(() => {
const es = new EventSource("/api/events");
es.addEventListener("snapshot", (e) => {
state.value = JSON.parse(e.data);
});
es.addEventListener("update", (e) => {
state.value = JSON.parse(e.data);
});
return () => es.close();
}, []);addRoutes((app) => {
app.get("/api/metrics/live", async (c) => {
const { streamSSE } = await import("hono/streaming");
return streamSSE(c, async (stream) => {
let running = true;
stream.onAbort(() => { running = false; });
while (running) {
const metrics = await collectMetrics();
await stream.writeSSE({ data: JSON.stringify(metrics) });
await new Promise((r) => setTimeout(r, 3000));
}
});
});
});Access the underlying Node.js HTTP server. Useful for WebSocket handlers.
import { onServer } from "@mauroandre/velojs/server";
onServer((httpServer) => {
const { WebSocketServer } = await import("ws");
const wss = new WebSocketServer({ noServer: true });
httpServer.on("upgrade", (req, socket, head) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
if (url.pathname === "/ws") {
wss.handleUpgrade(req, socket, head, (ws) => {
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
// Handle message
});
ws.on("close", () => {
// Cleanup
});
});
}
});
});Callbacks queue until the server starts. If called after startup, executes immediately.
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server port. Overrides the port set in defineConfig |
HOST |
— | Bind interface, in dev and production. Overrides defineConfig's hostname; in dev an explicit --host/server.host still wins |
NODE_ENV |
— | Set automatically by velojs start. Enables static file serving |
STATIC_BASE_URL |
"" |
CDN/bucket prefix for static assets |
VELO_TELEMETRY_FILE |
— | Telemetry destination file — present and non-empty activates the native collection (see Telemetry). Expands ~/$VAR |
VELO_TELEMETRY_SINK_URL |
— | Telemetry sink — present and non-empty also POSTs every event |
VELO_TELEMETRY_MAX_MB |
10 |
Telemetry file rotation limit, in MB |
veloPlugin() returns 8 plugins:
| Plugin | Purpose |
|---|---|
velo:config |
Build config (client/server modes, aliases, defines) |
velo:transform |
AST transforms (metadata injection, action stubs, loader removal) |
velo:static-url |
Rewrites CSS url(/path) to url(STATIC_BASE_URL/path) at build time |
velo:graph |
Generates .velojs/graph.json (route tree + dependency graph) on build and dev |
velo:leak-diagnostic |
Prints the server→client leak report on build and dev (read-only) |
@preact/preset-vite |
Preact JSX support |
@hono/vite-dev-server |
Dev server with SSR |
velo:ws-bridge |
Exposes Vite's HTTP server for WebSocket handlers in dev mode |
Applied during Vite's transform hook to files in appDirectory:
| # | Transform | When | What it does |
|---|---|---|---|
| 1 | removeMiddlewares |
Client only | Removes every middlewares property and the imports only it referenced |
| 2 | removeEndpointRoutes |
Client only | Strips handler: endpoint nodes from routes.tsx |
| 3 | injectMetadata |
Server + Client | Adds export const metadata = { moduleId, fullPath, path } |
| 4 | transformLoaderFunctions |
Server + Client | Injects moduleId: useLoader() → useLoader("moduleId") |
| 5 | transformActionsForClient |
Client only | Replaces action body with fetch() stub |
| 6 | transformStreamsForClient |
Client only | stream_* → { __isVeloEventStream, __path } stub |
| 7 | transformSocketsForClient |
Client only | socket_* → { __isVeloSocket, __path } stub |
| 8 | removeLoaders |
Client only | Removes export const loader entirely |
| 9 | pruneClientOnlyImports |
Client only | Drops imports left unreferenced by the strips above |
Only .ts/.tsx files inside appDirectory are transformed — a .jsx/.js page, or one outside it, is not touched at all.
velojs build
# 1. vite build → dist/client/ (client.[hash].js, client.[hash].css, .vite/manifest.json)
# 2. vite build --mode server → dist/server.js (SSR entry)| Module | Purpose |
|---|---|
virtual:velo/server-entry |
Server entry — imports server.tsx + routes, calls startServer() |
virtual:velo/client-entry |
Client entry — imports client.tsx + routes, calls startClient() |
/__velo_client.js |
Alias for client entry (used in dev) |
When routes.tsx changes, the plugin rebuilds the fullPath map and triggers a full page reload (not partial HMR).
What ships to the browser is checked on every velojs build (including build --static) and velojs dev: the terminal prints a report of every server module the client entry can reach — a leak of the server→client boundary.
A server module is a module that touches a Node builtin in its own code — static import, re-export or await import(), by any import path — or that matches a serverOnly pattern declared in the plugin config.
A leak is a server module reachable from what the browser loads, following static and dynamic imports.
The analysis reads the code after the client transforms — the same ones the build applies (loaders, actions, streams, sockets, middlewares and endpoints stripped, orphaned imports pruned) — so the normal VeloJS shape (a loader importing server code) is silent by construction: what the framework removes never travels.
Each leak in the report brings:
- the leaked module and what qualifies it as server (the Node builtin touched, or the
serverOnlypattern matched); - the full import chain from the client entry to it — file and line at every link;
- the entry point — the import where the chain pulls the first server module, marked
entry point (cut here): exactly where the cut happens.
[velojs] server->client leak analysis: 1 leak(s) in what ships to the client.
1) app/server-side.ts
server because: imports Node builtin "node:fs"
import chain from the client entry:
virtual:velo/client-entry (generated) -> app/routes.tsx
app/routes.tsx:3 -> app/pages/Home.tsx
app/pages/Home.tsx:6 -> app/server-side.ts <- entry point (cut here)
The report is read-only: nothing is blocked or rewritten; the cut is yours.
Two chains to the same module are two items — every cut point shows up — and the list is stable between runs.
When there is nothing to report, one line says so (0 leaks — what ships to the client is clean.), so silence is distinguishable from the analysis not running.
In dev the report appears when the server comes up (no navigation needed) and is re-emitted whenever a file change shifts what travels to the client — an import added stitches a new path into the next report, an import removed takes the leak out of it.
The report is the light, never the hand: nothing is blocked, rewritten, stubbed or moved, await import() included; the build finishes with success, the dev keeps serving, and the decision to cut any edge is entirely yours.
A module that touches no Node builtin is invisible to the analysis — the framework does not guess your app's domain. Declare it:
veloPlugin({
serverOnly: ["src/fsm/**", "server/**"],
});Patterns are globs (*, **, ?) matched against the module's file path relative to the project root, always with / as separator.
The declaration feeds the diagnostic only — nothing is blocked or altered because of it.
Limitation (craved): server code that touches no Node builtin and matches no serverOnly pattern generates no item; that is what the declaration is for.
VeloJS uses Node's AsyncLocalStorage to isolate data per request. Each SSR render runs in its own storage context, preventing data leaks between concurrent requests.
Hooks (useParams, useQuery, usePathname, Loader, useLoader) access this storage on the server via globalThis.__veloServerData.
VeloJS ships a backend testing toolkit at @mauroandre/velojs/testing. Spin up the app in memory, fire HTTP requests against the registered handlers, subscribe to event streams. No browser, no fragile mocks of framework internals — and a real TCP port when an external actor has to reach the app.
import { createTestApp } from "@mauroandre/velojs/testing";
import routes from "../app/routes.js";
import { stream_progress } from "../app/Deploy.js";
import { action_startDeploy } from "../app/Deploy.js";
const app = await createTestApp({
routes,
bootstrap: async () => { await connect(process.env.MONGO_URI!); },
getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
// port: 0, // optional — also serve this app over real TCP (see below)
});
// HTTP
const res = await app.get("/api/health");
// Convention helpers — pass the function, framework resolves the URL
const data = await app.loader(homeLoader, { params: { id: "abc" } });
await app.action(action_startDeploy, { body: { appId } });
// Streams — TestSubscription with snapshot, next(), close, etc
const sub = await app.subscribe(stream_progress, { channel: appId });
expect(sub.status).toBe(200);
const event = await sub.next({ timeoutMs: 2000 });
// Auth — sub-client with cookies bound automatically
const asAlice = app.as({ user: alice });
await asAlice.subscribe(stream_progress, { channel: appId });
// Live loader — open a channel with a principal, emit, await the next snapshot
const live = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
await live.next({ timeoutMs: 1000 }); // snapshot on connect
await emit("teamExpenses", { user: { teamId: 7 } });
const snapshot = await live.next({ timeoutMs: 1000 });
await app.close();Prerequisite: vitest.config.ts must include veloPlugin() so action/loader/stream metadata is injected.
| API | Purpose |
|---|---|
createTestApp(options) |
Build isolated app with bootstrap + auth callback |
app.port / app.url |
Real port and base URL of the TCP listener — undefined in the default in-memory mode |
app.get/post/put/patch/delete |
HTTP requests (cookies, headers, query, JSON/FormData body) |
app.action(fn, opts) |
Invoke action_* by function reference |
app.loader(fn, opts) |
Invoke loader and unwrap response data |
app.subscribe(stream, opts) |
Subscribe to a stream; returns TestSubscription with next/nextN/snapshot/close/closed |
app.channel(module, name, opts) |
Open a live-loader channel connection; opts.params declares the route params (validated for real — a wrong key rejects with 400); snapshots and slices arrive through next() (raw) or nextEvent() ({ type, data }) |
app.as({ user }) |
Sub-client with cookies bound to a user |
app.sessionCookies({ user }) |
Build cookies via getSessionCookie |
app.mockContext(opts) |
Escape hatch — partial Hono Context for direct invocation |
app.reset() |
Clear stream buffers/listeners between tests |
app.close() |
Tear down everything (zero open handles guaranteed) |
In-memory requests never leave the process, so an actor that does — a worker in another VM POSTing a notify callback — needs a real socket. Pass port and the same app instance is served over TCP as well:
const app = await createTestApp({ routes, port: 0 }); // 0 → free port
// Hand the URL to the external client (worker, container, another test process)
await provisioning.startWorker({ notifyUrl: `${app.url}/_action/Jobs/notify` });
// The worker POSTs over real HTTP; the in-memory subscription sees the event —
// same app, same stream registry, no glue and no second instance
const sub = await app.subscribe(stream_progress, { channel: jobId });
const event = await sub.next({ timeoutMs: 5000 });
await app.close(); // closes the listener, dropping in-flight SSE/WebSocket connectionsPages, endpoints, actions, ?_data=1 loaders, SSE and WebSocket all answer as in production; app.close() also terminates connections still open on the listener. port and hostname come from the options only — never from process.env.PORT/HOST (test determinism). Omitted hostname follows Node's default bind (all interfaces — the listener is reachable from the local network); app.url reports http://localhost:<port> unless an explicit hostname was given.
See Testing docs for the full guide with patterns, isolation, and FAQ.
| Import | Contents |
|---|---|
@mauroandre/velojs |
Types (AppRoutes, ActionArgs, LoaderArgs, Metadata, EventStream, EventStreamConfig, EmitFn, EmitOptions, SourceFn, PerChannelSourceFn, ChannelResolver), Scripts, Link, createEventStream, poll, defineConfig |
@mauroandre/velojs/server |
startServer, createApp, addRoutes, onServer, serverDataStorage, emit, registerChannels, ChannelDefinition, ChannelMap, ChannelRegistryOptions |
@mauroandre/velojs/client |
startClient |
@mauroandre/velojs/hooks |
Loader, useLoader, useEventStream, useParams, useQuery, useNavigate, usePathname, touch, Freshness |
@mauroandre/velojs/events |
createEventStream, poll, EventStream, EventStreamConfig, EmitFn, EmitOptions, SourceFn, PerChannelSourceFn, ChannelResolver (also re-exported from root) |
@mauroandre/velojs/testing |
createTestApp, TestApp, TestResponse, TestSubscription, TestChannelSubscription, ChannelEvent, CreateTestAppOptions, MockContextOptions |
@mauroandre/velojs/cookie |
getCookie, setCookie, deleteCookie, getSignedCookie, setSignedCookie |
@mauroandre/velojs/factory |
createMiddleware, createFactory |
@mauroandre/velojs/vite |
veloPlugin |
@mauroandre/velojs/config |
defineConfig, VeloConfig |
interface LoaderArgs {
params: Record<string, string>;
query: Record<string, string>;
c: Context; // Hono Context
}
interface ActionArgs<TBody = unknown> {
body: TBody;
params?: Record<string, string>;
query?: Record<string, string>;
c?: Context;
}
interface Metadata {
moduleId: string;
fullPath?: string;
path?: string;
}
interface RouteModule {
Component: ComponentType<any>;
loader?: (args: LoaderArgs) => Promise<any>;
channels?: readonly string[]; // live loader: channel names kept fresh
metadata?: Metadata;
[key: `action_${string}`]: (args: ActionArgs) => Promise<any>;
}
interface RouteNode {
path?: string;
module: RouteModule;
children?: RouteNode[];
middlewares?: MiddlewareHandler[];
isRoot?: boolean;
}
type AppRoutes = RouteNode[];
interface VeloConfig {
appDirectory?: string; // default: "./app"
routesFile?: string; // default: "routes.tsx"
serverInit?: string; // default: "server.tsx"
clientInit?: string; // default: "client.tsx"
port?: number; // default: 3000; the PORT env wins
hostname?: string; // bind interface, dev and production; the HOST env wins
serverOnly?: string[]; // glob patterns declaring server-only modules to the leak diagnostic
}Build a fully static site with pre-rendered HTML and JSON files:
velojs build --staticdist/
index.html # Pre-rendered HTML
index.json # Loader data
about/
index.html
index.json
client/
client.a1b2c3.js
client.x9y8z7.css
logos/ # Files from public/ are copied to dist/
logo.svg
Files in the public/ folder are automatically copied to dist/ root during static generation. Reference them with absolute paths:
<img src="/logos/logo.svg" />This works in both dev mode (Vite serves public/ at root) and static builds (public/ is copied to dist/).
For routes with :params, export a staticPaths function that returns all possible parameter combinations:
// app/pages/UserDetail.tsx
export const staticPaths = async () => {
const { getUsers } = await import("./user.service.js");
const users = await getUsers();
return users.map((u) => ({ id: u.id }));
};Routes without staticPaths are skipped with a warning.
A module that declares channels keeps rendering, but its channels are inert there: there is no server to speak SSE, so the client opens no connection, freshness stays "live" and the build warns, naming the module. Static output has no live data by construction.
The dist/ folder is self-contained. Deploy to any static hosting (Nginx, Cloudflare Pages, S3, etc.). No server required.
VeloJS has built-in cache management for zero-downtime deployments:
JS and CSS files are generated with content hashes (client.a1b2c3.js). When the content changes, the hash changes, and browsers automatically fetch the new version. Unchanged assets stay cached indefinitely.
HTML responses include Cache-Control: no-cache, so browsers always check for the latest version. Since HTML is small (a few KB), this has negligible performance impact.
When a user is navigating a SPA and a new deploy happens, VeloJS detects it automatically:
- Each build generates a unique
__VELO_BUILD_HASH__, embedded in the client JS - JSON data responses (both SSR and SSG) include a
__buildHashfield - On SPA navigation,
useLoadercompares the response hash with the client hash - If they differ, an internal
__veloUpdatePendingflag is set - On the next
<Link>click, VeloJS does a full page navigation instead of SPA — loading the new HTML with updated asset references
This means users get the new version without needing Ctrl+Shift+R. The transition is seamless — from the user's perspective, it looks like a normal page navigation.
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY app ./app
COPY tsconfig.json vite.config.ts ./
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
ENV PORT=3000
EXPOSE 3000
CMD ["npx", "velojs", "start"]velojs build
# dist/
# client/ # Static assets (JS, CSS, images)
# client.a1b2c3.js
# client.x9y8z7.css
# .vite/manifest.json
# server.js # SSR server entry (single file)Asset filenames include content hashes for long-term browser caching. The server build reads the Vite manifest to inject the correct filenames into <Scripts>.
In production, velojs start sets NODE_ENV=production automatically and serves static files from dist/client/. Assets carry content hashes and are public by design, so the server answers an existing file before the app's route table — a page route, an action endpoint or a route middleware (an auth guard redirecting to /login, for instance) never sees an asset path. Only the framework's own infra middlewares (trailing-slash normalization, the logger) run ahead of the asset lookup. Paths that are not files fall through to the app routes as usual (SSR pages, actions, streams, sockets, endpoints, the catch-all 404). HTML responses are served with Cache-Control: no-cache so browsers always fetch the latest HTML (which references the current hashed assets).
Set STATIC_BASE_URL to serve static assets from a CDN or S3 bucket:
STATIC_BASE_URL=https://cdn.example.com/assets node dist/server.jsThe <Scripts /> component and CSS url() references will use this prefix automatically. When STATIC_BASE_URL starts with http, the server mounts no local static at all — every path is an app path, and the assets' public-by-design semantics come from the bucket/CDN.
VeloJS includes everything you need. A single npm install @mauroandre/velojs brings:
- Hono — HTTP server and routing
- Preact — UI rendering (SSR + client)
- @preact/signals — Reactive state management
- wouter-preact — Client-side routing
- Vite — Build tool and dev server
- @preact/preset-vite — Preact JSX support
- @hono/vite-dev-server — SSR dev server
No need to install these separately.