@webjsdev/mcp 0.1.1 → 0.1.3
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/README.md +5 -3
- package/package.json +1 -1
- package/resources/AGENTS.md +26 -25
- package/resources/agent-docs/advanced.md +42 -1
- package/resources/agent-docs/built-ins.md +10 -8
- package/resources/agent-docs/components.md +4 -0
- package/resources/agent-docs/configuration.md +31 -0
- package/resources/agent-docs/framework-dev.md +1 -1
- package/resources/agent-docs/recipes.md +74 -77
- package/resources/agent-docs/testing.md +47 -2
- package/resources/agent-docs/typescript.md +32 -6
- package/src/mcp-docs.js +4 -2
- package/src/mcp.js +88 -10
- package/resources/agent-docs/ssr-partial-nav-design.md +0 -214
package/README.md
CHANGED
|
@@ -25,9 +25,11 @@ subcommand) delegates to this same server, so both routes run identical code.
|
|
|
25
25
|
## What it exposes
|
|
26
26
|
|
|
27
27
|
- **Introspection tools** (read-only, scoped to an `appDir`): `list_routes`,
|
|
28
|
-
`list_actions` (
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
`list_actions` (RPC endpoints plus the full data contract: HTTP verb, cache
|
|
29
|
+
config, and boolean flags for tags/invalidates/validate/middleware; reserved
|
|
30
|
+
config exports are excluded from the callable-action list), `list_components`,
|
|
31
|
+
`check` (the structured `webjs check` violations). Each projects an existing
|
|
32
|
+
`@webjsdev/server` data function and mutates nothing.
|
|
31
33
|
- **Knowledge layer**: an `init` mental-model primer, a `docs` retrieval tool,
|
|
32
34
|
MCP `resources` (the `agent-docs/*` corpus + `AGENTS.md` as `webjs-docs://*`),
|
|
33
35
|
and `prompts` (the recipes as guided workflows).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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,10 +14,9 @@ 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) + observability |
|
|
17
|
+
| `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps, dev/start task orchestration) + observability |
|
|
18
18
|
| `agent-docs/advanced.md` | Suspense streaming, performance, bundling, client router (prefetch, frames, view transitions, stream actions), WebSockets |
|
|
19
19
|
| `agent-docs/typescript.md` | TS at runtime + full-stack type safety |
|
|
20
|
-
| `agent-docs/deployment.md` | Production, runtime targets, embedded use |
|
|
21
20
|
| `agent-docs/service-worker.md` | The opt-in progressive-enhancement service worker (`public/sw.js`) |
|
|
22
21
|
| `agent-docs/testing.md` | Unit, browser, convention validation, the `handle()` test harness (`@webjsdev/server/testing`) |
|
|
23
22
|
| `agent-docs/framework-dev.md` | Monorepo dev (only when editing webjs itself): commands, repo-health git config, changelog flow, dev error overlay |
|
|
@@ -40,7 +39,7 @@ Claude Code enforces step 1 via `.claude/hooks/guard-branch-context.sh`. Other a
|
|
|
40
39
|
|
|
41
40
|
### Skills are routed deterministically, never skipped
|
|
42
41
|
|
|
43
|
-
A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. Tests in `test/hooks/route-skills.test.mjs
|
|
42
|
+
A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. The skills themselves are committed under `.claude/skills/` (alongside the hooks), so a fresh clone has both the router and the skills it routes to (no machine-local dependency). Tests in `test/hooks/route-skills.test.mjs`, which also asserts every skill the hook references is committed in-repo.
|
|
44
43
|
|
|
45
44
|
### Autonomous mode (sandbox / bypass permissions)
|
|
46
45
|
|
|
@@ -78,7 +77,7 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-f
|
|
|
78
77
|
|
|
79
78
|
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`.
|
|
80
79
|
|
|
81
|
-
- **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped
|
|
80
|
+
- **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.
|
|
82
81
|
- **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.
|
|
83
82
|
- **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.
|
|
84
83
|
- **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`.
|
|
@@ -101,7 +100,7 @@ A page/layout module still **loads** in the browser for its top-level side effec
|
|
|
101
100
|
|
|
102
101
|
`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.
|
|
103
102
|
|
|
104
|
-
**`.server.{js,ts}` is the one server boundary, an RPC + source-protection mechanism, NOT an RSC component.** With `'use server'` exports are RPC-callable (the browser import becomes a stub POSTing to `/__webjs/action/<hash>/<fn>`); without it the file is a server-only utility whose browser import **throws at module load** (
|
|
103
|
+
**`.server.{js,ts}` is the one server boundary, an RPC + source-protection mechanism, NOT an RSC component.** With `'use server'` exports are RPC-callable (the browser import becomes a stub POSTing to `/__webjs/action/<hash>/<fn>`); without it the file is a server-only utility whose browser import **throws at module load** (the DB driver, secrets, `node:*`, hashing). Consequence: **never import a no-`'use server'` util directly into a page, layout, or component** (it works at SSR but the client stub crashes on load); use it inside `'use server'` actions, `route.{js,ts}`, or `middleware`, and reach it from a page by importing a `'use server'` action (whose RPC stub loads safely client-side). This boundary, not a component annotation, is how a dependency is kept off the client (a date library used only during SSR belongs in `lib/format.server.ts`). See `agent-docs/components.md`.
|
|
105
104
|
|
|
106
105
|
---
|
|
107
106
|
|
|
@@ -135,7 +134,7 @@ lib/ app-wide code (lib/*.server.js infra, lib/utils/ bro
|
|
|
135
134
|
modules/<feature>/ feature-scoped: actions/ (mutations), queries/ (reads), components/, utils/, types.js
|
|
136
135
|
components/*.js SHARED presentational primitives
|
|
137
136
|
public/* static assets, served at /<name>
|
|
138
|
-
|
|
137
|
+
db/*.server.{js,ts} data layer (Drizzle: schema, columns, connection)
|
|
139
138
|
```
|
|
140
139
|
|
|
141
140
|
Every file is a plain ES module.
|
|
@@ -149,7 +148,7 @@ import { html, css, WebComponent, render } from '@webjsdev/core';
|
|
|
149
148
|
import { renderToString } from '@webjsdev/core/server';
|
|
150
149
|
```
|
|
151
150
|
|
|
152
|
-
The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server-only modules (`render-server.js`, `
|
|
151
|
+
The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server-only modules (`render-server.js`, `setCspNonceProvider`); `renderToString` / `renderToStream` live at `@webjsdev/core/server` for Node-side consumers.
|
|
153
152
|
|
|
154
153
|
| Export | Purpose |
|
|
155
154
|
|---|---|
|
|
@@ -159,8 +158,6 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
|
|
|
159
158
|
| `render(v, el)` | Client-side render into a DOM element. |
|
|
160
159
|
| `renderToString` | Server-side async render to HTML with DSD (from `/server`). |
|
|
161
160
|
| `notFound()` / `redirect(url[, status])` | Throw to return 404, or a redirect. No-status default is convention-picked at the catching site: 302 for a GET page-render gate, 307 (method-preserving) for a server-action redirect. Override with `redirect(url, 308)` or `redirect(url, { status })`. |
|
|
162
|
-
| `expose(p, fn)` | Tag a server action ALSO reachable at a REST path. Server-side only. |
|
|
163
|
-
| `validateInput(fn, validate)` | Attach an input validator running on BOTH the RPC and `expose()` REST paths (#245). Server-side only. See Server actions. |
|
|
164
161
|
| `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. |
|
|
165
162
|
| `<webjs-suspense .fallback=${html\`…\`}>` | Component-level streaming boundary element (#471): wraps one or more components, flushes `.fallback` on the first byte, streams the resolved content in (concurrently across boundaries, progressively on soft nav). The renderer-recognized opt-in for SLOW async-render data. |
|
|
166
163
|
| `connectWS(url, handlers)` / `richFetch<T>` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. |
|
|
@@ -197,7 +194,7 @@ MyThing.register('my-thing');
|
|
|
197
194
|
|
|
198
195
|
**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).
|
|
199
196
|
|
|
200
|
-
**Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, `static shadow = true`, `static refresh = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static refresh = true` (the explicit opt-in keeping the stale-while-revalidate on-load re-fetch that eliding drops, moot for request-stable data). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream).
|
|
197
|
+
**Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, `static shadow = true`, `static refresh = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static refresh = true` (the explicit opt-in keeping the stale-while-revalidate on-load re-fetch that eliding drops, moot for request-stable data). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC, never wrong data). Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`.
|
|
201
198
|
|
|
202
199
|
**Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `<slot>` works in either. A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. Install the `webjs` VSCode extension (`packages/editors/vscode`, VS Marketplace + Open VSX; also covers Cursor / Antigravity / Windsurf) or `webjs.nvim` (`packages/editors/nvim`, via lazy.nvim) for template highlighting + editor intelligence with no Lit plugin, or add the standalone `@webjsdev/intellisense` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `agent-docs/components.md` + `agent-docs/lit-muscle-memory-gotchas.md`.
|
|
203
200
|
|
|
@@ -244,11 +241,13 @@ Optional app-root file default-exporting a **schema object** (env-var names to a
|
|
|
244
241
|
|
|
245
242
|
The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: its browser stub throws at module load, so a page / layout / component that ends up SHIPPING to the browser and transitively imports one crashes the moment the module loads, while `webjs typecheck` and the rest of `webjs check` pass. `webjs check`'s `no-server-import-in-browser-module` rule catches this statically by reusing the build's elision verdict (it only flags modules that genuinely ship; a display-only page the framework elides is fine, because the framework strips its server import). A `'use server'` action is exempt: its browser stub is a working RPC, which is the intended way to call the server from a shipping module.
|
|
246
243
|
|
|
247
|
-
Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **
|
|
244
|
+
Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `agent-docs/recipes.md`.
|
|
248
245
|
|
|
249
|
-
|
|
246
|
+
**HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`, run around the action on BOTH the RPC and `route.ts` (including the `route()` adapter) boundaries; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why webjs needs this and Next does not: webjs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod.
|
|
250
247
|
|
|
251
|
-
|
|
248
|
+
### RPC + REST endpoint security
|
|
249
|
+
|
|
250
|
+
Client to action RPC posts `x-webjs-csrf` matching the cookie issued on first SSR (mismatch 403); prod errors are sanitized to `message` only. A `route.ts` REST endpoint (hand-written or via the `route()` adapter) is NOT CSRF-protected: 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`.
|
|
252
251
|
|
|
253
252
|
### Components (`components/*.{js,ts}`)
|
|
254
253
|
|
|
@@ -259,7 +258,7 @@ One custom element per file; call `Class.register('tag')` at module top level. S
|
|
|
259
258
|
## Modules architecture (preferred for non-trivial apps)
|
|
260
259
|
|
|
261
260
|
- **`modules/<feature>/actions/*.server.{js,ts}`** mutations, **`queries/*.server.{js,ts}`** reads (one function per file), **`components/*.{js,ts}`** feature-owned components (shared UI in top-level `components/`), **`utils/*.{js,ts}`** pure helpers (no `'use server'`, no DB), **`types.{js,ts}`** typedefs.
|
|
262
|
-
- **`lib/`** cross-cutting: `lib/*.server.{js,ts}` server-only infra (
|
|
261
|
+
- **`lib/`** cross-cutting: `lib/*.server.{js,ts}` server-only infra (session, hashing; the DB connection lives in `db/connection.server.ts`), `lib/utils/*` browser-safe helpers, `lib/*.ts` app-wide values.
|
|
263
262
|
|
|
264
263
|
### The `ActionResult<T>` envelope
|
|
265
264
|
|
|
@@ -294,7 +293,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
294
293
|
|
|
295
294
|
> 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.
|
|
296
295
|
|
|
297
|
-
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
|
|
296
|
+
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 (`better-sqlite3` / `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.
|
|
298
297
|
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.
|
|
299
298
|
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).
|
|
300
299
|
4. **Event (`@`), property (`.`), boolean (`?`) holes in `html` must be unquoted**, e.g. `@click=${fn}`, never `@click="${fn}"`.
|
|
@@ -311,17 +310,17 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
311
310
|
|
|
312
311
|
## Scaffolding
|
|
313
312
|
|
|
314
|
-
Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules,
|
|
313
|
+
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). 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.
|
|
315
314
|
|
|
316
|
-
Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (
|
|
315
|
+
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.
|
|
317
316
|
|
|
318
317
|
---
|
|
319
318
|
|
|
320
319
|
## CLI reference
|
|
321
320
|
|
|
322
321
|
```sh
|
|
323
|
-
webjs dev [--port N]
|
|
324
|
-
webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2)
|
|
322
|
+
webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch on Node, bun --hot on Bun). --no-hot runs in-process. Runs webjs.dev.before + webjs.dev.parallel (#550)
|
|
323
|
+
webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550)
|
|
325
324
|
webjs test [--server] [--browser] [--watch]
|
|
326
325
|
webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
|
|
327
326
|
webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check
|
|
@@ -329,19 +328,19 @@ webjs doctor # project-health checklist; non-zero exit on
|
|
|
329
328
|
webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
|
|
330
329
|
webjs typecheck [tsc args...] # the project's own tsc --noEmit
|
|
331
330
|
webjs create <name> [--template api|saas]
|
|
332
|
-
webjs db <
|
|
331
|
+
webjs db <generate|migrate|push|studio|seed> # wraps drizzle-kit (+ runs db/seed.server.ts)
|
|
333
332
|
webjs ui init | add <names...> | list | view <name>
|
|
334
333
|
webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json
|
|
335
334
|
```
|
|
336
335
|
|
|
337
|
-
`--from PROVIDER` accepts `jspm` (default), `jsdelivr`, `unpkg`, `skypack` and is persisted in the pin file. `PORT` is honoured when `--port` is absent; `webjs dev` emits `routes.d.ts` automatically. Running this repo's own apps (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`):
|
|
336
|
+
`--from PROVIDER` accepts `jspm` (default), `jsdelivr`, `unpkg`, `skypack` and is persisted in the pin file. `PORT` is honoured when `--port` is absent; `webjs dev` emits `routes.d.ts` automatically. Running this repo's own apps (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`): `cd` in and use **its** `npm run dev` / `npm start`; as of #550 a bare `webjs dev` / `webjs start` is equivalent (the per-app orchestration moved into the `webjs.dev` / `webjs.start` tasks config, which the primitive runs), so the npm scripts are now just thin aliases.
|
|
338
337
|
|
|
339
338
|
---
|
|
340
339
|
|
|
341
340
|
## Environment, server config, caching, observability
|
|
342
341
|
|
|
343
342
|
- **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `<script>` (no build); `NODE_ENV` is defined both sides. See `agent-docs/built-ins.md`.
|
|
344
|
-
- **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256),
|
|
343
|
+
- **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.start.before`, #550, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`). Type it with `WebjsConfig` + the JSON Schema. See `agent-docs/configuration.md`.
|
|
345
344
|
- **Caching + file storage** (`agent-docs/built-ins.md`). HTTP `Cache-Control`, the `cache()` query helper + `revalidateTag`, the server HTML response cache (`export const revalidate` + `revalidatePath`, #241), content-hash asset URLs (`?v=`, #243), conditional GET (ETag, #240), and `FileStore` + `diskStore` (streaming, traversal-safe, signed URLs, S3-pluggable, #247).
|
|
346
345
|
- **Observability** (`agent-docs/configuration.md`). Access log, `requestId()` + `X-Request-Id`, the `onError` APM hook, `GET /__webjs/version` (#239).
|
|
347
346
|
|
|
@@ -386,11 +385,13 @@ export default async function User({ params }: { params: { id: string } }) {
|
|
|
386
385
|
```ts
|
|
387
386
|
// modules/users/actions/update-profile.server.ts
|
|
388
387
|
'use server';
|
|
389
|
-
import {
|
|
388
|
+
import { eq } from 'drizzle-orm';
|
|
389
|
+
import { db } from '../../../db/connection.server.ts';
|
|
390
|
+
import { users } from '../../../db/schema.server.ts';
|
|
390
391
|
export async function updateProfile(input: { name: string }) {
|
|
391
392
|
const name = String(input?.name || '').trim();
|
|
392
393
|
if (!name) return { success: false, error: 'name required', status: 400 };
|
|
393
|
-
const row = await
|
|
394
|
+
const [row] = await db.update(users).set({ name }).where(eq(users.id, me.id)).returning();
|
|
394
395
|
return { success: true, data: row };
|
|
395
396
|
}
|
|
396
397
|
```
|
|
@@ -406,4 +407,4 @@ Not in v1. Do not implement as part of other tasks:
|
|
|
406
407
|
- **Bundling and per-route code splitting.** webjs is **no-build** (the Rails 7 + importmap model); prod perf comes from HTTP/2 multiplex + `<link rel="modulepreload">` hints, not concatenation. **Do not propose a bundler or `webjs build`.**
|
|
407
408
|
- **Vite-grade HMR with state preservation.** Custom elements only `define` once, so full reload is necessary; data reloads are near-instant via `fs.watch` to SSE.
|
|
408
409
|
- **React Server Components Flight.** Server actions + `Suspense` streaming cover the need.
|
|
409
|
-
- **Edge-runtime bundling / full portability** (
|
|
410
|
+
- **Edge-runtime bundling / full portability** (deployment guidance lives in the docs site at `/docs/deployment`), **i18n, image optimization** (layer libraries on top).
|
|
@@ -360,7 +360,7 @@ export async function GET(req) {
|
|
|
360
360
|
### Behavior
|
|
361
361
|
|
|
362
362
|
- **Preflight.** An `OPTIONS` request carrying `Access-Control-Request-Method` short-circuits with a `204` carrying the Allow-Methods / Allow-Headers / Max-Age headers. `next()` is NOT called. A disallowed-origin preflight returns a bare `204` with no CORS headers, so the browser blocks the follow-up.
|
|
363
|
-
- **Actual request.** `next()` runs, then `Access-Control-Allow-Origin` (plus credentials / exposed headers) is attached. A disallowed origin gets NO `Access-Control-Allow-Origin`, and the browser blocks the cross-origin read, but the server still serves the response (CORS is browser-enforced, not a server gate
|
|
363
|
+
- **Actual request.** `next()` runs, then `Access-Control-Allow-Origin` (plus credentials / exposed headers) is attached. A disallowed origin gets NO `Access-Control-Allow-Origin`, and the browser blocks the cross-origin read, but the server still serves the response (CORS is browser-enforced, not a server gate, so a mismatched actual request is never 403'd server-side).
|
|
364
364
|
- **`Vary: Origin`.** Appended (never clobbering an existing `Vary`) whenever the allowed origin is dynamic (a reflected, per-origin value), so a shared cache keys on `Origin` and cannot poison one origin's response onto another. A constant `*` (no credentials) does not vary, so no `Vary` is added.
|
|
365
365
|
|
|
366
366
|
### `credentials: true` requires an explicit origin allowlist (spec, enforced, warned)
|
|
@@ -1096,6 +1096,47 @@ branch return a normal `ActionResult` instead. `renderStream` is auto-registered
|
|
|
1096
1096
|
by the client router, so it (and the `<webjs-stream>` element) is available
|
|
1097
1097
|
wherever a layout imports `@webjsdev/core/client-router`.
|
|
1098
1098
|
|
|
1099
|
+
### Streaming RPC results: an action returns a stream (#489)
|
|
1100
|
+
|
|
1101
|
+
`<webjs-stream>` above is the SERVER-PUSHED render-side primitive (the server
|
|
1102
|
+
decides what changes and ships HTML actions). Streaming RPC is the complementary
|
|
1103
|
+
PULL-side primitive: a `'use server'` action that RETURNS a `ReadableStream`, an
|
|
1104
|
+
async iterable, or an async generator streams its chunks over the single RPC
|
|
1105
|
+
response, and the client gets back an async iterable to `for await`. This is the
|
|
1106
|
+
token-stream / progress / incremental-result case a component consumes
|
|
1107
|
+
imperatively after an interaction.
|
|
1108
|
+
|
|
1109
|
+
```ts
|
|
1110
|
+
// modules/ai/actions/stream-answer.server.ts
|
|
1111
|
+
'use server';
|
|
1112
|
+
export async function* streamAnswer(prompt: string) {
|
|
1113
|
+
for await (const token of llm.complete(prompt)) yield token;
|
|
1114
|
+
}
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
```ts
|
|
1118
|
+
// inside a component
|
|
1119
|
+
for await (const token of await streamAnswer(q)) {
|
|
1120
|
+
this.text.set(this.text.get() + token); // renders incrementally
|
|
1121
|
+
}
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
The wire is a sequence of length-prefixed frames (`application/vnd.webjs+stream`):
|
|
1125
|
+
each chunk is rich-serialized (a `Date` / `Map` / `BigInt` round-trips), then a
|
|
1126
|
+
terminal frame closes the stream. Detection is purely on the RETURN value, so any
|
|
1127
|
+
verb (#488) can stream and there is no config export to declare. Back-pressure is
|
|
1128
|
+
respected (a slow consumer throttles a fast producer), and the request
|
|
1129
|
+
`AbortSignal` (#492) cancels the source generator on a client disconnect or a
|
|
1130
|
+
superseded `async render()`. A streamed result is never cached, ETagged, or
|
|
1131
|
+
seeded (#472); a mutation that streams still emits its `X-Webjs-Invalidate`
|
|
1132
|
+
header. A mid-stream throw surfaces as an error from the iterable (the HTTP status
|
|
1133
|
+
is already 200, so wrap the `for await` in `try/catch`), the author message in
|
|
1134
|
+
prod. A truncated stream (a server crash, a dropped connection) also throws
|
|
1135
|
+
rather than completing silently: a healthy stream always ends in an explicit
|
|
1136
|
+
terminal frame, so a missing one is an error. For a slow region you want behind a fallback on the FIRST paint, use
|
|
1137
|
+
`<webjs-suspense>` instead; streaming RPC is for an imperative stream consumed
|
|
1138
|
+
after an interaction. Full reference: the [Data fetching](https://docs.webjs.com/docs/data-fetching) page.
|
|
1139
|
+
|
|
1099
1140
|
### Opt out per link
|
|
1100
1141
|
|
|
1101
1142
|
```html
|
|
@@ -13,7 +13,7 @@ layer to debug.
|
|
|
13
13
|
```js
|
|
14
14
|
// route handler
|
|
15
15
|
export async function GET() {
|
|
16
|
-
const posts = await
|
|
16
|
+
const posts = await db.query.posts.findMany();
|
|
17
17
|
return new Response(JSON.stringify(posts), {
|
|
18
18
|
headers: {
|
|
19
19
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
|
|
@@ -51,10 +51,10 @@ until the TTL expires:
|
|
|
51
51
|
// modules/posts/queries/list-posts.server.ts
|
|
52
52
|
'use server';
|
|
53
53
|
import { cache } from '@webjsdev/server';
|
|
54
|
-
import {
|
|
54
|
+
import { db } from '../../../db/connection.server.ts';
|
|
55
55
|
|
|
56
56
|
export const listPosts = cache(
|
|
57
|
-
async () =>
|
|
57
|
+
async () => db.query.posts.findMany({ orderBy: { createdAt: 'desc' } }),
|
|
58
58
|
{ key: 'posts', ttl: 60 }
|
|
59
59
|
);
|
|
60
60
|
```
|
|
@@ -65,12 +65,12 @@ wrapper), add `tags`. It is either a static `string[]` or a function
|
|
|
65
65
|
|
|
66
66
|
```ts
|
|
67
67
|
export const postById = cache(
|
|
68
|
-
async (id: string) =>
|
|
68
|
+
async (id: string) => db.query.posts.findFirst({ where: { id } }),
|
|
69
69
|
{ key: 'post', ttl: 300, tags: (id) => ['post:' + id] } // per-entity tag
|
|
70
70
|
);
|
|
71
71
|
|
|
72
72
|
export const listPosts = cache(
|
|
73
|
-
async () =>
|
|
73
|
+
async () => db.query.posts.findMany(),
|
|
74
74
|
{ key: 'posts', ttl: 60, tags: ['posts'] } // static tag
|
|
75
75
|
);
|
|
76
76
|
```
|
|
@@ -83,9 +83,11 @@ no import of the wrapper):
|
|
|
83
83
|
// modules/comments/actions/create-comment.server.ts
|
|
84
84
|
'use server';
|
|
85
85
|
import { revalidateTag, revalidatePath } from '@webjsdev/server';
|
|
86
|
+
import { db } from '../../../db/connection.server.ts';
|
|
87
|
+
import { comments } from '../../../db/schema.server.ts';
|
|
86
88
|
|
|
87
89
|
export async function createComment(input) {
|
|
88
|
-
await
|
|
90
|
+
await db.insert(comments).values(input);
|
|
89
91
|
await revalidateTag('post:' + input.postId); // postById(postId) recomputes
|
|
90
92
|
await revalidateTag('posts'); // listPosts recomputes
|
|
91
93
|
await revalidatePath('/blog'); // also evict the cached HTML
|
|
@@ -163,7 +165,7 @@ Evict on a write with `revalidatePath`:
|
|
|
163
165
|
import { revalidatePath } from '@webjsdev/server';
|
|
164
166
|
|
|
165
167
|
export async function publishPost(input) {
|
|
166
|
-
// ... persist via
|
|
168
|
+
// ... persist via Drizzle ...
|
|
167
169
|
await revalidatePath('/blog'); // next /blog request re-renders fresh
|
|
168
170
|
return { success: true };
|
|
169
171
|
}
|
|
@@ -248,7 +250,7 @@ export const { auth, signIn, signOut, handlers } = createAuth({
|
|
|
248
250
|
providers: [
|
|
249
251
|
Credentials({
|
|
250
252
|
async authorize(credentials) {
|
|
251
|
-
const user = await
|
|
253
|
+
const user = await db.query.users.findFirst({ where: { email: credentials.email } });
|
|
252
254
|
if (!user || !verifyPassword(credentials.password, user.passwordHash)) return null;
|
|
253
255
|
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
254
256
|
},
|
|
@@ -116,6 +116,10 @@ class Report extends WebComponent {
|
|
|
116
116
|
|
|
117
117
|
**Elision (#474).** A **bare** `async render()` (no other client signal, light DOM) is **elided** like any display-only component: the SSR'd data is the complete first paint, so the framework drops the module and the redundant on-hydration re-fetch (a common content / docs leaf shape). It ships only when it ALSO carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship. `static shadow = true` ships because Declarative Shadow DOM attaches only during HTML parsing, so a streamed (`<webjs-suspense>`) or soft-navigated shadow component arrives via a JS DOM insertion and needs its module to re-run `attachShadow`. `static refresh = true` is the explicit opt-in to keep the stale-while-revalidate on-load re-fetch that eliding drops (moot for request-stable data, the default; reach for it only when fresh-on-load matters). The only behaviour eliding removes is that on-load refresh; the first paint is byte-identical, verified by the differential-elision guard.
|
|
118
118
|
|
|
119
|
+
**SSR action seeding (#472).** For a SHIPPING async component (one that hydrates because it carries an interactivity signal), the on-hydration re-fetch is eliminated without eliding the module. Each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page as one `<script type="application/json" id="__webjs-seeds">` block, keyed by the action's hash + function name + serialized args. The generated client RPC stub reads that seed on its FIRST call: a hit resolves synchronously (no network, no hydration flicker), a later refetch or arg-change misses (consume-once) and goes to RPC as normal. So `const u = await getUser(this.id)` runs once, server-side, and its result is reused on the client's first render. The capture is a transparent server-side facade over the `'use server'` module (a synchronous load hook wraps each export in a recording `Proxy`), so there is **no source transform and no build step**: the browser source tab and the on-disk files are byte-unchanged, and the RPC stub the browser fetches is the same shape as before (it just consults the seed first). The install mechanism is runtime-neutral (#529): Node uses `module.registerHooks`, Bun uses a `Bun.plugin` `onLoad` (Bun has no `module.registerHooks`), and both emit the identical seed, so a page seeds the same on either runtime. Fail-open by construction: a key miss is always a normal RPC, never wrong data. Default on; opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0` (then the client re-fetches on hydration, hidden by stale-while-revalidate as before). A soft navigation carries the seed too: the client router ingests the incoming page's payload (`applySwap` -> `scanSeeds`) before its components hydrate, so a navigated async component also skips the refetch. Streamed regions (`<webjs-suspense>`) are NOT seeded (their data resolves after the first flush), so a slow boundary keeps the stale-while-revalidate refetch.
|
|
120
|
+
|
|
121
|
+
**Interactivity during a COLD on-hydration re-fetch (#528).** Seeding matters for more than a wasted round trip. When a SHIPPING async component DOES re-fetch on hydration (seeding off via `WEBJS_SEED=0`, a `static refresh = true` leaf, or a seed miss), its FIRST client commit is deferred until that re-fetch resolves, and that commit is the step that binds the component's event listeners. So for the fetch's duration the SSR'd markup is visible but NOT yet interactive: an `@click` does nothing until the data lands (the window equals the action's latency, not a constant). Seeding erases this by resolving the first render synchronously, so the component hydrates and binds its listeners immediately, which is the default and works on both Node and Bun (#529, seeding is no longer Node-only). The component is fully reactive once that first commit lands: this is purely a cold-start window, NOT a lost signal subscription (a bump after the commit re-renders normally). To keep an async leaf interactive from the first paint regardless, give it a synchronous first render (read request-known data from a prop / the constructor) and move the async work to a signal-driven `Task` or an `@event` handler.
|
|
122
|
+
|
|
119
123
|
### Streaming a slow region with `<webjs-suspense>` (#471)
|
|
120
124
|
|
|
121
125
|
`async render()` BLOCKS the first byte by default (the SSR HTML waits for the data). For a SLOW region where that wait hurts time-to-first-byte, wrap it in `<webjs-suspense>` to stream it: the fallback flushes immediately, the resolved content streams in.
|
|
@@ -208,6 +208,37 @@ Returns JSON describing the live build, alongside the `/__webjs/health` and `/__
|
|
|
208
208
|
|
|
209
209
|
---
|
|
210
210
|
|
|
211
|
+
## Dev/start task orchestration: `webjs.dev` / `webjs.start` (#550)
|
|
212
|
+
|
|
213
|
+
`webjs dev` and `webjs start` run an app's per-environment orchestration
|
|
214
|
+
themselves, read from the `package.json` `"webjs"` block, so the framework
|
|
215
|
+
primitive is not a degraded run versus `npm run dev` / `npm start`. The npm
|
|
216
|
+
scripts become thin aliases (`"dev": "webjs dev"`), and both forms behave
|
|
217
|
+
identically. This replaces the old `predev` / `prestart` npm hooks +
|
|
218
|
+
`concurrently` watchers (which a bare `webjs dev` silently skipped, #452).
|
|
219
|
+
|
|
220
|
+
```jsonc
|
|
221
|
+
"webjs": {
|
|
222
|
+
"dev": {
|
|
223
|
+
"before": ["webjs db migrate"], // one-shot, runs to completion first
|
|
224
|
+
"parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"] // long-lived, runs alongside the server
|
|
225
|
+
},
|
|
226
|
+
"start": {
|
|
227
|
+
"before": ["webjs db migrate"] // one-shot, before serving
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- **`before`** (dev and start): commands run sequentially to completion BEFORE the server boots (the old `predev` / `prestart`: `webjs db migrate`, a registry copy). A non-zero exit ABORTS the boot with a clear message, so a failed migration never serves stale code/schema.
|
|
233
|
+
- **`parallel`** (dev only): long-lived child processes that run ALONGSIDE the server (the old `concurrently` watchers: the Tailwind CLI `--watch`). They are spawned once in the parent (not on every hot-reload restart) and TORN DOWN on exit (SIGINT / SIGTERM / server exit), so a watcher cannot leak past the dev server.
|
|
234
|
+
- Each command runs through a shell, so a normal command line works. An empty / absent block means `webjs dev` / `start` run unchanged, so a plain app with no Tailwind/DB needs no config.
|
|
235
|
+
- The scaffold uses the Tailwind browser runtime (no CSS build step), so it ships only `dev.before` / `start.before` (the Drizzle migration apply); an app that adds the Tailwind CLI puts its `--watch` under `webjs.dev.parallel`. The in-repo apps (`examples/blog`, `website`, `docs`, ui-website) show the Tailwind `parallel` watcher pattern.
|
|
236
|
+
- **Prod note:** `before` runs at boot, so `webjs start` runs `webjs db migrate` in-process to apply pending migrations. Drizzle has no client-codegen step (the schema IS the types, inferred at compile time), so there is nothing to run at image-BUILD time. Authoring a new migration from a schema change is a dev-time `webjs db generate`, committed to source control, not a boot step. So `CMD ["npm", "start"]` and `CMD ["webjs", "start"]` are equivalent.
|
|
237
|
+
|
|
238
|
+
Read by `readAppTasks` in `packages/cli/lib/app-tasks.js` (pure, unit-tested); orchestrated in `packages/cli/bin/webjs.js` (`runBeforeSteps` / `startParallelTasks`).
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
211
242
|
## Server port resolution (`--port` / `PORT` / `.env`) (#447)
|
|
212
243
|
|
|
213
244
|
Both `webjs dev` and `webjs start` resolve the listen port with a single
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Framework development (editing webjs itself)
|
|
2
2
|
|
|
3
|
-
Read this only when editing the webjs monorepo (this repo), not a scaffolded app. The repo is buildless: `packages/` is plain `.js` with JSDoc (never add `.ts` there); TypeScript is fine in `examples/`, `docs/`, `website/`. Each in-repo app (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`) is run
|
|
3
|
+
Read this only when editing the webjs monorepo (this repo), not a scaffolded app. The repo is buildless: `packages/` is plain `.js` with JSDoc (never add `.ts` there); TypeScript is fine in `examples/`, `docs/`, `website/`. Each in-repo app (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`) is run from its OWN dir via `npm run dev` / `npm start`; as of #550 a bare `webjs dev` / `webjs start` is equivalent (each app's per-environment orchestration, the Tailwind watcher, `webjs db migrate`, the registry copy, moved into its `webjs.dev` / `webjs.start` tasks config, which `webjs dev`/`start` run). The sections below cover the repo-health git config, the changelog flow, and the dev error overlay.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -13,41 +13,36 @@ schema with the real domain models, then build features on top. This is the
|
|
|
13
13
|
transition agents most often get wrong, so it is the first recipe.
|
|
14
14
|
|
|
15
15
|
> **Two non-negotiables.** NEVER leave the example `User` model in
|
|
16
|
-
> `schema.
|
|
17
|
-
> it). NEVER persist app data in JSON files (`data/todos.json`,
|
|
18
|
-
> a module-scope array or `Map`, or in `localStorage`. Those
|
|
19
|
-
> reload and cannot scale. Every piece of stored data is a
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// prisma/schema.prisma
|
|
26
|
-
model Post {
|
|
27
|
-
id String @id @default(cuid())
|
|
28
|
-
title String
|
|
29
|
-
body String
|
|
30
|
-
published Boolean @default(false)
|
|
31
|
-
createdAt DateTime @default(now())
|
|
32
|
-
}
|
|
33
|
-
```
|
|
16
|
+
> `db/schema.server.ts` if the app does not actually have users (delete or
|
|
17
|
+
> replace it). NEVER persist app data in JSON files (`data/todos.json`,
|
|
18
|
+
> `db.json`), in a module-scope array or `Map`, or in `localStorage`. Those
|
|
19
|
+
> reset on every reload and cannot scale. Every piece of stored data is a
|
|
20
|
+
> Drizzle table.
|
|
21
|
+
|
|
22
|
+
1. **Edit `db/schema.server.ts`** to the real domain. Replace the example
|
|
23
|
+
`User` model with the models the app needs (the helpers come from
|
|
24
|
+
`db/columns.server.ts`).
|
|
34
25
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
26
|
+
```ts
|
|
27
|
+
// db/schema.server.ts
|
|
28
|
+
import { table, pk, text, bool, createdAt, index } from './columns.server.ts';
|
|
29
|
+
export const posts = table('posts', {
|
|
30
|
+
id: pk(),
|
|
31
|
+
title: text().notNull(),
|
|
32
|
+
body: text().notNull(),
|
|
33
|
+
published: bool().notNull().default(false),
|
|
34
|
+
createdAt: createdAt(),
|
|
35
|
+
}, (t) => [index(t.createdAt)]);
|
|
36
|
+
```
|
|
40
37
|
|
|
41
|
-
2. **
|
|
42
|
-
|
|
38
|
+
2. **Generate + migrate.** Drizzle has no codegen; `generate` turns the schema
|
|
39
|
+
into SQL, `migrate` applies it:
|
|
43
40
|
|
|
44
41
|
```sh
|
|
45
|
-
npm run db:
|
|
42
|
+
npm run db:generate # schema -> SQL migration (drizzle-kit)
|
|
43
|
+
npm run db:migrate # apply it to the dev SQLite database
|
|
46
44
|
```
|
|
47
45
|
|
|
48
|
-
This creates the migration, applies it to the dev SQLite database, and
|
|
49
|
-
regenerates the Prisma client.
|
|
50
|
-
|
|
51
46
|
3. **Generate one query and one action per operation**, one exported function
|
|
52
47
|
per file, named after the file, under the feature module. Reads go in
|
|
53
48
|
`queries/`, mutations in `actions/`. Both are `.server.ts` with
|
|
@@ -56,27 +51,28 @@ transition agents most often get wrong, so it is the first recipe.
|
|
|
56
51
|
```ts
|
|
57
52
|
// modules/posts/queries/list-posts.server.ts
|
|
58
53
|
'use server';
|
|
59
|
-
import {
|
|
54
|
+
import { db } from '../../../db/connection.server.ts';
|
|
60
55
|
export async function listPosts() {
|
|
61
|
-
return
|
|
56
|
+
return db.query.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' } });
|
|
62
57
|
}
|
|
63
58
|
```
|
|
64
59
|
|
|
65
60
|
```ts
|
|
66
61
|
// modules/posts/actions/create-post.server.ts
|
|
67
62
|
'use server';
|
|
68
|
-
import {
|
|
63
|
+
import { db } from '../../../db/connection.server.ts';
|
|
64
|
+
import { posts } from '../../../db/schema.server.ts';
|
|
69
65
|
export async function createPost(input: { title: string; body: string }) {
|
|
70
66
|
const title = String(input?.title || '').trim();
|
|
71
67
|
if (!title) return { success: false, error: 'title required', status: 400 };
|
|
72
|
-
const post = await
|
|
68
|
+
const [post] = await db.insert(posts).values({ title, body: String(input?.body || '') }).returning();
|
|
73
69
|
return { success: true, data: post };
|
|
74
70
|
}
|
|
75
71
|
```
|
|
76
72
|
|
|
77
73
|
4. **Wire it into a page** by calling the query (the page runs on the server,
|
|
78
74
|
so it imports the `.server` query directly and awaits it). Never import
|
|
79
|
-
|
|
75
|
+
the DB driver into a page; reach the database through the query.
|
|
80
76
|
|
|
81
77
|
```ts
|
|
82
78
|
// app/posts/page.ts
|
|
@@ -118,11 +114,13 @@ export default async function User({ params }: { params: { id: string } }) {
|
|
|
118
114
|
```ts
|
|
119
115
|
// modules/users/actions/update-profile.server.ts
|
|
120
116
|
'use server';
|
|
121
|
-
import {
|
|
117
|
+
import { eq } from 'drizzle-orm';
|
|
118
|
+
import { db } from '../../../db/connection.server.ts';
|
|
119
|
+
import { users } from '../../../db/schema.server.ts';
|
|
122
120
|
export async function updateProfile(input: { name: string }) {
|
|
123
121
|
const name = String(input?.name || '').trim();
|
|
124
122
|
if (!name) return { success: false, error: 'name required', status: 400 };
|
|
125
|
-
const row = await
|
|
123
|
+
const [row] = await db.update(users).set({ name }).where(eq(users.id, me.id)).returning();
|
|
126
124
|
return { success: true, data: row };
|
|
127
125
|
}
|
|
128
126
|
```
|
|
@@ -130,12 +128,12 @@ export async function updateProfile(input: { name: string }) {
|
|
|
130
128
|
Call it from a client component via a normal import. The dev server
|
|
131
129
|
rewrites the import to a typed RPC stub.
|
|
132
130
|
|
|
133
|
-
## Validate a server action's input
|
|
131
|
+
## Validate a server action's input (#245)
|
|
134
132
|
|
|
135
|
-
`
|
|
136
|
-
SERVER-SIDE before the action body on
|
|
137
|
-
|
|
138
|
-
|
|
133
|
+
Declare a `validate` config export beside the action (#488). It runs
|
|
134
|
+
SERVER-SIDE before the action body on the RPC boundary (the path a client
|
|
135
|
+
component import takes), receiving the action's FIRST argument. On failure
|
|
136
|
+
it returns a structured `ActionResult`
|
|
139
137
|
(`{ success: false, fieldErrors, status: 422 }`) the client reads as
|
|
140
138
|
`result.fieldErrors`. The framework ships no validation library; the
|
|
141
139
|
validator is a plain function (or a three-line zod adapter).
|
|
@@ -143,25 +141,24 @@ validator is a plain function (or a three-line zod adapter).
|
|
|
143
141
|
```ts
|
|
144
142
|
// modules/posts/actions/create-post.server.ts
|
|
145
143
|
'use server';
|
|
146
|
-
import {
|
|
147
|
-
import {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
(input)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
);
|
|
144
|
+
import { db } from '../../../db/connection.server.ts';
|
|
145
|
+
import { posts } from '../../../db/schema.server.ts';
|
|
146
|
+
|
|
147
|
+
// the validator: receives the action's FIRST argument
|
|
148
|
+
export const validate = (input: any) => {
|
|
149
|
+
const fieldErrors: Record<string, string> = {};
|
|
150
|
+
const title = String(input?.title || '').trim();
|
|
151
|
+
if (!title) fieldErrors.title = 'Title is required';
|
|
152
|
+
if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short';
|
|
153
|
+
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
|
|
154
|
+
return { success: true, data: { title, body: String(input.body) } }; // coerced input
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// the action body: runs ONLY when validation passes
|
|
158
|
+
export async function createPost(input: { title: string; body: string }) {
|
|
159
|
+
const [row] = await db.insert(posts).values(input).returning();
|
|
160
|
+
return { success: true, data: row };
|
|
161
|
+
}
|
|
165
162
|
```
|
|
166
163
|
|
|
167
164
|
Reading the structured failure in a client component is just a property
|
|
@@ -187,23 +184,21 @@ result becomes the contract envelope.
|
|
|
187
184
|
import { z } from 'zod';
|
|
188
185
|
const Schema = z.object({ title: z.string().min(1), body: z.string().min(10) });
|
|
189
186
|
|
|
190
|
-
export const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
},
|
|
198
|
-
);
|
|
187
|
+
export const validate = (i: any) => {
|
|
188
|
+
const r = Schema.safeParse(i);
|
|
189
|
+
return r.success
|
|
190
|
+
? { success: true, data: r.data }
|
|
191
|
+
: { success: false, fieldErrors: r.error.flatten().fieldErrors };
|
|
192
|
+
};
|
|
193
|
+
export async function createPost(input) { /* ... */ }
|
|
199
194
|
```
|
|
200
195
|
|
|
201
|
-
To ALSO expose the action
|
|
202
|
-
|
|
203
|
-
`
|
|
204
|
-
fieldErrors }` return becomes a 422 JSON response there; a
|
|
205
|
-
THROWS (the classic `Schema.parse` style) becomes a 400, and a
|
|
206
|
-
return transforms the input
|
|
196
|
+
To ALSO expose the action over REST with the SAME validator, put it behind
|
|
197
|
+
a `route.ts` and pass the validator as the `route()` adapter's `{ validate }`
|
|
198
|
+
option: `export const POST = route(createPost, { validate })`. A
|
|
199
|
+
`{ success: false, fieldErrors }` return becomes a 422 JSON response there; a
|
|
200
|
+
validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a
|
|
201
|
+
non-envelope return transforms the input.
|
|
207
202
|
|
|
208
203
|
## Add a component
|
|
209
204
|
|
|
@@ -414,7 +409,9 @@ the user-supplied filename as a key; `generateKey` makes an opaque one.
|
|
|
414
409
|
// modules/avatar/actions/save-avatar.server.ts
|
|
415
410
|
'use server';
|
|
416
411
|
import { getFileStore, generateKey } from '@webjsdev/server';
|
|
417
|
-
import {
|
|
412
|
+
import { eq } from 'drizzle-orm';
|
|
413
|
+
import { db } from '../../../db/connection.server.ts';
|
|
414
|
+
import { users } from '../../../db/schema.server.ts';
|
|
418
415
|
|
|
419
416
|
export async function saveAvatar(file: File) {
|
|
420
417
|
const key = generateKey(file.name); // <uuid>.<ext>, safe
|
|
@@ -423,7 +420,7 @@ export async function saveAvatar(file: File) {
|
|
|
423
420
|
await getFileStore().delete(key);
|
|
424
421
|
return { success: false, fieldErrors: { avatar: 'Max 5 MB' }, status: 422 };
|
|
425
422
|
}
|
|
426
|
-
await
|
|
423
|
+
await db.update(users).set({ avatarKey: key }).where(eq(users.id, 'me'));
|
|
427
424
|
return { success: true, data: { key, contentType } };
|
|
428
425
|
}
|
|
429
426
|
```
|
|
@@ -74,7 +74,7 @@ packages/
|
|
|
74
74
|
intellisense/test/plugin/ tsserver plugin tests (.test.mjs)
|
|
75
75
|
test/ cross-package only
|
|
76
76
|
ssr/ SSR pipeline (core + server)
|
|
77
|
-
actions/
|
|
77
|
+
actions/ server actions (core + server)
|
|
78
78
|
serialization/ json-negotiation (core + server)
|
|
79
79
|
scaffolds/ full app boots from CLI scaffolds
|
|
80
80
|
blog/ examples/blog smoke + browser e2e
|
|
@@ -108,6 +108,51 @@ cross-package suite (`packages/*/test/` + `test/`). They do NOT walk the in-repo
|
|
|
108
108
|
apps' own test dirs (`website/test/`, `examples/blog/test/`), so those run from
|
|
109
109
|
each app's OWN `webjs test` script, not the root runner.
|
|
110
110
|
|
|
111
|
+
### The Bun test matrix (#509)
|
|
112
|
+
|
|
113
|
+
webjs runs on Node 24+ or Bun (#508). The **Node suite (`npm test`) is the
|
|
114
|
+
source of truth**; a separate, additive **Bun matrix** re-runs the
|
|
115
|
+
runtime-sensitive suite under Bun to catch the long tail of cross-runtime
|
|
116
|
+
incompatibilities (a `node:*` API Bun implements differently, a crypto/stream
|
|
117
|
+
edge case, an error-message-format quirk).
|
|
118
|
+
|
|
119
|
+
- `node scripts/run-bun-tests.js` (needs `bun` on PATH; `BUN=…` overrides) runs
|
|
120
|
+
the `node:test` files under `test/`, `packages/core/test/`, and
|
|
121
|
+
`packages/server/test/` (excluding `browser/`, `e2e/`, the network-bound
|
|
122
|
+
`vendor/`) file by file via `bun test <file>`, and CLASSIFIES each result:
|
|
123
|
+
**pass**, **skip(node-only)** (a documented file that asserts Node-only
|
|
124
|
+
behavior or trips a Bun-test-runner quirk, listed with a reason in the
|
|
125
|
+
script's `DENYLIST`, its Bun behavior covered elsewhere), **skip(harness)**
|
|
126
|
+
(a Bun `node:test` compat gap, auto-detected by error signature),
|
|
127
|
+
**skip(env)** (needs Redis / a DOM), and **genuine fail** (a real Bun
|
|
128
|
+
failure, which fails the job). Set `WEBJS_BUN_TESTS=<substr,…>` to scope a
|
|
129
|
+
local run.
|
|
130
|
+
- CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508) and
|
|
131
|
+
`test/bun/listener.mjs` (#511, the listener-shell parity).
|
|
132
|
+
- Two cross-runtime test scripts also run under BOTH runtimes: `test/bun/smoke.mjs`
|
|
133
|
+
(boot + SSR + TS strip + a server-action RPC) and `test/bun/listener.mjs`
|
|
134
|
+
(`startServer` over a real socket: SSR + route + SSE + WebSocket). Plain assert
|
|
135
|
+
scripts (not `node:test`) so the same file runs identically on each runtime.
|
|
136
|
+
|
|
137
|
+
**Dev hot-reload of server modules (cross-runtime, #514).** webjs's dev server
|
|
138
|
+
re-imports a `route.ts` / `.server.ts` / page module per request with a
|
|
139
|
+
`?t=<timestamp>` query cache-bust to pick up edits. Node honors that query and
|
|
140
|
+
re-imports, so under `node --watch` an edit is picked up live. Bun's ESM loader
|
|
141
|
+
IGNORES the query string and exposes no module-eviction API, so that mechanism
|
|
142
|
+
alone leaves a server-side edit stale on Bun. The CLI closes the gap by
|
|
143
|
+
re-execing `webjs dev` under `bun --hot` on Bun (vs `node --watch` on Node),
|
|
144
|
+
whose file-watching cache invalidation makes the next re-import fresh with no
|
|
145
|
+
restart (`Bun.serve` is reused across hot reloads, so the listener is not
|
|
146
|
+
duplicated). The cross-runtime test `test/bun/dev-hot-reload.mjs` proves the
|
|
147
|
+
edit-picked-up behaviour on BOTH runtimes (and runs as a dedicated CI step on
|
|
148
|
+
Bun). The `api` dev-cache-bust UNIT test asserts the bare server-level `?t=`
|
|
149
|
+
mechanism directly (no supervisor), which Bun ignores by design, so it stays on
|
|
150
|
+
the Bun-matrix denylist; the user-facing hot reload it underpins is covered on
|
|
151
|
+
Bun by the dev-hot-reload script instead. A `--no-hot` flag opts out of the
|
|
152
|
+
supervisor on either runtime (run the server in-process, edits need a manual
|
|
153
|
+
restart). Component / page / layout SOURCE edits already hot-reload on both
|
|
154
|
+
runtimes (the served `.ts`/`.js` is read from disk per request, not imported).
|
|
155
|
+
|
|
111
156
|
### In-repo app tests in CI (#342)
|
|
112
157
|
|
|
113
158
|
Each in-repo app (`website`, `examples/blog`) carries its own test suite under
|
|
@@ -116,7 +161,7 @@ its `test/` dir and runs it through its own `webjs test` script (the website's
|
|
|
116
161
|
not discover these, so a dedicated `.github/workflows/ci.yml` job, **In-repo app
|
|
117
162
|
tests (website + blog)**, runs `npm test --workspace=@webjsdev/website` and
|
|
118
163
|
`npm test --workspace=@webjsdev/example-blog` (with Playwright installed for the
|
|
119
|
-
website browser tests and the
|
|
164
|
+
website browser tests and the SQLite DB prepared for the blog, the same setup
|
|
120
165
|
the `unit` + `e2e` jobs use). It is a required status check, so a regression in
|
|
121
166
|
an app's tests gates the merge. The app test dirs are not walked by the root
|
|
122
167
|
runner, so the framework-package tests never double-run. `docs` and the
|
|
@@ -6,12 +6,13 @@ run is part of the user-visible workflow, no separate build step:
|
|
|
6
6
|
|
|
7
7
|
- **Editor** (VS Code) runs the TypeScript language server continuously. Red-squiggle on wrong types, including non-erasable syntax (see below).
|
|
8
8
|
- **CI** (optional) runs `tsc --noEmit` against `tsconfig.json`. Type-check only. Also catches non-erasable syntax via `erasableSyntaxOnly`.
|
|
9
|
-
- **Dev + prod server** (runtime, both directions):
|
|
9
|
+
- **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.
|
|
10
10
|
|
|
11
11
|
## TypeScript feature support: erasable only
|
|
12
12
|
|
|
13
|
-
The framework
|
|
14
|
-
which only supports **erasable
|
|
13
|
+
The framework strips with Node 24+'s built-in `module.stripTypeScriptTypes`
|
|
14
|
+
(or `amaro` on Bun, the same engine), which only supports **erasable
|
|
15
|
+
TypeScript**: type annotations,
|
|
15
16
|
`interface`, `type`, `declare`, generics, `import type`, `as` casts,
|
|
16
17
|
and `satisfies`. Non-erasable syntax is rejected.
|
|
17
18
|
|
|
@@ -62,15 +63,15 @@ off so you catch the configuration drift before runtime.
|
|
|
62
63
|
|
|
63
64
|
## Import convention
|
|
64
65
|
|
|
65
|
-
Use explicit `.ts` extensions in imports.
|
|
66
|
-
|
|
66
|
+
Use explicit `.ts` extensions in imports. The runtime's type-stripping
|
|
67
|
+
and the dev server's HTTP handler both key on the
|
|
67
68
|
file URL ending in `.ts` / `.mts`. For mixed codebases, `.js` imports
|
|
68
69
|
that point at a `.ts` sibling also resolve in the dev server. Still
|
|
69
70
|
prefer explicit `.ts`.
|
|
70
71
|
|
|
71
72
|
```ts
|
|
72
73
|
// modules/posts/queries/list-posts.server.ts
|
|
73
|
-
import {
|
|
74
|
+
import { db } from '../../../db/connection.server.js'; // .js specifier resolves the .ts sibling
|
|
74
75
|
import { formatPost } from '../utils/slugify.ts'; // TS file
|
|
75
76
|
```
|
|
76
77
|
|
|
@@ -128,6 +129,31 @@ Supported: `Date`, `Map`, `Set`, `BigInt`, `Error`, `undefined`,
|
|
|
128
129
|
instances come through as plain objects, with prototypes lost and methods
|
|
129
130
|
gone (matches React Server Actions).
|
|
130
131
|
|
|
132
|
+
#### Catching a non-serializable signature at compile time (`SerializableActionFn`)
|
|
133
|
+
|
|
134
|
+
The class-instance / function caveat above is a silent runtime surprise: a
|
|
135
|
+
method-valued field or a function argument typechecks fine but vanishes on the
|
|
136
|
+
wire. The opt-in `SerializableActionFn` annotation turns it into a compile-time
|
|
137
|
+
error. webjs actions stay plain `export async function`s (the framework rewrites
|
|
138
|
+
the client import to an RPC stub at runtime, it does not wrap the authored
|
|
139
|
+
function), so the guard is an OPTIONAL type annotation the author applies when
|
|
140
|
+
they want it:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import type { SerializableActionFn } from '@webjsdev/core';
|
|
144
|
+
|
|
145
|
+
export const getUser: SerializableActionFn<(id: number) => Promise<User>> =
|
|
146
|
+
async (id) => db.user.find(id);
|
|
147
|
+
// ^ if User carries a method (or any arg is a function), this is a type error
|
|
148
|
+
// pointing at the offending member, instead of a value that silently loses
|
|
149
|
+
// it on the wire.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`Serializable<T>` (also exported) maps a fully serializable `T` to itself and a
|
|
153
|
+
non-serializable position (a function / method) to a branded `NonSerializable`
|
|
154
|
+
marker; `SerializableArgs` / `SerializableResult` apply it across an action's
|
|
155
|
+
parameters and (promise-unwrapped) return. All types-only, erased at runtime.
|
|
156
|
+
|
|
131
157
|
### API routes: opt in via content negotiation
|
|
132
158
|
|
|
133
159
|
`route.ts` handlers use standard JSON by default so external consumers
|
package/src/mcp-docs.js
CHANGED
|
@@ -333,11 +333,13 @@ const PROMPT_BODIES = {
|
|
|
333
333
|
'```ts',
|
|
334
334
|
'// modules/<feature>/actions/<name>.server.ts',
|
|
335
335
|
"'use server';",
|
|
336
|
-
"import {
|
|
336
|
+
"import { db } from '../../../db/connection.server.ts';",
|
|
337
|
+
"import { things } from '../../../db/schema.server.ts';",
|
|
337
338
|
'export async function doThing(input: { name: string }) {',
|
|
338
339
|
" const name = String(input?.name || '').trim();",
|
|
339
340
|
" if (!name) return { success: false, error: 'name required', status: 400 };",
|
|
340
|
-
'
|
|
341
|
+
' const [row] = await db.insert(things).values({ name }).returning();',
|
|
342
|
+
' return { success: true, data: row };',
|
|
341
343
|
'}',
|
|
342
344
|
'```',
|
|
343
345
|
'',
|
package/src/mcp.js
CHANGED
|
@@ -36,6 +36,13 @@ import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
|
|
|
36
36
|
|
|
37
37
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
38
38
|
|
|
39
|
+
// Mirrors packages/server/src/action-config.js. A drift test in
|
|
40
|
+
// packages/mcp/test/mcp.test.mjs asserts these stay in sync with the source.
|
|
41
|
+
/** HTTP verbs an action may declare (mirrors RPC_VERBS in action-config.js). */
|
|
42
|
+
const RPC_VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
43
|
+
/** Reserved config export names in a 'use server' file (mirrors RESERVED_CONFIG in action-config.js). */
|
|
44
|
+
const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'validate', 'middleware']);
|
|
45
|
+
|
|
39
46
|
/**
|
|
40
47
|
* The four read-only tools. Each takes an optional `{ appDir }` (default the
|
|
41
48
|
* server's cwd) and projects an EXISTING `@webjsdev/server` function's output
|
|
@@ -128,7 +135,7 @@ const TOOL_DEFS = [
|
|
|
128
135
|
{
|
|
129
136
|
name: 'list_actions',
|
|
130
137
|
description:
|
|
131
|
-
'List registered server actions (the .server.{js,ts} files with "use server"): file, exported function name,
|
|
138
|
+
'List registered server actions (the .server.{js,ts} files with "use server"): file, exported function name, the /__webjs/action/<hash>/<fn> RPC endpoint, HTTP verb (method), cache config, and boolean flags for tags/invalidates/validate/middleware. Config-only exports (method, cache, tags, invalidates, validate, middleware) are NOT listed as actions. Read-only.',
|
|
132
139
|
inputSchema: APPDIR_SCHEMA,
|
|
133
140
|
},
|
|
134
141
|
{
|
|
@@ -148,7 +155,7 @@ const TOOL_DEFS = [
|
|
|
148
155
|
/**
|
|
149
156
|
* Lexically extract the names exported from a module source. Recognises the
|
|
150
157
|
* common forms a server-action / route file uses without LOADING the module
|
|
151
|
-
* (loading would run its top-level side effects:
|
|
158
|
+
* (loading would run its top-level side effects: DB init, connections).
|
|
152
159
|
*
|
|
153
160
|
* export async function foo() {} export function foo() {}
|
|
154
161
|
* export const foo = ... export let/var foo = ...
|
|
@@ -206,6 +213,67 @@ export function extractRouteMethods(src) {
|
|
|
206
213
|
return METHODS.filter((m) => exported.has(m));
|
|
207
214
|
}
|
|
208
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Lexically extract the HTTP-verb action config declared as reserved sibling
|
|
218
|
+
* exports in a `'use server'` file (#488). Reads source text only (no module
|
|
219
|
+
* load) so there are no DB-init side effects.
|
|
220
|
+
*
|
|
221
|
+
* Returned shape:
|
|
222
|
+
* {
|
|
223
|
+
* method: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE', // default 'POST'
|
|
224
|
+
* cache: string | null, // raw RHS literal when present, else null
|
|
225
|
+
* tags: boolean, // whether the tags export is declared
|
|
226
|
+
* invalidates: boolean, // whether the invalidates export is declared
|
|
227
|
+
* validate: boolean, // whether the validate export is declared
|
|
228
|
+
* middleware: boolean, // whether the middleware export is declared
|
|
229
|
+
* }
|
|
230
|
+
*
|
|
231
|
+
* @param {string} src
|
|
232
|
+
* @returns {{ method: string, cache: string|null, tags: boolean, invalidates: boolean, validate: boolean, middleware: boolean }}
|
|
233
|
+
*/
|
|
234
|
+
export function extractActionConfig(src) {
|
|
235
|
+
// Extract `method` from: export const method = 'GET' (single/double/backtick)
|
|
236
|
+
const methodMatch = /\bexport\s+const\s+method\s*=\s*(['"`])([A-Za-z]+)\1/.exec(src);
|
|
237
|
+
let method = 'POST';
|
|
238
|
+
if (methodMatch) {
|
|
239
|
+
const upper = methodMatch[2].toUpperCase();
|
|
240
|
+
if (RPC_VERBS.has(upper)) method = upper;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Extract `cache` RHS. Single-line: `export const cache = 60` -> '60'
|
|
244
|
+
// Multi-line object: capture balanced braces across lines.
|
|
245
|
+
let cache = null;
|
|
246
|
+
const cacheMatch = /\bexport\s+const\s+cache\s*=\s*/.exec(src);
|
|
247
|
+
if (cacheMatch) {
|
|
248
|
+
const rest = src.slice(cacheMatch.index + cacheMatch[0].length);
|
|
249
|
+
if (rest.startsWith('{')) {
|
|
250
|
+
// Capture the balanced-brace object (may span lines).
|
|
251
|
+
let depth = 0;
|
|
252
|
+
let end = 0;
|
|
253
|
+
for (let i = 0; i < rest.length; i++) {
|
|
254
|
+
if (rest[i] === '{') depth++;
|
|
255
|
+
else if (rest[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
|
256
|
+
}
|
|
257
|
+
cache = rest.slice(0, end + 1).trim();
|
|
258
|
+
} else {
|
|
259
|
+
// Single-line: read up to the first newline or semicolon.
|
|
260
|
+
const lineMatch = /^([^\n;]+)/.exec(rest);
|
|
261
|
+
if (lineMatch) cache = lineMatch[1].trim();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Boolean presence of the remaining config names.
|
|
266
|
+
const exported = new Set(extractExportNames(src));
|
|
267
|
+
return {
|
|
268
|
+
method,
|
|
269
|
+
cache,
|
|
270
|
+
tags: exported.has('tags'),
|
|
271
|
+
invalidates: exported.has('invalidates'),
|
|
272
|
+
validate: exported.has('validate'),
|
|
273
|
+
middleware: exported.has('middleware'),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
209
277
|
/**
|
|
210
278
|
* The literal URL path for a page/api directory: `blog/[slug]` -> `/blog/[slug]`,
|
|
211
279
|
* the root `.` -> `/`. Route groups `(group)` and `_private` segments drop, the
|
|
@@ -274,23 +342,33 @@ export function makeToolRunners(deps) {
|
|
|
274
342
|
},
|
|
275
343
|
|
|
276
344
|
async list_actions(appDir) {
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
const idx = await buildActionIndex(appDir, false
|
|
282
|
-
/** @type {Array<{ file: string, fn: string, endpoint: string }>} */
|
|
345
|
+
// buildActionIndex is a pure file -> hash mapping that imports no module,
|
|
346
|
+
// so this stays truly read-only (no DB init, and no stray stdout
|
|
347
|
+
// from a loaded module corrupting the JSON-RPC channel). The RPC hash is
|
|
348
|
+
// over the file path only, so no module load is needed.
|
|
349
|
+
const idx = await buildActionIndex(appDir, false);
|
|
350
|
+
/** @type {Array<{ file: string, fn: string, endpoint: string, method: string, cache: string|null, tags: boolean, invalidates: boolean, validate: boolean, middleware: boolean }>} */
|
|
283
351
|
const actions = [];
|
|
284
352
|
for (const [file, hash] of idx.fileToHash) {
|
|
285
|
-
let
|
|
353
|
+
let src = '';
|
|
286
354
|
try {
|
|
287
|
-
|
|
355
|
+
src = await readFile(file, 'utf8');
|
|
288
356
|
} catch {}
|
|
357
|
+
const names = extractExportNames(src);
|
|
358
|
+
const config = extractActionConfig(src);
|
|
289
359
|
for (const fn of names) {
|
|
360
|
+
// Skip reserved config export names: they are config, not callable actions.
|
|
361
|
+
if (RESERVED_CONFIG.has(fn)) continue;
|
|
290
362
|
actions.push({
|
|
291
363
|
file: relative(appDir, file),
|
|
292
364
|
fn,
|
|
293
365
|
endpoint: `/__webjs/action/${hash}/${fn}`,
|
|
366
|
+
method: config.method,
|
|
367
|
+
cache: config.cache,
|
|
368
|
+
tags: config.tags,
|
|
369
|
+
invalidates: config.invalidates,
|
|
370
|
+
validate: config.validate,
|
|
371
|
+
middleware: config.middleware,
|
|
294
372
|
});
|
|
295
373
|
}
|
|
296
374
|
}
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
# SSR partial navigation: design note
|
|
2
|
-
|
|
3
|
-
**Status:** SHIPPED (feature/nested-layout-partial-swap, 2026-05-16).
|
|
4
|
-
The mechanism described below is implemented and tested. This document
|
|
5
|
-
is preserved as the design record. Runtime reference for callers lives
|
|
6
|
-
in `agent-docs/advanced.md` (Client router section) and the framework
|
|
7
|
-
API table in `AGENTS.md`.
|
|
8
|
-
|
|
9
|
-
**Motivating bug (resolved):** ui-website docs sidenav lost scroll on
|
|
10
|
-
every link click because the docs layout sat 2 levels deep under the
|
|
11
|
-
root layout, beyond `findLayoutShell`'s body-direct-child probe.
|
|
12
|
-
**Previous workaround (now deleted):** `app/docs/layout.ts` saved /
|
|
13
|
-
restored `.docs-sidenav` `scrollTop` via `sessionStorage` on every
|
|
14
|
-
`webjs:navigate` event. Removed in the same PR as the framework fix.
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
**What actually shipped vs. what's below:**
|
|
19
|
-
- The recommendation in this doc was `<webjs-frame>` as the primary
|
|
20
|
-
primitive. During design discussion the decision evolved to make
|
|
21
|
-
layout-marker discovery **auto-derived from folder structure**, so
|
|
22
|
-
layout authors write nothing. `<webjs-frame>` ships as the escape
|
|
23
|
-
hatch for non-layout partial-swap regions (rare).
|
|
24
|
-
- The marker format is `<!--wj:children:<segment-path>-->` comment
|
|
25
|
-
pairs (Remix v3 lineage), not the `<webjs-frame>`-element approach
|
|
26
|
-
sketched below.
|
|
27
|
-
- Wire-byte optimization, snapshot cache, keyed DOM diff with live-
|
|
28
|
-
attribute preservation, and per-segment `<template id="wj-loading:...">`
|
|
29
|
-
cloning all shipped in the same PR (originally deferred as v2+).
|
|
30
|
-
|
|
31
|
-
The original `<webjs-frame>`-centric sketch below is preserved as
|
|
32
|
-
historical context.
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
## Goal
|
|
37
|
-
|
|
38
|
-
Preserve the DOM of any layout, at any depth, across same-origin navigations. Re-render only the deepest segment that actually changed.
|
|
39
|
-
|
|
40
|
-
## Non-goals
|
|
41
|
-
|
|
42
|
-
- Per-segment data fetching (Remix v3 `<Frame src>` style). Wire model stays one SSR response per nav.
|
|
43
|
-
- React-style reconciler with full keyed-DOM diff inside the swap region. Out of scope for v1, can come later.
|
|
44
|
-
- Parallel routes / intercepting routes (Next.js feature). Separate design.
|
|
45
|
-
|
|
46
|
-
## Background: how the four references handle this
|
|
47
|
-
|
|
48
|
-
| Framework | Mechanism | Wire format | Scope decided by |
|
|
49
|
-
|---|---|---|---|
|
|
50
|
-
| **Turbo** | `<turbo-frame id="X">` (flat DOM element) | full HTML response, server may optimize via `Turbo-Frame: X` header | Innermost enclosing `<turbo-frame>` of the click (`closest()`) |
|
|
51
|
-
| **Remix v3** | `<!--rmx:f:id-->...<!--/rmx:f-->` comment markers + per-frame `src` | per-frame HTML or `<template id>` streams | Author-declared `<Frame name="...">` + `rmx-target` on link |
|
|
52
|
-
| **Next.js App Router** | Recursive `FlightRouterState` tuple + per-segment `CacheNode` tree | RSC Flight (`react-server-dom-webpack`) | Server walks the tree, returns from divergence point |
|
|
53
|
-
| **Lit Labs** | `Routes` controller with `outlet()` + child controllers via `RoutesConnectedEvent` | full template re-render (no partial scoping) | N/A (full subtree re-render every nav) |
|
|
54
|
-
|
|
55
|
-
**Closest fit to webjs's current router:** Turbo. webjs already mirrors Turbo Drive (link interception, body swap, `pushState`, `data-no-router` ≡ `data-turbo="false"`).
|
|
56
|
-
|
|
57
|
-
## Recommendation
|
|
58
|
-
|
|
59
|
-
Adopt a Turbo-style frame primitive: `<webjs-frame id="...">`. Layouts that want partial-swap behavior wrap their replaceable region:
|
|
60
|
-
|
|
61
|
-
```ts
|
|
62
|
-
// app/docs/layout.ts
|
|
63
|
-
import { html } from '@webjsdev/core';
|
|
64
|
-
import { sidenav } from './sidenav.ts';
|
|
65
|
-
|
|
66
|
-
export default function DocsLayout({ children }) {
|
|
67
|
-
return html`
|
|
68
|
-
<div class="docs-grid">
|
|
69
|
-
${sidenav()}
|
|
70
|
-
<webjs-frame id="docs-content">${children}</webjs-frame>
|
|
71
|
-
</div>
|
|
72
|
-
`;
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### Algorithm: `packages/core/src/router-client.js` delta
|
|
77
|
-
|
|
78
|
-
Existing `findLayoutShell(body)` stays as a fallback. Add `findActiveFrame(linkEl)`:
|
|
79
|
-
|
|
80
|
-
```js
|
|
81
|
-
function findActiveFrame(linkEl) {
|
|
82
|
-
// Walk up through shadow boundaries and into light DOM via composedPath at call site.
|
|
83
|
-
const frame = linkEl.closest('webjs-frame');
|
|
84
|
-
return frame ? frame.id : null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function navigate(url, event) {
|
|
88
|
-
const frameId = event ? findActiveFrame(event.target) : null;
|
|
89
|
-
|
|
90
|
-
const res = await fetch(url, {
|
|
91
|
-
headers: frameId ? { 'X-Webjs-Frame': frameId } : {},
|
|
92
|
-
});
|
|
93
|
-
if (!res.headers.get('content-type')?.startsWith('text/html')) {
|
|
94
|
-
// existing fallback: full nav
|
|
95
|
-
window.location.href = url;
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const html = await res.text();
|
|
100
|
-
const incoming = Document.parseHTMLUnsafe(html);
|
|
101
|
-
|
|
102
|
-
// 1. Frame path: preferred if active frame exists in both.
|
|
103
|
-
if (frameId) {
|
|
104
|
-
const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
105
|
-
const source = incoming.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
106
|
-
if (target && source) {
|
|
107
|
-
target.replaceChildren(...source.childNodes);
|
|
108
|
-
mergeHead(incoming.head);
|
|
109
|
-
runFrameScripts(target);
|
|
110
|
-
customElements.upgrade(target);
|
|
111
|
-
history.pushState({}, '', url);
|
|
112
|
-
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url, frameId } }));
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 2. Existing layout-shell path (one level deep).
|
|
118
|
-
const shell = findLayoutShell(document.body);
|
|
119
|
-
const incomingShell = shell ? findLayoutShell(incoming.body) : null;
|
|
120
|
-
if (shell && incomingShell && shellsMatch(shell, incomingShell)) {
|
|
121
|
-
swapShellContent(shell, incomingShell);
|
|
122
|
-
/* existing path... */
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// 3. Full body swap (existing fallback).
|
|
127
|
-
document.body.replaceChildren(...incoming.body.childNodes);
|
|
128
|
-
/* existing path... */
|
|
129
|
-
}
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
That's the entire detection delta: a `querySelector` keyed by the active frame's id, with the existing logic preserved as fallback.
|
|
133
|
-
|
|
134
|
-
### `<webjs-frame>` element: ~30 lines
|
|
135
|
-
|
|
136
|
-
```js
|
|
137
|
-
// packages/core/src/webjs-frame.js
|
|
138
|
-
import { WebComponent, html } from './index.js';
|
|
139
|
-
|
|
140
|
-
export class WebjsFrame extends WebComponent {
|
|
141
|
-
static properties = { id: { type: String, reflect: true } };
|
|
142
|
-
render() { return html`<slot></slot>`; }
|
|
143
|
-
}
|
|
144
|
-
WebjsFrame.register('webjs-frame');
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
Light DOM (default): no shadow boundary, no slot mechanics. The element exists purely as a swap anchor with an addressable `id`. Children are normal light-DOM children that the router replaces via `replaceChildren`.
|
|
148
|
-
|
|
149
|
-
### Server side: `X-Webjs-Frame` request header (optional optimization)
|
|
150
|
-
|
|
151
|
-
When set, the SSR pipeline can return only the matching frame's HTML wrapped in a minimal stub document, skipping the rest of the layout chain. Wire is still plain HTML (no new format). v1 ships **without** this optimization. Full SSR response, client extracts what it needs. The header is forward-compat for the perf pass.
|
|
152
|
-
|
|
153
|
-
### Head merging
|
|
154
|
-
|
|
155
|
-
Same as today's `mergeHead`: replace `<title>`, merge `<meta>` tags by `name`/`property`, append new `<link>`/`<style>` elements, dedupe.
|
|
156
|
-
|
|
157
|
-
### Script handling inside the swap region
|
|
158
|
-
|
|
159
|
-
Re-execute `<script>` elements that match the existing one-level-shell path's `runScripts` logic. Idempotent registration via `Class.register()` makes this safe. The framework already handles `customElements.define` collisions.
|
|
160
|
-
|
|
161
|
-
## Edge cases
|
|
162
|
-
|
|
163
|
-
| Case | Behavior |
|
|
164
|
-
|---|---|
|
|
165
|
-
| Click on a link inside `<webjs-frame>` but `data-no-router` | Full browser navigation (existing semantics) |
|
|
166
|
-
| Click on a link *outside* any frame, both pages share a `findLayoutShell` match | Falls through to existing layout-shell path |
|
|
167
|
-
| Frame in old page but not in new (route change leaves the layout tree) | Frame lookup fails → fall to layout-shell or full body swap. Correct. |
|
|
168
|
-
| Nested `<webjs-frame>`s | Innermost wins: `closest('webjs-frame')` returns the nearest enclosing frame. Mirrors Turbo behavior. |
|
|
169
|
-
| Form submission inside a frame | Same. POST response gets the same frame-extract treatment. (Implement in form-submit path alongside link-click.) |
|
|
170
|
-
| Hash-fragment-only navigation | Existing behavior. No fetch, browser handles. |
|
|
171
|
-
| `data-frame="_top"` on a link | Escapes the enclosing frame, full nav. (Turbo precedent.) |
|
|
172
|
-
|
|
173
|
-
## What this fixes
|
|
174
|
-
|
|
175
|
-
- **ui-website docs sidenav scroll**: docs layout wraps content in `<webjs-frame id="docs-content">`. Sidenav lives *outside* the frame. Navigation between `/docs/components/a` → `/docs/components/b` only swaps frame children. The sidenav DOM is untouched, and `<aside>` scroll position is preserved natively. The `sessionStorage` workaround in `app/docs/layout.ts` can be deleted.
|
|
176
|
-
- **Any nested-layout app**: the same primitive works whether the partial-swap region is 2, 3, or 5 levels deep.
|
|
177
|
-
- **Mixed layouts**: pages that don't opt-in fall through to the existing one-level shell detection or full body swap. No regression risk.
|
|
178
|
-
|
|
179
|
-
## What's deliberately deferred (future passes)
|
|
180
|
-
|
|
181
|
-
1. **Keyed `data-key` DOM diff inside the frame.** Adopt Remix v3's `diff-dom.ts` algorithm to preserve input values, `<details>` open state, popover state, scroll positions on inner scroll containers across nav. Today's `replaceChildren` is coarse, but fine for v1 since the frame *itself* is preserved (outer scroll, sidenav, etc.).
|
|
182
|
-
2. **`X-Webjs-Frame` server optimization** to avoid re-rendering layouts the client already has.
|
|
183
|
-
3. **Server-pushed partial updates** (turbo-stream equivalent) via `<webjs-stream action="replace" target="...">`. Separate feature, useful for SSE/WebSocket-driven UI.
|
|
184
|
-
4. **Frame-scoped error boundaries.** If a frame fetch 5xxs, render only the frame's `error.ts`, not the whole page.
|
|
185
|
-
|
|
186
|
-
## Implementation plan
|
|
187
|
-
|
|
188
|
-
1. **`packages/core/src/webjs-frame.js`**: new file, the custom element.
|
|
189
|
-
2. **`packages/core/index.js`**: export `WebjsFrame`. Re-export `<webjs-frame>` via auto-registration import (so any app that imports `@webjsdev/core` gets it).
|
|
190
|
-
3. **`packages/core/src/router-client.js`**: add `findActiveFrame()`, frame-swap branch in `navigate()`. Preserve existing `findLayoutShell` and full-body fallback.
|
|
191
|
-
4. **`packages/core/src/router-client.js` (form path)**: apply the same frame-extract to form submit responses.
|
|
192
|
-
5. **`packages/server/src/dev.js`**: accept `X-Webjs-Frame` header in dev mode (no-op for v1 but adds the request signal for telemetry).
|
|
193
|
-
6. **Tests:**
|
|
194
|
-
- `packages/core/test/routing/router-client.test.js`: frame detection, querySelector with various ids, fallback when source frame missing.
|
|
195
|
-
- `test/e2e/nested-layout-partial-swap.test.mjs`: load `/docs/components/a`, scroll sidenav, click `/docs/components/b`, assert scroll preserved AND only frame children swapped.
|
|
196
|
-
7. **Docs:**
|
|
197
|
-
- `AGENTS.md`: add `<webjs-frame>` to the public API table.
|
|
198
|
-
- `agent-docs/advanced.md`: new "Frames" section under the client-router doc.
|
|
199
|
-
- `docs/` app: a new docs page showing the pattern.
|
|
200
|
-
8. **`packages/cli/templates/`**: none for v1 (frames are opt-in, no scaffold change needed).
|
|
201
|
-
9. **ui-website cleanup**: remove the `sessionStorage` workaround in `app/docs/layout.ts` after the frame lands and tests prove scroll preservation.
|
|
202
|
-
|
|
203
|
-
## Open questions
|
|
204
|
-
|
|
205
|
-
1. **Should `<webjs-frame>` ship a `src=""` attribute** for lazy loading like turbo-frame does? Probably yes eventually, but not for v1. The motivating use case is layout-scope, not lazy data.
|
|
206
|
-
2. **Should there be a sub-element registry** (something like `data-layout="docs"` as a shorthand)? Keep it explicit for v1: one mechanism. Consider sugar later.
|
|
207
|
-
3. **`X-Webjs-Frame` header naming.** `Webjs-Frame` (like `Turbo-Frame`) is shorter. Either works, and v1 implements neither for response routing so the bikeshed is deferred.
|
|
208
|
-
|
|
209
|
-
## References
|
|
210
|
-
|
|
211
|
-
- Turbo source: `frame_controller.js:132-148` (response parse), `frame_renderer.js:5-16` (swap), `link_interceptor.js:48` (innermost-frame rule)
|
|
212
|
-
- Remix v3 source: `packages/component/src/lib/frame.ts:1134-1146` (comment markers), `diff-dom.ts:124-162` (live-attr preservation list)
|
|
213
|
-
- Next.js source: `packages/next/src/client/components/router-reducer/ppr-navigations.ts:230,251,292,354,486` (cache reuse vs create), `walk-tree-with-flight-router-state.tsx:106-112` (server-side tree walk)
|
|
214
|
-
- webjs current: `packages/core/src/router-client.js` (`findLayoutShell`, single body-children scan)
|