Ruwuter is a server-first router and HTML renderer for Cloudflare-style Web APIs. It matches
generated URLPattern routes, runs loaders/actions, renders nested server components to HTML, and
lets ordinary links and forms work without JavaScript.
Browser JavaScript is optional and explicit: move one event callback into a browser module or mount a controller for behavior that spans elements, then load the small activation runtime.
The deployed server path uses Fetch API objects, Web Streams, URLPattern, and AsyncLocalStorage
from node:async_hooks. The node: specifier is a shared server-runtime compatibility surface
rather than a Node-only deployment target: Node.js and
Deno provide it directly, and
Vercel Edge provides
the WinterCG-compatible subset Ruwuter uses.
On Cloudflare Workers, enable only AsyncLocalStorage with
nodejs_als,
or use nodejs_compat when the application needs broader Node compatibility:
Do not replace node:async_hooks with a no-op or module-global mock. Ruwuter uses
AsyncLocalStorage to keep server context and streamed Suspense state isolated across concurrent
requests and asynchronous stream pulls. Each context provider derives its own store, so sibling
branches never observe one another's values and server context stays readable inside a Suspense
boundary whose content resolves after the surrounding branch has streamed. A runtime without
equivalent async-context semantics is not currently supported; a Promise-only userland polyfill
cannot reliably provide them.
The other Node built-in imports (node:fs, node:path, and node:process) are confined to the
file-route generator, CLI, and Vite build integration. They are not part of the deployed router
path.
pnpm add jsr:@mewhhaha/ruwuter
# or: deno add jsr:@mewhhaha/ruwuterimport { Router } from "@mewhhaha/ruwuter";
import { routes } from "./app/routes.ts";
const router = Router(routes);
export default {
fetch(request: Request, env: Env, executionContext: ExecutionContext) {
return router.handle(request, env, executionContext);
},
};Route loaders, actions, headers, and fragment handlers receive named context:
type RequestContext = {
request: Request;
params: Record<string, string>;
env: Env;
executionContext: ExecutionContext;
signal: AbortSignal;
};GET and HEAD use loaders/default components. Actions handle POST, PUT, PATCH, and
DELETE. Matched but unsupported methods return 405 with Allow; OPTIONS returns 204 with
Allow. Nested route loaders start concurrently; their results and headers are still applied in
parent-to-leaf order. Because every loader starts before settlement, child loader side effects may
run even when a parent redirects or fails; later rejections are observed and do not become unhandled
promises.
The router itself stays out of error and not-found presentation: unmatched requests return an empty
404, and errors that are not a Response rethrow to the caller. Wrap handle to provide your own
responses:
import { html } from "@mewhhaha/ruwuter";
export default {
async fetch(request: Request, env: Env, executionContext: ExecutionContext) {
try {
const response = await router.handle(request, env, executionContext);
if (response.status === 404 && !response.body) {
return html(<h1>Not found: {new URL(request.url).pathname}</h1>, { status: 404 });
}
return response;
} catch (error) {
executionContext.waitUntil(reportError(error));
return html(<h1>Something went wrong</h1>, { status: 500 });
}
},
};Once a streamed response has begun, a later rendering failure cannot change its status or reach your catch; the already-committed body ends early instead.
import { html, json } from "@mewhhaha/ruwuter";
export const loader = () => {
return json({ ok: true }, { status: 200 });
};
export const action = async () => {
return html(<p>Created</p>, {
status: 201,
headers: { "Cache-Control": "private" },
});
};Define browser controllers with typed props and static ref tokens. Use
controller(moduleHref, props) on the DOM root that owns the browser behavior.
import clientRuntime from "@mewhhaha/ruwuter/client.js?url&no-inline";
import { controller } from "@mewhhaha/ruwuter/browser";
import { palette } from "./app/controllers.ts";
export default function Palette() {
const mounted = controller(palette, { initiallyOpen: false });
return (
<html>
<head>
<script type="module" src={clientRuntime}></script>
</head>
<body>
<section {...mounted.root()}>
<button ref={mounted.refs.open} type="button">Open</button>
<dialog ref={mounted.refs.dialog}>...</dialog>
</section>
</body>
</html>
);
}// app/palette.client.ts
"use client";
import { defineController, on } from "@mewhhaha/ruwuter/browser";
export type PaletteController = {
props: {
initiallyOpen: boolean;
};
refs: {
open: HTMLButtonElement;
dialog: HTMLDialogElement;
};
};
export default defineController<PaletteController>(({ refs, props, signal }) => {
if (props.initiallyOpen) refs.dialog.showModal();
on(refs.open).click(() => refs.dialog.showModal(), { signal });
return () => {
refs.dialog.close();
};
});With the Vite plugin enabled, every *.client.ts or *.client.tsx under the app folder contributes
a typed export to generated app/controllers.ts. The URL is served as compiled JavaScript in dev
and emitted as a dedicated cache-busted chunk in production; an invalid default export fails type
checking. The generator refuses to overwrite an unmarked, user-owned controllers.ts.
For same-file client logic, enable the experimental build-time macro:
// vite.config.ts
import { defineConfig } from "vite";
import { ruwuter } from "@mewhhaha/ruwuter/vite";
export default defineConfig({
plugins: [ruwuter({ appFolder: "./app", clientMacro: true })],
});
// a route module
import { client, on } from "@mewhhaha/ruwuter/browser";
const palette = client<{
props: { initiallyOpen: boolean };
refs: { open: HTMLButtonElement; dialog: HTMLDialogElement };
}>(({ refs, props, signal }) => {
on(refs.open).click(() => refs.dialog.showModal(), { signal });
if (props.initiallyOpen) refs.dialog.showModal();
});client() must initialize a top-level const. Its callback may use browser globals and imported
bindings, but cannot capture other module values; pass server values through controller props. The
macro emits a separate browser controller in both dev and production and adds nothing to the default
runtime. Without clientMacro: true, client() throws with an actionable error.
For one local event, the same plugin can move a callback directly from server JSX:
import { move } from "@mewhhaha/ruwuter/browser";
export default function Counter({ count }: { count: number }) {
return (
<button
type="button"
on:click={move({ count }, async (event, values) => {
const button = event.currentTarget;
const { default: confetti } = await import("canvas-confetti");
button.textContent = String(values.count + 1);
confetti();
})}
>
{count}
</button>
);
}move() is also Vite-only and requires clientMacro: true. The event and element types come from
the on:event prop, while the values object must be JSON-safe. The callback may use browser
globals, static imports, and dynamic imports; Vite bundles installed dependencies normally. It
cannot capture server bindings, so rendered values cross the boundary explicitly through move()'s
first argument. The HTML contains only an event name, a same-origin browser module URL, and those
JSON values—never function source or evaluated code. Load client.js once on any page that uses
moved events.
The runtime mounts each controller or moved-event root once. On removal it waits for the mutation batch, ignores DOM moves, aborts the root signal, and then runs any controller cleanup callback.
HTML-over-the-wire fragments are explicit route-module exports, not discovered component names.
import { fragment } from "@mewhhaha/ruwuter";
export const fragments = {
sidebar: fragment(async ({ env }) => <aside>{env.SITE_NAME}</aside>),
save: fragment(async ({ request }) => {
const fields = await request.formData();
return <p>Saved {String(fields.get("name"))}</p>;
}, { methods: ["POST"] }),
};Fetch fragments from the reserved namespace:
/products/keyboard/_ruwuter/sidebar
Fragments handle GET and HEAD by default. Pass methods to opt into mutation requests;
OPTIONS and Allow are derived from each fragment's declared methods.
Generate static routes from an app folder:
deno run -A jsr:@mewhhaha/ruwuter/fs-routes/cli ./appOr use the Vite plugin:
import { defineConfig } from "vite";
import { ruwuter } from "@mewhhaha/ruwuter/vite";
export default defineConfig({
plugins: [ruwuter({ appFolder: "./app" })],
});The plugin regenerates routes, types, and typed controller hrefs during builds and relevant
dev-server updates. Controller sources and documented client.js / resolve.js / swap.js /
navigate.js ?url imports become executable browser chunks; application-wide import.meta.url
rewriting is not used.
Out-of-order Suspense streaming is optional. Wrap the document with SuspenseProvider and load the
resolver runtime only when you opt into streamed template replacement.
import resolveRuntime from "@mewhhaha/ruwuter/resolve.js?url&no-inline";
import { Suspense, SuspenseProvider } from "@mewhhaha/ruwuter/components";
export default function Page() {
return (
<html>
<head>
<script type="module" src={resolveRuntime}></script>
</head>
<body>
<SuspenseProvider>
<Suspense
fallback={<p>Loading</p>}
errorFallback={(error) => <p>Could not load: {String(error)}</p>}
>
{async () => <p>Ready</p>}
</Suspense>
</SuspenseProvider>
</body>
</html>
);
}A rejected boundary is logged and contained: its errorFallback replaces that boundary while other
boundaries keep streaming. Without errorFallback, its original fallback stays in place. Target ids
combine a per-provider crypto.randomUUID() with a counter, so boundaries stay unique within a
render and a cold isolate answering a fragment request cannot reissue an id the host page already
used.
Enhanced same-origin links and forms are an optional browser entrypoint. It uses the Navigation API when available and otherwise leaves ordinary document navigation untouched.
import navigateRuntime from "@mewhhaha/ruwuter/navigate.js?url&no-inline";
export default function Document({ children }) {
return (
<html>
<head>
<meta name="rw-navigate-target" content="#app" />
</head>
<body>
<main id="app">{children}</main>
<script type="module" src={navigateRuntime}></script>
</body>
</html>
);
}The destination must render the same target selector. The runtime fetches same-origin GET and POST
navigations, preserves form encoding, parses the returned document, and replaces only the target's
children inside a View Transition. Browser reloads, hash changes, downloads, cross-origin URLs, and
unsupported browsers keep native behavior. Configure it from a module with
enhanceNavigation({ target: "#app", viewTransition: false }) when a meta element is inconvenient.
Enhanced navigation waits for the complete response. Pages relying on progressive out-of-order
Suspense streaming should include <meta name="rw-navigate" content="reload">; the runtime then
finishes with a normal document load. Because that reload would repeat a submission, it applies to
GET navigations only — a POST renders the response it already fetched. Add data-rw-reload to an
individual link or form to skip interception before fetching.
deno task ciThis runs formatting, linting, typecheck, unit tests, DOM integration tests, and gzip size gates.
The enforced browser budgets are 1,400 B for client.js, 500 B for resolve.js, 1,450 B for
swap.js, and 1,500 B for opt-in navigate.js.
MIT
{ "compatibility_flags": ["nodejs_als"] }