@webjsdev/mcp 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/resources/AGENTS.md +96 -34
- package/resources/SKILL.md +50 -7
- package/resources/corpus.json +3 -3
- package/resources/references/auth-and-sessions.md +15 -0
- package/resources/references/built-ins.md +93 -3
- package/resources/references/client-router-and-streaming.md +54 -5
- package/resources/references/components.md +57 -5
- package/resources/references/data-and-actions.md +3 -1
- package/resources/references/module-structure.md +229 -0
- package/resources/references/muscle-memory-gotchas.md +31 -5
- package/resources/references/optimistic-ui.md +23 -11
- package/resources/references/routing-and-pages.md +107 -9
- package/resources/references/runtime.md +24 -5
- package/resources/references/styling.md +48 -3
- package/resources/references/testing.md +13 -0
- package/resources/references/ui-kit.md +39 -3
- package/src/mcp-source.js +148 -6
- package/src/mcp.js +9 -2
package/resources/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ Use this skill for end-to-end WebJs app work. It helps you choose the right laye
|
|
|
9
9
|
|
|
10
10
|
## Full Documentation
|
|
11
11
|
|
|
12
|
-
This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). The complete hosted docs live at https://webjs.dev/docs.
|
|
12
|
+
This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). To check ONE export's contract, run `npx webjs source <Export>` (for example `npx webjs source createAuth`, or `--pkg core` to narrow): it prints that export's signature and the doc comment above it from the installed package, so you read the declaration instead of the file. The MCP `source` tool does the same with `export`. Open the source file when the question is about behaviour the signature does not state. The complete hosted docs live at https://webjs.dev/docs.
|
|
13
13
|
|
|
14
14
|
## What WebJs Is
|
|
15
15
|
|
|
@@ -24,6 +24,8 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou
|
|
|
24
24
|
|
|
25
25
|
**Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and a `<form action=${importedAction}>` submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.
|
|
26
26
|
|
|
27
|
+
**Islands, and why their size is a decision.** A WebJs page is server-rendered HTML with small interactive components embedded in it, each hydrating on its own when the browser upgrades its tag. That is the islands model, and what makes it pay is that the sea is free: static markup a page renders costs the browser nothing, because a page never hydrates. So an island is not a unit of code organisation, it is a unit of shipped JavaScript, and it should wrap the interactive part and stop. Absorbing a page's static markup into a component to keep things tidy converts free HTML into shipped JavaScript, and it takes every display-only child down with it, because a component rendered by a component that ships can no longer be elided. Size the island to the behaviour, not to the section of the design it happens to sit in. `references/components.md` has the stopping rule and a worked before/after.
|
|
28
|
+
|
|
27
29
|
## When To Use This Skill
|
|
28
30
|
|
|
29
31
|
- New features or refactors touching pages, routes, actions, components, data, auth, sessions, styling, or tests
|
|
@@ -31,9 +33,44 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou
|
|
|
31
33
|
- Answering "how should this be structured in WebJs?"
|
|
32
34
|
- Finding the right export, reference doc, or default pattern for a task
|
|
33
35
|
|
|
36
|
+
## Reach For The Right Primitive
|
|
37
|
+
|
|
38
|
+
Scan this BEFORE deciding how to build something, while the shape of the code is still open. WebJs ships a primitive for most of the jobs below, and the mistake to guard against is rarely choosing badly between them. It is that the primitive never comes to mind at all, so the React-shaped version gets hand-rolled in its place. That version compiles, passes `webjs check`, and ships, so nothing catches it. The "reflex to resist" column is what the hand-rolled version usually looks like.
|
|
39
|
+
|
|
40
|
+
Rows point rather than explain. The reference is the authority on the rule, and it is the durable half: the demo lives in the scaffold gallery, which an app deletes with `npm run gallery:clear` once it has outgrown it.
|
|
41
|
+
|
|
42
|
+
| I need to... | Reach for | Reflex to resist | Reference | Demo |
|
|
43
|
+
| --- | --- | --- | --- | --- |
|
|
44
|
+
| add a URL, static or with a dynamic segment | a file at `app/<path>/page.ts`, `[id]` for a param | registering the route in a table or config | `references/routing-and-pages.md` | `app/features/routing` |
|
|
45
|
+
| abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` |
|
|
46
|
+
| set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `<head>` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` |
|
|
47
|
+
| make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` |
|
|
48
|
+
| render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` |
|
|
49
|
+
| get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` |
|
|
50
|
+
| call server code from the browser | import the `'use server'` function and call it | hand-writing `fetch()` against an endpoint | `references/data-and-actions.md` | `app/features/server-actions` |
|
|
51
|
+
| expose JSON to a caller outside the app | `route.ts` with named `GET` / `POST` exports | a server action, which is the in-app path | `references/routing-and-pages.md` | `app/features/route-handler` |
|
|
52
|
+
| write data from a form, JS off included | `<form action=${importedAction}>` | an `@submit` handler calling `fetch()` | `references/data-and-actions.md` | `app/features/forms` |
|
|
53
|
+
| make a mutation feel instant | `optimistic()` | a manual try-catch that restores a cached copy | `references/optimistic-ui.md` | `app/features/optimistic-ui` |
|
|
54
|
+
| navigate without a full page reload | nothing, the router is already on | importing or configuring a router | `references/client-router-and-streaming.md` | `app/features/client-router` |
|
|
55
|
+
| cross-fade a navigation instead of snapping | the `view-transition` meta, via page metadata | animating the swap yourself | `references/client-router-and-streaming.md` | `app/features/view-transitions` |
|
|
56
|
+
| show tokens or progress as the server produces them | an action returning an async generator | polling, or a socket for a one-shot answer | `references/client-router-and-streaming.md` | `app/features/streaming` |
|
|
57
|
+
| change ONE element after a write | `<webjs-stream>` | redrawing the whole list around it | `references/client-router-and-streaming.md` | `app/features/stream` |
|
|
58
|
+
| paint the page before a slow region is ready | `<webjs-suspense>` with a fallback | blocking the whole page on the slow await | `references/client-router-and-streaming.md` | `app/features/suspense` |
|
|
59
|
+
| refresh one region on its own, with no navigation | `<webjs-frame>` | a stateful component that fetches and re-renders | `references/client-router-and-streaming.md` | `app/features/frames` |
|
|
60
|
+
| hold a live two-way connection | a `WS()` route export plus `connectWS()` | polling on an interval | `references/client-router-and-streaming.md` | `app/features/websockets` |
|
|
61
|
+
| push one update to every client on a socket path | `broadcast()` | every client polling for changes | `references/client-router-and-streaming.md` | `app/features/broadcast` |
|
|
62
|
+
| add login and a signed-in-only route | `createAuth` plus a redirect in the page | rolling password hashing and session cookies | `references/auth-and-sessions.md` | `app/features/auth` |
|
|
63
|
+
| remember something per visitor across requests | `getSession()` on a signed cookie | a module-level map keyed by user | `references/auth-and-sessions.md` | `app/features/sessions` |
|
|
64
|
+
| stop re-rendering a page identical for everyone | `export const revalidate` | caching by hand in a module variable | `references/built-ins.md` | `app/features/caching` |
|
|
65
|
+
| read config or a secret at runtime | `process.env` server-side, `WEBJS_PUBLIC_` for the browser | importing a config module into a component | `references/built-ins.md` | `app/features/env` |
|
|
66
|
+
| stop one caller hammering an endpoint | the `rateLimit()` middleware | counting requests inside the handler | `references/built-ins.md` | `app/features/rate-limit` |
|
|
67
|
+
| accept an upload and serve it back | `FileStore` plus a streaming route | buffering the file in memory or writing to `public/` | `references/built-ins.md` | `app/features/file-storage` |
|
|
68
|
+
| keep the app usable offline | the opt-in service worker | caching responses in `localStorage` | `references/service-worker.md` | `app/features/service-worker` |
|
|
69
|
+
| see these composed in one real feature | the todo example app | stitching the single-feature demos together | `references/optimistic-ui.md` | `app/examples/todo` |
|
|
70
|
+
|
|
34
71
|
## Load Only The References You Need
|
|
35
72
|
|
|
36
|
-
Classify the task first, then load the smallest useful reference set. Each reference starts with a "What This Covers" section; read that to confirm relevance before reading the rest. Loading more than two or three at once usually means the task is not narrowed yet.
|
|
73
|
+
The table above routes by the job; this one routes by the topic, for when you already know which surface you are working on. Classify the task first, then load the smallest useful reference set. Each reference starts with a "What This Covers" section; read that to confirm relevance before reading the rest. Loading more than two or three at once usually means the task is not narrowed yet.
|
|
37
74
|
|
|
38
75
|
| Task involves... | Start with |
|
|
39
76
|
| --------------------------------------------------------------------------- | --------------------------------------------- |
|
|
@@ -43,6 +80,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
43
80
|
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
|
|
44
81
|
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
|
|
45
82
|
| Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
|
|
83
|
+
| Where a repeated markup helper lives (`utils/ui/` vs `lib/`), and fragment vs display-only component | `references/styling.md` |
|
|
46
84
|
| Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
|
|
47
85
|
| Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
|
|
48
86
|
| The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
|
|
@@ -51,6 +89,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
51
89
|
| Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` |
|
|
52
90
|
| Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` |
|
|
53
91
|
| Offline support, an asset cache, the opt-in service worker | `references/service-worker.md` |
|
|
92
|
+
| Splitting a large file, or how big a module may be | `references/module-structure.md` |
|
|
54
93
|
| A pattern that feels like Next.js or Lit but might not transfer | `references/muscle-memory-gotchas.md` |
|
|
55
94
|
|
|
56
95
|
Common bundles:
|
|
@@ -66,7 +105,7 @@ Common bundles:
|
|
|
66
105
|
2. **Start from the server.** Add the page/route and its server action or query before wiring interactive UI. A page render or a `<form>` POST should already return correct HTML before any component hydrates.
|
|
67
106
|
3. **Put code in the narrowest owner.** Route-local first (`modules/<feature>/`), promote to `lib/` or `components/` only when reuse is real.
|
|
68
107
|
4. **Keep server-only code behind `.server.ts`.** The DB driver, secrets, and `node:*` never belong in a page, layout, or component.
|
|
69
|
-
5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser.
|
|
108
|
+
5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser. Then wrap the interactive part and STOP: the static markup around it stays in the page, where it costs nothing.
|
|
70
109
|
6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it.
|
|
71
110
|
7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`).
|
|
72
111
|
8. **Type every boundary from its source, never `unknown` or `any`.** The row type comes from the schema (`typeof todos.$inferSelect`), the action's input from a named `interface` and its result from `ActionResult<T>`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`. `unknown` belongs on a payload nothing has vouched for yet that the next line narrows, and on a parameter of your own helper that forwards into an `html` template hole. Everywhere else, including a layout's `children`, it is a missing type. See `references/typescript.md`.
|
|
@@ -84,8 +123,10 @@ app/ ROUTING ONLY (thin adapters importing from modules/)
|
|
|
84
123
|
error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins)
|
|
85
124
|
middleware.ts root middleware
|
|
86
125
|
modules/<feature>/ actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts),
|
|
87
|
-
components
|
|
88
|
-
|
|
126
|
+
components/ (custom elements), types.ts,
|
|
127
|
+
utils/ (pure; returns data, or an html fragment under utils/ui/)
|
|
128
|
+
lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers,
|
|
129
|
+
lib/utils/ui.ts app-wide html fragments (lib/ui/ once they grow)
|
|
89
130
|
components/*.ts shared presentational custom elements (one per file)
|
|
90
131
|
db/*.server.ts Drizzle: schema, connection
|
|
91
132
|
public/* static assets, served at /public/<name>
|
|
@@ -114,7 +155,7 @@ Find the right export fast. Load the linked reference for full examples.
|
|
|
114
155
|
|
|
115
156
|
### `@webjsdev/core` (browser + isomorphic)
|
|
116
157
|
|
|
117
|
-
- `html` / `css` tagged templates. `WebComponent({ ... })` base-class factory; `prop(type?, opts?)` declares one reactive property. `register(tag
|
|
158
|
+
- `html` / `css` tagged templates. `WebComponent({ ... })` base-class factory; `prop(type?, opts?)` declares one reactive property. `Class.register('tag')` binds the tag.
|
|
118
159
|
- `signal` / `computed` reactive state, `effect(fn)` client-only reaction (returns a disposer), `batch(fn)` coalesced writes; `render(v, el)` client render.
|
|
119
160
|
- `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary.
|
|
120
161
|
- `Suspense({fallback, children})` page-level streaming; `<webjs-suspense>` component-level streaming.
|
|
@@ -133,7 +174,7 @@ Find the right export fast. Load the linked reference for full examples.
|
|
|
133
174
|
|
|
134
175
|
### File conventions
|
|
135
176
|
|
|
136
|
-
`page.ts` (server-only fn), `layout.ts` (embeds `children`), `route.ts` (HTTP handler), `middleware.ts`, `*.server.ts` (server boundary), `error.ts` / `loading.ts` / `not-found.ts` / `forbidden.ts` / `unauthorized.ts` (boundaries), metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `opengraph-image.ts`).
|
|
177
|
+
`page.ts` (server-only fn), `layout.ts` (embeds `children`), `route.ts` (HTTP handler), `middleware.ts`, `*.server.ts` (server boundary), `error.ts` / `loading.ts` / `not-found.ts` / `forbidden.ts` / `unauthorized.ts` (boundaries), metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `opengraph-image.ts`), and the static app-root icon files (`icon.svg`, `apple-icon.png`, `manifest.webmanifest`, `favicon.ico`), auto-linked into `<head>`. The scaffold's `app/icon.svg` is a placeholder: replace it with the app's own icon.
|
|
137
178
|
|
|
138
179
|
## Canonical Patterns
|
|
139
180
|
|
|
@@ -223,6 +264,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
|
|
|
223
264
|
|
|
224
265
|
## Testing Defaults
|
|
225
266
|
|
|
267
|
+
- `npm run ci` before every push: it runs the `webjs.ci` step list in `package.json` (correctness, project health, types, a dependency audit, then the server, browser, and e2e test layers) with a result line per step, and CI runs the same list, so a green local run predicts the pipeline. `npm run ci -- --only Tests` runs one layer while iterating. See `references/testing.md` and `references/built-ins.md`.
|
|
226
268
|
- Prefer server/handler tests first: drive the app with `handle()` from `@webjsdev/server/testing` and assert on the `Response`.
|
|
227
269
|
- Add a browser test (`npm run test:browser`) for anything touching hydration, the client router, slots, or custom-element upgrade. A unit test is necessary but NOT sufficient for a browser-facing change.
|
|
228
270
|
- Render the app and LOOK for any UI change: `npm run check` and `npm run typecheck` pass even when a layout collapses. Static tools give no signal for a visual defect.
|
|
@@ -231,6 +273,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
|
|
|
231
273
|
## Common Mistakes To Avoid
|
|
232
274
|
|
|
233
275
|
- Treating a page or layout like a React component and expecting its markup to hydrate. It runs server-only; put interactivity in a component.
|
|
276
|
+
- Promoting a whole page section to a component so that one control inside it can be interactive. The island should wrap the control and the state it reads. An oversized island ships its own JS AND un-elides every display-only component inside it, so the cost is not linear in what you moved.
|
|
234
277
|
- Importing a `.server.ts` utility (no `'use server'`) directly into a shipping component. Its browser stub throws at load; reach it through a `'use server'` action.
|
|
235
278
|
- Using a `static properties` block or a class-field initializer for reactive props instead of the `WebComponent({ ... })` factory.
|
|
236
279
|
- Quoting an event / property / boolean hole (`@click="${fn}"`).
|
package/resources/corpus.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "@webjsdev/mcp",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"sha": "
|
|
5
|
-
"copiedAt": "2026-
|
|
3
|
+
"version": "0.1.16",
|
|
4
|
+
"sha": "8486559f7d327bfa083547955e513cf28f118902",
|
|
5
|
+
"copiedAt": "2026-10-08T16:42:45.301Z"
|
|
6
6
|
}
|
|
@@ -101,6 +101,8 @@ export const GET = handlers.GET;
|
|
|
101
101
|
export const POST = handlers.POST;
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
**Cookie names.** `createAuth` sets `webjs.auth` (session) and, for OAuth, `webjs.auth.state` and `webjs.auth.redirect` in development. In production (`NODE_ENV=production`) each carries the `__Host-` prefix (`__Host-webjs.auth`, ...), which a browser stores only when `Secure`, `Path=/` and without `Domain`, so an app on a sibling subdomain cannot toss its own session cookie into yours. Production reads only the prefixed names; a test or tool that hand-sets the cookie in production must use the prefixed name.
|
|
105
|
+
|
|
104
106
|
**The no-JS sign-in / sign-out flow is plain forms** (progressive-enhancement-safe). Sign in by POSTing to `/api/auth/signin/credentials` with a hidden `redirectTo`, and read `?error` (mapped from `pages.error`) for feedback; sign out by POSTing to `/api/auth/signout`:
|
|
105
107
|
|
|
106
108
|
```html
|
|
@@ -113,8 +115,21 @@ export const POST = handlers.POST;
|
|
|
113
115
|
<form method="POST" action="/api/auth/signout"><button>Log out</button></form>
|
|
114
116
|
```
|
|
115
117
|
|
|
118
|
+
**OAuth sign-in returns the user where they started, with the same one form.** POST to `/api/auth/signin/github` (or `google`) with a hidden `redirectTo`, or link to `GET /api/auth/signin/github?redirectTo=/dashboard/x`; `signIn('github', undefined, { redirectTo })` does the same from an action. The target rides through the provider round trip in a short-lived signed cookie and the callback lands on it, so no wrapper around the auth route is needed:
|
|
119
|
+
|
|
120
|
+
```html
|
|
121
|
+
<form method="POST" action="/api/auth/signin/github">
|
|
122
|
+
<input type="hidden" name="redirectTo" value="/dashboard/x">
|
|
123
|
+
<button>Sign in with GitHub</button>
|
|
124
|
+
</form>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A `redirectTo` that arrives from a request (a form field or a query param, for OAuth or credentials) must be a same-origin local path: one leading `/`, not followed by `/` or `\`. An absolute URL, a protocol-relative `//host`, or a backslash variant is dropped (not repaired) and the sign-in lands on `/`, so the field is never an open redirect. A denied sign-in still goes to `pages.error`.
|
|
128
|
+
|
|
116
129
|
For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a form-bound action can return directly (the framework honors a returned `Response` verbatim).
|
|
117
130
|
|
|
131
|
+
**The header updates on its own after sign-in and sign-out.** A layout that reads `auth()` to render "Sign in" / "Sign out" is re-rendered after any mutating form submission the router handles (a form bound to a sign-in action, or a plain POST to `/api/auth/signin/credentials` / `/api/auth/signout`), and its markup is morphed in place, so there is nothing to refresh by hand (#1557). Only a sign-out done over RPC from a component (`await logout(); navigate('/')`) needs `await refreshPage('shell')` after it, since that navigation is a plain GET.
|
|
132
|
+
|
|
118
133
|
Sessions are JWT by default (stateless, scales horizontally). OAuth
|
|
119
134
|
providers handle the full redirect flow. Read the session anywhere on the
|
|
120
135
|
server with `auth()`.
|
|
@@ -21,7 +21,28 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
|
|
|
21
21
|
|---|---|
|
|
22
22
|
| `REDIS_URL` | When set, sessions, rate limit, and cache use Redis instead of memory |
|
|
23
23
|
| `SESSION_SECRET` / `AUTH_SECRET` | Session and auth signing (see `auth-and-sessions.md`) |
|
|
24
|
-
| `PORT` | Listen port. Precedence `--port` flag, then `PORT` (real env or `.env`), then `8080` |
|
|
24
|
+
| `PORT` | Listen port. Precedence `--port` flag, then `PORT` (real env or `.env`), then `8080`. A taken port fails fast: `webjs dev` / `webjs start` exit 98 with `port 8080 is already in use by PID <n> (<command>)`, on Node and Bun alike |
|
|
25
|
+
| `WEBJS_REUSE_PORT` | `1` lets several `webjs dev` / `webjs start` processes share one port (`SO_REUSEPORT`, Linux; the kernel balances connections across them). Off by default, so a second server on a taken port fails instead of silently taking a share of the requests |
|
|
26
|
+
| `WEBJS_SOURCE_LOCATIONS` | `webjs dev` only. `1` stamps `data-webjs-src="<app-relative-file>:<line>"` on the elements of the app's `html` templates (see below); `0` turns off a config default. Same as `webjs.dev.sourceLocations: true`. Ignored by `webjs start` |
|
|
27
|
+
| `WEBJS_EMBED_ORIGINS` | `webjs dev` only. Comma-separated parent origins (`https://builder.dev,http://localhost:8080`) allowed to frame the dev server and receive the embed bridge's messages (see below). Replaces `webjs.dev.embedOrigins` when set. Ignored by `webjs start` |
|
|
28
|
+
| `WEBJS_DEV_RELOAD_IDLE` | `webjs dev` only. Seconds of no edit and no interaction after which the live-reload stream closes so an idle host can sleep (`webjs.dev.reloadIdle`; this wins). Off by default; see `runtime.md` |
|
|
29
|
+
|
|
30
|
+
**Source locations for tooling (`webjs.dev.sourceLocations: true` or `WEBJS_SOURCE_LOCATIONS=1`, dev only, off by default).** A tool that hosts the app (an inspector, click-to-edit in an embedding builder) can map a clicked element back to the line that wrote it. With the variable set, `webjs dev` adds `data-webjs-src="components/todo-list.ts:12"` to every element opening tag written in an `html` template inside the app, both in the SSR markup and in client renders (one source transform applied to the served module and to the module the server imports, so the two agree and hydration is unaffected). Read it with `el.closest('[data-webjs-src]')`. Not annotated: `*.server.*` modules, `node_modules`, `css` / `svg` tagged templates, `html` / `head` / `body` / head-only and raw-text elements, and the descendants of `svg` / `math`. Lines are exact; nothing reaches production. The importmap `<script>` in `<head>` carries an unrelated `data-webjs-src` (the app-source deploy id), so match the `file:line` value shape when querying the whole document.
|
|
31
|
+
|
|
32
|
+
**Embed bridge for iframe previews (`webjs.dev.embedOrigins` or `WEBJS_EMBED_ORIGINS`, dev only, off by default).** A tool that previews the app inside an iframe (an app builder, a docs playground) lists its own origin(s) in `package.json` (`"webjs": { "dev": { "embedOrigins": ["https://builder.example"] } }`) or sets `WEBJS_EMBED_ORIGINS` (comma-separated, replaces the config list for that run). `webjs dev` then (1) drops `X-Frame-Options` and adds those origins to a CSP `frame-ancestors`, so the frame loads without the app stripping headers in `webjs.headers`, and (2) inlines a small nonce-signed script into every document that, when framed by a listed origin, posts to `window.parent` (with that exact target origin, never `*`):
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
{ source: 'webjs-embed', type: 'ready', path, title } // document parsed
|
|
36
|
+
{ source: 'webjs-embed', type: 'navigate', path, title } // every client-router navigation + popstate
|
|
37
|
+
{ source: 'webjs-embed', type: 'console', level: 'error' | 'warn', message, dropped? } // max 20/s
|
|
38
|
+
{ source: 'webjs-embed', type: 'error', message, stack, file, line, column } // window error + unhandledrejection
|
|
39
|
+
{ source: 'webjs-embed', type: 'network', method, url, status, error? } // fetch/XHR status >= 500, or status 0 on failure
|
|
40
|
+
{ source: 'webjs-embed', type: 'server-error', kind, message, file, line, path } // the dev error overlay went up
|
|
41
|
+
{ source: 'webjs-embed', type: 'select', src, tag, text, rect: { x, y, width, height }, shiftKey, metaKey, altKey, ctrlKey } // a click in inspect mode (modifiers for multi-select, #1532)
|
|
42
|
+
{ source: 'webjs-embed', type: 'hold', enabled } // acknowledges a host hold command (#1532)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
and accepts, only from `window.parent` on a listed origin: `{ source: 'webjs-embed-host', type: 'navigate', path }` (a local path; a soft navigation when the client router is on), `{ source: 'webjs-embed-host', type: 'reload' }`, `{ source: 'webjs-embed-host', type: 'resume' }` (reopens a live-reload stream closed by `webjs.dev.reloadIdle`; every host command does this too), and `{ source: 'webjs-embed-host', type: 'inspect', enabled: true | false }` (hover highlight plus click capture that posts `select`; `src` is the nearest `data-webjs-src`, so pair it with `WEBJS_SOURCE_LOCATIONS=1` for click-to-edit), and `{ source: 'webjs-embed-host', type: 'hold', enabled: true | false }` (#1532: while held, live-reload signals are collected instead of applied, so a builder's agent can edit many files without the preview flickering through each save; releasing applies ONE reload at the strongest verdict collected, or none when nothing changed; the hold is kept in `sessionStorage`, so a new document in the same tab starts held, and only the host's release ends it). A full dev reload or a host `reload` keeps the scroll position of the path it reloads (#1532). Paths are app paths (`/api/x`, `/components/x.ts`). Unset adds zero bytes; `webjs start` never injects it nor relaxes a header.
|
|
25
46
|
|
|
26
47
|
Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
|
|
27
48
|
|
|
@@ -93,6 +114,8 @@ html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
|
|
|
93
114
|
|
|
94
115
|
That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, so this is a convention rather than a `webjs check` rule (`webjs doctor` does flag the plain form, see below). Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched.
|
|
95
116
|
|
|
117
|
+
`asset()` is a PROVIDER SEAM: `@webjsdev/server` installs the resolver at boot by importing `@webjsdev/core` and calling a setter, and that only reaches your app when both sides load the SAME copy of core. Two copies on disk are two independent sets of module-scope state, so the setter lands on one and your `asset()` reads the other, which returns bare paths and never says why. `cspNonce()` and the bound-form identity resolver behind `<form action=${fn}>` sit on the same seam and go inert together. `@webjsdev/server` therefore declares core as a PEER dependency, so npm resolves it against your app's copy and reports a genuine conflict at install time rather than nesting a second one. If you ever do end up with two (a hand-rolled install, a vendored copy), the symptom is those three features quietly doing nothing rather than any error, so reach for `npm ls @webjsdev/core` and `npm dedupe` before looking anywhere else.
|
|
118
|
+
|
|
96
119
|
Forgetting it is the one real cost of opt-in, so `webjs doctor` catches it: a page, layout, or error boundary writing a plain `<link rel="stylesheet" href="/public/app.css">` gets a WARN naming the `file:line` and the fix (#1095). It reads your source and rewrites nothing, and it stays quiet about the non-marks that are deliberate: a cross-origin sheet, a `rel="icon"`, a `rel="preload"`, and any `href=${expr}` hole. Same posture as Rails (a `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url from the build graph, surfaced through `links()`): take the fingerprint at the point the url is PRODUCED, never by rewriting a rendered document. A warning is easy to miss, so make it fatal in the app that cares: gate `UNMARKED_ASSET_LINKS` to `error` (see the doctor severity gate below) and one `npm run doctor` step in CI stops the un-versioned url reaching a deploy. The scaffold ships exactly that.
|
|
97
120
|
|
|
98
121
|
It is opt-in rather than automatic because only the author knows which urls are the REQUEST. Do NOT mark a `rel="preload"` hint whose asset is actually fetched by CSS `url()`: the preload cache is keyed on the full url, so a versioned hint can never satisfy the unversioned request the stylesheet makes, and the file is fetched twice. Mark the thing that fetches, not the hint. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing).
|
|
@@ -109,7 +132,11 @@ import { rateLimit } from '@webjsdev/server';
|
|
|
109
132
|
export default rateLimit({ window: '1m', max: 60 });
|
|
110
133
|
```
|
|
111
134
|
|
|
112
|
-
Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the
|
|
135
|
+
Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the framework-stamped socket peer), `message`, `store`, `trustProxy` (honour the forwarded-IP headers; inert while `WEBJS_NO_TRUST_PROXY=1` is set, which outranks it and keeps the limiter on the framework-stamped peer). Over-limit responds `429` with `Retry-After` and `X-RateLimit-*` headers; an allowed response carries the remaining-quota headers too. For multi-instance scaling, set the global store to Redis once at startup.
|
|
136
|
+
|
|
137
|
+
**The default key is the socket PEER, which is the visitor only when the browser connects to you directly.** Deploy behind a CDN or a platform router and the peer is that proxy, so `trustProxy: true` is what a deployed limiter almost always wants. Get it wrong and nothing looks broken: a single shared proxy buckets every visitor together, and a proxy POOL (the common case) hands out one full allowance PER proxy, so the effective limit is multiplied by the pool size while `X-RateLimit-Remaining` still counts down convincingly inside each bucket. Diagnose it by sending the requests over ONE keep-alive connection, which pins them to one peer: counts that descend there but reset on a fresh connection mean you are bucketing proxies. `trustProxy: true` has one precondition, that the proxy in front strips an inbound `X-Forwarded-For` before adding its own (Cloudflare, Railway, Fly, Render, and Vercel do; nginx and Caddy only if configured), or a client can forge the header and choose its own bucket.
|
|
138
|
+
|
|
139
|
+
**Behind a CDN, `trustProxy: true` alone is usually still wrong, so name the header: `rateLimit({ trustProxy: true, clientIpHeader: 'cf-connecting-ip' })`.** The default chain starts at the leftmost `X-Forwarded-For` entry, which behind Cloudflare is Cloudflare's EGRESS address rather than the visitor. Cloudflare pins one egress IP per connection, so the symptom is a limiter that counts down correctly for a button that pings on one connection and never refuses anyone who opens a new one. When `clientIpHeader` is set it is the only wire header read (falling back to the peer, then `_anon_`), a blank value falls through rather than becoming a key every visitor shares, and a comma chain is split so an appending proxy cannot mint a bucket per hop. The framework will NOT prefer `CF-Connecting-IP` on its own, because Cloudflare overwrites it, which makes it unforgeable behind Cloudflare and forgeable anywhere else: on an nginx or bare-platform deploy a client could then send it and outrank the header the real proxy set. Name the header YOUR edge sets and overwrites.
|
|
113
140
|
|
|
114
141
|
## Broadcast
|
|
115
142
|
|
|
@@ -210,9 +237,51 @@ An over-limit body responds `413` without buffering the whole payload.
|
|
|
210
237
|
|
|
211
238
|
`before` runs to completion first (a non-zero exit aborts the boot). `parallel` (dev only) runs long-lived watchers alongside the server and tears them down on exit. `watch` (dev only) adds extra live-reload directories outside the app tree.
|
|
212
239
|
|
|
240
|
+
### Local CI (`webjs.ci`)
|
|
241
|
+
|
|
242
|
+
`webjs ci` runs the step list the block declares, the Rails 8.1 `bin/ci` posture: your machine is the first CI runner, and a cloud pipeline runs the SAME list by calling `npm run ci`, so the two cannot drift. Each step prints a heading, then `✅ <title> passed in 2.11s` or `❌ <title> failed in 0.01s`; the run ends with every failure listed and one total line, and exits 1 on any failure.
|
|
243
|
+
|
|
244
|
+
```jsonc
|
|
245
|
+
{ "webjs": { "ci": { "steps": [
|
|
246
|
+
{ "title": "Setup", "run": "webjs db migrate" },
|
|
247
|
+
{ "title": "Checks", "parallel": 2, "steps": [ // two at a time
|
|
248
|
+
"webjs check", // a string is a command titled by itself
|
|
249
|
+
{ "title": "Types", "run": "webjs typecheck" },
|
|
250
|
+
{ "title": "Tests", "steps": [ // a nested group takes ONE slot, runs in order
|
|
251
|
+
{ "title": "Tests: server", "run": "webjs test --server" },
|
|
252
|
+
{ "title": "Tests: e2e", "run": "webjs test --server", "env": { "WEBJS_E2E": "1" } }
|
|
253
|
+
] }
|
|
254
|
+
] }
|
|
255
|
+
] } } }
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
A step is a string, a `{ title, run, env? }` command, or a `{ title, steps, parallel? }` group. `parallel` is a slot count (default 1); a parallel group captures each step's output and replays it whole when the step finishes, so two steps never interleave, and a group nested inside it takes one slot and runs sequentially (it cannot declare `parallel`, which the reader reports rather than honours). `env` is per-step, so the e2e opt-in does not depend on a shell prefix. Every child runs with `CI=true`, `node_modules/.bin` on PATH, and `.env` loaded first (a real env var wins), so a `webjs db migrate` step sees `DATABASE_URL`.
|
|
259
|
+
|
|
260
|
+
Flags: `-f` / `--fail-fast` stops after the first failure (the default runs everything and lists every failure), `--only <title>` runs one step or group by title (repeatable, an unknown title is an error rather than an empty green run), `--json` emits one document on stdout (`{ ok, seconds, steps: [{ title, run, group, ok, code, seconds, output? }] }`, failed steps carrying their captured output, the human report on stderr) for an agent loop, and `--signoff` runs `gh signoff` after a green run. To hold a merge until a LOCAL run is green, install `basecamp/gh-signoff`, run `gh signoff install` once (a branch-protection rule requiring the `signoff` status), and run `npm run ci -- --signoff`; a red run posts nothing.
|
|
261
|
+
|
|
262
|
+
Under GitHub Actions each step is a `::group::` in the log, a failed step is an `::error::` annotation, and a step table is appended to the job summary, so a single job running the whole list still names the layer that broke. The scaffold's workflow is exactly that one job; `webjs create --skip-ci` omits it and the local list always ships. A malformed block (a group with no title, a nested `parallel`, an unknown key) refuses to run and names every problem by JSON path, because a silently dropped step is a check that never ran. Nothing declared is exit 1 too, naming any workspace member that declares one, since "ran zero steps" would read as green.
|
|
263
|
+
|
|
264
|
+
### Bring your own ORM (`webjs.db`)
|
|
265
|
+
|
|
266
|
+
Drizzle is the scaffold DEFAULT, not lock-in. The runtime never imports it, `db/connection.server.ts` is the app's own file, and `webjs db` is adapter-driven: a `db` block maps each verb to the shell command `webjs db <verb>` runs instead of the drizzle-kit default (node_modules/.bin on PATH like a `before` step, extra CLI args appended).
|
|
267
|
+
|
|
268
|
+
```jsonc
|
|
269
|
+
{ "webjs": {
|
|
270
|
+
"db": {
|
|
271
|
+
"generate": "prisma migrate dev --create-only",
|
|
272
|
+
"migrate": "prisma migrate deploy",
|
|
273
|
+
"push": "prisma db push",
|
|
274
|
+
"studio": "prisma studio",
|
|
275
|
+
"reset": "prisma migrate reset --force"
|
|
276
|
+
}
|
|
277
|
+
} }
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Any key is a verb (`reset` above adds `webjs db reset`). A verb the block does not name keeps its default (drizzle-kit for `generate` / `migrate` / `push` / `studio`, `db/seed.server.ts` for `seed`), so an app with no block is unchanged and the scaffold emits none. The payoff is that `webjs db migrate` stays one spelling across ORMs, so the scaffolded `dev.before` / `start.before`, the Dockerfile, CI, and the deploy docs all keep working after a swap. Write the bare binary (`prisma migrate deploy`), not `npx prisma ...`, since a pure Bun image has no `npx`. The swap itself is the app's own files: replace `db/connection.server.ts` with the new client, drop `drizzle.config.ts` / `db/columns.server.ts`, and keep server-only imports behind `.server.ts` as before.
|
|
281
|
+
|
|
213
282
|
### Doctor severity gate
|
|
214
283
|
|
|
215
|
-
`webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE`
|
|
284
|
+
`webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` plus `FRAMEWORK_LINKS` depend on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries.
|
|
216
285
|
|
|
217
286
|
```jsonc
|
|
218
287
|
{ "webjs": {
|
|
@@ -227,6 +296,27 @@ Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports
|
|
|
227
296
|
|
|
228
297
|
Two guarantees worth knowing. A result that could not check (a network or toolchain outage) is capped at `warn` and can never be escalated, so a jspm or npm outage cannot red your CI. And a malformed gate exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. That covers an unknown code, a bad severity, a wrong shape (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`, since every one of those would otherwise leave the build un-gated while the `package.json` looks gated. Under `--json` the offenders come back as a `configErrors` array alongside an empty `results`, each entry a `{ kind }` of `malformed` / `unknown-key` / `unknown-code` / `bad-severity`. Wire it up with one workflow step, `npm run doctor`, and change what is fatal in `package.json` rather than in the workflow.
|
|
229
298
|
|
|
299
|
+
`APP_ICON` warns while the favicon is still the scaffold's placeholder `app/icon.svg` (marked `data-webjs-placeholder`). Replace it with the app's own icon (`references/routing-and-pages.md`, "App icon and manifest").
|
|
300
|
+
|
|
301
|
+
### Dependency audit allowlist
|
|
302
|
+
|
|
303
|
+
`webjs audit` runs `npm audit` or `bun audit` (by the nearest lockfile, so a workspace member uses the root's) and fails on any advisory at or above `webjs.audit.level` (default `high`) that `webjs.audit.ignore` does not list. The scaffold's `Security: dependency audit` CI step runs it.
|
|
304
|
+
|
|
305
|
+
```jsonc
|
|
306
|
+
{ "webjs": {
|
|
307
|
+
"audit": {
|
|
308
|
+
"level": "high",
|
|
309
|
+
"ignore": [
|
|
310
|
+
{ "id": "GHSA-vfj7-8cjw-p6xm", "reason": "braces has no patched release; reached only through dev tooling" }
|
|
311
|
+
]
|
|
312
|
+
}
|
|
313
|
+
} }
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
The allowlist is the ONE place an accepted advisory lives, each with its reason. Accept only an advisory with no patched release that the app's users cannot reach; upgrade anything that has a fix. Never "fix" a red audit with `npm audit fix --force`, which proposes breaking majors that often keep the same vulnerable chain. A malformed block (unknown key, bad level, an entry without an id or a reason) exits 1, and an id the audit stops reporting prints as stale, so remove it then.
|
|
317
|
+
|
|
318
|
+
Overrides apply only at a WORKSPACE ROOT. The scaffold's `overrides` block (the `puppeteer-core` and `basic-ftp` security floors) is ignored once the app is a member of an npm or bun workspace, so move it into the root `package.json`; `webjs doctor` warns with `WORKSPACE_OVERRIDES` until you do.
|
|
319
|
+
|
|
230
320
|
## Observability
|
|
231
321
|
|
|
232
322
|
Wired at the single response funnel, covering pages, routes, actions, and assets uniformly.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
|
-
- The automatic client router (SPA-style partial swaps), how it opts out, and programmatic `navigate()` / `revalidate()`.
|
|
5
|
+
- The automatic client router (SPA-style partial swaps), how it opts out, and programmatic `navigate()` / `revalidate()` / `refreshPage()`.
|
|
6
6
|
- Link prefetch with device-adaptive defaults.
|
|
7
7
|
- `<webjs-frame>` partial-swap regions (WebJs's Turbo Frames).
|
|
8
8
|
- View Transitions opt-in.
|
|
@@ -17,6 +17,8 @@ Read this when a task touches client navigation, prefetch, partial-page swaps, s
|
|
|
17
17
|
|
|
18
18
|
The router auto-enables the moment `@webjsdev/core` loads in the browser, which is any page that ships a component. There is nothing to import or opt into. It intercepts same-origin `<a>` clicks (including inside shadow DOM), fetches the target HTML, and replaces only the inside of the deepest shared layout. Outer header, sidenav, and footer DOM is never re-rendered, so scroll positions, input values, and `<details>` state survive a navigation.
|
|
19
19
|
|
|
20
|
+
**A mutating form submission re-renders the layouts (#1557).** A link click keeps the layout chrome by construction, but a form action can change what a layout renders: a sign-in sets the cookie the header reads, a sign-out clears it. So a POST / PUT / PATCH / DELETE form the router submits sends NO `X-Webjs-Have` (the redirect `fetch` follows keeps request headers, so a have-header would make the server short-circuit the very layouts that need to re-run), and the swap applies the boundary plan as usual and then MORPHS the chrome outside the plan's range, level by level from `<body>` down. The morph is keyed + positional and state-preserving, so a hydrated layout component survives unless its own markup changed, and form controls keep what the reader typed (a 422 re-render included). When the chain from `<body>` to the range cannot be paired safely (a different depth or tag, or a hydrated component wrapping `${children}`), it takes the full-body tier instead, which is correct and only loses component state. Nodes appended to `<body>` at runtime (a dev overlay, a dialog portal) trail the server-rendered content and are left alone. A frame-targeted submission keeps its frame-only swap, and an RPC mutation followed by `navigate()` is a GET navigation, so call `refreshPage('shell')` after it when the layout depends on what changed.
|
|
21
|
+
|
|
20
22
|
**The nav parse must preserve comments.** SSR wraps each layout's children AND the page itself in a KEYED boundary comment pair (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`, #1015). The route-key is the region's resolved concrete path with each substituted param value percent-encoded (so a user-controlled value can never terminate the comment or collide with the `:` delimiter). The router STRICTLY scans both the live and incoming DOM into segment maps: a close must id-match its innermost open, and ANY truncation, mispair, duplicate, or legacy anonymous open poisons the whole scan. The swap decision is two-tier with Next.js remount parity: a CHANGED route-key REPLACES (a fresh remount, permanents regrafted) at the PARENT of the shallowest changed boundary (a layout's boundary wraps only its children, so its own param-derived markup lives in the parent's range; anchoring there remounts the layout chrome too, exactly like Next re-rendering the layout with new params), else MORPH (the keyed state-preserving reconcile) at the deepest shared boundary when it is the leaf on both sides. The X-Webjs-Have header carries `segment:route-key` entries so the server re-renders (and re-ships) a dynamic layout the client holds for other params instead of short-circuiting past it. A poisoned scan or no shared segment degrades to a FULL PAGE LOAD (dev logs the cause), never a guessed recovery, so silent DOM corruption is structurally impossible. Hydration keys off another comment (`<!--webjs-hydrate-->`, which `__isHydrating()` reads as a component's first child). So the router and hydration both ride on comments SURVIVING the parse that turns a navigation response into a Document, which makes that parse a load-bearing correctness boundary rather than an implementation detail.
|
|
21
23
|
|
|
22
24
|
`Document.parseHTMLUnsafe` STRIPS every comment in Chromium 150 (#1007). No other parse API does: `DOMParser`, `setHTMLUnsafe`, `template.innerHTML`, and plain `innerHTML` all preserve them, and so does the document's own navigation parser, which is why a hard refresh always looked correct and only soft nav broke. With the boundaries gone the router degrades to a full page load (correct, just not soft); with `webjs-hydrate` gone a slotted light-DOM component misses the hydration adopt path. `parseHTML` therefore PROBES `parseHTMLUnsafe` once for losslessness instead of sniffing versions, uses it when it is lossless (it is the only single-pass API that also processes Declarative Shadow DOM), and otherwise parses with `DOMParser`, which preserves comments. A fixed browser silently returns to the fast path.
|
|
@@ -44,24 +46,59 @@ enableClientRouter(); // turn soft navigation back on
|
|
|
44
46
|
|
|
45
47
|
Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped.
|
|
46
48
|
|
|
49
|
+
**Per link, keep the reader's scroll offset with `data-preserve-scroll`.** A forward navigation scrolls to top, matching what a browser does and what Next and Remix 3 do. The attribute is the escape hatch for a navigation that changes only part of what the reader is looking at: a filter, sort, or tab link whose control sits below the fold, a pager, or a form that re-renders in place with validation errors. WebJs wants it more than most, because a searchParams-only navigation already morphs the deepest shared boundary and preserves hydrated component state, so the scroll is the only thing such a navigation still throws away.
|
|
50
|
+
|
|
51
|
+
```html
|
|
52
|
+
<nav data-preserve-scroll> <!-- covers every link inside -->
|
|
53
|
+
<a href="?sort=new">Newest</a>
|
|
54
|
+
<a href="?sort=top">Top</a>
|
|
55
|
+
<a href="/" data-preserve-scroll="false">Home</a> <!-- opts back out -->
|
|
56
|
+
</nav>
|
|
57
|
+
<form method="post" action=${saveDraft} data-preserve-scroll>...</form>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
It resolves through `closest()`, so one mark on a wrapping element covers every link in it (the same walk `data-webjs-frame` uses), and `data-preserve-scroll="false"` on a nearer element opts back out. On a form the lookup starts at the submitter and falls back to the form itself, so a marked form covers its own buttons whether they sit inside it or are attached from elsewhere with `form="id"`. The fallback only fills in a missing mark, so `data-preserve-scroll="false"` on a button still opts that button out of a marked form.
|
|
61
|
+
|
|
62
|
+
Three things it does NOT do. A hash link still scrolls to its anchor, because the reader named a target and a named target beats a blanket preference. It is inert on a frame-targeted link, since a frame swap never writes a scroll to begin with. And it is inert with JS off, where the link is a plain `<a>` and the browser does whatever it does, so nothing about a page's correctness may depend on it.
|
|
63
|
+
|
|
64
|
+
It carries the reader's CURRENT offset onto the destination; it does not restore the destination's remembered offset. Those are different features, and the second one is not something WebJs ships. So this is the wrong tool for a "back to the list" link, where the offset the reader wants is the one they had in the list, not the one they have in the article.
|
|
65
|
+
|
|
47
66
|
**Programmatic navigation and cache eviction.**
|
|
48
67
|
|
|
49
68
|
```js
|
|
50
69
|
import { navigate, revalidate } from '@webjsdev/core';
|
|
51
70
|
await navigate('/about'); // push history
|
|
52
71
|
await navigate('/login', { replace: true }); // replace history
|
|
72
|
+
await navigate('/products?sort=new', { scroll: false }); // keep the reader's offset
|
|
53
73
|
revalidate('/products/123'); // evict one URL from the snapshot cache
|
|
54
74
|
revalidate(); // clear the entire snapshot cache
|
|
55
75
|
```
|
|
56
76
|
|
|
57
|
-
The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward.
|
|
77
|
+
The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward. The popstate an in-page fragment CLICK produces is absorbed rather than re-navigated (#1437), so an anchor click restores nothing and re-fetches nothing, the repeat click of one anchor included (that one REPLACES its entry rather than pushing, so it arrives with the url unchanged). The gate is PROVENANCE, the router marking the click it bowed out of and the next popstate consuming that mark, rather than any comparison of urls: a Back between two entries differing only by fragment can still need a re-render, because `getSubmitAction` prefers the raw `action` ATTRIBUTE and that carries no fragment, so a bound-submitter form declaring `action="/p"` pushes its 422 re-render at `/p` while the reader sits at `/p#sec`. A popstate with no click behind it therefore stays on the normal path, which means an ordinary Back or Forward between two fragment states still re-renders. Telling those apart would need to know whether the DOM was replaced between the two ENTRIES, which is per-entry state the router does not keep.
|
|
78
|
+
|
|
79
|
+
**In-place refresh of the page you are on.** `refreshPage(mode)` re-renders the CURRENT url on the server and applies it without a page load.
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { refreshPage } from '@webjsdev/core';
|
|
83
|
+
await refreshPage(); // 'page': morph the deepest shared boundary
|
|
84
|
+
await refreshPage('shell'); // replace the whole body (the layout's own markup changed)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It records no history entry and never scrolls, so the reader keeps their place and Back still goes to the previous page. `'page'` morphs the deepest shared boundary, so the outer layout's DOM and the hydrated state of its components survive; `'shell'` replaces the whole body, which is what a LAYOUT change needs, since a layout's own header, nav, and footer sit outside every children range and a boundary morph would leave them untouched. Component instances do not survive a `'shell'` refresh.
|
|
88
|
+
|
|
89
|
+
It sends no `X-Webjs-Have`, deliberately: the server short-circuits at the first layout the client already holds, and a same-url request matches every one of them, so the response would omit the very layout that changed. It resolves `false` when it did not apply (the router is disabled, or the fetch failed), so a caller falls back to a full load.
|
|
90
|
+
|
|
91
|
+
It does NOT reload changed component modules and cannot: `customElements.define` is once-per-tag and a module url is fetched once per document. A caller whose change touched browser code has to reload. This is exactly why the dev live-reload client calls `refreshPage` for a page or layout edit and `location.reload()` for a component edit (#1398, and see `references/runtime.md` for which dev modes get the refresh).
|
|
92
|
+
|
|
93
|
+
Keep the two scroll concerns apart. The Back/Forward restore below is the BROWSER's and has no per-link knob, because the offset it replays is one the browser recorded. The forward-navigation scroll-to-top is the router's own write, and `data-preserve-scroll` is its knob.
|
|
58
94
|
|
|
59
95
|
**Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app:
|
|
60
96
|
|
|
61
|
-
- **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the
|
|
97
|
+
- **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the restore, which is the BROWSER's (see the next bullet) and which the router protects with a suppression window while the page settles. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
|
|
98
|
+
- **The BROWSER restores Back/Forward scroll, not the router, and an app must not set `history.scrollRestoration = 'manual'`** (#1428). The router FORCES `history.scrollRestoration` to `auto` on start (and puts the app's own value back on `disableClientRouter()`), so setting `'manual'` yourself does not take effect while the router runs. Under `auto` the browser records a scroll position per history entry, replays it on a traverse, and composes the iOS edge back-swipe GESTURE PREVIEW from that same recorded state. The router writes no scroll on a restore at all: it reserves the recorded height (below) so the browser's replay lands on a document that can hold the offset, and that is the whole mechanism. One writer, the same model Next and Remix 3 use. Taking `manual` suppresses the recording, so every scrolled page previews BLANK for the whole gesture. That is what the router itself used to do, inherited from Turbo Drive's `assumeControlOfScrollRestoration`, and it is why Turbo still previews blank the same way: Turbo is single-writer too, but the writer is the APP. An app that sets `manual` re-breaks the preview app-wide.
|
|
62
99
|
- **An app that sets `overflow-anchor` on `<html>` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element.
|
|
63
100
|
- **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A FRAME-TARGETED navigation or submission is the exception, on exactly the rule that decides frame targeting everywhere else (the enclosing frame, an explicit `data-webjs-frame="<id>"` from anywhere, or the frame's own `src`; `_top` and an unresolvable id are page navigations and do close the window). It swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own.
|
|
64
|
-
- **
|
|
101
|
+
- **The recorded HEIGHT is reserved across the restore, so the offset is always reachable.** A snapshot records the page's settled `scrollHeight` alongside the offset, and the restore holds that height on the root element until the page has filled in. Without it the swapped-in markup is briefly shorter than the page it came from, the browser clamps the restore to whatever the short document allowed, and the reader lands short. The reservation removes that window rather than correcting for it afterwards, which is what retired the older catch-up that used to chase the offset as the page grew. It is released on the same settle that closes the anchoring window, on the same ceiling, and when another navigation supersedes the restore, but never on user input: releasing the height under a reader mid-scroll is the one harm an early release could do. An app's own inline `min-height` on the root is saved and put back, the same contract the anchoring window keeps.
|
|
65
102
|
- **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over.
|
|
66
103
|
|
|
67
104
|
Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore.
|
|
@@ -110,6 +147,10 @@ A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<for
|
|
|
110
147
|
|
|
111
148
|
**The cache is ANCHOR-VALIDATED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part from the deepest boundary it short-circuited on. That boundary is the fragment's ANCHOR, and the fragment applies to any live DOM that still offers it with the same route-key. So on consume the router checks the anchor, not the whole `have` string: a root-anchored fragment survives an unrelated navigation and stays a cache hit, while one anchored at `/docs` is discarded once you leave /docs, because applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip. The router also never prefetches the page it is already on (#1106), since that request cannot serve any later navigation and only occupies a capped cache slot; a hover's intent timer routinely fires after the click it belongs to has already swapped, which is when that happens. Both behaviours are internal; nothing to configure.
|
|
112
149
|
|
|
150
|
+
**The cache also carries a FRAME dimension (#1407).** A link that drives a `<webjs-frame>` is prefetched with the same `X-Webjs-Frame` header its click will send, so the cached body is the frame subtree the swap actually needs and the click resolves with no round trip. The server marks that sliced response `X-Webjs-Frame: <id>` on the way out, and varies on the request header, so the cache keys the entry by URL plus frame id: a page fragment can never be applied into a frame region, nor a frame subtree into a page swap, and each dimension is a separate entry for one URL. A frame entry is validated differently from a page one, because a subtree carries no boundary comment to anchor on: what has to hold is that a live `<webjs-frame>` with that id is still in the document, checked at consume time. Two responses are refused outright. A body answering a framed request WITHOUT the server's marker is a whole document (a streamed render skips the slice, and so does an id absent from the output), so it is discarded rather than stored under either key. The REFUSAL is remembered though, in a small memo kept outside the fragment cache, so a link on a streaming route re-asks about once per TTL instead of on every hover, without occupying a cache slot a real fragment could use (that memo set is itself capped, so a page with many distinct refused frame links can re-ask sooner). Only a detected deploy or an in-place `refreshPage` drops those memos early, since those are the two moments the SOURCE can have changed. `revalidate()` leaves them, because it is the post-mutation api and clearing there would drop every memo on every write; a mutation CAN change a render's streamed shape (a page may render `Suspense` conditionally on fetched data), but a memo that outlives that costs one skipped warm-up for that key until the TTL runs out, which is the cheaper side of the trade. And a framed link pointing at the URL the page is already on is a frame refresh, which must show fresh bytes, so #1106 excludes it in its own dimension.
|
|
151
|
+
|
|
152
|
+
One consequence to know when reading a network tab: dedupe is per dimension, so a page holding TWO links to one URL, one driving a frame and one not, warms both and issues two speculative requests where it previously issued one. That is not redundancy, since the two responses genuinely differ and a click on either link needs its own; suppressing one would leave whichever link lost unwarmed ahead of the click, which bites hardest on touch, where `viewport` is the default and the only thing left is the `touchstart` warm at tap time. Both stay inside the same cache cap, concurrency gate, TTL, and `Save-Data` gate as every other prefetch, and the change adds no new trigger and no per-link fan-out.
|
|
153
|
+
|
|
113
154
|
## `<webjs-frame>` Partial-Swap Regions
|
|
114
155
|
|
|
115
156
|
`<webjs-frame>` is WebJs's take on Turbo Frames, so most `<turbo-frame>` muscle memory transfers. It is a lazy, URL-addressable region that swaps on its own, driven by a link or form targeting its id, and it ships zero component JS. Use it for a region that loads or refreshes INDEPENDENTLY of a full-page navigation (a marketing widget, tabbed UI, a filtered results panel), which a page or layout cannot express.
|
|
@@ -118,7 +159,15 @@ A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<for
|
|
|
118
159
|
html`<webjs-frame id="activity">…contents…</webjs-frame>`
|
|
119
160
|
```
|
|
120
161
|
|
|
121
|
-
On click the router walks `closest('webjs-frame')` from the target. If a frame is found and the response carries a matching `<webjs-frame id>`, the swap is scoped to that frame's children, and the server returns ONLY that subtree.
|
|
162
|
+
On click the router walks `closest('webjs-frame')` from the target. If a frame is found and the response carries a matching `<webjs-frame id>`, the swap is scoped to that frame's children, and the server returns ONLY that subtree. A link that drives a frame participates in link prefetch like any other, in that frame's own dimension (#1407), so a hovered or viewport-warmed frame link swaps on click with no round trip. A `<webjs-frame src>` SELF-load is the exception: it neither reads nor keeps that cache, since asking a frame to load its own src is a freshness request rather than a hover being followed. See the prefetch section above for the frame dimension's rules.
|
|
163
|
+
|
|
164
|
+
**A frame swap never moves the window scroll.** A page navigation scrolls to top, the way a browser does; a frame swap changes one region and leaves the rest of the document standing, the reader's scroll offset included. That holds for a nested link, an external `data-webjs-frame` trigger, a frame-targeted form submission, and a `src` self-load alike, and it holds for a `#hash` on a frame link too, which rides the URL without moving the viewport. It does NOT cover a pure fragment link whose path and query match the page it sits on, because the router never sees one: the click handler bows out before `preventDefault`, so the browser does its own native fragment jump and the window moves.
|
|
165
|
+
|
|
166
|
+
**Every spelling of a fragment link is the browser's, the bare `#` included** (#1437). `href="#"` is the back-to-top idiom and it serializes with an EMPTY fragment, which reads identically to no fragment at all through `URL.hash`, so the bow-out tests the `href` for a `#` instead. A `<a href="#">Back to top</a>` therefore scrolls to top natively, inside a frame as well as outside one. `href=""` is NOT a fragment link: it resolves to the current url with the fragment REMOVED, which the spec reloads rather than jumps, so the router navigates it like any other link.
|
|
167
|
+
|
|
168
|
+
The escapes are page navigations and DO scroll: `data-webjs-frame="_top"`, and an id `resolveTargetFrameId` cannot match to a live frame, which warns and degrades to a normal nav. Do not read that second one as covering a RESPONSE that lacks the requested frame (the `webjs:frame-missing` warning). There the frame resolved and the nav stayed frame-scoped, so the offset holds and only the panel is left unchanged. Turbo's `autoscroll` opt-in, which scrolls the frame itself into view on swap, has no WebJs equivalent; the router simply never writes scroll for a frame.
|
|
169
|
+
|
|
170
|
+
**Read "never moves" as "WebJs never writes one", not as a guarantee the viewport cannot move.** A swap that makes the panel SHORTER shortens the document with it, and a reader parked near the bottom is then holding an offset the document can no longer reach, so the browser clamps it. Measured on the gallery's frames demo: filtering from All to Done at the bottom of the page moves the window from 474 to 405, exactly the 69px the document lost. The router wrote no scroll there (verified with every scrolling API instrumented), and any DOM change that shortens a page does the same thing. Keeping the frame a stable height across its states avoids it entirely.
|
|
122
171
|
|
|
123
172
|
**External targeting.** A trigger does not have to be nested inside the frame. An `<a>` or `<form>` carrying `data-webjs-frame="<id>"` drives that frame from anywhere (an explicit `data-webjs-frame` wins over the enclosing-frame default). `data-webjs-frame="_top"` is a reserved token forcing a full-page navigation that breaks out of the frame.
|
|
124
173
|
|