ursprung
v0.0.10
Published
A full-stack TypeScript application framework for Cloudflare Workers, written from scratch.
Readme
ursprung
ursprung is a full-stack TypeScript application framework. It builds applications for Cloudflare Workers. The name is a German word for origin. Write the name in lowercase, always.
ursprung is written from scratch. It contains its own lexer, its own parser, its own printer, its own module resolver, its own module graph, its own content hasher and its own renderer. It uses no bundler. No other build tool operates after it.
AI agents are first-class users of ursprung. Thus the build does not stop at the first error. It collects every diagnostic and reports them together. Each diagnostic names the module, the position, the rule and a remedy. An agent learns all the problems of an application from one build.
The package has two runtime dependencies. ursprung puts both of them through its own pipeline: it resolves, parses, colours, hashes and emits them the same way it treats a module of your application.
signal-polyfillsupplies the TC39 Signals proposal. It is the state model of ursprung's reactivity. Asignal()is a subclass of itsSignal.State, and not a wrapper around one.capnwebsupplies the RPC transport of the Server boundary. It reaches the Worker only when an application crosses a Server boundary. An application that crosses none never compiles it.
Status
Read this section before you install the package.
This is version 0.0.9. Do not use ursprung in production. The interfaces can change in each version, and the changelog in this package records each change.
Version 0.0.6 was the first version that builds an application and runs it. It made one thin thread through every part of the system: one Route, one Server component and static HTML. Each version after it makes the thread thicker. The thread is much thicker now, but it is still a thread.
ursprung builds two applications today, and it deploys both of them:
- ursprung.dev — the site. Five Routes, one Layout and Server components only.
- demo.ursprung.dev — the demo application. Nine Routes, and one specimen for each capability in the list below.
What operates now
The application surface
- A Config file and a Route file define an application. The application writes no Worker code of its own.
- The filename declares the Side of a module. A name with
.server.makes a Server module. A name with.client.makes a Client module. A name with.shared.makes a Shared module. There is no directive and no configuration for this. - A Route can carry a Layout, a Server component, an
apiobject,metaand child Routes. - A child Route joins its path onto its ancestors'. The full Layout chain above it wraps it.
- A Route has one Canonical URL and it carries no trailing slash. The router sends a 308 for the other form.
- A path segment can be a parameter, for example
:id. The matched values reach a component asprops.paramsand reach server code asgetContext().params. - A static segment beats a parameter segment. The build sorts the route table, and the router never consults declaration order.
- A Route carries
meta, an opaque value. The application givesmetaa shape, and the render reads it withgetContext().meta. staticDirnames the Static directory. ursprung copies every file under it into the client output as opaque bytes.- A module can import a JSON file as a Data module.
Server rendering
- JSX evaluates to a Description. The printer writes the Description into the
output as an object literal. Thus the output contains no call to a JSX runtime.
ursprung/jsx-runtimesupplies types only and never enters the graph. - Attribute names are the names of HTML. Write
class, notclassName. Writeonclick, notonClick. - The router matches a request and gives back a full HTML document. It gives back
a
text/plain404 for a path that does not match. - A Server component can be async. The Walk stops at the first Suspension. The response commits there, and the document goes to the browser in more than one part.
- A document that contains no Suspension takes the other path. The router gives it
back as one buffered response that states its
content-length. - An API route declares one handler for each HTTP method. A handler receives the request and the Request context, and it gives back a response. It never enters the Walk.
- One Route can be a Page route and an API route at the same time. A method the
apiobject declares goes to the handler. Every other method renders the page. - A Route that renders nothing refuses a method it does not declare. The response
is a 405 with an
Allowheader. getContext()gives the Request context: the request, the bindings, the params, the Route'smeta, andwaitUntil. A call outside a request throws.
Resumption
- A Server component can import a Client component. Nothing marks the import and nothing registers the component. The module graph does the rest.
- The document carries one
<link rel="modulepreload">for each Client module of the matched Route. - The document carries a Resumability payload, an Address for each Addressable position, and a small dispatch bootstrap. The handler itself never reaches the browser as code in the document.
- An event awakens the Client root and runs the handler. This is Awakening. The browser creates no node for the markup that the server sent.
- The bootstrap warms the runtime two times before a click. It preloads the
modules when the document goes idle. It starts the import on
pointerdownover an Addressable position.
Reactivity
ursprung/reactivesuppliessignal(),computed(),effect()andonCleanup().signal()gives a state cell. Read it with.get(). Write it with.set().- A function child that reads a Signal is a live position. The server finds it by evaluation and wraps it in a Region. A Sink updates that Region in the browser through the DOM Host.
- A live position whose new value is a Description destroys the Region. The
renderer disposes the Owner, runs each
onCleanup, and walks a new instance in. <For>is the Keyed list. It takeseach,keyand a child function. A reorder moves the nodes that the server rendered; it does not build new ones.<Pending fallback>shows a Renderable while a rebuild awaits. The fallback is ignored during Server rendering, because a Server component blocks in order.<Pending failed>is the error boundary. The error UI lands in the Region that threw, and not in the wrapper's Region.- A throwing effect and a throwing event handler are caught at their own site. ursprung reports each one with a correlation id, and the neighbours keep operating.
- An
effect()body never runs during Server rendering.
The Server boundary
- A Client module can import a Server module by name. The build cuts the edge and
puts a Stub module in the client output. The call becomes a POST to
/_ursprung/rpc. - Each name that crosses is a public HTTP endpoint. Anyone who can open the page can call it. Nothing marks an export as public; the import is what publishes it.
- A server error does not reach the browser. The browser reads
Internal error [rpc-<id>], and the Worker's log holds the original message under the same id. - The build classifies nothing, so a crossed constant becomes a function-shaped Stub. A read of it as a value throws at the use site and names its wire name.
- A name that the Server module does not export is a build error, URS3005.
- A third-party module that imports first-party server code is a build error, URS3006, when the client reaches it. The diagnostic names the full import chain.
The build and the output
- The build emits real ES modules. Wrangler uploads them with
noBundle. - Each Emitted module carries a content hash in its name. The Root entrypoint is the one exception.
- Each emitted client module is served from
/_ursprung/, the Reserved client prefix. The author's own static files keep the root of the assets directory. - The Build host checks the two Wrangler configuration files before the build starts. See Configuration agreements.
What does not operate yet
- There is no source map. The printer records a position for each printed node, so source maps stay additive, but nothing writes one. A stack trace from workerd cannot be mapped back to a module.
- There is no watch mode and no incremental build.
ursprung buildis the only command. Wrangler runs it again before eachdevand eachdeploy. - There is no native target. The glossary names Screens, Staging buffers and update packages, and none of them is built.
- There is no
ursprung/ui, no data layer and no auth layer. - There is no head-management interface. A page's title comes from
metaon the Route node, and a Layout writes it. A head element that a component opens after</head>is a render error.
What ursprung does not do at all
These are decisions, and not gaps.
- ursprung does not hydrate. It resumes. It never executes the component tree a second time to attach behaviour.
- The parser builds no scope model and no type model. So ursprung cannot see
that you wrote
{count.get()}where you meant{() => count.get()}. The first form is legal, it renders one time, and it never updates again. - ursprung ships no polyfill, and it downlevels nothing. Syntax that a host does not support is an error on that host.
- A build that succeeds does not promise valid JavaScript. ursprung's
diagnostics are about ursprung's rules.
tscand the host own the rest.
Installation
npm install ursprungRequirements
Node must load TypeScript without a flag. ursprung imports the Config file and the Route file to evaluate them. A Node that cannot do type stripping cannot run the build. Wrangler needs the same capability to load its own TypeScript configuration. An ursprung application thus needs it two times. Bun has the capability too.
This requirement is a capability, not a version number. The package names no Node version anywhere.
Wrangler must use the experimental TypeScript configuration format. An
application carries two Wrangler files, cloudflare.config.ts and
wrangler.config.ts. Give --experimental-new-config to each Wrangler command.
The Worker must have Node.js compatibility. The Request context is an
AsyncLocalStorage store. Set the nodejs_compat flag, or use a compatibility
date of 2026-08-04 or later, where workerd enables it for you.
An application
The Config file is the single entry point. The Build host evaluates it before the build starts.
// ursprung.config.ts
import { defineConfig } from "ursprung";
import routes from "./routes.ts";
export default defineConfig({
routes,
compatibilityDate: "2026-08-11",
outDir: "./dist",
// Optional. Absent means ursprung copies nothing.
staticDir: "./public",
});The Route file declares the routes. The Config file imports it.
// routes.ts
import { defineRoutes } from "ursprung";
export default defineRoutes({
layout: new URL("./src/root.server.tsx", import.meta.url),
component: new URL("./src/home.server.tsx", import.meta.url),
children: [{ path: "about", component: new URL("./src/about.server.tsx", import.meta.url) }],
});Each new URL is a Module reference. It names a module and does not import it.
ursprung does not load the module during evaluation. A Module reference is data,
not a function. The build reads it and never calls it.
The filename declares the Side of a module. A name with .server. makes a Server
module. A name with .client. makes a Client module. A name with .shared. makes
a Shared module. There is no directive and no configuration for this.
// src/home.server.tsx
export default function Home() {
return (
<main>
<h1>Hello</h1>
</main>
);
}JSX evaluates to a Description. A Description is inert data. The printer writes it
into the output as an object literal. Thus the output contains no call to a JSX
runtime. ursprung/jsx-runtime supplies types only and never enters the graph.
Attribute names are the names of HTML. Write class, not className.
A Client component
A Server component can import a Client component. Nothing marks the import and nothing registers the component. The filename is the whole declaration, and the module graph does the rest.
// src/counter.client.tsx
import { signal } from "ursprung/reactive";
export default function Counter(props: { start: number }) {
const count = signal(props.start);
return (
<button type="button" onclick={() => count.set(count.get() + 1)}>
Clicked {() => count.get()} times
</button>
);
}signal() gives back a state cell. Read it with .get(). Write it with .set().
Replace the value. Do not mutate it.
Write {() => count.get()}, not {count.get()}. The arrow is what makes the
position a live position. The second form is legal. It renders one time and never
updates again. ursprung builds no scope model, thus it cannot detect the mistake
for you.
The server renders this component and finds the live position by evaluation. It wraps the position in a Region. It writes an Address on the button, because the button carries a handler. The handler is not in the document. The first event awakens the Client root, runs the handler, and the Sink writes the new text into the Region.
A list
<For> is the Keyed list, and it comes from the root export. A Server component
can write one over a plain array. A Client component can write one over a Signal,
and then a write to that Signal reconciles the rows.
import { For } from "ursprung";
import { signal } from "ursprung/reactive";
const builds = signal([{ id: "a", branch: "main" }]);
<For each={builds} key={(build) => build.id}>
{(build, index) => (
<li>
{() => index.get() + 1} — {() => build.get().branch}
</li>
)}
</For>;The child function receives cells, and not values. A changed field updates its row in place. A reorder moves the nodes that the server rendered. Two rows with the same key are a runtime error.
An async component and a fallback
A Server component can be async. The Walk stops at the first Suspension, the response commits there, and the rest of the document follows in later parts.
<Pending> gives its children a Driver of their own. Its fallback fills the
Region while the children are pending. The fallback operates on the client only:
during Server rendering an async component blocks in order, so there is no pending
state to show. Its failed prop is the error boundary, and the error UI lands in
the Region that threw.
import { Pending } from "ursprung";
<Pending fallback={<span>Waiting…</span>} failed={(error) => <p>{String(error)}</p>}>
<Reading depth={depth} />
</Pending>;The Server boundary
A Client module can import a Server module. The build cuts that edge. It puts a Stub module in the client output, with the same export names bound to RPC calls.
// src/soundings.server.ts
export function sounding(depth: number) {
return { depth, path: getContext().request.url };
}// src/boundary.client.tsx
import { sounding } from "./soundings.server.ts";The import is the whole mechanism. On the server the call is a direct one. In the
browser the same line is a POST to /_ursprung/rpc. Two rules follow from that,
and both are important:
- Each crossed name is a public endpoint. Anyone who can open the page can call it. Check the arguments in the handler, and do not depend on the caller.
- Call from a handler, and not from the body. A Client component's body runs during Server rendering, and it runs again at each Awakening. A call in the body fires a request each time.
An API route
A route node can carry an api object. Each key is an HTTP method, and each value
names a module and an export.
{
path: "api/builds",
api: {
GET: { module: new URL("./src/api/builds.server.ts", import.meta.url), export: "readBuilds" },
},
}// src/api/builds.server.ts
import { type ApiHandler } from "ursprung/server";
export const readBuilds: ApiHandler = (request, context) => {
return Response.json({ builds: [], from: context.request.url });
};A handler gives back a response, and it never enters the Walk. Thus it chooses any
status at any time, which a Page route cannot do after its first await. A node
that carries a component and an api together answers the declared methods from
the handler and every other method from the page. That is how a native
<form method="post"> posts to its own URL.
Response.redirect() needs an absolute URL in a Worker. A relative one throws
a TypeError. Write the location header yourself.
Route metadata
A route node carries meta. ursprung never reads inside the value. It carries the
value from the Route file to getContext().meta, and the application renders it.
The value must be JSON-representable, because the build prints it into a generated
module.
The shape is the application's. Augment RouteMeta one time:
declare module "ursprung" {
interface RouteMeta {
readonly title: string;
}
}getContext().meta gives the matched node's own value. ursprung does not
merge it with the values of the ancestors. An opaque value permits one composition
rule only, and that rule is replacement.
The Route node is where a title comes from, because head content cannot be
hoisted. The root Layout writes the <head>, and each component below it runs
after </head> is closed. A head element that opens after that point is a render
error. Markup inside an <svg> is not affected, so an SVG <title> still
operates.
Static files
staticDir names one directory. ursprung copies every file under it into
<outDir>/client, and it keeps the relative paths. The files are opaque bytes:
ursprung does not compile them, does not hash them, and does not put them in the
graph. Dotfiles and unknown extensions are included, because the directory is the
declaration.
The field is optional and has no default. A staticDir that names no directory is
a build error, URS5007, and not a silent skip. An entry called _ursprung at the
root of the directory is a build error too, URS5008: that name belongs to the
emitted client modules.
Do not name a static file so that it hides a Route. An index.html at the
root of the assets directory answers /, and the renderer never runs.
new URL("./thing.svg", import.meta.url) is a build error in a first-party
module, URS3007. import.meta.url names the emitted module's own location,
which is one flat directory, and a sibling asset is never there. Name the file by
its served path instead. A third-party package that uses the form is not affected.
How the build operates
Wrangler starts the build. Set the command in wrangler.config.ts, and set the
script in package.json.
// wrangler.config.ts
import { defineWranglerConfig } from "wrangler/experimental-config";
export default defineWranglerConfig({
build: { command: "npm run build" },
});"scripts": { "build": "ursprung build" }The command runs a package script rather than the ursprung bin. A package script
is what puts node_modules/.bin on the PATH, and the shell that Wrangler uses
does not add it.
ursprung build is the only command. It reads ./ursprung.config.ts unless you
name another path as its argument.
The build has two halves, and they stay apart.
The Build host does all the impure work. It evaluates the Config file. It reads
the application directory into a Virtual filesystem. It examines the two Wrangler
files. It writes the output to outDir.
The build is one synchronous function. It reads the Virtual filesystem. It touches no real file and no Node interface. It returns the output bytes, and the Build host writes them. It returns each diagnostic and throws none.
The build runs these phases in order:
- Parse. The parser reads each module and finds where the type syntax ends. It builds no scope model and no type model.
- Resolve. The resolver applies Node's
ESM_RESOLVE. Each Side resolves under its own Condition set. - Colour. The traversal runs one time for each Side. It gives each module its Reach.
- Hash. The hasher computes a content hash for each Emitted module. Module cycles are legal, so the hasher operates on the condensation of the graph.
- Emit. The printer prints each module from its own syntax tree. A pure subtree prints byte for byte from the source.
Application code may use erasable syntax only. ursprung refuses ten constructs,
which are erasableSyntaxOnly's six plus four more that it wrongly permits:
enum, parameter properties, an instantiated namespace, import x = require(),
export =, the angle-bracket assertion <T>expr, both kinds of decorator,
accessor fields, and export as namespace.
The output
The build writes two directories into outDir.
server/ holds the server output. Wrangler uploads it. server/index.js is the
Root entrypoint, and it carries the router. It is the only file with no content
hash in its name, because Wrangler names it in the configuration.
client/ holds the client output. This directory is the assets directory. The
asset store serves it. Each emitted client module is under client/_ursprung/,
and the files that ursprung copied from the Static directory keep the root.
The reserved prefix does two things. An author's file and an emitted module can no
longer collide, so no precedence rule is necessary. And a cache policy has one
stable name to address: write /_ursprung/* in an _headers file of your own.
A Client module reaches both outputs. The two copies are byte-identical when the module has no dependency that differs between the Sides.
The Build host empties outDir at the start of each build. Do not write anything
into it by hand, and run any other build step after ursprung.
Configuration agreements
ursprung writes no Wrangler configuration. It examines your configuration instead, and it reports each disagreement. The Build host makes these checks before it starts the build. Each diagnostic names the file, the field and the correct value.
| File | Field | Required value |
| ---------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| wrangler.config.ts | noBundle | true |
| wrangler.config.ts | baseDir | <outDir>/server |
| wrangler.config.ts | assetsDirectory | <outDir>/client |
| wrangler.config.ts | rules | an ESModule rule for **/*.js |
| wrangler.config.ts | build.command | a command that runs the ursprung build |
| wrangler.config.ts | build.cwd | absent |
| cloudflare.config.ts | entrypoint | <outDir>/server/index.js |
| cloudflare.config.ts | compatibilityFlags / date | Node.js compatibility on: nodejs_compat, or date ≥ 2026-08-04 without no_nodejs_compat; do not name the enable flag beside that date |
| cloudflare.config.ts | compatibilityDate | equal to the date in ursprung.config.ts |
| cloudflare.config.ts | assets.htmlHandling | drop-trailing-slash |
| cloudflare.config.ts | assets.notFoundHandling | absent, or none |
The rule for **/*.js is the one that fails quietly. Wrangler has no default rule
for .js. Without this rule Wrangler drops each module except the entrypoint from
the upload. The Worker then fails at run time, and the build says nothing. One
more check runs after emission for the same reason: ursprung compares your rules
globs against the modules it wrote, and reports each module that the globs miss.
TypeScript
An application carries one Program. Extend the Base config. An application that overrides an option owns that option.
{ "extends": "ursprung/tsconfig" }The Base config is the whole TypeScript configuration of an application. It sets the libraries, the JSX mode, the module resolution and the strictness. The reason for each option is a comment beside that option in the file.
ursprung/tsconfig.app.json is the same file under its own name. Do not write
extends: "ursprung", because that specifier fails with TS6053.
One Program holds both Sides, because a Server component imports a Client component and the two cannot sit in separate compilations. Know what that costs: tsc accepts a DOM global in a Server module, and it accepts a Node global there too. The build is what refuses the mistakes that matter.
The package surface
These five specifiers are what an application writes.
| Specifier | What it carries |
| ---------------------- | ----------------------------------------------------------- |
| ursprung | defineConfig, defineRoutes, <For>, <Pending>, types |
| ursprung/reactive | signal, computed, effect, onCleanup |
| ursprung/server | getContext, and the ApiHandler type |
| ursprung/jsx-runtime | types only; it never enters the graph |
| ursprung/tsconfig | the Base config |
The package exports four more specifiers — /build, /awaken, /rpc and
/tsconfig.app.json. The generated output and the tooling name them. Do not write
them yourself.
ursprung/server declares the workerd and worker conditions only. A Client
module that imports it does not get a Stub module. The import fails to resolve,
which is the intended result: the alternative is an RPC surface onto the
framework's own internals.
The subpath was called ursprung/client before version 0.0.9. It is
ursprung/reactive now. There is no alias.
What is in the package
dist/ holds JavaScript and type declarations. It holds no TypeScript source. tsc
compiles ursprung before each publication.
This is a requirement, not a preference. Node refuses to strip types from a file
under a node_modules path, and it refuses on purpose. Thus a package that
publishes TypeScript source cannot load in Node at all.
The package is ESM only. A require() call from CommonJS does not work.
Provenance
GitHub Actions publishes each release with npm provenance. Each version on the registry carries a signed attestation. The attestation links the tarball to the commit and to the workflow run that built it.
npm audit signaturesMore information
The architecture is decided in the open, and the decisions are written before the code.
- ursprung.dev — the site
- The dev log — short entries, newest first
- The blog — the long posts
- The glossary — every term this document uses
- The repository — the decision records and the open questions
License
MIT © Benedikt Müller
