Skip to content

Commit eb0b0ee

Browse files
committed
feat: use esbuild for TS on both SSR + hydration; default to no build
The dev server now registers an esbuild ESM loader hook (`module.register()`) at startup. Every server-side `.ts` import flows through esbuild's transform — same transformer the dev server already used for browser-bound `.ts` requests. SSR and hydration produce equivalent JS, full TS feature support (enums, decorators, parameter properties). Other changes: - esbuild moves from a hopeful scaffold devDep to a real `dependency` of `@webjskit/server`. `loadEsbuild`'s two-step user-app lookup is no longer needed and gone. - Scaffold's `devDependencies` no longer lists esbuild (transitive via @webjskit/server). - Scaffold's `package.json` no longer ships a `build` script. `webjs build` remains in the CLI as an opt-in production-bundle command for users who explicitly want it; default story is no build. - README, AGENTS.md, website hero copy, docs (typescript, ssr, getting- started, deployment, editor-setup, routing, ai-first) updated to drop the "Node strips types natively" framing and describe the unified esbuild path. Tests: - New `test/esbuild-loader.test.js` (7 tests) covering erasable types, enums, parameter properties, namespaces, generics, non-TS delegation, mtime caching. - Extended `test/dev-handler.test.js` with a non-erasable TS request test proving the browser-bound transform handles enum + parameter property. - All 640 unit tests pass. Versions: - `@webjskit/server`: 0.1.1 → 0.2.0 (esbuild now a dep, behavior change) - `@webjskit/cli`: 0.1.4 → 0.2.0 (server pin updated to 0.2.0)
1 parent b0a65f0 commit eb0b0ee

19 files changed

Lines changed: 328 additions & 137 deletions

File tree

AGENTS.md

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,11 @@ inspired by NextJs, Lit, and Rails.
172172
cache store, rate limiting — all built in with pluggable adapters.
173173
- **No build step.** Source files are served to the browser as native ES modules.
174174
- **JSDoc or TypeScript.** Plain `.js` with JSDoc is the default; `.ts`/`.mts`
175-
files are a supported first-class option — Node 23.6+ strips types at runtime
176-
for server files, and the dev server strips types via esbuild when serving
177-
browser-facing `.ts` files. No ahead-of-time build step either way.
175+
files are a supported first-class option. The dev server registers an esbuild
176+
loader hook at startup so every `.ts` import — server-side (SSR) or browser-
177+
fetched (hydration) — flows through the same esbuild transform. Same JS
178+
output for both paths, full TypeScript feature support (enums, decorators,
179+
parameter properties), no ahead-of-time build step you run.
178180
- **SSR + CSR by default.** Pages are server-rendered (real HTML, no
179181
hydration fallback). Interactive web components render as light DOM
180182
by default — global CSS and Tailwind utility classes apply directly.
@@ -1344,21 +1346,30 @@ participation. No `tsc` run is part of the user-visible workflow:
13441346
Red-squiggle on wrong types.
13451347
- **CI** (optional) runs `tsc --noEmit` against `tsconfig.json` at the
13461348
app root — type-check only, zero generated files.
1347-
- **Dev server** (runtime): when the browser requests a `.ts` file, the
1348-
dev server transforms via `esbuild.transform()` (~0.5–1ms per file,
1349-
cached by mtime) and serves JavaScript with an inline sourcemap.
1350-
- **Node server-side** (runtime): Node 23.6+ natively strips types
1351-
from `.ts` / `.mts` modules on import. Pages, layouts, server actions
1352-
and route handlers all run unchanged.
1353-
- **`webjs build`**: esbuild already handles `.ts` in its bundle entry
1354-
graph; no extra config needed.
1349+
- **Dev server** (runtime, both directions): the server registers an
1350+
esbuild ESM loader hook at startup (`module.register()`) so every `.ts`
1351+
import — server-side (SSR pages, layouts, actions, routes) or browser-
1352+
fetched (`/components/foo.ts`) — flows through the same `esbuild.transform()`
1353+
call (~0.5–1ms per file, cached by mtime). This is deliberate: SSR and
1354+
hydration must produce equivalent JS, and esbuild is a superset of any
1355+
built-in stripper.
1356+
- **`webjs build`**: same esbuild used for the optional production bundle;
1357+
no extra config or install needed (esbuild is a hard dep of `@webjskit/server`).
1358+
1359+
### TypeScript feature support
1360+
1361+
Because esbuild handles both server-side and browser-bound `.ts` files,
1362+
every TS feature esbuild supports works in webjs: enums, namespaces with
1363+
runtime values, parameter properties, decorators (legacy and Stage-3),
1364+
generics, type assertions, etc. No "stick to erasable syntax" caveat.
13551365
13561366
### Import convention
13571367
1358-
Use explicit `.ts` extensions in imports. This is what Node's native
1359-
TS support expects and matches the framework's resolution. For mixed
1360-
codebases, `.js` imports that point at a `.ts` sibling also resolve
1361-
in the dev server (fallback) — but prefer explicit `.ts` for clarity.
1368+
Use explicit `.ts` extensions in imports. The esbuild loader hook expects
1369+
file URLs ending in `.ts` / `.mts`; the dev server's `.ts` URL handler
1370+
expects the same. For mixed codebases, `.js` imports that point at a `.ts`
1371+
sibling also resolve in the dev server (fallback) — but prefer explicit
1372+
`.ts` for clarity.
13621373
13631374
```ts
13641375
// modules/posts/queries/list-posts.server.ts
@@ -1388,18 +1399,19 @@ no separate build:
13881399
}
13891400
```
13901401
1391-
### What doesn't work with Node's strip-types
1402+
### TypeScript feature support
13921403
1393-
Node's runtime stripper handles **erasable syntax only**. The following
1394-
don't run and need to be avoided (or moved into dev dependencies that
1395-
pre-compile):
1404+
Because the dev server uses esbuild on both sides (the `module.register()`
1405+
loader hook for server-side imports and `tsResponse` for browser-bound
1406+
files), every TS feature esbuild supports works:
13961407
1397-
- `enum`, `namespace`
1408+
- `type`, `interface`, generics, `as`, conditional/mapped/template-literal types
1409+
- `enum` (string + numeric), `namespace` with runtime values
13981410
- Parameter properties (`constructor(public x: number)`)
1399-
- Legacy decorators (`@foo` with emit)
1411+
- Decorators (legacy `experimentalDecorators` + Stage-3 standard)
14001412
1401-
All other TS — `type`, `interface`, generics, `as`, conditional types,
1402-
mapped types, template-literal types — run fine.
1413+
There is no "stick to erasable syntax" caveat. SSR and hydration always
1414+
produce equivalent JS because the same esbuild call transforms both.
14031415
14041416
## Full-stack type safety (actions + API routes)
14051417

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ TypeScript with zero build step, real SSR with Declarative Shadow DOM.
1212
## Why webjs
1313

1414
- **AI-first.** Predictable file conventions, one function per file, explicit `.server.ts` boundary, `AGENTS.md` contract — designed so LLMs modify code without loading the entire codebase into context.
15-
- **No build.** `.ts` files served directly. Node 23.6+ strips types at runtime; the dev server strips types via esbuild for the browser (~1ms/file, cached). Edit, refresh, done.
15+
- **No build step you run.** `.ts` files served directly. The dev server transforms TypeScript via esbuild for both server-side imports (SSR) and browser-bound modules (hydration) — same transformer for both, ~1ms/file, cached by mtime. Full TS feature support (enums, decorators, parameter properties — anything esbuild handles). Edit, refresh, done.
1616
- **Web components, light DOM by default.** Pages and components render as light DOM so global CSS and Tailwind utilities apply directly — no `::part`, no `:host`, no CSS-var plumbing. Shadow DOM is opt-in (`static shadow = true`) when you need scoped styles or real `<slot>` projection. Both modes SSR fully, no hydration runtime.
1717
- **Tailwind CSS by default.** The scaffold ships with the Tailwind browser runtime + `@theme` design tokens. Prefer hand-written CSS? Opt out entirely — the framework works just as well with vanilla CSS when you follow the wrapper-scoping convention (`.page-<route>`, `.layout-<name>`, component-tag scoped). Full recipe in the [Styling docs](./docs/app/docs/styling/page.ts).
1818
- **Full-stack type safety.** Import a `.server.ts` function from a component — TypeScript sees the real signature. superjson on the wire preserves `Date`, `Map`, `Set`, `BigInt`.

docs/app/docs/ai-first/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ modules/posts/queries/get-post.server.ts → exports getPost()</pre>
5858
5959
<h3>4. No Build Step = What You See Is What Runs</h3>
6060
<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; the runtime does another.</p>
61-
<p>webjs has <strong>no build step</strong>. The <code>.ts</code> file you see is the file that runs (Node strips types; the dev server strips types for the browser). There's 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.</p>
61+
<p>webjs has <strong>no build step you run</strong>. The <code>.ts</code> file you see is the file that runs the dev server transforms TypeScript via esbuild on import (server-side) and on request (browser-side), with the same transformer for both. There's 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.</p>
6262
6363
<h3>5. Explicit Server Boundary</h3>
6464
<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 — the naming convention prevents it. And the framework enforces it: <code>.server.ts</code> files are rewritten to RPC stubs for the browser.</p>

docs/app/docs/deployment/page.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const metadata = { title: 'Deployment — webjs' };
55
export default function Deployment() {
66
return html`
77
<h1>Deployment</h1>
8-
<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 23.6+: a VPS, a container, a PaaS like Fly.io or Railway, or behind a reverse proxy on bare metal.</p>
8+
<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 20.6+: a VPS, a container, a PaaS like Fly.io or Railway, or behind a reverse proxy on bare metal.</p>
99
1010
<h2>Dev vs Prod</h2>
1111
<p>webjs has two modes, controlled by the CLI command:</p>
@@ -31,8 +31,7 @@ webjs build [--no-minify] [--no-sourcemap]</pre>
3131
<li>Server-only files (<code>.server.ts</code>, <code>route.ts</code>, <code>middleware.ts</code>) are never included.</li>
3232
<li>In production, SSR pages load <code>/__webjs/bundle.js</code> instead of individual modules, collapsing dozens of requests into one.</li>
3333
</ul>
34-
<p>esbuild is required for the build step. Install it as a dev dependency:</p>
35-
<pre>npm install -D esbuild</pre>
34+
<p>esbuild is bundled with <code>@webjskit/server</code> — no separate install needed. <code>webjs build</code> runs immediately after a fresh scaffold.</p>
3635
<p>For small apps (under ~20 components), unbundled serving in production is perfectly viable. The build step is an optimisation, not a requirement.</p>
3736
3837
<h2>Production Features</h2>
@@ -206,8 +205,8 @@ HEALTHCHECK CMD curl -f http://localhost:3000/__webjs/health || exit 1
206205
CMD ["npx", "webjs", "start"]</pre>
207206
<p>Tips:</p>
208207
<ul>
209-
<li>Use <code>node:23-slim</code> (not <code>node:23-alpine</code>) for native TypeScript type stripping support.</li>
210-
<li>Run <code>npm ci --production</code> to skip dev dependencies. If you run <code>webjs build</code> in the container, install esbuild first (move it to regular <code>dependencies</code> or use a multi-stage build).</li>
208+
<li><code>node:slim</code> works fine — esbuild ships its own native binary, so no extra system packages are required.</li>
209+
<li>Run <code>npm ci --production</code> to skip dev dependencies. esbuild is a dependency of <code>@webjskit/server</code>, so it's always available for <code>webjs build</code> without extra setup.</li>
211210
<li>Set <code>HEALTHCHECK</code> to the built-in health endpoint for container orchestrators.</li>
212211
<li>For apps with Prisma, add <code>RUN npx prisma generate</code> before the CMD.</li>
213212
</ul>
@@ -288,7 +287,7 @@ pm2 start "webjs start" --name my-app</pre>
288287
289288
<h2>Deployment Checklist</h2>
290289
<ul>
291-
<li>Node 23.6+ installed (for native TypeScript type stripping).</li>
290+
<li>Node 20.6+ installed (required for the esbuild loader hook).</li>
292291
<li><code>npm ci --production</code> to install only production dependencies.</li>
293292
<li>Run <code>npx prisma generate</code> if you use Prisma.</li>
294293
<li>Optionally run <code>webjs build</code> for a single-file client bundle.</li>

docs/app/docs/editor-setup/page.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default function EditorSetup() {
1616
1717
<h2>Prerequisites</h2>
1818
<ul>
19-
<li><strong>Node 23.6+</strong> for native TypeScript type-stripping at runtime.</li>
19+
<li><strong>Node 20.6+</strong> for the esbuild loader hook the dev server registers at startup.</li>
2020
<li><strong>TypeScript 5.6+</strong> as a dev dependency (<code>npm i -D typescript</code>). The framework itself has no TS dependency; you only need it for editor intellisense.</li>
2121
<li>A <code>tsconfig.json</code> in your app. The scaffold generates one.</li>
2222
</ul>
@@ -40,7 +40,7 @@ export default function EditorSetup() {
4040
<ul>
4141
<li><code>moduleResolution: "NodeNext"</code> — required for the framework's <code>exports</code> map to resolve correctly.</li>
4242
<li><code>allowImportingTsExtensions: true</code> — lets you write <code>import { x } from './foo.ts'</code>, matching how webjs serves them.</li>
43-
<li><code>noEmit: true</code> — TypeScript type-checks only; webjs strips types at request time.</li>
43+
<li><code>noEmit: true</code> — TypeScript type-checks only; webjs transforms <code>.ts</code> via esbuild at import / request time.</li>
4444
<li><code>plugins: [{ name: 'ts-lit-plugin' }]</code> — enables template-literal intelligence (details below).</li>
4545
</ul>
4646

docs/app/docs/getting-started/page.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default function GettingStarted() {
99
1010
<h2>Prerequisites</h2>
1111
<ul>
12-
<li><strong>Node.js 23.6+</strong>required for native TypeScript type-stripping. Node 20+ works if you stick to plain JavaScript.</li>
12+
<li><strong>Node.js 20.6+</strong>needed for <code>module.register()</code>, the API webjs uses to install its esbuild TypeScript loader at startup.</li>
1313
<li><strong>npm</strong> (or any package manager).</li>
1414
</ul>
1515
@@ -118,8 +118,7 @@ Counter.register('my-counter');</pre>
118118
119119
<h2>How It Works</h2>
120120
<ul>
121-
<li><strong>Server-side:</strong> Node 23.6+ strips TypeScript types at runtime. Your <code>.ts</code> pages and server actions run directly.</li>
122-
<li><strong>Client-side:</strong> The dev server transforms <code>.ts</code> files via esbuild (~1ms/file, cached by mtime) before serving to the browser.</li>
121+
<li><strong>TypeScript:</strong> The dev server registers an esbuild loader hook at startup. Every <code>.ts</code> import — server-side or browser-fetched — flows through esbuild's <code>transform()</code>. Same transformer for SSR and hydration, ~1ms/file, cached by mtime.</li>
123122
<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>
124123
<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>
125124
</ul>

docs/app/docs/routing/page.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ export default function Routing() {
5252
5353
<p>
5454
Files can use <code>.ts</code>, <code>.js</code>, <code>.mts</code>, or
55-
<code>.mjs</code> extensions. TypeScript files run natively on the server via
56-
Node 23.6+ type-stripping — no build step required.
55+
<code>.mjs</code> extensions. TypeScript files run via the dev server's
56+
esbuild loader hook — no build step required.
5757
</p>
5858
5959
<!-- ===== PAGES ===== -->

docs/app/docs/ssr/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default async function Home() {
2525
\`;
2626
}</pre>
2727
28-
<p>Because Node 23.6+ strips TypeScript types natively, no compilation step is needed. The file above runs directly on the server.</p>
28+
<p>The dev server's esbuild loader hook transforms TypeScript on import, so the file above runs directly on the server with no manual compilation step.</p>
2929
3030
<h2>The SSR Pipeline</h2>
3131
<p>When the server receives a GET request for a page URL, the pipeline runs in this order:</p>

0 commit comments

Comments
 (0)