Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,26 @@ jobs:
npx prisma db seed
- run: npm test

bun:
name: Bun runtime smoke (#508)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
# npm ci installs the workspace + the `amaro` optionalDependency that the
# Bun stripper backend needs.
- run: npm ci
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Boot a webjs app under Bun: SSR + TypeScript strip (via amaro, since Bun
# has no built-in stripper) + a server-action RPC round-trip.
- name: webjs runtime smoke on Bun
run: bun test/bun/smoke.mjs

browser:
name: Browser (web-test-runner / Playwright)
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-f

An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive `static properties`, the lit lifecycle hooks, ReactiveControllers, the `lit-html` directive set, `html` / `css` templates) so lit training data transfers directly, but webjs ships its own no-build implementation under `packages/core/src/`. Decorators are the one lit exception (invariant 10); use `declare` + `static properties`.

- **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped via Node 24+'s `module.stripTypeScriptTypes` (invariant 10 + `agent-docs/typescript.md`). Node 24+ is required, enforced by an early `assertNodeVersion()` preflight.
- **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `agent-docs/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. Edge runtimes (no filesystem) are a separate, later target.
- **SSR + CSR by default.** Pages are server-rendered HTML; components render light DOM by default, shadow DOM opt-in via `static shadow = true` with DSD SSR.
- **Progressive enhancement is the default architecture.** Pages and components are SSR'd; with JS off, content reads, `<a>` navigates, `<form>` server actions submit, display-only elements render. JS is opt-in *per interactive behaviour* (`@click`, a reactive property assignment, a signal mutation). Never write a first paint that depends on hydration; never use `fetch` + JS where a `<form>` + server action would do.
- **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `agent-docs/components.md`.
Expand Down
11 changes: 6 additions & 5 deletions agent-docs/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ run is part of the user-visible workflow, no separate build step:

- **Editor** (VS Code) runs the TypeScript language server continuously. Red-squiggle on wrong types, including non-erasable syntax (see below).
- **CI** (optional) runs `tsc --noEmit` against `tsconfig.json`. Type-check only. Also catches non-erasable syntax via `erasableSyntaxOnly`.
- **Dev + prod server** (runtime, both directions): Node 24+'s built-in TypeScript type-stripping handles server-side `.ts` imports automatically (`process.features.typescript === 'strip'`). Browser-bound `.ts` requests go through `module.stripTypeScriptTypes` on the dev server, which performs whitespace replacement: every (line, column) in the source maps to the same position in the stripped output, so no sourcemap needs to be shipped and stack traces are byte-exact. The transform is cached by mtime (~microseconds per cache hit). Implementation backing: Node ships the [`amaro`](https://github.com/nodejs/amaro) package internally, which wraps SWC's WASM TypeScript transform in a position-preserving strip-only mode. If the framework ever needs to run on a non-Node runtime (Bun, Deno) we will install `amaro` directly or an equivalent position-preserving stripper (Sucrase preserves lines but not columns; SWC's strip mode also works).
- **Dev + prod server** (runtime, both directions): the runtime's TypeScript type-stripping handles server-side `.ts` imports automatically (on Node, `process.features.typescript === 'strip'`; Bun runs `.ts` natively). Browser-bound `.ts` requests go through the pluggable stripper in `packages/server/src/ts-strip.js` (#508) on the dev server, which performs whitespace replacement: every (line, column) in the source maps to the same position in the stripped output, so no sourcemap needs to be shipped and stack traces are byte-exact. The transform is cached by mtime (~microseconds per cache hit). Implementation backing: on **Node 24+** it is the built-in `module.stripTypeScriptTypes` (which itself wraps the [`amaro`](https://github.com/nodejs/amaro) package, SWC's WASM TypeScript transform in position-preserving strip-only mode); on **Bun** (no such built-in) it is `amaro` loaded directly, an `optionalDependency` of `@webjsdev/server`, producing byte-identical output. `WEBJS_TS_STRIPPER=builtin|amaro` forces a backend. Edge runtimes without a filesystem are a separate, later target.

## TypeScript feature support: erasable only

The framework uses Node 24+'s built-in `module.stripTypeScriptTypes`,
which only supports **erasable TypeScript**: type annotations,
The framework strips with Node 24+'s built-in `module.stripTypeScriptTypes`
(or `amaro` on Bun, the same engine), which only supports **erasable
TypeScript**: type annotations,
`interface`, `type`, `declare`, generics, `import type`, `as` casts,
and `satisfies`. Non-erasable syntax is rejected.

Expand Down Expand Up @@ -62,8 +63,8 @@ off so you catch the configuration drift before runtime.

## Import convention

Use explicit `.ts` extensions in imports. Node 24+'s built-in
type-stripping and the dev server's HTTP handler both key on the
Use explicit `.ts` extensions in imports. The runtime's type-stripping
and the dev server's HTTP handler both key on the
file URL ending in `.ts` / `.mts`. For mixed codebases, `.js` imports
that point at a `.ts` sibling also resolve in the dev server. Still
prefer explicit `.ts`.
Expand Down
2 changes: 1 addition & 1 deletion docs/app/docs/ai-first/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ modules/posts/queries/get-post.server.ts → exports getPost()</pre>

<h3>4. No Build Step = What You See Is What Runs</h3>
<p>Frameworks with build pipelines transform source code before it executes. The JSX you write becomes <code>React.createElement</code> calls. Your imports become webpack chunks. Your CSS modules get hashed classnames. An AI agent reading the source sees one thing, while the runtime does another.</p>
<p>webjs has <strong>no build step you run</strong>. The <code>.ts</code> file you see is the file that runs. <code>.ts</code> imports are stripped of types by Node 24+'s built-in <code>module.stripTypeScriptTypes</code>, which is whitespace replacement: every <code>(line, column)</code> in the source maps to the same position in the output, so stack traces stay byte-exact. The same transform runs server-side and on browser-bound requests. No intermediate representation, no generated code, no output directory. An AI agent can reason about what the code does by reading the file, because the file IS what runs. See <a href="/docs/no-build">No-Build Model</a> for the full pipeline.</p>
<p>webjs has <strong>no build step you run</strong>. The <code>.ts</code> file you see is the file that runs. <code>.ts</code> imports are stripped of types by the runtime's stripper (Node 24+'s built-in <code>module.stripTypeScriptTypes</code>, or <code>amaro</code> on Bun), which is whitespace replacement: every <code>(line, column)</code> in the source maps to the same position in the output, so stack traces stay byte-exact. The same transform runs server-side and on browser-bound requests. No intermediate representation, no generated code, no output directory. An AI agent can reason about what the code does by reading the file, because the file IS what runs. See <a href="/docs/no-build">No-Build Model</a> for the full pipeline.</p>

<h3>5. Explicit Server Boundary</h3>
<p>The <code>.server.ts</code> extension is a visible, greppable marker that says "this code runs only on the server." An AI agent never accidentally puts a database call in a component, because the naming convention prevents it. And the framework enforces it: <code>.server.ts</code> files are rewritten to RPC stubs for the browser.</p>
Expand Down
6 changes: 3 additions & 3 deletions docs/app/docs/deployment/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const metadata = { title: 'Deployment | webjs' };
export default function Deployment() {
return html`
<h1>Deployment</h1>
<p>webjs runs as a standard Node.js server. There is no static export, no serverless adapter, no edge runtime. Deploy it anywhere you can run Node 24+ (the minimum is set by Node's built-in TypeScript type-stripping): a VPS, a container, a PaaS like Fly.io or Railway, or behind a reverse proxy on bare metal.</p>
<p>webjs runs as a standard server on <strong>Node 24+ or Bun</strong>. There is no static export, no serverless adapter, and no edge runtime yet. Deploy it anywhere you can run Node or Bun: a VPS, a container, a PaaS like Fly.io or Railway, or behind a reverse proxy on bare metal. On Node the minimum is set by the built-in TypeScript type-stripping; on Bun the stripping comes from <code>amaro</code> automatically, so the same source runs on either.</p>

<h2>Dev vs Prod</h2>
<p>webjs has two modes, controlled by the npm script (which wraps the underlying <code>webjs dev</code> / <code>webjs start</code> CLI):</p>
Expand Down Expand Up @@ -334,7 +334,7 @@ HEALTHCHECK CMD curl -f http://localhost:8080/__webjs/health || exit 1
CMD ["npx", "webjs", "start"]</pre>
<p>Tips:</p>
<ul>
<li><code>node:slim</code> works fine. webjs strips TypeScript via Node 24+'s built-in <code>module.stripTypeScriptTypes</code>, so no extra system packages are needed.</li>
<li><code>node:slim</code> works fine. webjs strips TypeScript via the runtime's stripper (Node's built-in <code>module.stripTypeScriptTypes</code>, or <code>amaro</code> on a Bun image), so no extra system packages are needed.</li>
<li><code>npm ci --omit=dev</code> skips dev dependencies. <code>@webjsdev/server</code> is a runtime dependency. webjs is buildless end-to-end: there is no bundler or transpiler at deploy time.</li>
<li>Set <code>HEALTHCHECK</code> to the built-in health endpoint for container orchestrators.</li>
<li>For apps with Prisma, add <code>RUN npx prisma generate</code> before the CMD.</li>
Expand Down Expand Up @@ -400,7 +400,7 @@ pm2 start "webjs start" --name my-app</pre>

<h2>Deployment Checklist</h2>
<ul>
<li>Node 24+ installed (required for the built-in TypeScript type-stripping that the framework uses for both server-side imports and browser-bound <code>.ts</code> files).</li>
<li>Node 24+ or Bun installed (the TypeScript type-stripping for both server-side imports and browser-bound <code>.ts</code> files comes from Node's built-in on Node, or <code>amaro</code> on Bun).</li>
<li><code>npm ci --omit=dev</code> to install only runtime dependencies.</li>
<li>Run <code>npx prisma generate</code> if you use Prisma.</li>
<li>No build step. Source <code>.js</code> / <code>.ts</code> files are deployed as-is. TypeScript types are stripped on first request via Node's built-in stripper (whitespace replacement, byte-exact positions, no sourcemap overhead) and cached by mtime.</li>
Expand Down
4 changes: 2 additions & 2 deletions docs/app/docs/getting-started/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default function GettingStarted() {

<h2>Prerequisites</h2>
<ul>
<li><strong>Node.js 24+</strong>: webjs uses Node's built-in TypeScript type-stripping (<code>process.features.typescript === 'strip'</code>), which is default-on and stable from Node 24.</li>
<li><strong>Node.js 24+ or Bun</strong>: on Node, webjs uses the built-in TypeScript type-stripping (<code>process.features.typescript === 'strip'</code>), default-on and stable from Node 24. On Bun (which has no such built-in), it strips via <code>amaro</code> automatically. Run a Bun app with <code>bun --bun run dev</code> / <code>bun --bun run start</code>.</li>
<li><strong>npm</strong> (or any package manager).</li>
</ul>

Expand Down Expand Up @@ -132,7 +132,7 @@ Counter.register('my-counter');</pre>

<h2>How It Works</h2>
<ul>
<li><strong>TypeScript:</strong> Node 24+ strips types natively via <code>module.stripTypeScriptTypes</code> (whitespace replacement, byte-exact line + column preservation, no sourcemap shipped). Every <code>.ts</code> file, whether server-side or browser-fetched, goes through the same stripper, so SSR and hydration produce identical JS. The transform is cached by mtime. Only erasable TypeScript is supported; <code>enum</code>, <code>namespace</code> with values, parameter properties, and legacy decorators fail at strip time with a pointer at the <code>no-non-erasable-typescript</code> lint rule.</li>
<li><strong>TypeScript:</strong> types are stripped by the runtime's stripper, Node 24+'s built-in <code>module.stripTypeScriptTypes</code> or <code>amaro</code> on Bun (byte-identical, whitespace replacement, byte-exact line + column preservation, no sourcemap shipped). Every <code>.ts</code> file, whether server-side or browser-fetched, goes through the same stripper, so SSR and hydration produce identical JS. The transform is cached by mtime. Only erasable TypeScript is supported; <code>enum</code>, <code>namespace</code> with values, parameter properties, and legacy decorators fail at strip time with a pointer at the <code>no-non-erasable-typescript</code> lint rule.</li>
<li><strong>SSR:</strong> Pages are rendered to HTML strings on the server. Light-DOM components serialize as plain children with a <code>&lt;!--webjs-hydrate--&gt;</code> marker. Shadow-DOM components (opt-in) emit Declarative Shadow DOM so scoped styles paint before JS loads.</li>
<li><strong>Hydration:</strong> When JS loads, custom elements upgrade and become interactive. The fine-grained renderer preserves focus, cursor position, and form state across state updates.</li>
<li><strong>Progressive enhancement:</strong> Pages and every custom element are SSR'd. Each component's <code>render()</code> runs on the server, so its initial HTML is in the response before any script loads. With JS disabled: content reads, <code>&lt;a&gt;</code> links navigate, <code>&lt;form&gt;</code> + server actions submit, and even an interactive component (counter, dropdown, tabs) paints its initial state correctly. JS is opt-in <em>per interactive behavior</em>, not per component: a counter renders as "0" without JS, and only the +/- click handling needs scripts. See <a href="/docs/progressive-enhancement">Progressive Enhancement</a>.</li>
Expand Down
2 changes: 1 addition & 1 deletion docs/app/docs/no-build/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default function NoBuild() {
<ol>
<li>Browser parses HTML, finds <code>&lt;script type="module" src="/app/page.ts"&gt;</code> (or follows an <code>import</code> from one that was already loaded).</li>
<li>Server receives the request. Reads the file from disk.</li>
<li>If the file is <code>.ts</code> / <code>.mts</code>, runs Node 24+'s built-in <code>module.stripTypeScriptTypes</code>. This is whitespace replacement: every <code>(line, column)</code> in the source maps to the same position in the output, so no sourcemap is needed and stack traces are byte-exact. Only erasable TypeScript is supported. <code>enum</code>, <code>namespace</code> with values, parameter properties, legacy decorators, and <code>import = require</code> fail at strip time and the dev server returns a 500 naming the file. The scaffolded <code>tsconfig.json</code> turns on <code>erasableSyntaxOnly: true</code> by default so the compiler rejects these in your own code at edit time. Almost no npm package ships <code>.ts</code> source anyway: published packages compile to <code>.js</code> with sidecar <code>.d.ts</code> type files, which the runtime serves as plain JavaScript with no transform.</li>
<li>If the file is <code>.ts</code> / <code>.mts</code>, runs the runtime's stripper: Node 24+'s built-in <code>module.stripTypeScriptTypes</code>, or <code>amaro</code> on Bun (byte-identical). This is whitespace replacement: every <code>(line, column)</code> in the source maps to the same position in the output, so no sourcemap is needed and stack traces are byte-exact. Only erasable TypeScript is supported. <code>enum</code>, <code>namespace</code> with values, parameter properties, legacy decorators, and <code>import = require</code> fail at strip time and the dev server returns a 500 naming the file. The scaffolded <code>tsconfig.json</code> turns on <code>erasableSyntaxOnly: true</code> by default so the compiler rejects these in your own code at edit time. Almost no npm package ships <code>.ts</code> source anyway: published packages compile to <code>.js</code> with sidecar <code>.d.ts</code> type files, which the runtime serves as plain JavaScript with no transform.</li>
<li>Result is cached in memory keyed by <code>(absolute path, mtime)</code>. A file edit invalidates naturally.</li>
<li>Response is served as <code>application/javascript</code> with appropriate cache headers. In dev: <code>no-cache</code> (always revalidate, so an edit shows up immediately). In prod: a request that carries the content-hash <code>?v=&lt;digest&gt;</code> query the framework emits (see below) is served <code>public, max-age=31536000, immutable</code>; a bare un-fingerprinted request falls back to <code>public, max-age=3600</code>. Either way a weak ETag rides along for conditional GET.</li>
<li>Browser executes the module, encounters its imports, repeat from step 1 for each.</li>
Expand Down
2 changes: 1 addition & 1 deletion docs/app/docs/ssr/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default async function Home() {
\`;
}</pre>

<p>Node 24+ strips TypeScript types natively when it imports a <code>.ts</code> file, so the file above runs directly on the server with no manual compilation step.</p>
<p>The runtime (Node 24+ or Bun) strips TypeScript types natively when it imports a <code>.ts</code> file, so the file above runs directly on the server with no manual compilation step.</p>

<h2>The SSR Pipeline</h2>
<p>When the server receives a GET request for a page URL, the pipeline runs in this order:</p>
Expand Down
Loading
Loading