@webjsdev/mcp 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/resources/AGENTS.md +20 -13
- package/resources/agent-docs/advanced.md +30 -3
- package/resources/agent-docs/built-ins.md +5 -5
- package/resources/agent-docs/components.md +69 -6
- package/resources/agent-docs/configuration.md +29 -1
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +3 -8
- package/resources/agent-docs/runtime.md +65 -0
- package/resources/agent-docs/styling.md +43 -0
- package/resources/agent-docs/testing.md +25 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The webjs Model Context Protocol server: live app introspection (routes / actions / components / check) plus the framework knowledge layer (docs, recipes, source) for AI coding agents",
|
|
6
6
|
"bin": {
|
package/resources/AGENTS.md
CHANGED
|
@@ -14,7 +14,8 @@ reference there.
|
|
|
14
14
|
| `agent-docs/components.md` | WebComponent deep-dive (controllers, hooks, light/shadow DOM, slots) |
|
|
15
15
|
| `agent-docs/styling.md` | Tailwind helpers + vanilla-CSS opt-out conventions |
|
|
16
16
|
| `agent-docs/built-ins.md` | Auth, sessions, env vars, caching (cache(), HTTP cache, asset-hash, conditional GET), rate-limit, broadcast, file storage |
|
|
17
|
-
| `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps, dev/start task orchestration) + observability |
|
|
17
|
+
| `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, client-router opt-out, ingress caps, dev/start task orchestration) + observability |
|
|
18
|
+
| `agent-docs/runtime.md` | Node vs Bun (commands, install, the listener/stripper/SQLite/WebSocket differences) + the future-Deno seam |
|
|
18
19
|
| `agent-docs/advanced.md` | Suspense streaming, performance, bundling, client router (prefetch, frames, view transitions, stream actions), WebSockets |
|
|
19
20
|
| `agent-docs/typescript.md` | TS at runtime + full-stack type safety |
|
|
20
21
|
| `agent-docs/service-worker.md` | The opt-in progressive-enhancement service worker (`public/sw.js`) |
|
|
@@ -61,8 +62,8 @@ When interactive approval is disabled, never block on questions. Auto-decide: on
|
|
|
61
62
|
|
|
62
63
|
Every code change MUST include, automatically:
|
|
63
64
|
|
|
64
|
-
1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). `npm test` does NOT run browser or
|
|
65
|
-
2. **Documentation.**
|
|
65
|
+
1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** webjs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `agent-docs/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`).
|
|
66
|
+
2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + `agent-docs/*.md` for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`docs/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
|
|
66
67
|
3. **Convention validation.** Run `webjs check` and fix violations.
|
|
67
68
|
|
|
68
69
|
### Git workflow (mandatory)
|
|
@@ -89,7 +90,7 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-f
|
|
|
89
90
|
|
|
90
91
|
An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive 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/`. Reactive properties are the one deliberate divergence (invariant 10): instead of lit's `@property()` decorator or a `static properties` block, webjs declares them through the declare-free base-class factory `extends WebComponent({ count: Number })`.
|
|
91
92
|
|
|
92
|
-
- **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. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target.
|
|
93
|
+
- **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. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target. See `agent-docs/runtime.md` for the Node vs Bun command + difference reference.
|
|
93
94
|
- **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.
|
|
94
95
|
- **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.
|
|
95
96
|
- **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`.
|
|
@@ -108,7 +109,13 @@ webjs has **no server/client component split.** There is no RSC render tree, no
|
|
|
108
109
|
- **Components hydrate.** The module loads in the browser, registers the custom element, the browser upgrades the SSR'd tag, and `render()` / lifecycle / `@event` / signals run client-side. Per-element, islands-style. **All interactivity lives here.**
|
|
109
110
|
- **Pages and layouts do NOT hydrate.** Their function runs only on the server to produce HTML and is never re-invoked in the browser. So a page/layout cannot be interactive in its own markup (an `@click` in a page template is dropped at SSR; a signal read in a page body never re-renders). For interactivity, render a component's tag.
|
|
110
111
|
|
|
111
|
-
A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade)
|
|
112
|
+
A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade). The client router is automatic and needs no import: it auto-enables when `@webjsdev/core` loads in the browser (the bundle every component pulls), so any page that ships a component gets client navigation for free (#620). That load is also how its imports reach the client (`import dayjs` at the top of a page fetches dayjs when the module loads, not via hydration). An inert page/layout is dead weight, which is exactly when elision drops it. A page/layout that is non-inert ONLY because it imports interactive components is **import-only** (#605): since it never hydrates, the boot emits its component modules directly and drops the page/layout module, so the browser fetches just the interactive leaves (it still ships whole when it has its own client side effect: a `client-router` import, a module-scope call, or a self-registering bare import).
|
|
113
|
+
|
|
114
|
+
**Keep pages and layouts as pure carriers (#621), so their modules stay out of the network tab.** A page/layout drops from the boot (import-only / inert) only while its sole browser-relevant job is registering the components it imports. It starts SHIPPING its own module the moment its closure does any OTHER client work, which is invisible in `npm test` (an elision verdict, not a behaviour change) and easy to introduce by accident. Avoid, in a page/layout (and in any util it imports):
|
|
115
|
+
- module-scope client work: a top-level call, a `new SomethingNonData()`, a browser-global (`window` / `document` / `customElements`) access, or a bare side-effect import of a non-component or `@webjsdev/core/client-router` (routing is automatic). A page-template `@event` / inline-`<script>` is fine (it is SSR output, never module client work, #623 / #634).
|
|
116
|
+
- importing a client-effecting NON-component util. A helper that touches a client global or self-executes drags the whole page in. Put client-only behaviour inside a component; put server-only work in `.server.{js,ts}` (it never reaches the client closure); and if a util MIXES a pure helper with client-global code (the `cn.ts` shape, #619), split the client part into its own module so the pure helper does not pin every importer.
|
|
117
|
+
|
|
118
|
+
Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the boot `<script type="module">`; if one does, something in its closure does client work and is not a component.
|
|
112
119
|
|
|
113
120
|
`route.{js,ts}` is the one routing file that is NOT isomorphic: a server-only HTTP handler (named `GET` / `POST` exports), the webjs equivalent of a Next route handler. It never ships to the client.
|
|
114
121
|
|
|
@@ -230,7 +237,7 @@ MyThing.register('my-thing');
|
|
|
230
237
|
|
|
231
238
|
**Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local.
|
|
232
239
|
|
|
233
|
-
**Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults
|
|
240
|
+
**Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field initializer (e.g., `count = 0`), which clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`.
|
|
234
241
|
|
|
235
242
|
**Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
|
|
236
243
|
|
|
@@ -287,7 +294,7 @@ Server actions export named async functions whose args + returns round-trip thro
|
|
|
287
294
|
|
|
288
295
|
### RPC + REST endpoint security
|
|
289
296
|
|
|
290
|
-
|
|
297
|
+
Action RPC CSRF is an **Origin / `Sec-Fetch-Site` check** (the Remix 3 / Go 1.25 model), NOT a token cookie: a state-changing verb passes only when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers, no `Sec-Fetch-Site`) the `Origin` host matches the request host, or the source is in `webjs.allowedOrigins`; otherwise 403. **No `Set-Cookie` rides the SSR HTML**, which is why a page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout for a whole visitor-identical app) is CDN-edge-cacheable. A safe GET action is CSRF-exempt. Prod errors are sanitized to `message` only. A `route.ts` REST endpoint (hand-written or via the `route()` adapter) is NOT covered by this check: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `agent-docs/advanced.md`.
|
|
291
298
|
|
|
292
299
|
### Components (`components/*.{js,ts}`)
|
|
293
300
|
|
|
@@ -317,15 +324,15 @@ type ActionResult<T> =
|
|
|
317
324
|
|
|
318
325
|
## Styling: Tailwind-first
|
|
319
326
|
|
|
320
|
-
**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. See `agent-docs/styling.md`.
|
|
327
|
+
**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `agent-docs/styling.md`.
|
|
321
328
|
|
|
322
329
|
---
|
|
323
330
|
|
|
324
331
|
## Client navigation: automatic, nothing to opt into
|
|
325
332
|
|
|
326
|
-
Nested layouts auto-emit `<!--wj:children:<segment-path>-->` markers; the client router walks both DOMs and replaces only the deepest shared layout's children slot, preserving outer-layout DOM identity. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (the server returns only the divergent fragment); scroll is restored on back/forward. A non-GET `<form>` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
|
|
333
|
+
The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. Nested layouts auto-emit `<!--wj:children:<segment-path>-->` markers; the client router walks both DOMs and replaces only the deepest shared layout's children slot, preserving outer-layout DOM identity. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (the server returns only the divergent fragment); scroll is restored on back/forward. A non-GET `<form>` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
|
|
327
334
|
|
|
328
|
-
The advanced client-router surface is in `agent-docs/advanced.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition">`, plus `data-webjs-permanent` to persist a live element),
|
|
335
|
+
The advanced client-router surface is in `agent-docs/advanced.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition">`, plus `data-webjs-permanent` to persist a live element), **stream actions** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2).
|
|
329
336
|
|
|
330
337
|
---
|
|
331
338
|
|
|
@@ -333,7 +340,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
333
340
|
|
|
334
341
|
> Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.com/docs/troubleshooting) is keyed by symptom (the throw-at-load server import, the backtick-in-template 500, the TypeScript strip failure, the SSR browser-global crash, the missing-frame swap) and maps each back to the invariant and the `webjs check` rule below.
|
|
335
342
|
|
|
336
|
-
1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing a DB driver (`
|
|
343
|
+
1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing a DB driver (`pg`), `node:*`, or any server-only dep from a component or an `app/**` page / layout / loading / error / not-found file crashes the browser at module load.
|
|
337
344
|
2. **Every `*.server.{js,ts}` file with `'use server'` exports must be `async` functions returning serializer-safe values.** Args and results round-trip via webjs's wire. Files without `'use server'` (server-only utilities) can export anything, including singletons.
|
|
338
345
|
3. **Custom element tag names must contain a hyphen** (HTML spec). Pass the tag to `Class.register('tag-name')`, not a static field. Any short-string quote works: `'tag-name'`, `"tag-name"`, or `` `tag-name` `` (single-line, no interpolation).
|
|
339
346
|
4. **Event (`@`), property (`.`), boolean (`?`) holes in `html` must be unquoted**, e.g. `@click=${fn}`, never `@click="${fn}"`.
|
|
@@ -350,7 +357,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
350
357
|
|
|
351
358
|
## Scaffolding
|
|
352
359
|
|
|
353
|
-
Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors any of the three for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `
|
|
360
|
+
Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors any of the three for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs <name>` auto-detects it. Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous.
|
|
354
361
|
|
|
355
362
|
Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **Treat the scaffold as REFERENCE, not the final product:** replace the example page / `User` model / components and adapt `app/layout.ts` (brand, nav, content width; the default `<main class="max-w-[760px]">` reading column needs widening for a full-bleed app). ENFORCED: examples carry a `webjs-scaffold-placeholder` comment and `no-scaffold-placeholder` fails until the content is replaced and the marker deleted. Docs at https://docs.webjs.com.
|
|
356
363
|
|
|
@@ -364,7 +371,7 @@ webjs start [--port N] # prod server; source IS the runtime, plain H
|
|
|
364
371
|
webjs test [--server] [--browser] [--watch]
|
|
365
372
|
webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
|
|
366
373
|
webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check
|
|
367
|
-
webjs doctor # project-health checklist; non-zero exit on a hard fail
|
|
374
|
+
webjs doctor # project-health checklist (incl. a page/layout elision advisory, #646); non-zero exit on a hard fail
|
|
368
375
|
webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
|
|
369
376
|
webjs typecheck [tsc args...] # the project's own tsc --noEmit
|
|
370
377
|
webjs create <name> [--template api|saas]
|
|
@@ -380,7 +380,7 @@ The warning is informational; the request still proceeds. Treat it as a prompt t
|
|
|
380
380
|
|
|
381
381
|
## Client router: nested-layout-aware partial swap
|
|
382
382
|
|
|
383
|
-
|
|
383
|
+
The client router auto-enables when `@webjsdev/core` loads in the browser (any page that ships a component), no import needed (#620). It gives SPA-style navigation that
|
|
384
384
|
preserves outer-layout DOM identity at any depth. Intercepts same-origin
|
|
385
385
|
`<a>` clicks (incl. inside shadow DOM via `composedPath()`), fetches the
|
|
386
386
|
target HTML, and replaces only the inside of the deepest shared layout.
|
|
@@ -868,6 +868,17 @@ revalidation fetch that follows does **not** scroll, so the restored
|
|
|
868
868
|
position survives the refresh. Cache miss → browser-native scroll
|
|
869
869
|
restoration takes over.
|
|
870
870
|
|
|
871
|
+
Every programmatic nav scroll (the popstate restore and the
|
|
872
|
+
scroll-to-top on a forward nav) is issued with `behavior: 'instant'`, so
|
|
873
|
+
an app-level `html { scroll-behavior: smooth }` does not animate it. The
|
|
874
|
+
restore matches a native page load (an instant jump), not a visible
|
|
875
|
+
slide. The one nav scroll left smooth is a hash-anchor (`#section`)
|
|
876
|
+
target, where smooth scrolling is the intent. So `scroll-behavior:
|
|
877
|
+
smooth` on `<html>` only affects in-page anchors, not route
|
|
878
|
+
transitions. In development the router logs a one-time console hint when
|
|
879
|
+
it detects that setting (and notes that pairing it with a sticky
|
|
880
|
+
`backdrop-filter` header can flash on iOS during navigation).
|
|
881
|
+
|
|
871
882
|
Inner scroll containers (e.g. `.docs-sidenav`) are preserved
|
|
872
883
|
automatically by the outer-layout-DOM-identity invariant. They stay
|
|
873
884
|
mounted across nav and keep their `scrollTop` natively.
|
|
@@ -1111,7 +1122,7 @@ more parts in a `Response` with the stream content type. A page `action` may
|
|
|
1111
1122
|
return `streamResponse(...)` directly (it is honored verbatim); on the no-JS
|
|
1112
1123
|
branch return a normal `ActionResult` instead. `renderStream` is auto-registered
|
|
1113
1124
|
by the client router, so it (and the `<webjs-stream>` element) is available
|
|
1114
|
-
|
|
1125
|
+
on any page that loads `@webjsdev/core` (the router auto-enables there).
|
|
1115
1126
|
|
|
1116
1127
|
### Streaming RPC results: an action returns a stream (#489)
|
|
1117
1128
|
|
|
@@ -1175,7 +1186,23 @@ Use `data-no-router` for:
|
|
|
1175
1186
|
|
|
1176
1187
|
### Loading indicator
|
|
1177
1188
|
|
|
1178
|
-
|
|
1189
|
+
**When to use it.** You want a CSS-only progress affordance (a top bar, a `cursor: progress`) while a client navigation is in flight, with no JavaScript. For a JS-driven indicator, skip this and listen for the `webjs:navigate` event (and `webjs:submit-start` for forms) instead, which always fires.
|
|
1190
|
+
|
|
1191
|
+
**How to use it.** It is OPT-IN. Add `data-webjs-nav-progress` to your `<html>` element (once, in the root layout), and the router then sets a `data-navigating` attribute on `<html>` while a nav is in flight (deferred 150ms, so quick sub-150ms navs never trigger it). Style off that attribute:
|
|
1192
|
+
|
|
1193
|
+
```html
|
|
1194
|
+
<html data-webjs-nav-progress> <!-- opt in once -->
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
```css
|
|
1198
|
+
html[data-navigating] { cursor: progress; }
|
|
1199
|
+
html[data-navigating]::after {
|
|
1200
|
+
content: ''; position: fixed; top: 0; left: 0; right: 0; height: 2px;
|
|
1201
|
+
background: var(--accent); animation: progress 1s ease-in-out infinite;
|
|
1202
|
+
}
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
**Why it is opt-in, not default (#610).** Toggling an attribute on `<html>` re-runs global style resolution, and on WebKit (so every iOS browser) that re-resolves the page's `oklch()` / `color-mix()` design tokens to an equivalent oklab representation and repaints them for one frame. On a token-driven theme that is a visible background flash, worst on a slow mobile forward nav (which exceeds the 150ms defer). So without the opt-in the attribute is never written and the flash cannot happen. Enable it only when your theme does not lean on wide-gamut color tokens, or use the event-based path above.
|
|
1179
1206
|
|
|
1180
1207
|
## WebSockets
|
|
1181
1208
|
|
|
@@ -137,10 +137,10 @@ The cache is keyed by the FULL URL (path + search) only, with no per-user
|
|
|
137
137
|
keying, so a page that reads `cookies()` / a session / per-user data MUST
|
|
138
138
|
NOT set `revalidate`. The framework also refuses to cache (defense in
|
|
139
139
|
depth) any response that is not a `200`, is a streamed Suspense body,
|
|
140
|
-
sets
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
sets any `Set-Cookie`, or runs under CSP (its body carries a per-request
|
|
141
|
+
nonce). SSR responses no longer carry a CSRF cookie (action CSRF is an
|
|
142
|
+
Origin / `Sec-Fetch-Site` check, not a token cookie), so a cacheable page
|
|
143
|
+
is cookieless and safe to share.
|
|
144
144
|
|
|
145
145
|
**Framework defense, not just the contract.** When the render reads
|
|
146
146
|
per-user state through a framework helper (`cookies()`, `headers()`,
|
|
@@ -221,7 +221,7 @@ The ETag is WEAK (`W/"..."`). It hashes the UNCOMPRESSED body and the prod compr
|
|
|
221
221
|
| A `route.{js,ts}` handler returning a `ReadableStream` (incl. an SSE `text/event-stream`) | The body is not marked buffered, so the funnel never reads it. Buffering a stream would blow up memory, and an SSE stream never ends so the read would hang forever |
|
|
222
222
|
| Non-GET / non-HEAD, and any status other than 200 | A validator is only meaningful for a successful, replayable read |
|
|
223
223
|
|
|
224
|
-
**Stable-body handling.** The ETag is computed over the response's OWN body bytes, so an identical body yields an identical ETag across requests. Per-response varying bits that ride RESPONSE HEADERS (the `x-webjs-build` id, the
|
|
224
|
+
**Stable-body handling.** The ETag is computed over the response's OWN body bytes, so an identical body yields an identical ETag across requests. Per-response varying bits that ride RESPONSE HEADERS (the `x-webjs-build` id, the CSP nonce on the header) are NOT part of the body hash, so they do not destabilise the ETag. The one body-level varying input is the CSP nonce stamped INTO the inline boot script: with CSP enabled the HTML body changes every request, so its ETag changes every request and a 304 is simply never produced for that page (correct, not a bug). CSP is off by default, so the common cacheable-page case has a stable body and a stable ETag. The 304 preserves the validators and caching headers (`ETag`, `Cache-Control`, `Vary`, plus the framework's `X-Webjs-Build` / `X-Request-Id` and any `Set-Cookie`) and drops only the body-describing headers (`Content-Length`, `Content-Type`, `Content-Encoding`), so a shared cache and the client router behave identically to a 200.
|
|
225
225
|
|
|
226
226
|
## Sessions
|
|
227
227
|
|
|
@@ -20,6 +20,13 @@ Built-in constructors (`String`, `Number`, `Boolean`, `Array`, `Object`) feed
|
|
|
20
20
|
the default attribute coercion. For anything the default can't parse correctly
|
|
21
21
|
(Date, Map, Set, discriminated unions) supply a custom `converter`.
|
|
22
22
|
|
|
23
|
+
For an array-typed prop, pass `Array`, not `Object` (`items: prop<Tag[]>(Array)`).
|
|
24
|
+
The default converter handles both identically (each JSON-encodes the value), so
|
|
25
|
+
`Object` does not break anything, but `Array` states the prop's shape to the next
|
|
26
|
+
reader. The `array-prop-uses-array-type` `webjs check` rule flags an array-typed
|
|
27
|
+
generic (`T[]`, `readonly T[]`, `Array<T>`, `ReadonlyArray<T>`) declared with the
|
|
28
|
+
`Object` constructor.
|
|
29
|
+
|
|
23
30
|
## Declaring reactive properties: the base-class factory
|
|
24
31
|
|
|
25
32
|
Reactive properties are declared by passing the property shape into the **base-class factory** `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is no `static properties` block and no `declare` line. A direct `static properties` block throws at runtime (caught statically by the `no-static-properties` rule).
|
|
@@ -52,14 +59,20 @@ class Dialog extends WebComponent({
|
|
|
52
59
|
}
|
|
53
60
|
```
|
|
54
61
|
|
|
55
|
-
Set defaults
|
|
62
|
+
Set defaults in the constructor after `super()`. A declarative `default` option also exists (lit-parity, a function default runs per instance for a fresh object / array), but the constructor is the recommended way. **Never** use a class-field initializer (e.g., `count = 0` or `student: Student = { ... }`): the framework installs reactive getter/setter on `this` inside the constructor via `Object.defineProperty`, and a class-field initializer compiles to an assignment after `super()` that uses `[[Define]]` to overwrite the accessor, silently breaking reactivity. The `reactive-props-no-class-field` rule catches this.
|
|
56
63
|
|
|
57
64
|
```ts
|
|
58
|
-
//
|
|
65
|
+
// set defaults in the constructor after super()
|
|
59
66
|
class Counter extends WebComponent({
|
|
60
|
-
count: prop(Number, {
|
|
61
|
-
items: prop(Array
|
|
62
|
-
}) {
|
|
67
|
+
count: prop(Number, { reflect: true }),
|
|
68
|
+
items: prop(Array),
|
|
69
|
+
}) {
|
|
70
|
+
constructor() {
|
|
71
|
+
super();
|
|
72
|
+
this.count = 0;
|
|
73
|
+
this.items = [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
63
76
|
```
|
|
64
77
|
|
|
65
78
|
## Lifecycle hooks (lit-aligned)
|
|
@@ -188,7 +201,7 @@ as it has none of the following.
|
|
|
188
201
|
- An overridden lifecycle hook (anything in the table above), as a method or an arrow class field.
|
|
189
202
|
- A `signal` / `computed` / `watch` / `Task` / `ref` / `live` / streaming directive imported from `@webjsdev/core`, OR a transitive import of a module that reads shared module-scope signal state.
|
|
190
203
|
- An `addController(...)` or `requestUpdate()` call.
|
|
191
|
-
- Any code that runs at module load. A display-only module's top level may only *declare* things (imports, the `WebComponent` class, `const` / `let` / `var`, pure initializers like `css\`...\``) and *register* the component (`X.register(...)` / `customElements.define(...)`). Any other top-level call, `new`, dynamic `import(...)`, or top-level `await` is client work and ships (a top-level `fetch('/track')`, `new WebSocket(...)`, `setTimeout(...)`, `someInit()`). This is checked structurally as an allowlist of safe top-level forms, not a denylist of global names, so a brand-new browser API is caught automatically with no code change. Code inside a method, `render()`, or an uninvoked function does not count (it does not run at load), nor do these words in rendered template text or a `.fetch` / `.location` member access.
|
|
204
|
+
- Any code that runs at module load. A display-only module's top level may only *declare* things (imports, the `WebComponent` class, `const` / `let` / `var`, pure initializers like `css\`...\``) and *register* the component (`X.register(...)` / `customElements.define(...)`). Any other top-level call, a non-data `new`, dynamic `import(...)`, or top-level `await` is client work and ships (a top-level `fetch('/track')`, `new WebSocket(...)`, `setTimeout(...)`, `someInit()`). A module-scope pure-DATA constructor is the one exception (`const TAGS = new Set([...])`, a compiled `new RegExp(...)`, a parsed `new URL(...)`, a typed array): it produces inert data with no side effect, so it does not force shipping. This is checked structurally as an allowlist of safe top-level forms, not a denylist of global names, so a brand-new browser API is caught automatically with no code change. Code inside a method, `render()`, or an uninvoked function does not count (it does not run at load), nor do these words in rendered template text or a `.fetch` / `.location` member access.
|
|
192
205
|
- A rendered `<slot>`. Light-DOM slots rely on the client projection runtime, and proving a slot is purely native (shadow DOM) is beyond static analysis, so any `<slot>` ships.
|
|
193
206
|
- Being rendered or imported by a component that itself ships (an interactive parent can re-create the child on the client).
|
|
194
207
|
|
|
@@ -236,6 +249,56 @@ which fails if a new `WebComponent` hook is added without teaching the
|
|
|
236
249
|
analyser about it. If you add an interactivity feature to the framework,
|
|
237
250
|
update that file.
|
|
238
251
|
|
|
252
|
+
## Pages and layouts: keep them carriers, out of the network tab
|
|
253
|
+
|
|
254
|
+
The same elision applies to whole routes, and it is what keeps `page.ts`
|
|
255
|
+
and `layout.ts` out of the browser's network tab. A page/layout NEVER
|
|
256
|
+
hydrates: it loads in the browser only to register the components it
|
|
257
|
+
imports (so their tags upgrade). When that registration is its sole
|
|
258
|
+
browser-relevant job, the framework drops the page/layout module and the
|
|
259
|
+
boot script imports those components directly. It is **import-only** (the
|
|
260
|
+
page/layout is dropped but reaches shipping components, #605) or **inert**
|
|
261
|
+
(it reaches none, so zero application JS, #179).
|
|
262
|
+
|
|
263
|
+
A page/layout starts SHIPPING its own module the moment its closure does
|
|
264
|
+
any OTHER client work. Because that is an elision verdict, not a behaviour
|
|
265
|
+
change, it is invisible in `npm test` and easy to introduce by accident
|
|
266
|
+
(a code sample mistaken for real code, #634; a util that touches a client
|
|
267
|
+
global, #619). To keep a route's modules out of the network tab:
|
|
268
|
+
|
|
269
|
+
- Do not give a page/layout its own module-scope client work: no top-level
|
|
270
|
+
call, no `new SomethingNonData()`, no browser-global access
|
|
271
|
+
(`window` / `document` / `customElements`) at module scope, no bare
|
|
272
|
+
side-effect import of a non-component, and no
|
|
273
|
+
`import '@webjsdev/core/client-router'` (routing is automatic, #620). A
|
|
274
|
+
page-template `@event` or inline `<script>` is fine: it is SSR output,
|
|
275
|
+
never module client work (the analyser scans route-module template
|
|
276
|
+
content as inert, #623 / #634).
|
|
277
|
+
- Do not import a client-effecting NON-component util into a page/layout
|
|
278
|
+
(or into a component chain a page reaches). A helper that touches a
|
|
279
|
+
client global or self-executes drags the whole page in. Put client-only
|
|
280
|
+
behaviour inside a component; put server-only work in `.server.{js,ts}`
|
|
281
|
+
(it never enters the client closure); and if a util MIXES a pure helper
|
|
282
|
+
with client-global code (the `cn.ts` shape, #619), split the client part
|
|
283
|
+
into its own module so the pure helper does not pin every importer.
|
|
284
|
+
|
|
285
|
+
Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab
|
|
286
|
+
or the boot `<script type="module">`. If one does, something in its
|
|
287
|
+
closure does client work and is not a component; that is the thing to move
|
|
288
|
+
into a component or a `.server.{js,ts}` file.
|
|
289
|
+
|
|
290
|
+
`webjs doctor` names this for you (#646). Its "Page/layout elision (carrier
|
|
291
|
+
hygiene)" check runs the same elision verdict and, for every page/layout
|
|
292
|
+
that ships whole, prints the FIRST client-effecting blocker by name, for
|
|
293
|
+
example `app/page.ts ships whole. Its first client-effecting blocker is
|
|
294
|
+
lib/track.ts, which references a browser global at module scope, runs code
|
|
295
|
+
at module scope, or has a bare side-effect import and is not a component`.
|
|
296
|
+
It names the first blocker only; if a module has several, it stays shipped
|
|
297
|
+
until each is moved out. It is ADVISORY (a `warn`, never a hard fail): a page
|
|
298
|
+
legitimately MAY ship, and the analyser is biased toward shipping by design,
|
|
299
|
+
so this is a "you may not have intended this" hint. When the module's own
|
|
300
|
+
code is the cause, it prints `ships whole because it <reason>`.
|
|
301
|
+
|
|
239
302
|
### Elision is what keeps a server import off a display-only page (and `webjs check` guards the seam)
|
|
240
303
|
|
|
241
304
|
Elision is also why a page can call a server-only utility and stay
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Server configuration (the `package.json` `"webjs"` block) + observability
|
|
2
2
|
|
|
3
|
-
The full reference for the `package.json` `"webjs"` config block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps) plus the observability surfaces. Moved out of `AGENTS.md` to keep it lean. Env vars live in `agent-docs/built-ins.md`.
|
|
3
|
+
The full reference for the `package.json` `"webjs"` config block (security headers, CSP, redirects, trailing-slash, basePath, allowedOrigins, client-router opt-out, ingress caps) plus the observability surfaces. Moved out of `AGENTS.md` to keep it lean. Env vars live in `agent-docs/built-ins.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -123,6 +123,34 @@ An app served under a sub-path (`example.com/app/`) behind a proxy that does NOT
|
|
|
123
123
|
|
|
124
124
|
Mechanism: `normalizeBasePath` / `readBasePath` / `withBasePath` / `stripBasePath` in `packages/server/src/base-path.js`; the ingress strip is in `dev.js`'s `produce()` (before `applyRedirects`), the importmap-target prefix in `importmap.js` (`setBasePath`), the boot / preload / reload prefix in `ssr.js`.
|
|
125
125
|
|
|
126
|
+
## Cross-origin allowlist: `webjs.allowedOrigins` in package.json (#659)
|
|
127
|
+
|
|
128
|
+
Server-action RPC CSRF is an Origin / `Sec-Fetch-Site` check (the Remix 3 / Go 1.25 model, see `agent-docs/built-ins.md` and the docs-site Security page): a state-changing request passes when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers) the `Origin` host equals the request host. A legitimate CROSS-site caller (a reverse proxy serving the app under several hostnames, an admin panel on a sibling domain) is rejected unless its origin is allowlisted:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{ "webjs": { "allowedOrigins": ["admin.example.com", "https://studio.example.com"] } }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- A bare host (`admin.example.com`) or a full origin (`https://studio.example.com`) are both accepted; only the host is compared.
|
|
135
|
+
- The check honors `x-forwarded-host`, so it works behind a CDN / reverse proxy (the Cloudflare-in-front-of-Railway setup) without extra config.
|
|
136
|
+
- Default `[]` (same-origin only). This is the CSRF allowlist ONLY; it does not configure CORS (use the `cors()` middleware for cross-origin `route.ts` reads).
|
|
137
|
+
|
|
138
|
+
Because the check needs nothing on the page (no token cookie), SSR HTML carries no `Set-Cookie`, so a visitor-identical page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout) is CDN-edge-cacheable. Mechanism: `verifyOrigin` / `readAllowedOrigins` in `packages/server/src/csrf.js`, threaded through `invokeAction` (`actions.js`) from `dev.js`.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Client-router opt-out: `webjs.clientRouter` in package.json (#629)
|
|
143
|
+
|
|
144
|
+
The client router is automatic: it auto-enables in the browser whenever `@webjsdev/core` loads (any page that ships a component), so SPA-style navigation needs no import (#620). `webjs.clientRouter: false` opts the WHOLE app out, for an app that genuinely wants plain full-page (MPA) navigation despite shipping interactive components.
|
|
145
|
+
|
|
146
|
+
```jsonc
|
|
147
|
+
{ "webjs": { "clientRouter": false } } // pure MPA, full-page navigation
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Default `true` (and any value other than the literal `false`), so an existing app is byte-identical. When off, the render path emits a tiny inline `<script>window.__WEBJS_CLIENT_ROUTER__=false</script>` BEFORE the deferred boot module; the core bundle's module-end auto-enable reads that flag and skips, so the components on the page still upgrade and stay interactive while link clicks and form submits fall back to native browser navigation. `disableClientRouter()` (from `@webjsdev/core`) stays the per-page programmatic escape hatch, and `enableClientRouter()` turns it back on.
|
|
151
|
+
|
|
152
|
+
Mechanism: `readClientRouterEnabled` (`packages/server/src/dev.js`) reads the key at boot and on every rebuild and calls `setClientRouterEnabled` (`ssr.js`); `wrapHead` emits the flag; the module-end gate lives in `packages/core/src/router-client.js`.
|
|
153
|
+
|
|
126
154
|
---
|
|
127
155
|
|
|
128
156
|
## Request ingress hardening: body-size limit (413) + server timeouts (on by default)
|
|
@@ -280,15 +280,10 @@ connectedCallback() {
|
|
|
280
280
|
|
|
281
281
|
In Lit, class-field initializers (like `student: Student = { name: '', email: '' }`) are commonly used. In webjs, the base class installs reactive accessors on `this` inside the constructor via `Object.defineProperty` (to support SSR property hydration and signals). Under modern V8 class-field semantics, a class-field initializer compiles to an assignment after `super()`, which uses `[[Define]]` and overwrites the accessor, silently breaking reactivity. The footgun is still live with factory-declared props, so it stays a gotcha.
|
|
282
282
|
|
|
283
|
-
The fix is to declare the prop in the base-class factory `WebComponent({ ... })` (which types it automatically, no `declare` line) and set its default
|
|
283
|
+
The fix is to declare the prop in the base-class factory `WebComponent({ ... })` (which types it automatically, no `declare` line) and set its default in the constructor after `super()`, never as a class-field initializer:
|
|
284
284
|
|
|
285
285
|
```ts
|
|
286
|
-
// default
|
|
287
|
-
class StudentCard extends WebComponent({
|
|
288
|
-
student: prop<Student>(Object, { default: () => ({ name: '', email: '' }) }),
|
|
289
|
-
}) {}
|
|
290
|
-
|
|
291
|
-
// or set the default in the constructor (fully typed, no declare needed)
|
|
286
|
+
// set the default in the constructor (fully typed, no declare needed)
|
|
292
287
|
class StudentCard extends WebComponent({
|
|
293
288
|
student: prop<Student>(Object),
|
|
294
289
|
}) {
|
|
@@ -457,7 +452,7 @@ must move with it, use `repeat()` with a stable key.
|
|
|
457
452
|
| `Task` for client-time async | `Task` (no change, that's its job) |
|
|
458
453
|
| `window.X` or `document.X` in constructor or `render()` | Move to `connectedCallback` |
|
|
459
454
|
| Top-level `import` of browser-only library | Dynamic `import()` inside `connectedCallback` |
|
|
460
|
-
| `student: Student = { ... }` field initializer | Base-class factory `WebComponent({ student: Object })`, default
|
|
455
|
+
| `student: Student = { ... }` field initializer | Base-class factory `WebComponent({ student: Object })`, default in the constructor after `super()` |
|
|
461
456
|
| `@property()` decorator / `static properties` block | Base-class factory `WebComponent({ ... })` (the `prop()` helper carries options) |
|
|
462
457
|
| `static styles = css` / inline `<style>` with semantic class names in a light-DOM component | Tailwind utilities (the default); or `static shadow = true` for genuinely scoped CSS |
|
|
463
458
|
| Plain `.map()` for an interactive/stateful list | Works (reconciles in place, keeps node identity); use `repeat(items, key, t)` only when the list **reorders** |
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Runtime: Node and Bun (and future Deno)
|
|
2
|
+
|
|
3
|
+
webjs runs on **Node 24+** or **Bun**. The app source is identical on either; only
|
|
4
|
+
the listener shell, the type stripper, and a few built-ins differ. The selection
|
|
5
|
+
is a runtime-neutral seam in `startServer` (it picks the `node:http` shell on
|
|
6
|
+
Node and the `Bun.serve` shell on Bun), designed to also host a future
|
|
7
|
+
`Deno.serve` / embedded adapter. Deno is planned, not yet supported. The
|
|
8
|
+
user-facing reference is the docs-site page `/docs/runtime`; keep the two in sync.
|
|
9
|
+
|
|
10
|
+
## Node vs Bun
|
|
11
|
+
|
|
12
|
+
| Area | Node 24+ | Bun |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Install | `npm install` (required) | `bun install` (required, like Node) |
|
|
15
|
+
| Run | `npm run dev` / `npm run start` | `bun run dev` / `bun run start` |
|
|
16
|
+
| Listener | `node:http` shell | native `Bun.serve` (about 1.9x req/s on the listening path) |
|
|
17
|
+
| TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
|
|
18
|
+
| SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
|
|
19
|
+
| Hot reload | `node --watch` | `bun --hot` |
|
|
20
|
+
| WebSocket | the `ws` library | native `Bun.serve` + the `BunWsAdapter` bridge |
|
|
21
|
+
| 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
|
|
22
|
+
|
|
23
|
+
The bytes the browser fetches are identical across runtimes. The 103 Early Hints
|
|
24
|
+
gap costs only a small first-load latency edge where an edge forwards 103, never
|
|
25
|
+
correctness (the modulepreload hints still ship in the document head).
|
|
26
|
+
|
|
27
|
+
## Scaffolding the runtime
|
|
28
|
+
|
|
29
|
+
`webjs create <name>` defaults to Node. `webjs create <name> --runtime bun` (or
|
|
30
|
+
`bun create webjs <name>`, auto-detected from the invoking package manager,
|
|
31
|
+
#541) emits a Bun-flavored app: `bun.lock`, a pure `oven/bun:1` Dockerfile +
|
|
32
|
+
bun-install CI, and bun-command agent docs. `--runtime` is orthogonal to
|
|
33
|
+
`--template`.
|
|
34
|
+
|
|
35
|
+
## Bun
|
|
36
|
+
|
|
37
|
+
A Bun app installs with `bun install` (like Node), then its `dev` / `start` /
|
|
38
|
+
`db` scripts force `bun --bun` so the server runs on Bun:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
bun install
|
|
42
|
+
bun run dev # or: bun run start
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun
|
|
46
|
+
(selecting the native `Bun.serve` listener and `amaro` type stripping); the app's
|
|
47
|
+
deps resolve from `node_modules`, the same as Node. The `start.before` migrate
|
|
48
|
+
(`webjs db migrate`) runs under Bun too. Commit a `bun.lock` (the Bun analog of
|
|
49
|
+
`package-lock.json`) for reproducible, offline installs; the scaffold's Bun
|
|
50
|
+
Dockerfile runs `bun install` and serves via `CMD ["bun", "--bun", "run",
|
|
51
|
+
"start"]`.
|
|
52
|
+
|
|
53
|
+
## SQLite busy_timeout (#674)
|
|
54
|
+
|
|
55
|
+
Both `node:sqlite` and `bun:sqlite` default `busy_timeout` to 0, so a contended
|
|
56
|
+
write throws `database is locked` immediately (better-sqlite3, used before the
|
|
57
|
+
built-in drivers, defaulted to 5000ms). The generated connection sets `PRAGMA
|
|
58
|
+
busy_timeout = 5000` + `PRAGMA journal_mode = WAL` on the raw client before
|
|
59
|
+
drizzle wraps it, on both runtime branches.
|
|
60
|
+
|
|
61
|
+
## Future runtimes
|
|
62
|
+
|
|
63
|
+
The listener seam is runtime-neutral, so a `Deno.serve` shell (or an embedded
|
|
64
|
+
adapter) slots in at the same point when added. Edge runtimes with no filesystem
|
|
65
|
+
are a separate, later target. Until then, treat Deno as planned, not supported.
|
|
@@ -234,3 +234,46 @@ More files you write, more discipline required, slight rename cost (2
|
|
|
234
234
|
textual edits when a route folder moves). In exchange: no browser-
|
|
235
235
|
runtime script, no `@theme` block, idiomatic CSS, plain cascade you can
|
|
236
236
|
debug with any tool.
|
|
237
|
+
|
|
238
|
+
## Gotcha: a sticky header flickers on iOS, use `position: fixed`
|
|
239
|
+
|
|
240
|
+
A `position: sticky` header (the common `sticky top-0` pattern) flickers its
|
|
241
|
+
background for one frame on iOS WebKit (every iOS browser, since they all use
|
|
242
|
+
WebKit) during a client-router FORWARD navigation. The scroll-to-top the router
|
|
243
|
+
runs after the content swap drives a sticky stuck-to-static recompute that
|
|
244
|
+
WebKit mis-repaints. It is iOS-only (fine on desktop and Android, and invisible
|
|
245
|
+
in DevTools device emulation, so it shows only on a real device). #610 confirmed
|
|
246
|
+
on-device that neither compositor promotion (`translateZ(0)` / `will-change`)
|
|
247
|
+
nor changing the swap paint timing fixes it.
|
|
248
|
+
|
|
249
|
+
The fix is `position: fixed` instead of sticky. A fixed header is always pinned
|
|
250
|
+
and never does the scroll-relative recompute, so the repaint bug never fires.
|
|
251
|
+
There is no reliable CSS-only fix that KEEPS `position: sticky` (the GPU-promotion
|
|
252
|
+
hacks `translateZ(0)` / `translate3d` / `will-change` / a wrapper element are all
|
|
253
|
+
variants that do NOT reliably work, confirmed on-device for #610). Preserving the
|
|
254
|
+
header across nav is correct and standard (Next.js, Remix, SvelteKit all preserve
|
|
255
|
+
nested layouts); only the `sticky` positioning is the problem.
|
|
256
|
+
|
|
257
|
+
Because fixed leaves normal flow, reserve the header height on the content below.
|
|
258
|
+
Use a single `--header-height` custom property as the source of truth rather than
|
|
259
|
+
a hardcoded number, and keep it exact with a `ResizeObserver` so it tracks
|
|
260
|
+
responsive / dynamic headers (degrades fine with no JS via the default):
|
|
261
|
+
|
|
262
|
+
```css
|
|
263
|
+
:root { --header-h: 56px; } /* sane SSR first-paint default */
|
|
264
|
+
header { position: fixed; inset-inline: 0; top: 0; }
|
|
265
|
+
body { padding-top: var(--header-h); }
|
|
266
|
+
```
|
|
267
|
+
```js
|
|
268
|
+
// refine the default to the header's real height (and on resize)
|
|
269
|
+
const hdr = document.querySelector('header');
|
|
270
|
+
const apply = () => document.documentElement.style.setProperty('--header-h', hdr.offsetHeight + 'px');
|
|
271
|
+
apply();
|
|
272
|
+
new ResizeObserver(apply).observe(hdr);
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The example blog's root layout does exactly this (`examples/blog/app/layout.ts`).
|
|
276
|
+
For an app/dashboard layout, an alternative is an app-shell scroll container (a
|
|
277
|
+
non-scrolling `100dvh` flex column with `<main>` as the internal scroller), which
|
|
278
|
+
needs no offset at all but changes the scroll model (the window no longer
|
|
279
|
+
scrolls, so the iOS URL bar stays visible).
|
|
@@ -116,6 +116,17 @@ runtime-sensitive suite under Bun to catch the long tail of cross-runtime
|
|
|
116
116
|
incompatibilities (a `node:*` API Bun implements differently, a crypto/stream
|
|
117
117
|
edge case, an error-message-format quirk).
|
|
118
118
|
|
|
119
|
+
**Bun parity is part of the definition of done, not an afterthought.** A change
|
|
120
|
+
to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve`
|
|
121
|
+
listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`,
|
|
122
|
+
the TS stripper, auth / session / cors) is NOT done until you have run the Bun
|
|
123
|
+
matrix green AND added or updated a `test/bun/<feature>.mjs` cross-runtime
|
|
124
|
+
assertion for the surface. This is enforced at commit time by
|
|
125
|
+
`.claude/hooks/require-bun-parity-with-runtime-src.sh`, which BLOCKS a commit
|
|
126
|
+
that stages runtime-sensitive `packages/*/src` with no `test/bun/**` test (the
|
|
127
|
+
escape hatch `WEBJS_BUN_VERIFIED=1` is only for when an existing `test/bun`
|
|
128
|
+
script already covers the surface AND you ran it under Bun).
|
|
129
|
+
|
|
119
130
|
- `node scripts/run-bun-tests.js` (needs `bun` on PATH; `BUN=…` overrides) runs
|
|
120
131
|
the `node:test` files under `test/`, `packages/core/test/`, and
|
|
121
132
|
`packages/server/test/` (excluding `browser/`, `e2e/`, the network-bound
|
|
@@ -127,8 +138,10 @@ edge case, an error-message-format quirk).
|
|
|
127
138
|
**skip(env)** (needs Redis / a DOM), and **genuine fail** (a real Bun
|
|
128
139
|
failure, which fails the job). Set `WEBJS_BUN_TESTS=<substr,…>` to scope a
|
|
129
140
|
local run.
|
|
130
|
-
- CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508)
|
|
131
|
-
`test/bun/listener.mjs` (#511, the listener-shell parity)
|
|
141
|
+
- CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508),
|
|
142
|
+
`test/bun/listener.mjs` (#511, the listener-shell parity), and
|
|
143
|
+
`test/bun/timeouts.mjs` (#663, the `Bun.serve` `idleTimeout` wiring; the pure
|
|
144
|
+
ms-to-seconds mapping is unit-tested under the matrix too).
|
|
132
145
|
- Two cross-runtime test scripts also run under BOTH runtimes: `test/bun/smoke.mjs`
|
|
133
146
|
(boot + SSR + TS strip + a server-action RPC) and `test/bun/listener.mjs`
|
|
134
147
|
(`startServer` over a real socket: SSR + route + SSE + WebSocket). Plain assert
|
|
@@ -249,7 +262,7 @@ test exercises the production contract, never a parallel fake).
|
|
|
249
262
|
|
|
250
263
|
```js
|
|
251
264
|
import { createRequestHandler } from '@webjsdev/server';
|
|
252
|
-
import { testRequest,
|
|
265
|
+
import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
|
|
253
266
|
from '@webjsdev/server/testing';
|
|
254
267
|
|
|
255
268
|
const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
|
|
@@ -267,12 +280,13 @@ A bare path (`/about`) is prefixed with a dummy origin (the pipeline only reads
|
|
|
267
280
|
`pathname` + `search`); a full URL string or a pre-built `Request` works too.
|
|
268
281
|
The optional third arg is a standard `RequestInit` (method, headers, body).
|
|
269
282
|
|
|
270
|
-
###
|
|
283
|
+
### The auth/session helpers
|
|
271
284
|
|
|
272
|
-
|
|
273
|
-
`
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
Server-action CSRF is an Origin / `Sec-Fetch-Site` check, so a test needs no
|
|
286
|
+
CSRF setup: `invokeActionForTest` models a same-origin browser POST and passes
|
|
287
|
+
the check automatically, and `rawActionRequest(app, file, fn, args, {
|
|
288
|
+
crossOrigin: true })` models a cross-site request to assert the 403.
|
|
289
|
+
`loginAndGetCookies(handle, { email, password })` drives the
|
|
276
290
|
REAL credentials login through `handle()` (the `createAuth` route handler) and
|
|
277
291
|
captures the genuine signed session `Set-Cookie`, so a follow-up request can hit
|
|
278
292
|
a protected route as the logged-in user:
|
|
@@ -302,8 +316,9 @@ const out = await invokeActionForTest(app, 'modules/posts/actions/create.server.
|
|
|
302
316
|
|
|
303
317
|
`invokeActionForTest` serializes `args` with the webjs serializer (exactly as
|
|
304
318
|
the generated client stub does), POSTs them to the REAL
|
|
305
|
-
`/__webjs/action/<hash>/<fn>` endpoint
|
|
306
|
-
|
|
319
|
+
`/__webjs/action/<hash>/<fn>` endpoint as a same-origin request (so it passes
|
|
320
|
+
the Origin / Sec-Fetch-Site CSRF check), and parses the response with the
|
|
321
|
+
serializer. The action is addressed by the SHA-256
|
|
307
322
|
hash of its `.server.{js,ts}` file path (absolute or appDir-relative) plus the
|
|
308
323
|
function name, the same scheme the stub uses (`actionEndpoint(appDir, file, fn)`
|
|
309
324
|
returns that path if you need it directly).
|