@kuratchi/js
v0.0.32
Published
A thin, Cloudflare Workers-native web framework with Svelte-inspired syntax
Readme
@kuratchi/js
Cloudflare Workers-native web framework with file-based routing, server actions, and Durable Object support.
Install
npm install @kuratchi/jsQuick start
npx kuratchi create my-app
cd my-app
bun run devHow it works
kuratchi build (or kuratchi watch) scans src/routes/ and generates framework output:
| File | Purpose |
|---|---|
| .kuratchi/routes.ts | Compiled routes, actions, RPC handlers, and render functions |
| .kuratchi/worker.ts | Stable wrangler entry - re-exports the fetch handler plus all Durable Object and Agent classes |
| .kuratchi/do/*.ts | Generated Durable Object RPC proxy modules for .do.ts file imports |
Point wrangler at the entry and you're done. No src/index.ts needed.
For the framework's internal compiler/runtime orchestration and tracked implementation roadmap, see ARCHITECTURE.md.
// wrangler.jsonc
{
"main": ".kuratchi/worker.ts"
}Routes
Place .kuratchi files inside src/routes/. The file path becomes the URL pattern.
src/app.kuratchi → document shell (optional)
src/routes/index.kuratchi → /
src/routes/items/index.kuratchi → /items
src/routes/blog/[slug]/index.kuratchi → /blog/:slug
src/routes/layout.kuratchi → shared layout wrapping all routesFile extension: Every Kuratchi source file uses the
.kuratchiextension — both routes (src/routes/) and components (anywhere else). The compiler only discovers files ending in.kuratchi; plain.htmlfiles insrc/routes/are ignored. Use.htmlonly for genuine static-HTML assets served fromsrc/assets/.
Execution model
Kuratchi routes are SSR by default, with a client-first authored <script> model.
src/routesdefines server-rendered route modules.- Top-level
<script>blocks inapp.kuratchi,layout.kuratchi, and pages are authored as client-first code. $server/*imports are the explicit server/RPC escape hatch.const x = await serverFn()blocks SSR until the value resolves, then hydrates that value into the browser copy.const x = serverFn()returns an async value with.pending,.error, and.successfor non-blocking SSR + streaming.- Template expressions, reactive attributes,
if, andforblocks render on the server for the initial response and update in the browser when they read reactive state. src/serveris for private server-only modules and reusable backend logic.src/middleware.tsis the request middleware entrypoint for interception and guards.- Reactive
$:code runs in the client copy of the top script.
Route files still render on the server, but the authored script model matches the web: keep top-level script logic browser-friendly, and use $server/* when you need the framework to cross into server execution.
Client reactivity
Kuratchi uses $: as the reactive primitive:
- Top-level
letbindings become reactive when they are read by$:,bind:value, or a live template expression. $: name = expressiondefines derived state (and auto-declaresnameif you didn't writelet namealready).$: statementand$: { ... }define effects.- Template text, normal attributes,
ifblocks, andforblocks that read reactive state update in the browser. bind:value={state.path}wires form controls back into reactive state.- Bindings inside template loops can use loop locals directly, like
bind:value={forms[item.id].selected}.
<script>
const allCells = await listCells();
let selectedLocationId = '';
let selectedCellId = '';
let selectOptions = [];
$: selectOptions = allCells.filter(
cell => cell.locationId === selectedLocationId,
);
$: if (!selectOptions.some(cell => cell.id === selectedCellId)) {
selectedCellId = '';
}
</script>
<select bind:value={selectedLocationId}>
<option value="">Choose...</option>
for (const location of locations) {
<option value={location.id}>{location.name}</option>
}
</select>
<select bind:value={selectedCellId}>
<option value="">Choose...</option>
for (const cell of selectOptions) {
<option value={cell.id}>{cell.name}</option>
}
</select>Do not use browser-only scripts for state that can be expressed this way. Reach for an extra <script> only when integrating a DOM-only library or API that has no SSR-safe representation.
Route file structure
<script>
import { getItems, addItem, deleteItem } from '$server/items';
const items = await getItems();
const suggestions = addItem();
</script>
<!-- Template — plain HTML with minimal extensions -->
if (suggestions.pending) {
<p>Saving…</p>
} else if (suggestions.error) {
<p>{suggestions.error}</p>
}
<ul>
for (const item of items) {
<li>{item.title}</li>
}
</ul>The $server/ alias resolves to src/server/. Use that as the canonical home for reusable server-only modules.
Private server logic should live in src/server/ and be imported into routes explicitly.
Static assets (src/assets)
Put plain CSS, images, and other static files in src/assets/.
Kuratchi mirrors that directory into the generated public assets output and keeps Wrangler's assets.directory in sync automatically, so you can reference files with /assets/... by default.
<link rel="stylesheet" href="/assets/app.css" />If you want a different public URL prefix, pass assetsPrefix as a Vite plugin option (or to compile() directly when using the legacy CLI):
// vite.config.ts
import { kuratchi } from '@kuratchi/js/vite';
export default defineConfig({
plugins: [kuratchi({ /* assetsPrefix: '/static/' coming from compile() */ })],
});The kuratchi() plugin currently ships routesDir, serverDir, libDir, and security options; assetsPrefix is wired through compile() for the legacy CLI path and inherits the default /assets/ for the Vite path.
Server-side asset access
Use the kuratchi:assets virtual module when server code needs to read a static asset through the app's configured ASSETS binding.
import { fetchAsset } from 'kuratchi:assets';
const response = await fetchAsset('/reports/q126_breakdown_by_product_devplat.csv');
if (!response.ok) return null;
const csv = await response.text();Pass the same public URL path you would use in markup, not the source file path. For example, if the asset is reachable in the browser at /reports/data.csv, pass /reports/data.csv.
Behavior:
- Uses the current request origin in dev so asset fetches behave the same way as the running app.
- Falls back to an internal asset hostname when there is no active request context.
- Returns the raw
Responseso your code controls parsing (text(),json(),arrayBuffer(), headers, status handling).
Failure behavior:
- Throws if the app does not have an
ASSETSbinding configured. - Does not coerce missing assets into
null; checkresponse.okyourself and handle 404/403/other statuses explicitly.
CSS processing
CSS files in src/assets/ can be processed during build. All CSS tooling is opt-in — install only what you need.
Minification
To enable CSS minification via Lightning CSS:
npm install lightningcssMinification is automatic in production builds when lightningcss is installed.
Tailwind CSS
To enable Tailwind, install the required packages and pass the css option to compile() (legacy CLI) or wire Tailwind directly into your Vite config (Vite path):
npm install tailwindcss @tailwindcss/postcss postcssFor the Vite path, the standard PostCSS / Tailwind v4 setup works (postcss.config.js + a CSS file with @import 'tailwindcss';).
Then use Tailwind's CSS-first configuration in your CSS file:
/* src/assets/app.css */
@import "tailwindcss";
@plugin "daisyui";CSS config options
css: {
tailwind: boolean; // Enable Tailwind processing (default: false)
plugins: string[]; // Tailwind plugins to load (default: [])
minify: boolean; // Enable minification (default: true in production, requires lightningcss)
}App shell and layout
src/app.kuratchi owns the document shell. It contains <!DOCTYPE html>, <html>, <head>, and <body> and renders the composed layout/page output through exactly one <slot></slot>:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My App</title>
</head>
<body>
<slot></slot>
</body>
</html>src/routes/layout.kuratchi wraps every page as a fragment. Use <slot></slot> where page content renders:
<script>
import { url } from 'kuratchi:request';
const currentPath = url.pathname;
</script>
<nav>
<a href="/" class={currentPath === '/' ? 'active' : ''}>Home</a>
<a href="/items" class={currentPath.startsWith('/items') ? 'active' : ''}>Items</a>
</nav>
<main>
<slot></slot>
</main>Template syntax
Interpolation
<p>{title}</p>
<p>{@html bodyHtml}</p> <!-- sanitized HTML -->
<p>{@raw trustedHtml}</p> <!-- unescaped, unsafe -->Conditionals
if (items.length === 0) {
<p>Nothing here yet.</p>
} else {
<p>{items.length} items</p>
}Loops
for (const item of items) {
<li>{item.title}</li>
}Attribute expressions
Use {expression} in attribute values for dynamic content:
<!-- Ternary expressions -->
<div class={isActive ? 'active' : 'inactive'}>...</div>
<button class={count > 0 ? 'has-items' : ''}>View ({count})</button>
<!-- Any JS expression -->
<a href={`/items/${item.id}`}>{item.name}</a>
<img src={user.avatar} alt={user.name} />Boolean attributes
Boolean attributes like disabled, checked, selected, etc. are conditionally rendered based on the expression value:
<!-- Renders: <button disabled> or <button> -->
<button disabled={isLoading}>Submit</button>
<!-- Form elements -->
<input type="checkbox" checked={todo.completed} />
<option selected={item.id === selectedId}>{item.name}</option>
<!-- Other boolean attributes -->
<details open={showDetails}>...</details>
<input readonly={!canEdit} />
<input required={isRequired} />Supported boolean attributes: disabled, checked, selected, readonly, required, hidden, open, autofocus, autoplay, controls, default, defer, formnovalidate, inert, loop, multiple, muted, novalidate, reversed, async.
Components
Components are .kuratchi files imported by name. Three resolution rules cover every common case:
<script>
// 1. $lib alias → src/lib/<name>.kuratchi
import Card from '$lib/card.kuratchi';
// 2. Package → node_modules/@scope/pkg/src/lib/<name>.kuratchi
import Badge from '@kuratchi/ui/badge.kuratchi';
// 3. Relative → resolved against the importer's directory
import Chart from './widgets/chart.kuratchi';
</script>
<Card title="Stack">
<Badge variant="success">Live</Badge>
<Chart series={data} />
</Card>Components can live anywhere in your project — src/lib/ is the conventional default for shared components, but co-location (src/lib/widgets/chart.kuratchi, etc.) is fully supported via relative imports.
One hard rule: components cannot live under src/routes/. The compiler throws a clear error if you try to import a route file as a component. Route files are routes; components are components. If you need to share markup between routes, move it to $lib/.
Component props (kuratchi:component)
Components declare their props with an explicit import — no ambient props reference:
<!-- src/lib/card.kuratchi -->
<script>
import { props } from 'kuratchi:component';
const { title, variant = 'default' } = props<{
title?: string;
variant?: 'default' | 'success';
}>();
</script>
<div class="card card-{variant}">
if (title) {
<h2>{title}</h2>
}
<slot></slot>
</div>
<style>
.card { border: 1px solid; padding: 1rem; }
.card-success { border-color: green; }
</style>props<T>() is callable AND indexable. Both styles work and reference the same data:
<!-- Destructure (recommended) -->
<script>
import { props } from 'kuratchi:component';
const { title } = props<{ title: string }>();
</script>
<h2>{title}</h2>
<!-- Property access (also valid) -->
<script>
import { props } from 'kuratchi:component';
</script>
<h2>{props.title}</h2>Slots use the platform-native <slot></slot> element. Children passed between a component's open and close tags render at the slot site. The corresponding JS-level access (props.children) is available inside the script if you need it programmatically.
Migration note: the old "ambient props" pattern (referencing props without an import) is gone. Every component must import { props } from 'kuratchi:component' if it touches the identifier.
Client Reactivity ($:)
Inside the top <script> block, Kuratchi supports Svelte-style reactive labels for the browser copy of the route/layout/app script:
<script>
let users = ['Alice'];
$: console.log(`Users: ${users.length}`);
function addUser() {
users.push('Bob'); // reactive update, no reassignment required
}
</script>Block form is also supported:
<script>
let form = { first: '', last: '' };
$: {
const fullName = `${form.first} ${form.last}`.trim();
console.log(fullName);
}
</script>Notes:
- Route files are server-rendered by default.
- Top-level
<script>blocks are authored as client-first code;$:runs in the browser copy of that script. - Object/array
letbindings are proxy-backed automatically when$:is used. $: name = exprworks; when replacing proxy-backed values, the compiler preserves reactivity under the hood.- You do not need to predeclare derived aliases:
$: showDetails = selected === 'x'is valid on its own. - Do not put direct
document/windowaccess in the top script when it must participate in SSR. Move browser-only DOM work to a second<script>block after the template. - You should not need
if (browser)style guards in normal Kuratchi top-script code. If browser-only DOM access is required, move that code to a second<script>block after the template.
$lib/ Shared Imports
Use $lib/* for shared browser-safe code that can execute during SSR and in the browser copy of the top script. The $lib/ alias resolves to src/lib/.
// src/lib/format.ts
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}<script>
import { formatBytes } from '$lib/format';
import { getFiles } from '$server/files';
const files = await getFiles();
</script>
for (const file of files) {
<div>{file.name} - {formatBytes(file.size)}</div>
}Key behavior:
$lib/imports work in server templates (SSR) AND client scripts- Use for utilities, formatters, validators, and DOM helpers
- Client scripts in template body are executed on the client
Client-Side DOM Manipulation
For browser-only code, use normal <script> blocks in the template body:
<script>
import { getMessages } from '$server/chat';
const messages = await getMessages(chatId);
</script>
<div id="messages">
for (const msg of messages) {
<div>{msg.content}</div>
}
</div>
<!-- Client-side script - runs in browser -->
<script>
import { initChatUI } from '$lib/chat-ui';
const chatId = window.location.pathname.split('/').pop();
initChatUI(chatId);
</script>Behavior:
- Inline client
<script>blocks are bundled with esbuild - Kuratchi adds
type="module"for you when the script contains ES module imports $lib/imports are resolved and bundled for the browser$server/imports in client scripts become RPC stubs (future feature)
Failure and edge behavior:
- Namespace imports like
import * as api from '$server/foo'are currently rejected in browser code. - Remote call failures reject with the server error message when available, otherwise
HTTP <status>.
Awaited remote reads
For renderable remote reads, use direct await fn(args) markup. Kuratchi lowers it to a route query, renders it on the server, and refreshes it after successful remote calls.
<script>
import { getMigrationConnectionStatus } from '$server/incus';
</script>
<p>{await getMigrationConnectionStatus(sourceIp)}</p>Behavior:
- The read runs during the initial server render.
- Kuratchi emits refresh metadata so the same block can be re-fetched without a full page reload.
- Successful remote calls automatically invalidate awaited reads on the current page.
Failure and edge behavior:
- The supported syntax is direct markup form:
{await fn(args)}. - Awaited reads are intended for values that render cleanly to text/HTML output.
- Complex promise expressions or chained property access should be wrapped in a dedicated server helper that returns the render-ready value.
Form actions
Export server functions from a route's <script> block and reference them with action={fn}. The compiler automatically registers them as dispatchable actions.
<script>
import { addItem, deleteItem } from '$server/items';
</script>
<!-- Standard form — POST-Redirect-GET -->
<form action={addItem} method="POST">
<input type="text" name="title" required />
<button type="submit">Add</button>
</form>The action function receives the raw FormData. Throw ActionError to surface a message back to the form — see Error handling.
// src/server/items.ts
import { ActionError } from '@kuratchi/js';
export async function addItem({ formData }: FormData): Promise<void> {
const title = (formData.get('title') as string)?.trim();
if (!title) throw new ActionError('Title is required');
// write to DB...
}Redirect after action
Call redirect() inside an action or load() to immediately exit and send the user to a different URL. throw redirect() also works, but is redundant because redirect() already throws:
import { redirect } from '@kuratchi/js';
export async function createItem({ formData }: FormData): Promise<void> {
const id = await db.items.insert({ title: formData.get('title') });
redirect(`/items/${id}`);
}Error handling
Action errors
Throw ActionError from a form action to surface a user-facing message in the template. The error message is bound directly to the action by name — if you have multiple forms on the same page, each has its own isolated error state.
import { ActionError } from '@kuratchi/js';
export async function signIn({ formData }: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
if (!email || !password) throw new ActionError('Email and password are required');
const user = await db.findUser(email);
if (!user || !await verify(password, user.passwordHash)) {
throw new ActionError('Invalid credentials');
}
}In the template, the action's state object is available under its function name:
<script>
import { signIn } from '$server/auth';
</script>
<form action={signIn}>
(signIn.error ? `<p class="error">${signIn.error}</p>` : '')
<input type="email" name="email" />
<input type="password" name="password" />
<button type="submit">Sign in</button>
</form>The state object shape: { error?: string, loading: boolean, success: boolean }.
actionName.error— set onActionErrorthrow, cleared on next successful actionactionName.loading— set by the client bridge during form submission (CSS target:form[data-action-loading])actionName.success— reserved for future use
Throwing a plain Error instead of ActionError keeps the message hidden in production and shows a generic "Action failed" message. Use ActionError for expected validation failures; let plain errors propagate for unexpected crashes.
Load errors
Throw PageError from a route's load scope to return the correct HTTP error page. Without it, any thrown error becomes a 500.
import { PageError } from '@kuratchi/js';
// In src/routes/posts/[id]/index.kuratchi <script> block:
import { params } from '@kuratchi/js/request';
const post = await db.posts.findOne({ id: params.id });
if (!post) throw new PageError(404);
if (!post.isPublished && !currentUser?.isAdmin) throw new PageError(403);PageError accepts any HTTP status. The framework renders the matching custom error page (src/routes/404.kuratchi, src/routes/500.kuratchi, etc.) if one exists, otherwise falls back to the built-in error page.
throw new PageError(404); // → 404 page
throw new PageError(403, 'Admin only'); // → 403 page, message shown in dev
throw new PageError(401, 'Login required'); // → 401 pageFor soft load failures where the page should still render (e.g. a widget that failed to fetch), return the error as data from load() and handle it in the template:
<script>
const { data: recommendations, error: recError } = await safeGetRecommendations();
</script>
(recError ? '<p class="notice">Could not load recommendations.</p>' : '')
for (const rec of (recommendations ?? [])) {
<article>{rec.title}</article>
}Async Values
Kuratchi provides a native JS pattern for handling async data with loading, error, and success states.
Two Patterns
| Pattern | Returns | Use case |
|---------|---------|----------|
| const x = fn() | AsyncValue<T> | Need loading/error states |
| const x = await fn() | T | Just need the value (blocks) |
AsyncValue API
When you call an async function without await, it returns an AsyncValue<T> with metadata:
interface AsyncValue<T> extends T {
pending: boolean; // true while loading
error: string | null; // error message if failed
success: boolean; // true when resolved
}Usage
<div>
const todos = getTodos();
if (todos.pending) {
<div class="skeleton">Loading...</div>
}
if (todos.error) {
<p class="error">Failed: {todos.error}</p>
}
for (const todo of todos) {
<TodoItem todo={todo} />
}
</div>With if/else:
<div>
const todos = getTodos();
if (todos.pending) {
<Skeleton />
} else if (todos.error) {
<p>Failed: {todos.error}</p>
} else if (todos.length > 0) {
for (const todo of todos) {
<TodoItem todo={todo} />
}
} else {
<p>No todos yet.</p>
}
</div>Live Workflow Status (kuratchi:workflow)
Import workflowStatus from the kuratchi:workflow virtual module to read a Cloudflare Workflow's status. The first argument is a compile-time-typed string-literal union of your discovered *.workflow.ts basenames; passing an unknown name is a type error.
<script>
import { params } from 'kuratchi:request';
import { workflowStatus } from 'kuratchi:workflow';
// Name is typed: only 'migration' | 'data-sync' | ... (whatever *.workflow.ts files exist)
const status = await workflowStatus('migration', params.id, { poll: '2s' });
</script>
if (status.error) {
<ErrorBanner error={status.error} />
} else if (status.status === 'running') {
<ProgressBar progress={status.output?.progress} />
} else if (status.status === 'complete') {
<CompletedBanner result={status.output} />
}When you pass { poll }, the framework injects a tiny directive script that re-fetches the URL every interval and swaps <body> with the freshly rendered HTML. Every {status.*} reference re-evaluates server-side on each tick — no client reactivity to wire up.
Options:
poll— interval as string ('2s','500ms','1m') or number of milliseconds. Omit for a one-shot read.until(value)— override the default terminal predicate. Default stops on'complete','completed','errored', or'terminated'.
Multiple polls on one page — call workflowStatus(..., { poll }) as many times as you like. The shortest interval wins, and polling only stops when every call reports terminal:
<script>
const statuses = Object.fromEntries(await Promise.all(
activeJobs.map(async (j) => [j.id, await workflowStatus('migration', j.id, { poll: '2s' })])
));
</script>Blocking (await)
When you don't need loading states, use await:
<script>
const todos = await getTodos(); // blocks until resolved
</script>
for (const todo of todos) {
<TodoItem todo={todo} />
}Progressive Enhancement
Button Actions
Use onclick={fn(args)} for button-style server actions:
<button onclick={deleteItem(item.id)} type="button">Delete</button>
<button onclick={toggleItem(item.id, true)} type="button">Done</button>data-select-all / data-select-item — checkbox groups
Sync a "select all" checkbox with a group of item checkboxes:
<input type="checkbox" data-select-all="todos" />
for (const todo of todos) {
<input type="checkbox" data-select-item="todos" value={todo.id} />
}RPC
For Durable Objects, RPC is file-driven and automatic.
- Put handler logic in a
.do.tsfile insrc/server/. - Exported functions in that file become RPC methods.
- Import the
.do.tsfile directly — the framework auto-generates RPC proxies. - RPC methods are still server-side code. They are exposed intentionally by the framework runtime, not because route files are client-side.
<script>
import { getOrgUsers, createOrgUser } from '$server/auth.do';
const users = await getOrgUsers();
</script>
<form action={createOrgUser} method="POST">
<input type="email" name="email" required />
<button type="submit">Create</button>
</form>RPC Validation Without Dependencies
Kuratchi ships a small built-in schema API for route RPCs and Durable Object RPC methods, so you do not need zod, valibot, or any other runtime dependency just to validate client-callable input.
Declare schemas in a companion schemas object. Keys must match the public RPC function or method names:
import { schema, type InferSchema } from '@kuratchi/js';
export const schemas = {
createSite: schema({
name: schema.string().min(1),
slug: schema.string().min(1),
publish: schema.boolean().optional(false),
}),
};
export async function createSite(data: InferSchema<typeof schemas.createSite>) {
return { id: `${data.slug}-1`, publish: data.publish };
}This works for normal exported route RPC functions without changing the function declaration style. The schema lives alongside the function instead of wrapping it.
Durable Object classes use the same convention via static schemas:
import { DurableObject } from 'cloudflare:workers';
import { schema, type InferSchema } from '@kuratchi/js';
export default class SitesDO extends DurableObject {
static schemas = {
saveDraft: schema({
title: schema.string().min(1),
content: schema.string().min(1),
}),
};
async saveDraft(data: InferSchema<(typeof SitesDO.schemas).saveDraft>) {
return { ok: true, slug: data.title.toLowerCase().replace(/ /g, '-') };
},
}If the payload does not match the schema, Kuratchi returns 400 with a validation error instead of executing the RPC. Schema-backed RPCs accept a single object argument.
Rules:
- Route RPC modules use
export const schemas = { ... }. - Durable Object classes use
static schemas = { ... }. - Schema keys must match public function or method names exactly.
- Schema-backed RPC entrypoints take one object argument.
- Today, the typed handler pattern is
InferSchema<typeof schemas.name>orInferSchema<(typeof MyDO.schemas).methodName>.
Available schema builders:
schema({ ... })schema.string()schema.number()schema.boolean()schema.file().optional(defaultValue).list().min(value)
Example with nested objects, arrays, and defaults:
import { schema, type InferSchema } from '@kuratchi/js';
export const schemas = {
createProfile: schema({
name: schema.string().min(1),
info: schema({
height: schema.number(),
likesDogs: schema.boolean().optional(false),
}),
attributes: schema.string().list(),
}),
};
export async function createProfile(data: InferSchema<typeof schemas.createProfile>) {
return { ok: true, profile: data };
}Durable Objects
Durable Object behavior is enabled by filename suffix.
- Any file ending in
.do.tsis treated as a Durable Object handler file. - Any file not ending in
.do.tsis treated as a normal server module. - No required folder name.
src/server/auth.do.ts,src/server/foo/bar/sites.do.ts, etc. all work.
Writing a Durable Object
Extend the native Cloudflare DurableObject class. Public methods automatically become RPC-accessible:
// src/server/user.do.ts
import { DurableObject } from 'cloudflare:workers';
export default class UserDO extends DurableObject {
async getName() {
return await this.ctx.storage.get('name');
}
async setName(name: string) {
this._validate(name);
await this.ctx.storage.put('name', name);
}
// NOT RPC-accessible (underscore prefix)
_validate(name: string) {
if (!name) throw new Error('Name required');
}
// NOT RPC-accessible (lifecycle method)
async alarm() {
// Handle alarm
}
}RPC rules:
- Public methods (
getName,setName) → RPC-accessible - Underscore prefix (
_validate) → NOT RPC-accessible - Private/protected (
private foo()) → NOT RPC-accessible - Lifecycle methods (
constructor,fetch,alarm,webSocketMessage, etc.) → NOT RPC-accessible
Using from routes
Import from the .do.ts file directly using $server/:
<script>
import { getName, setName } from '$server/user.do';
const name = await getName();
</script>
<h1>Hello, {name}</h1>The framework handles RPC wiring automatically.
Auto-Discovery
Durable Objects are auto-discovered from .do.ts files. No config needed.
Naming convention:
user.do.ts→ bindingUSER_DOorg-settings.do.ts→ bindingORG_SETTINGS_DO
Override binding name with static binding:
export default class UserDO extends DurableObject {
static binding = 'CUSTOM_BINDING'; // Optional override
// ...
}The framework auto-syncs discovered DOs to wrangler.jsonc.
Custom DO stub resolution
By default the framework's auto-discovered DO classes resolve via idFromName('global') (singleton instance per binding). Apps that need per-user / per-tenant routing register their own resolver at runtime:
// src/server/do-routing.ts
import { __registerDoResolver } from '@kuratchi/js/runtime/do.js';
import { getCurrentUser } from '@kuratchi/auth';
import { env } from 'cloudflare:workers';
__registerDoResolver('USER_DO', async () => {
const user = await getCurrentUser();
if (!user?.organizationId) return null;
const ns = (env as any).USER_DO;
return ns.get(ns.idFromName(user.organizationId));
});For auth-integrated routing specifically, @kuratchi/auth exposes getOrgStubByName(doName) (sync — pass the routing key directly) and getOrgClient(organizationId) (async — resolves the routing key from the admin DB first). The package wires these internally during signin/signup so most apps just call getCurrentUser() and let the auth middleware handle routing.
Agents
Kuratchi treats src/server/**/*.agents.ts as a first-class Worker export convention.
- Any
.agents.tsfile undersrc/server/is scanned during build. - The file must export a class with either
export class MyAgentorexport default class MyAgent. - The compiler re-exports that class from
.kuratchi/worker.js, so Wrangler can bind it directly. .agents.tsfiles are not route modules and are not converted into Durable Object RPC proxies.
// src/server/ai/session.agents.ts
import { Agent } from 'agents';
export class SessionAgent extends Agent {
async onRequest() {
return Response.json({ ok: true });
}
}// wrangler.jsonc
{
"durable_objects": {
"bindings": [{ "name": "AI_SESSION", "class_name": "SessionAgent" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["SessionAgent"] }
]
}Failure and edge behavior:
- If a
.agents.tsfile does not export a class, the build fails. - Kuratchi only auto-discovers
.agents.tsfiles undersrc/server/. - You still need Wrangler Durable Object bindings and migrations because Agents run as Durable Objects.
Workflows
Kuratchi auto-discovers .workflow.ts files in src/server/. No config needed.
// src/server/migration.workflow.ts
import { WorkflowEntrypoint, WorkflowStep } from 'cloudflare:workers';
import type { WorkflowEvent } from 'cloudflare:workers';
export class MigrationWorkflow extends WorkflowEntrypoint<Env, MigrationParams> {
async run(event: WorkflowEvent<MigrationParams>, step: WorkflowStep) {
// workflow steps...
}
}On build, Kuratchi:
- Scans
src/server/for.workflow.tsfiles - Derives binding from filename:
migration.workflow.ts→MIGRATION_WORKFLOW - Infers class name from the exported class
- Auto-adds/updates the workflow entry in
wrangler.jsonc
Zero config required. Just create the file and the framework handles everything:
name: derived from binding (e.g.,MIGRATION_WORKFLOW→migration-workflow)binding: derived from filename (e.g.,migration.workflow.ts→MIGRATION_WORKFLOW)class_name: inferred from the exported class
Examples:
migration.workflow.ts→MIGRATION_WORKFLOWbindingbond.workflow.ts→BOND_WORKFLOWbindingnew-site.workflow.ts→NEW_SITE_WORKFLOWbinding
Workflow Status Polling
Use workflowStatus from the kuratchi:workflow virtual module to read a workflow's live status. The first argument is typed as a compile-time union of your discovered *.workflow.ts basenames, so unknown names fail type-check.
<script>
import { params } from 'kuratchi:request';
import { workflowStatus } from 'kuratchi:workflow';
const status = await workflowStatus('migration', params.id, { poll: '2s' });
</script>
if (status.status === 'running') {
<div class="spinner">Running...</div>
} else if (status.status === 'complete') {
<div>✓ Complete</div>
}When you pass { poll }, the framework re-fetches the page on each interval and swaps <body> with the fresh server render — no client reactivity code. Polling stops automatically when until(status) returns true (default: status === 'complete' | 'completed' | 'errored' | 'terminated').
Name mapping (filename basename → workflowStatus name):
migration.workflow.ts→'migration'james-bond.workflow.ts→'james-bond'site.workflow.ts→'site'
Multiple polls on one page — call workflowStatus(..., { poll }) as many times as you need. The shortest interval wins, and polling stops only when every call reports terminal.
status is an AsyncValue<T> where T is the Cloudflare InstanceStatus:
{
status: 'queued' | 'running' | 'paused' | 'errored' | 'terminated' | 'complete' | 'waiting' | 'unknown';
error?: { name: string; message: string; };
output?: unknown;
}Plus the standard AsyncValue flags: pending, error (string | null), success.
Queue Consumers
Kuratchi auto-discovers .queue.ts files in src/server/ for consuming Cloudflare Queue messages. No config needed.
// src/server/notifications.queue.ts
export default async function(batch: MessageBatch<NotificationPayload>, env: Env, ctx: ExecutionContext) {
for (const message of batch.messages) {
console.log('Processing notification:', message.body);
// Handle the message...
message.ack();
}
}On build, Kuratchi:
- Scans
src/server/for.queue.tsfiles - Derives the expected queue binding from filename:
notifications.queue.ts→NOTIFICATIONS - Auto-wires a unified
queue()handler that dispatches to the correct file based onbatch.queue
Filename → Binding mapping:
notifications.queue.ts→ expectsNOTIFICATIONSqueue bindingemail-jobs.queue.ts→ expectsEMAIL_JOBSqueue binding
Producer vs Consumer:
- Producer (sending): Just call
env.QUEUE.send()anywhere — no.queue.tsfile needed - Consumer (receiving): Create a
.queue.tsfile to handle incoming messages
Requirements:
- Define the queue in
wrangler.jsoncwith matching binding name - Run
wrangler typesto get typedenv.QUEUEbindings
Containers
Kuratchi auto-discovers .container.ts files in src/server/. On every build, the framework writes containers[], durable_objects.bindings, and migrations[].new_sqlite_classes (when opted-in) into wrangler.jsonc — no manual entries required.
// src/server/wordpress.container.ts
import { Container } from 'cloudflare:workers';
export default class WordPress extends Container<Env> {
static image = './docker/wordpress.Dockerfile'; // REQUIRED — Dockerfile path OR registry reference
static instanceType = 'standard'; // 'lite' (default) or 'standard'
static maxInstances = 5;
static sqlite = true; // opt into new_sqlite_classes migration
}Image accepts either a local Dockerfile path (wrangler resolves the build context) or a registry reference (docker.io/library/redis:7.2-alpine, etc.). If you omit static image and a sibling <basename>.Dockerfile exists next to the .container.ts, it's picked up automatically. Omitting both triggers a compile-time error.
Binding derivation follows the same rule as every other convention:
wordpress.container.ts→WORDPRESS_CONTAINERredis.container.ts→REDIS_CONTAINER
Full reference: apps/docs/framework/containers.mdx.
Sandbox
Kuratchi ships first-class support for Cloudflare Sandbox — the Durable Object-backed runtime for ad-hoc shells, untrusted code, and code-interpreter agents — via its own .sandbox.ts convention. Sandbox is distinct from .container.ts because it's a specialized SDK: the class, image, and SQLite-storage requirement are all supplied by the framework.
bun add @cloudflare/sandbox// src/server/shell.sandbox.ts
import { Sandbox } from '@cloudflare/sandbox';
export default class ShellSandbox extends Sandbox<Env> {}That is the whole file. On build, Kuratchi writes:
// wrangler.jsonc — auto-synced, do not edit by hand
{
"containers": [
{ "name": "shell-sandbox", "class_name": "ShellSandbox", "image": "docker.io/cloudflare/sandbox:0.8.11", "instance_type": "lite" }
],
"durable_objects": { "bindings": [{ "name": "SHELL_SANDBOX", "class_name": "ShellSandbox" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ShellSandbox"] }]
}No Dockerfile needed — the default image tag tracks the installed @cloudflare/sandbox version so the SDK and the container runtime can never drift. Override with static image = '...' for Python variants or custom builds.
Multiple sandboxes in one project
Because binding + class + migration all derive from the filename, a project can host any number of sandboxes:
src/server/shell.sandbox.ts → SHELL_SANDBOX (default image)
src/server/python.sandbox.ts → PYTHON_SANDBOX (static image = '…:0.8.11-python')
src/server/code-interpreter.sandbox.ts → CODE_INTERPRETER_SANDBOXUsage
import { env } from 'cloudflare:workers';
import { getSandbox } from '@cloudflare/sandbox';
export async function runCommand(name: string, command: string) {
const sandbox = getSandbox(env.SHELL_SANDBOX, name);
const { stdout, stderr, exitCode } = await sandbox.exec(command);
return { stdout, stderr, exitCode };
}The second argument to getSandbox() is a routing key (same semantics as DurableObjectNamespace.idFromName). Same key → same container; fresh key → fresh container. Treat the filesystem as scratch: destroy() wipes it, and Cloudflare may reclaim long-idle sandboxes.
Healthcheck: the top-level handle has no ping(); use exec('true') for a canonical liveness probe.
Full reference: apps/docs/framework/sandbox.mdx.
Convention-Based Auto-Discovery
Kuratchi uses file suffixes to auto-discover and register worker classes. No config needed — just create the file:
| Suffix | Location | Binding Pattern | Example |
|--------|----------|-----------------|---------|
| .workflow.ts | src/server/**/*.workflow.ts | FILENAME_WORKFLOW | migration.workflow.ts → MIGRATION_WORKFLOW |
| .container.ts | src/server/**/*.container.ts | FILENAME_CONTAINER | wordpress.container.ts → WORDPRESS_CONTAINER |
| .sandbox.ts | src/server/**/*.sandbox.ts | FILENAME_SANDBOX | shell.sandbox.ts → SHELL_SANDBOX |
| .queue.ts | src/server/**/*.queue.ts | FILENAME | notifications.queue.ts → NOTIFICATIONS |
| .agents.ts | src/server/**/*.agents.ts | (manual wrangler config) | session.agents.ts |
| .do.ts | src/server/**/*.do.ts | filename or static binding = '...' | auth.do.ts → AUTH_DO |
Automatic Wrangler Config Sync
Kuratchi automatically syncs wrangler.jsonc during every build. This eliminates duplicate configuration for:
- Workflows — auto-discovered from
.workflow.tsfiles - Containers — auto-discovered from
.container.tsfiles (writescontainers[],durable_objects.bindings, and opt-in SQLite migrations) - Sandboxes — auto-discovered from
.sandbox.tsfiles (same as containers plus default image resolution from the installed@cloudflare/sandboxversion) - Queues — auto-discovered from
.queue.tsfiles - Durable Objects — auto-discovered from
.do.tsfiles
The sync is additive and non-destructive:
- New entries are added automatically
- Existing entries are updated if the class name changes
- Manually-added wrangler config (D1, KV, R2, vars, etc.) is preserved
- Removed entries are cleaned up from wrangler.jsonc
Requirements:
- Uses
wrangler.jsoncorwrangler.json(TOML is not supported for auto-sync) - Creates
wrangler.jsoncif no wrangler config exists
Runtime APIs
Virtual Modules
In route <script> blocks, use the kuratchi: virtual modules:
| Virtual Module | Description |
|----------------|-------------|
| kuratchi:request | Safe request state in route <script> blocks: url, pathname, searchParams, params, slug, method |
| kuratchi:navigation | Server-side redirect helper |
Request helpers
Import pre-parsed request state from kuratchi:request. The compiler
enforces the safe subset — importing locals, headers, or any other
server-only value fails the build.
import { url, pathname, searchParams, params, slug, method } from 'kuratchi:request';
const page = pathname;
const tab = searchParams.get('tab');
const postId = params.id;
const postSlug = slug;urlis the parsedURLfor the current request.pathnameis the full path, like/blog/hello-world.searchParamsisurl.searchParamsfor the current request.paramsis the matched route params object, like{ slug: 'hello-world' }.slugisparams.slugwhen the matched route defines aslugparam.methodis the HTTP method.
For locals, headers, or anything derived from auth state, use a
$server/* module and call getLocals() / getRequest() from
@kuratchi/js there — then return precomputed values to the template.
Server Module Helpers
For server modules (src/server/*.ts), import from @kuratchi/js:
import {
getCtx,
getEnv,
getRequest,
getLocals,
getParams,
getParam,
redirect,
RedirectError,
} from '@kuratchi/js';Server-side redirect
Import redirect from kuratchi:navigation for server-side redirects:
import { redirect } from 'kuratchi:navigation';
// Redirect to another page (throws RedirectError, caught by framework)
redirect('/dashboard');
redirect('/login', 302);redirect() works in route scripts, $server/ modules, and form actions. It throws a RedirectError that the framework catches and converts to a proper HTTP redirect response (default 303 for POST-Redirect-GET).
Middleware
Optional request middleware file. Export a MiddlewareDefinition from
src/middleware.ts to intercept requests before they reach the framework router.
Use it for agent routing, pre-route auth, or custom response/error handling.
import { defineMiddleware, type MiddlewareDefinition } from '@kuratchi/js';
const middleware: MiddlewareDefinition = {
agents: {
async request(ctx, next) {
if (!ctx.url.pathname.startsWith('/agents/')) {
return next();
}
return new Response('Agent response');
},
},
};
export default defineMiddleware(middleware);ctx includes:
ctx.url- parsed URLctx.request- raw Requestctx.env- Cloudflare env bindingsnext()- pass control to the next handler
Environment bindings
Cloudflare env is server-only.
- Route top-level
<script>, routeload()functions, server actions, API handlers, and other server modules can read env. - Templates, components, and client
<script>blocks cannot read env directly. - If a value must reach the browser, compute it in the server route script and reference it in the template, or return it from
load()explicitly.
<script>
import { env } from 'cloudflare:workers';
const turnstileSiteKey = env.TURNSTILE_SITE_KEY || '';
</script>
if (turnstileSiteKey) {
<div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>
}Server modules can still access env directly:
import { env } from 'cloudflare:workers';
const result = await env.DB.prepare('SELECT 1').run();Virtual Modules
Kuratchi provides kuratchi:* virtual modules for accessing framework state and utilities. These follow the same pattern as Cloudflare's cloudflare:workers.
kuratchi:environment
import { dev } from 'kuratchi:environment';
if (dev) {
// Skip auth checks, enable debug logging, etc.
}devistrueduringkuratchi dev,falsein production
kuratchi:request
In route <script> blocks, only the compile-time safe subset is allowed.
Server-only state (locals, headers) must be read inside a $server/*
module via @kuratchi/js helpers.
import { url, pathname, searchParams, params, slug, method } from 'kuratchi:request';
console.log(url.href);
console.log(params.slug);
console.log(searchParams.get('tab'));To access locals (e.g. locals.userId) from a template, wrap it in a
$server/* function:
// src/server/user.ts
import { getLocals } from '@kuratchi/js';
export function currentUserId(): number {
return (getLocals() as { userId: number }).userId;
}<!-- src/routes/settings/index.kuratchi -->
<script>
import { currentUserId } from '$server/user';
const userId = await currentUserId();
</script>kuratchi:navigation
import { redirect } from 'kuratchi:navigation';
// Server-side redirect (throws RedirectError)
redirect('/login', 303);All kuratchi:* modules work in:
- Page route scripts (
index.kuratchi) - Middleware (
src/middleware.ts) - Durable Objects (
.do.ts) - Server modules (
src/server/*.ts)
Security
Philosophy. Kuratchi enforces exactly two things: origin integrity (your server only accepts calls from your own browser code) and visibility boundaries (_-prefixed exports are unreachable from the outside). Everything else — authentication, authorization, rate limiting, audit logging — is your responsibility. A framework that auto-enforces auth creates a false sense of safety; a framework that enforces the origin boundary frees you to focus on the real question of who is allowed to do what.
There is no KURATCHI_SECRET to configure, no CSRF token in your HTML, no framework-level requireAuth toggle. The building blocks are the two unconditional guarantees below plus opt-in response headers.
Default Security Headers
All responses include these headers automatically:
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-origin
Strict Same-Origin Gate (unconditional)
Every ?_rpc=… request is rejected with 403 unless it carries either:
Sec-Fetch-Site: same-origin(every modern browser sends this on same-originfetch()), orOrigin: <same as request URL origin>
Non-browser clients (curl, server-to-server scripts, cron jobs) and any cross-origin browser request are blocked before your handler runs. Same-origin form POSTs are accepted under a slightly relaxed rule (top-level navigations may omit Sec-Fetch-Site) but reject any cross-origin Origin.
Combined with SameSite=Lax on any session cookie an auth library sets, this eliminates classic CSRF attacks without the framework having to mint its own token. The gate is always on and cannot be disabled — RPC is designed to be reachable only from your own frontend.
Public vs. Private Server Functions
One universal rule for what counts as externally reachable:
- Exports whose name starts with
_are private. They cannot be referenced from a route template as an action, await-query, or RPC. They remain importable by other server-side code —_helper()called from a public server function still works. - Durable Object methods. Only
publicmethods that do not start with_are copied onto the generated DO class prototype. TSprivate/protectedand_-prefixed methods are invisible to the Workers RPC binding at runtime, not just to the compiler proxy. - Lifecycle names (
constructor,fetch,alarm,webSocketMessage,webSocketClose,webSocketError,onInit,onAlarm,onMessage) are never exposed as RPC.
Referencing a _ export from a route template is a compile-time error.
Authentication and Authorization are Your Job
The framework populates locals.user and locals.session from whatever auth hook/library you plug in (e.g. @kuratchi/auth). It never reads those values to decide whether to run your handler. Guard handlers explicitly:
import { requireAuth } from '@kuratchi/auth';
export async function deleteItem(id: string) {
const user = await requireAuth(); // throws ActionError('Unauthorized') if missing
if (!user.canDelete(id)) throw new ActionError('Forbidden');
return db.items.delete(id);
}
// Private helper — framework refuses to expose it as RPC even if a template
// accidentally references it.
export async function _auditDelete(userId: string, itemId: string) {
await db.audit.insert({ userId, itemId, action: 'delete' });
}This keeps the auth model next to the operation it protects, where it belongs.
Content Security Policy (with per-request nonces)
Configure response-header security through the Vite plugin's security option. To opt into strict CSP with per-request nonces on the framework-injected inline scripts (workflow poll, client bridge, theme init, etc.), use the literal placeholder {NONCE} in your policy — Kuratchi generates a fresh nonce per request, substitutes it into the header, and stamps the same nonce onto every emitted <script> tag.
// vite.config.ts
import { kuratchi } from '@kuratchi/js/vite';
export default defineConfig({
plugins: [
kuratchi({
security: {
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'nonce-{NONCE}'; object-src 'none'",
strictTransportSecurity: "max-age=31536000; includeSubDomains",
permissionsPolicy: "camera=(), microphone=(), geolocation=()",
},
}),
cloudflare({ viteEnvironment: { name: 'ssr' } }),
],
});Without {NONCE}, the CSP is emitted verbatim and no nonce work is done.
HTML Sanitization
The {@html} directive sanitizes output to prevent XSS:
- Removes dangerous elements (
<script>,<iframe>,<object>,<embed>,<style>,<template>, …). - Strips all
on*event handlers. - Neutralizes
javascript:andvbscript:URLs. - Removes
data:URLs fromsrcattributes.
For rich user-generated HTML, reach for DOMPurify on top of this.
Query Override Protection
Query function calls via x-kuratchi-query-fn headers are validated against a per-route allow-list — only functions registered for the current route can be invoked. Unknown names return 403. Automatic, no configuration.
Client Bridge Security
Client-side handler dispatch validates route and handler IDs against safe patterns, uses hasOwnProperty checks to block prototype-chain traversal, and rejects known pollution targets (__proto__, constructor, prototype). Automatic.
Error Information Protection
In production, only developer-controlled ActionError / PageError messages are surfaced to the client. Generic Error details are hidden to prevent leaking implementation information. Dev mode shows the full message for debugging.
throw new ActionError('Invalid email format'); // shown to user
throw new Error('Database connection failed at line 42'); // replaced by "Internal Server Error" in prodProject layout
The framework is convention-driven. There is no project-level kuratchi.config.ts — request-time concerns (auth, ORM auto-migration, custom middleware) live in src/middleware.ts, and build-time concerns (security headers, route/server directory overrides) are passed to the Vite plugin.
src/
routes/ # auto-discovered route files (.kuratchi)
server/ # auto-discovered DOs/workflows/containers/queues/agents
*.do.ts # → durable_objects.bindings + class re-export
*.workflow.ts # → workflows[]
*.container.ts # → containers[] + DO bindings + sqlite migrations
*.queue.ts # → queues.consumers[]
*.agents.ts # → DO bindings (agents are DOs under the hood)
middleware.ts # auth, autoMigrate, custom steps
app.css # global stylesheet (auto-discovered)
vite.config.ts # plugin options: routesDir, serverDir, libDir, security
wrangler.jsonc # bindings (auto-synced from server/ conventions)src/middleware.ts
import { defineMiddleware } from '@kuratchi/js';
import { autoMigrate } from '@kuratchi/orm';
import { kuratchiAuthMiddleware } from '@kuratchi/auth/middleware';
import { adminSchema } from './server/schemas/admin';
import { authConfig } from './server/auth-config';
export default defineMiddleware({
// Auto-migrate D1 on cold start (idempotent, runs once per isolate).
migrate: autoMigrate({ DB: adminSchema }),
// Auth — credentials, sessions, guards, OAuth, rate-limit, turnstile.
auth: kuratchiAuthMiddleware(authConfig),
// Custom steps (logging, feature flags, custom routing) go here.
});autoMigrate and kuratchiAuthMiddleware are just middleware steps — there's no special framework slot for either. The same shape lets you compose any third-party auth / ORM / observability tool you want.
vite.config.ts
import { defineConfig } from 'vite';
import { cloudflare } from '@cloudflare/vite-plugin';
import { kuratchi } from '@kuratchi/js/vite';
export default defineConfig({
plugins: [
kuratchi({
// Optional. All defaults are sensible.
// routesDir: 'src/routes',
// serverDir: 'src/server',
// libDir: 'src/lib',
// security: { contentSecurityPolicy: "...", strictTransportSecurity: "..." },
}),
cloudflare({ viteEnvironment: { name: 'ssr' } }),
],
});Tailwind / DaisyUI
The Vite path uses standard PostCSS. Drop a postcss.config.js and an @import 'tailwindcss'; in src/app.css:
/* src/app.css */
@import 'tailwindcss';
@plugin 'daisyui';// postcss.config.js
export default {
plugins: { '@tailwindcss/postcss': {} },
};Then use Tailwind classes in your templates as normal.
@kuratchi/ui theme
Apps that use @kuratchi/ui import the theme stylesheet via src/app.css:
@import '@kuratchi/ui/styles/theme.css';…and add <ThemeInit /> to the layout's <head> to prevent flash-of-wrong-theme on hydration.
CLI
npx kuratchi build # one-shot build
npx kuratchi watch # watch mode (for use with wrangler dev)Vite plugin
The framework ships a Vite plugin that's the default build path going forward. Apps install Vite + the Cloudflare Vite plugin and add one line to their config:
// vite.config.ts
import { defineConfig } from 'vite';
import { cloudflare } from '@cloudflare/vite-plugin';
import { kuratchi } from '@kuratchi/js/vite';
export default defineConfig({
plugins: [
kuratchi(),
cloudflare({ viteEnvironment: { name: 'ssr' } }),
],
});That's it. The plugin discovers routes under src/routes/**/*.kuratchi, generates a Workers-compatible worker entry, manages virtual modules ($server/*, $lib/*, kuratchi:request, kuratchi:layout, kuratchi:component, kuratchi:manifest, …), and keeps wrangler.jsonc in sync with Durable Object / Queue / Workflow / Container conventions discovered in src/server/.
The plugin imports cleanly into vite.config.ts only. The Vite plugin code never enters the worker bundle — it lives behind a separate package subpath (@kuratchi/js/vite) so importing from @kuratchi/js/runtime/* in worker code stays cost-free.
Components are first-class. See the Components section above for the resolution rules and kuratchi:component API. The Vite plugin and the legacy CLI share the same compiler primitives, so component behavior is identical across both build paths.
HMR: editing a .kuratchi route or component triggers a recompile of every consumer. Editing a $lib/<name>.kuratchi component re-emits every route that imports it transitively.
Required peer deps (when using the Vite plugin):
npm install -D vite @cloudflare/vite-plugin wranglerBoth vite and @cloudflare/vite-plugin are declared as optional peer dependencies — they're only required if you actually use @kuratchi/js/vite. Apps that build through the legacy CLI don't need them.
Testing the Framework
Run framework tests from packages/kuratchi-js:
bun run testWatch mode:
bun run test:watchTypeScript & Worker types
npx wrangler typesThen include the generated types in tsconfig.json:
{
"compilerOptions": {
"types": ["./worker-configuration.d.ts"]
}
}