@webjsdev/mcp 0.1.3 → 0.1.5
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 +3 -1
- package/package.json +1 -1
- package/resources/AGENTS.md +67 -20
- package/resources/agent-docs/advanced.md +59 -15
- package/resources/agent-docs/built-ins.md +5 -5
- package/resources/agent-docs/components.md +113 -19
- package/resources/agent-docs/configuration.md +29 -1
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +16 -27
- package/resources/agent-docs/recipes.md +49 -6
- package/resources/agent-docs/runtime.md +91 -0
- package/resources/agent-docs/styling.md +43 -0
- package/resources/agent-docs/testing.md +27 -12
- package/resources/agent-docs/typescript.md +1 -1
- package/src/mcp-docs.js +2 -4
- package/src/mcp-source.js +73 -1
- package/src/mcp.js +26 -2
package/README.md
CHANGED
|
@@ -34,7 +34,9 @@ subcommand) delegates to this same server, so both routes run identical code.
|
|
|
34
34
|
MCP `resources` (the `agent-docs/*` corpus + `AGENTS.md` as `webjs-docs://*`),
|
|
35
35
|
and `prompts` (the recipes as guided workflows).
|
|
36
36
|
- **`source` tool**: reads the framework's own no-build source from
|
|
37
|
-
`node_modules/@webjsdev/*/src` (read-only, traversal-guarded).
|
|
37
|
+
`node_modules/@webjsdev/*/src` (read-only, traversal-guarded). Under Bun
|
|
38
|
+
zero-install (no `node_modules`) it falls back to Bun's global cache, where the
|
|
39
|
+
packages live, so it works without an install.
|
|
38
40
|
|
|
39
41
|
The docs corpus is bundled into the package at `prepack`, so `npx @webjsdev/mcp`
|
|
40
42
|
is self-contained; in the monorepo it falls back to the live repo-root docs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The webjs Model Context Protocol server: live app introspection (routes / actions / components / check) plus the framework knowledge layer (docs, recipes, source) for AI coding agents",
|
|
6
6
|
"bin": {
|
package/resources/AGENTS.md
CHANGED
|
@@ -14,7 +14,8 @@ reference there.
|
|
|
14
14
|
| `agent-docs/components.md` | WebComponent deep-dive (controllers, hooks, light/shadow DOM, slots) |
|
|
15
15
|
| `agent-docs/styling.md` | Tailwind helpers + vanilla-CSS opt-out conventions |
|
|
16
16
|
| `agent-docs/built-ins.md` | Auth, sessions, env vars, caching (cache(), HTTP cache, asset-hash, conditional GET), rate-limit, broadcast, file storage |
|
|
17
|
-
| `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps, dev/start task orchestration) + observability |
|
|
17
|
+
| `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, client-router opt-out, ingress caps, dev/start task orchestration) + observability |
|
|
18
|
+
| `agent-docs/runtime.md` | Node vs Bun (commands, the zero-install Bun bootstrap, the listener/stripper/SQLite/WebSocket differences) + the future-Deno seam |
|
|
18
19
|
| `agent-docs/advanced.md` | Suspense streaming, performance, bundling, client router (prefetch, frames, view transitions, stream actions), WebSockets |
|
|
19
20
|
| `agent-docs/typescript.md` | TS at runtime + full-stack type safety |
|
|
20
21
|
| `agent-docs/service-worker.md` | The opt-in progressive-enhancement service worker (`public/sw.js`) |
|
|
@@ -37,6 +38,18 @@ reference there.
|
|
|
37
38
|
|
|
38
39
|
Claude Code enforces step 1 via `.claude/hooks/guard-branch-context.sh`. Other agents check manually.
|
|
39
40
|
|
|
41
|
+
### One task per git worktree when agents run concurrently
|
|
42
|
+
|
|
43
|
+
webjs is worked by MULTIPLE agents at once. If more than one agent (or more than one in-flight task) shares ONE working checkout, they collide: a `git checkout` in one moves `HEAD` under the other, so the next commit lands on the WRONG branch. This has happened (a `chore: release` commit landed on an unrelated `feat/` branch, with a contaminated changelog). So give each task its own worktree:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
git worktree add -b <prefix>/<slug> ../<repo>-<slug> origin/main
|
|
47
|
+
cd ../<repo>-<slug> # do ALL work for the task here
|
|
48
|
+
# ... commit + push from this worktree; after merge: git worktree remove ../<repo>-<slug>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Git enforces one-branch-per-worktree, so separate worktrees make the collision impossible. Before any commit in a shared checkout, confirm `git branch --show-current` is still the branch you created; if it moved, you are colliding, switch to a worktree. A lone agent in a clean checkout may still use a plain branch. The repo's `.hooks/pre-commit` additionally BLOCKS a published-library (`core`/`server`/`cli`/`mcp`/`ui`/`intellisense`) version bump on any non-`chore/release-*` branch, the canonical wrong-branch-release symptom.
|
|
52
|
+
|
|
40
53
|
### Skills are routed deterministically, never skipped
|
|
41
54
|
|
|
42
55
|
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.
|
|
@@ -49,8 +62,8 @@ When interactive approval is disabled, never block on questions. Auto-decide: on
|
|
|
49
62
|
|
|
50
63
|
Every code change MUST include, automatically:
|
|
51
64
|
|
|
52
|
-
1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). `npm test` does NOT run browser or
|
|
53
|
-
2. **Documentation.**
|
|
65
|
+
1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** webjs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `agent-docs/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`).
|
|
66
|
+
2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + `agent-docs/*.md` for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`docs/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
|
|
54
67
|
3. **Convention validation.** Run `webjs check` and fix violations.
|
|
55
68
|
|
|
56
69
|
### Git workflow (mandatory)
|
|
@@ -75,9 +88,9 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-f
|
|
|
75
88
|
|
|
76
89
|
## What webjs is
|
|
77
90
|
|
|
78
|
-
An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive
|
|
91
|
+
An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive properties, the lit lifecycle hooks, ReactiveControllers, the `lit-html` directive set, `html` / `css` templates) so lit training data transfers directly, but webjs ships its own no-build implementation under `packages/core/src/`. Reactive properties are the one deliberate divergence (invariant 10): instead of lit's `@property()` decorator or a `static properties` block, webjs declares them through the declare-free base-class factory `extends WebComponent({ count: Number })`.
|
|
79
92
|
|
|
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.
|
|
93
|
+
- **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `agent-docs/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target. See `agent-docs/runtime.md` for the Node vs Bun command + difference reference (and the zero-install Bun bootstrap).
|
|
81
94
|
- **SSR + CSR by default.** Pages are server-rendered HTML; components render light DOM by default, shadow DOM opt-in via `static shadow = true` with DSD SSR.
|
|
82
95
|
- **Progressive enhancement is the default architecture.** Pages and components are SSR'd; with JS off, content reads, `<a>` navigates, `<form>` server actions submit, display-only elements render. JS is opt-in *per interactive behaviour* (`@click`, a reactive property assignment, a signal mutation). Never write a first paint that depends on hydration; never use `fetch` + JS where a `<form>` + server action would do.
|
|
83
96
|
- **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `agent-docs/components.md`.
|
|
@@ -96,7 +109,13 @@ webjs has **no server/client component split.** There is no RSC render tree, no
|
|
|
96
109
|
- **Components hydrate.** The module loads in the browser, registers the custom element, the browser upgrades the SSR'd tag, and `render()` / lifecycle / `@event` / signals run client-side. Per-element, islands-style. **All interactivity lives here.**
|
|
97
110
|
- **Pages and layouts do NOT hydrate.** Their function runs only on the server to produce HTML and is never re-invoked in the browser. So a page/layout cannot be interactive in its own markup (an `@click` in a page template is dropped at SSR; a signal read in a page body never re-renders). For interactivity, render a component's tag.
|
|
98
111
|
|
|
99
|
-
A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade)
|
|
112
|
+
A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade). The client router is automatic and needs no import: it auto-enables when `@webjsdev/core` loads in the browser (the bundle every component pulls), so any page that ships a component gets client navigation for free (#620). That load is also how its imports reach the client (`import dayjs` at the top of a page fetches dayjs when the module loads, not via hydration). An inert page/layout is dead weight, which is exactly when elision drops it. A page/layout that is non-inert ONLY because it imports interactive components is **import-only** (#605): since it never hydrates, the boot emits its component modules directly and drops the page/layout module, so the browser fetches just the interactive leaves (it still ships whole when it has its own client side effect: a `client-router` import, a module-scope call, or a self-registering bare import).
|
|
113
|
+
|
|
114
|
+
**Keep pages and layouts as pure carriers (#621), so their modules stay out of the network tab.** A page/layout drops from the boot (import-only / inert) only while its sole browser-relevant job is registering the components it imports. It starts SHIPPING its own module the moment its closure does any OTHER client work, which is invisible in `npm test` (an elision verdict, not a behaviour change) and easy to introduce by accident. Avoid, in a page/layout (and in any util it imports):
|
|
115
|
+
- module-scope client work: a top-level call, a `new SomethingNonData()`, a browser-global (`window` / `document` / `customElements`) access, or a bare side-effect import of a non-component or `@webjsdev/core/client-router` (routing is automatic). A page-template `@event` / inline-`<script>` is fine (it is SSR output, never module client work, #623 / #634).
|
|
116
|
+
- importing a client-effecting NON-component util. A helper that touches a client global or self-executes drags the whole page in. Put client-only behaviour inside a component; put server-only work in `.server.{js,ts}` (it never reaches the client closure); and if a util MIXES a pure helper with client-global code (the `cn.ts` shape, #619), split the client part into its own module so the pure helper does not pin every importer.
|
|
117
|
+
|
|
118
|
+
Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the boot `<script type="module">`; if one does, something in its closure does client work and is not a component.
|
|
100
119
|
|
|
101
120
|
`route.{js,ts}` is the one routing file that is NOT isomorphic: a server-only HTTP handler (named `GET` / `POST` exports), the webjs equivalent of a Next route handler. It never ships to the client.
|
|
102
121
|
|
|
@@ -139,12 +158,30 @@ db/*.server.{js,ts} data layer (Drizzle: schema, columns, connection)
|
|
|
139
158
|
|
|
140
159
|
Every file is a plain ES module.
|
|
141
160
|
|
|
161
|
+
### Imports: prefer the `#` root alias over deep relatives (#555)
|
|
162
|
+
|
|
163
|
+
App-internal imports use the **`#` path alias** instead of deep `../../../`
|
|
164
|
+
relatives: `import { db } from '#db/connection.server.ts'`, `import { Button }
|
|
165
|
+
from '#components/ui/button.ts'`, `#lib/...`, `#modules/...`. It is Node's native
|
|
166
|
+
`package.json "imports"` field (the scaffold ships the single catch-all `"#*":
|
|
167
|
+
"./*"`, so any top-level folder is aliased with no config change), resolved at
|
|
168
|
+
runtime by Node 24+ AND Bun with **no build step and no tsconfig `paths`**. The
|
|
169
|
+
sigil is `#` (not `@`) and there is **no slash after it** (`#lib/...`, not
|
|
170
|
+
`#/lib/...`): a `#/`-prefixed key does not resolve on Bun. The webjs server
|
|
171
|
+
expands the same map for the import graph / auth gate / elision / browser
|
|
172
|
+
importmap, so a `#`-aliased `.server.ts` still trips the server-only boundary.
|
|
173
|
+
A same-directory import stays relative (`./sibling.ts`); only deep relatives
|
|
174
|
+
become `#`. The alias addresses a top-level SUBDIRECTORY (`#lib/...`,
|
|
175
|
+
`#components/...`); a bare root-level file (`./env.ts`) is imported relatively
|
|
176
|
+
(the browser importmap scope is per-directory, so a `#`-imported root file has
|
|
177
|
+
no browser mapping in dev). Opt out anywhere by writing a plain relative import.
|
|
178
|
+
|
|
142
179
|
---
|
|
143
180
|
|
|
144
181
|
## Public API of `@webjsdev/core`
|
|
145
182
|
|
|
146
183
|
```js
|
|
147
|
-
import { html, css, WebComponent, render } from '@webjsdev/core';
|
|
184
|
+
import { html, css, WebComponent, prop, render } from '@webjsdev/core';
|
|
148
185
|
import { renderToString } from '@webjsdev/core/server';
|
|
149
186
|
```
|
|
150
187
|
|
|
@@ -153,7 +190,8 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
|
|
|
153
190
|
| Export | Purpose |
|
|
154
191
|
|---|---|
|
|
155
192
|
| `html` / `css` | Tagged template literals. `css` goes in `static styles`. |
|
|
156
|
-
| `WebComponent` | Base class for interactive components. |
|
|
193
|
+
| `WebComponent` | Base class for interactive components. Called as `WebComponent({ ... })` it returns a typed base declaring reactive properties. |
|
|
194
|
+
| `prop(type?, opts?)` | Declares one reactive property inside the `WebComponent({ ... })` factory with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`) and a narrowable TS type (`prop<Student>(Object)`). |
|
|
157
195
|
| `register(tag, C)` | Tag binding. Auto-called by `Class.register('tag')`. |
|
|
158
196
|
| `render(v, el)` | Client-side render into a DOM element. |
|
|
159
197
|
| `renderToString` | Server-side async render to HTML with DSD (from `/server`). |
|
|
@@ -179,18 +217,27 @@ lit-html parity: `repeat` (keyed lists), `unsafeHTML(str)` (trusted raw HTML, **
|
|
|
179
217
|
## `WebComponent` essentials
|
|
180
218
|
|
|
181
219
|
```ts
|
|
182
|
-
|
|
220
|
+
// Reactive properties are declared in the base-class factory (declare-free)
|
|
221
|
+
class MyThing extends WebComponent({
|
|
222
|
+
count: prop(Number, { reflect: true })
|
|
223
|
+
}) {
|
|
183
224
|
static shadow = false; // default light DOM; true = scoped shadow DOM
|
|
184
225
|
static lazy = false; // true = load module on viewport entry
|
|
185
|
-
static properties = { count: { type: Number, reflect: true } };
|
|
186
|
-
declare count: number; // TS only, typed accessor
|
|
187
226
|
static styles = css`…`;
|
|
227
|
+
|
|
228
|
+
constructor() {
|
|
229
|
+
super();
|
|
230
|
+
this.count = 0; // Fully typed, no declare!
|
|
231
|
+
}
|
|
232
|
+
|
|
188
233
|
render() { return html`…`; }
|
|
189
234
|
}
|
|
190
235
|
MyThing.register('my-thing');
|
|
191
236
|
```
|
|
192
237
|
|
|
193
|
-
**Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local.
|
|
238
|
+
**Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local.
|
|
239
|
+
|
|
240
|
+
**Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field initializer (e.g., `count = 0`), which clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`.
|
|
194
241
|
|
|
195
242
|
**Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
|
|
196
243
|
|
|
@@ -247,7 +294,7 @@ Server actions export named async functions whose args + returns round-trip thro
|
|
|
247
294
|
|
|
248
295
|
### RPC + REST endpoint security
|
|
249
296
|
|
|
250
|
-
|
|
297
|
+
Action RPC CSRF is an **Origin / `Sec-Fetch-Site` check** (the Remix 3 / Go 1.25 model), NOT a token cookie: a state-changing verb passes only when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers, no `Sec-Fetch-Site`) the `Origin` host matches the request host, or the source is in `webjs.allowedOrigins`; otherwise 403. **No `Set-Cookie` rides the SSR HTML**, which is why a page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout for a whole visitor-identical app) is CDN-edge-cacheable. A safe GET action is CSRF-exempt. Prod errors are sanitized to `message` only. A `route.ts` REST endpoint (hand-written or via the `route()` adapter) is NOT covered by this check: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `agent-docs/advanced.md`.
|
|
251
298
|
|
|
252
299
|
### Components (`components/*.{js,ts}`)
|
|
253
300
|
|
|
@@ -277,15 +324,15 @@ type ActionResult<T> =
|
|
|
277
324
|
|
|
278
325
|
## Styling: Tailwind-first
|
|
279
326
|
|
|
280
|
-
**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. See `agent-docs/styling.md`.
|
|
327
|
+
**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `agent-docs/styling.md`.
|
|
281
328
|
|
|
282
329
|
---
|
|
283
330
|
|
|
284
331
|
## Client navigation: automatic, nothing to opt into
|
|
285
332
|
|
|
286
|
-
Nested layouts auto-emit `<!--wj:children:<segment-path>-->` markers; the client router walks both DOMs and replaces only the deepest shared layout's children slot, preserving outer-layout DOM identity. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (the server returns only the divergent fragment); scroll is restored on back/forward. A non-GET `<form>` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
|
|
333
|
+
The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. Nested layouts auto-emit `<!--wj:children:<segment-path>-->` markers; the client router walks both DOMs and replaces only the deepest shared layout's children slot, preserving outer-layout DOM identity. Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (the server returns only the divergent fragment); scroll is restored on back/forward. A non-GET `<form>` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
|
|
287
334
|
|
|
288
|
-
The advanced client-router surface is in `agent-docs/advanced.md`: **link prefetch** (on by default, `intent`
|
|
335
|
+
The advanced client-router surface is in `agent-docs/advanced.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition">`, plus `data-webjs-permanent` to persist a live element), **stream actions** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2).
|
|
289
336
|
|
|
290
337
|
---
|
|
291
338
|
|
|
@@ -293,11 +340,11 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
293
340
|
|
|
294
341
|
> Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.com/docs/troubleshooting) is keyed by symptom (the throw-at-load server import, the backtick-in-template 500, the TypeScript strip failure, the SSR browser-global crash, the missing-frame swap) and maps each back to the invariant and the `webjs check` rule below.
|
|
295
342
|
|
|
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 (`
|
|
343
|
+
1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing a DB driver (`pg`), `node:*`, or any server-only dep from a component or an `app/**` page / layout / loading / error / not-found file crashes the browser at module load.
|
|
297
344
|
2. **Every `*.server.{js,ts}` file with `'use server'` exports must be `async` functions returning serializer-safe values.** Args and results round-trip via webjs's wire. Files without `'use server'` (server-only utilities) can export anything, including singletons.
|
|
298
345
|
3. **Custom element tag names must contain a hyphen** (HTML spec). Pass the tag to `Class.register('tag-name')`, not a static field. Any short-string quote works: `'tag-name'`, `"tag-name"`, or `` `tag-name` `` (single-line, no interpolation).
|
|
299
346
|
4. **Event (`@`), property (`.`), boolean (`?`) holes in `html` must be unquoted**, e.g. `@click=${fn}`, never `@click="${fn}"`.
|
|
300
|
-
5. **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core` and read via `signal.get()` inside `render()`; the built-in SignalWatcher tracks the reads and re-renders. Module-scope signals share state across components; instance-scope signals (constructor) are component-local.
|
|
347
|
+
5. **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core` and read via `signal.get()` inside `render()`; the built-in SignalWatcher tracks the reads and re-renders. Module-scope signals share state across components; instance-scope signals (constructor) are component-local. The factory `WebComponent({ ... })` is reserved for values riding an HTML attribute, reflected to one, or arriving via `.prop=${value}` SSR hydration. For fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
|
|
301
348
|
6. **Page and layout default exports must be functions.** They return a value (usually `TemplateResult`). They do not call `render()` themselves.
|
|
302
349
|
7. **Light-DOM components with custom CSS MUST prefix every class selector with their tag name.** Tailwind utilities are unique by construction, so prefer them.
|
|
303
350
|
8. **Non-root layouts and pages MUST NOT** write `<!doctype>` / `<html>` / `<head>` / `<body>`. Only the root layout may.
|
|
@@ -310,7 +357,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
310
357
|
|
|
311
358
|
## Scaffolding
|
|
312
359
|
|
|
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.
|
|
360
|
+
Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors any of the three for Bun (the dev/start/db scripts run through a generated `webjs-bun.mjs` bootstrap under `bun --bun`, so the SERVER runs on Bun AND Bun auto-install resolves deps on demand at the package.json/`bun.lock` versions (an `onLoad` specifier rewrite pins them so zero-install fetches the declared version, not latest, #685), so a Bun app serves with NO `bun install`, install optional for editor types / offline, #675; `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/check/typecheck tooling stays plain `webjs` on Node and still expects an install); `bun create webjs <name>` auto-detects it. Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous.
|
|
314
361
|
|
|
315
362
|
Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **Treat the scaffold as REFERENCE, not the final product:** replace the example page / `User` model / components and adapt `app/layout.ts` (brand, nav, content width; the default `<main class="max-w-[760px]">` reading column needs widening for a full-bleed app). ENFORCED: examples carry a `webjs-scaffold-placeholder` comment and `no-scaffold-placeholder` fails until the content is replaced and the marker deleted. Docs at https://docs.webjs.com.
|
|
316
363
|
|
|
@@ -324,7 +371,7 @@ webjs start [--port N] # prod server; source IS the runtime, plain H
|
|
|
324
371
|
webjs test [--server] [--browser] [--watch]
|
|
325
372
|
webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
|
|
326
373
|
webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check
|
|
327
|
-
webjs doctor # project-health checklist; non-zero exit on a hard fail
|
|
374
|
+
webjs doctor # project-health checklist (incl. a page/layout elision advisory, #646); non-zero exit on a hard fail
|
|
328
375
|
webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
|
|
329
376
|
webjs typecheck [tsc args...] # the project's own tsc --noEmit
|
|
330
377
|
webjs create <name> [--template api|saas]
|
|
@@ -380,7 +380,7 @@ The warning is informational; the request still proceeds. Treat it as a prompt t
|
|
|
380
380
|
|
|
381
381
|
## Client router: nested-layout-aware partial swap
|
|
382
382
|
|
|
383
|
-
|
|
383
|
+
The client router auto-enables when `@webjsdev/core` loads in the browser (any page that ships a component), no import needed (#620). It gives SPA-style navigation that
|
|
384
384
|
preserves outer-layout DOM identity at any depth. Intercepts same-origin
|
|
385
385
|
`<a>` clicks (incl. inside shadow DOM via `composedPath()`), fetches the
|
|
386
386
|
target HTML, and replaces only the inside of the deepest shared layout.
|
|
@@ -570,20 +570,30 @@ affects a cached page.
|
|
|
570
570
|
### Link prefetch
|
|
571
571
|
|
|
572
572
|
Same-origin in-app links are prefetched speculatively so a click
|
|
573
|
-
resolves from a warm cache with no round-trip. On by default
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
573
|
+
resolves from a warm cache with no round-trip. On by default (no per-link
|
|
574
|
+
opt-in needed), the way Next / Nuxt / SvelteKit ship auto-prefetch. The
|
|
575
|
+
prefetch request carries the same `X-Webjs-Have` header a real navigation
|
|
576
|
+
sends, so the server returns the same divergent fragment; that fragment
|
|
577
|
+
lands in a dedicated prefetch cache (separate from the back/forward
|
|
578
|
+
snapshot cache) and `fetchAndApply` consumes it via `prefetchTake` before
|
|
579
|
+
falling back to the network.
|
|
580
|
+
|
|
581
|
+
The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve
|
|
582
|
+
both input modalities. On a hover-capable fine pointer (mouse / trackpad)
|
|
583
|
+
the default is `intent` (warm on hover / focus, a real head-start before
|
|
584
|
+
the click). On touch the default is `viewport` (warm as links settle
|
|
585
|
+
on-screen), because a touch device has no hover and `touchstart` fires at
|
|
586
|
+
tap time, too late to front-run the navigation. The modality is detected
|
|
587
|
+
with `matchMedia('(hover: hover) and (pointer: fine)')`, not a user-agent
|
|
588
|
+
sniff. A per-link `data-prefetch` always overrides the adaptive default.
|
|
580
589
|
|
|
581
590
|
Per link, set `data-prefetch` (a valid-HTML `data-*` attribute, the shape
|
|
582
591
|
SvelteKit and Astro use; Next / Nuxt / Remix use a component prop, which
|
|
583
592
|
webjs has no equivalent for since links are plain `<a href>`):
|
|
584
593
|
|
|
585
594
|
```html
|
|
586
|
-
<a href="/dashboard">
|
|
595
|
+
<a href="/dashboard">adaptive: intent on pointer, viewport on touch (default)</a>
|
|
596
|
+
<a href="/dashboard" data-prefetch="intent">hover / focus / touch</a>
|
|
587
597
|
<a href="/dashboard" data-prefetch="render">eager on insert</a>
|
|
588
598
|
<a href="/dashboard" data-prefetch="viewport">on scroll-into-view</a>
|
|
589
599
|
<a href="/dashboard" data-prefetch="none">never</a>
|
|
@@ -591,9 +601,14 @@ webjs has no equivalent for since links are plain `<a href>`):
|
|
|
591
601
|
|
|
592
602
|
Next-style aliases are accepted: `true` = `render`, `auto` = `viewport`,
|
|
593
603
|
`false` = `none`. `intent` waits a short dwell (~100ms) after hover/focus
|
|
594
|
-
so a pointer passing over a link does not fetch it
|
|
595
|
-
IntersectionObserver at threshold 0.5
|
|
596
|
-
|
|
604
|
+
so a pointer passing over a link does not fetch it. `viewport` uses an
|
|
605
|
+
IntersectionObserver at threshold 0.5 and waits a ~250ms dwell, cancelled
|
|
606
|
+
the instant the link scrolls back out, so a fast scroll through a long
|
|
607
|
+
list spends no requests (the same gate Astro / Next / Nuxt / Remix /
|
|
608
|
+
TanStack / Turbo apply). On touch, `touchstart` additionally warms the
|
|
609
|
+
tapped link itself (a single request for a link about to be navigated).
|
|
610
|
+
`render` and `viewport` are applied by a document scan on enable and after
|
|
611
|
+
each navigation.
|
|
597
612
|
|
|
598
613
|
Only internal links qualify, using the same eligibility as a click:
|
|
599
614
|
cross-origin, `download`, `target` other than `_self`, non-HTML
|
|
@@ -602,7 +617,9 @@ Opt out with `data-prefetch="none"`, `data-no-prefetch`, or
|
|
|
602
617
|
`rel="external"`. Speculation is bounded by a concurrency cap (excess
|
|
603
618
|
requests queue and drain as slots free, rather than being dropped),
|
|
604
619
|
in-flight de-dupe, and an LRU + TTL cache, and is disabled entirely under
|
|
605
|
-
`Save-Data
|
|
620
|
+
`Save-Data`, `prefers-reduced-data`, or a 2g `effectiveType` connection.
|
|
621
|
+
The guiding rule: snappy, but never at the cost of bloating the client
|
|
622
|
+
network tab; when the two conflict, the gate under-fetches. A mutating form submission and
|
|
606
623
|
`revalidate(url)` both evict the prefetch cache alongside the snapshot
|
|
607
624
|
cache, so a fragment prefetched before a mutation is never served stale.
|
|
608
625
|
|
|
@@ -851,6 +868,17 @@ revalidation fetch that follows does **not** scroll, so the restored
|
|
|
851
868
|
position survives the refresh. Cache miss → browser-native scroll
|
|
852
869
|
restoration takes over.
|
|
853
870
|
|
|
871
|
+
Every programmatic nav scroll (the popstate restore and the
|
|
872
|
+
scroll-to-top on a forward nav) is issued with `behavior: 'instant'`, so
|
|
873
|
+
an app-level `html { scroll-behavior: smooth }` does not animate it. The
|
|
874
|
+
restore matches a native page load (an instant jump), not a visible
|
|
875
|
+
slide. The one nav scroll left smooth is a hash-anchor (`#section`)
|
|
876
|
+
target, where smooth scrolling is the intent. So `scroll-behavior:
|
|
877
|
+
smooth` on `<html>` only affects in-page anchors, not route
|
|
878
|
+
transitions. In development the router logs a one-time console hint when
|
|
879
|
+
it detects that setting (and notes that pairing it with a sticky
|
|
880
|
+
`backdrop-filter` header can flash on iOS during navigation).
|
|
881
|
+
|
|
854
882
|
Inner scroll containers (e.g. `.docs-sidenav`) are preserved
|
|
855
883
|
automatically by the outer-layout-DOM-identity invariant. They stay
|
|
856
884
|
mounted across nav and keep their `scrollTop` natively.
|
|
@@ -1094,7 +1122,7 @@ more parts in a `Response` with the stream content type. A page `action` may
|
|
|
1094
1122
|
return `streamResponse(...)` directly (it is honored verbatim); on the no-JS
|
|
1095
1123
|
branch return a normal `ActionResult` instead. `renderStream` is auto-registered
|
|
1096
1124
|
by the client router, so it (and the `<webjs-stream>` element) is available
|
|
1097
|
-
|
|
1125
|
+
on any page that loads `@webjsdev/core` (the router auto-enables there).
|
|
1098
1126
|
|
|
1099
1127
|
### Streaming RPC results: an action returns a stream (#489)
|
|
1100
1128
|
|
|
@@ -1158,7 +1186,23 @@ Use `data-no-router` for:
|
|
|
1158
1186
|
|
|
1159
1187
|
### Loading indicator
|
|
1160
1188
|
|
|
1161
|
-
|
|
1189
|
+
**When to use it.** You want a CSS-only progress affordance (a top bar, a `cursor: progress`) while a client navigation is in flight, with no JavaScript. For a JS-driven indicator, skip this and listen for the `webjs:navigate` event (and `webjs:submit-start` for forms) instead, which always fires.
|
|
1190
|
+
|
|
1191
|
+
**How to use it.** It is OPT-IN. Add `data-webjs-nav-progress` to your `<html>` element (once, in the root layout), and the router then sets a `data-navigating` attribute on `<html>` while a nav is in flight (deferred 150ms, so quick sub-150ms navs never trigger it). Style off that attribute:
|
|
1192
|
+
|
|
1193
|
+
```html
|
|
1194
|
+
<html data-webjs-nav-progress> <!-- opt in once -->
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
```css
|
|
1198
|
+
html[data-navigating] { cursor: progress; }
|
|
1199
|
+
html[data-navigating]::after {
|
|
1200
|
+
content: ''; position: fixed; top: 0; left: 0; right: 0; height: 2px;
|
|
1201
|
+
background: var(--accent); animation: progress 1s ease-in-out infinite;
|
|
1202
|
+
}
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
**Why it is opt-in, not default (#610).** Toggling an attribute on `<html>` re-runs global style resolution, and on WebKit (so every iOS browser) that re-resolves the page's `oklch()` / `color-mix()` design tokens to an equivalent oklab representation and repaints them for one frame. On a token-driven theme that is a visible background flash, worst on a slow mobile forward nav (which exceeds the 150ms defer). So without the opt-in the attribute is never written and the flash cannot happen. Enable it only when your theme does not lean on wide-gamut color tokens, or use the event-based path above.
|
|
1162
1206
|
|
|
1163
1207
|
## WebSockets
|
|
1164
1208
|
|
|
@@ -137,10 +137,10 @@ The cache is keyed by the FULL URL (path + search) only, with no per-user
|
|
|
137
137
|
keying, so a page that reads `cookies()` / a session / per-user data MUST
|
|
138
138
|
NOT set `revalidate`. The framework also refuses to cache (defense in
|
|
139
139
|
depth) any response that is not a `200`, is a streamed Suspense body,
|
|
140
|
-
sets
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
sets any `Set-Cookie`, or runs under CSP (its body carries a per-request
|
|
141
|
+
nonce). SSR responses no longer carry a CSRF cookie (action CSRF is an
|
|
142
|
+
Origin / `Sec-Fetch-Site` check, not a token cookie), so a cacheable page
|
|
143
|
+
is cookieless and safe to share.
|
|
144
144
|
|
|
145
145
|
**Framework defense, not just the contract.** When the render reads
|
|
146
146
|
per-user state through a framework helper (`cookies()`, `headers()`,
|
|
@@ -221,7 +221,7 @@ The ETag is WEAK (`W/"..."`). It hashes the UNCOMPRESSED body and the prod compr
|
|
|
221
221
|
| A `route.{js,ts}` handler returning a `ReadableStream` (incl. an SSE `text/event-stream`) | The body is not marked buffered, so the funnel never reads it. Buffering a stream would blow up memory, and an SSE stream never ends so the read would hang forever |
|
|
222
222
|
| Non-GET / non-HEAD, and any status other than 200 | A validator is only meaningful for a successful, replayable read |
|
|
223
223
|
|
|
224
|
-
**Stable-body handling.** The ETag is computed over the response's OWN body bytes, so an identical body yields an identical ETag across requests. Per-response varying bits that ride RESPONSE HEADERS (the `x-webjs-build` id, the
|
|
224
|
+
**Stable-body handling.** The ETag is computed over the response's OWN body bytes, so an identical body yields an identical ETag across requests. Per-response varying bits that ride RESPONSE HEADERS (the `x-webjs-build` id, the CSP nonce on the header) are NOT part of the body hash, so they do not destabilise the ETag. The one body-level varying input is the CSP nonce stamped INTO the inline boot script: with CSP enabled the HTML body changes every request, so its ETag changes every request and a 304 is simply never produced for that page (correct, not a bug). CSP is off by default, so the common cacheable-page case has a stable body and a stable ETag. The 304 preserves the validators and caching headers (`ETag`, `Cache-Control`, `Vary`, plus the framework's `X-Webjs-Build` / `X-Request-Id` and any `Set-Cookie`) and drops only the body-describing headers (`Content-Length`, `Content-Type`, `Content-Encoding`), so a shared cache and the client router behave identically to a 200.
|
|
225
225
|
|
|
226
226
|
## Sessions
|
|
227
227
|
|
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
| Option | Type | Default | Meaning |
|
|
6
6
|
|---|---|---|---|
|
|
7
|
-
|
|
7
|
+
These options are passed to the `prop()` helper inside the `WebComponent({ ... })` factory (e.g. `count: prop(Number, { reflect: true })`); the bare form `count: Number` is shorthand for `prop(Number)`.
|
|
8
|
+
|
|
9
|
+
| Option | Type | Default | Meaning |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| `type` | `Number\|String\|Boolean\|Object\|Array` | `String` | Used by the default attribute converter (the first `prop()` argument) |
|
|
8
12
|
| `reflect` | `boolean` | `false` | Property changes write back to the HTML attribute |
|
|
9
13
|
| `state` | `boolean` | `false` | Internal-only. No attribute, not in `observedAttributes` |
|
|
14
|
+
| `attribute` | `string` | derived from the prop name | The HTML attribute name the property rides |
|
|
15
|
+
| `default` | value or `() => value` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
|
|
10
16
|
| `hasChanged` | `(newVal, oldVal) => boolean` | strict `!==` | Custom change detection |
|
|
11
17
|
| `converter` | `{ fromAttribute?, toAttribute? }` | type-based | Custom attribute ↔ property serialization |
|
|
12
18
|
|
|
@@ -14,18 +20,60 @@ Built-in constructors (`String`, `Number`, `Boolean`, `Array`, `Object`) feed
|
|
|
14
20
|
the default attribute coercion. For anything the default can't parse correctly
|
|
15
21
|
(Date, Map, Set, discriminated unions) supply a custom `converter`.
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
For an array-typed prop, pass `Array`, not `Object` (`items: prop<Tag[]>(Array)`).
|
|
24
|
+
The default converter handles both identically (each JSON-encodes the value), so
|
|
25
|
+
`Object` does not break anything, but `Array` states the prop's shape to the next
|
|
26
|
+
reader. The `array-prop-uses-array-type` `webjs check` rule flags an array-typed
|
|
27
|
+
generic (`T[]`, `readonly T[]`, `Array<T>`, `ReadonlyArray<T>`) declared with the
|
|
28
|
+
`Object` constructor.
|
|
18
29
|
|
|
19
|
-
|
|
20
|
-
constructor via `Object.defineProperty`. Without `declare`, TypeScript
|
|
21
|
-
emits `student = undefined` after `super()`, which under modern class-
|
|
22
|
-
field semantics uses `[[Define]]` to overwrite the accessor. Result:
|
|
23
|
-
`this.student = …` no longer goes through the setter, no `requestUpdate`,
|
|
24
|
-
no `hasChanged`, no reflect, and reactivity silently breaks.
|
|
30
|
+
## Declaring reactive properties: the base-class factory
|
|
25
31
|
|
|
26
|
-
The
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
Reactive properties are declared by passing the property shape into the **base-class factory** `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is no `static properties` block and no `declare` line. A direct `static properties` block throws at runtime (caught statically by the `no-static-properties` rule).
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
class Counter extends WebComponent({
|
|
36
|
+
count: Number
|
|
37
|
+
}) {
|
|
38
|
+
constructor() {
|
|
39
|
+
super();
|
|
40
|
+
this.count = 0; // fully typed, no declare needed
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`). The `prop()` helper carries options and narrows the TS type:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
class Dialog extends WebComponent({
|
|
49
|
+
open: prop(Boolean, { reflect: true }), // reflects to the `open` attribute
|
|
50
|
+
showClose: prop(Boolean, { attribute: 'show-close-button' }), // custom attribute name
|
|
51
|
+
variant: prop<'info' | 'danger'>(String, { reflect: true }), // narrowed union type
|
|
52
|
+
student: prop<Student>(Object), // narrowed object type
|
|
53
|
+
internal: prop({ state: true }), // internal state, no attribute, no type
|
|
54
|
+
}) {
|
|
55
|
+
constructor() {
|
|
56
|
+
super();
|
|
57
|
+
this.student = { name: '', email: '' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Set defaults in the constructor after `super()`. A declarative `default` option also exists (lit-parity, a function default runs per instance for a fresh object / array), but the constructor is the recommended way. **Never** use a class-field initializer (e.g., `count = 0` or `student: Student = { ... }`): the framework installs reactive getter/setter on `this` inside the constructor via `Object.defineProperty`, and a class-field initializer compiles to an assignment after `super()` that uses `[[Define]]` to overwrite the accessor, silently breaking reactivity. The `reactive-props-no-class-field` rule catches this.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// set defaults in the constructor after super()
|
|
66
|
+
class Counter extends WebComponent({
|
|
67
|
+
count: prop(Number, { reflect: true }),
|
|
68
|
+
items: prop(Array),
|
|
69
|
+
}) {
|
|
70
|
+
constructor() {
|
|
71
|
+
super();
|
|
72
|
+
this.count = 0;
|
|
73
|
+
this.items = [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
29
77
|
|
|
30
78
|
## Lifecycle hooks (lit-aligned)
|
|
31
79
|
|
|
@@ -55,9 +103,7 @@ See [`/docs/lifecycle`](https://docs.webjs.com/docs/lifecycle) for per-hook usag
|
|
|
55
103
|
A component can fetch its own server data into the first paint. `render()` may be `async`, so you write the natural line directly:
|
|
56
104
|
|
|
57
105
|
```ts
|
|
58
|
-
class UserProfile extends WebComponent {
|
|
59
|
-
static properties = { uid: { type: String } };
|
|
60
|
-
declare uid: string;
|
|
106
|
+
class UserProfile extends WebComponent({ uid: String }) {
|
|
61
107
|
async render() {
|
|
62
108
|
const u = await getUser(this.uid); // a 'use server' action: real fn at SSR, RPC stub on the client
|
|
63
109
|
return html`<h3>${u.name}</h3>`;
|
|
@@ -75,9 +121,7 @@ Writing `await` makes the function async by the JS rule, and every render path a
|
|
|
75
121
|
3. **`renderFallback()` is the OPTIONAL re-fetch loading UI.** Define it to OVERRIDE the stale-while-revalidate default with a loading state (skeleton / spinner) shown DURING a client re-fetch. It is shown ONLY on a re-fetch, NEVER on the first paint, and it does NOT create a server-streaming boundary. It is a prop-aware method (not a static field), so it can branch on the component's current state.
|
|
76
122
|
|
|
77
123
|
```ts
|
|
78
|
-
class UserActivity extends WebComponent {
|
|
79
|
-
static properties = { uid: { type: String } };
|
|
80
|
-
declare uid: string;
|
|
124
|
+
class UserActivity extends WebComponent({ uid: String }) {
|
|
81
125
|
renderFallback() { return html`<div class="skeleton h-24"></div>`; } // shown only while a re-fetch is in flight
|
|
82
126
|
async render() {
|
|
83
127
|
const items = await getActivity(this.uid);
|
|
@@ -153,11 +197,11 @@ server/client split to reason about. A component stays elidable as long
|
|
|
153
197
|
as it has none of the following.
|
|
154
198
|
|
|
155
199
|
- An `@event` binding in a template (`@click=${...}`), or a native event-handler property (`.onclick=${...}`).
|
|
156
|
-
- A reactive property
|
|
200
|
+
- A factory-declared reactive property (`WebComponent({ ... })`) that is not `{ state: true }`. Attribute-driven or `.prop`-driven values are the channel a parent uses to push client updates.
|
|
157
201
|
- An overridden lifecycle hook (anything in the table above), as a method or an arrow class field.
|
|
158
202
|
- A `signal` / `computed` / `watch` / `Task` / `ref` / `live` / streaming directive imported from `@webjsdev/core`, OR a transitive import of a module that reads shared module-scope signal state.
|
|
159
203
|
- An `addController(...)` or `requestUpdate()` call.
|
|
160
|
-
- Any code that runs at module load. A display-only module's top level may only *declare* things (imports, the `WebComponent` class, `const` / `let` / `var`, pure initializers like `css\`...\``) and *register* the component (`X.register(...)` / `customElements.define(...)`). Any other top-level call, `new`, dynamic `import(...)`, or top-level `await` is client work and ships (a top-level `fetch('/track')`, `new WebSocket(...)`, `setTimeout(...)`, `someInit()`). This is checked structurally as an allowlist of safe top-level forms, not a denylist of global names, so a brand-new browser API is caught automatically with no code change. Code inside a method, `render()`, or an uninvoked function does not count (it does not run at load), nor do these words in rendered template text or a `.fetch` / `.location` member access.
|
|
204
|
+
- Any code that runs at module load. A display-only module's top level may only *declare* things (imports, the `WebComponent` class, `const` / `let` / `var`, pure initializers like `css\`...\``) and *register* the component (`X.register(...)` / `customElements.define(...)`). Any other top-level call, a non-data `new`, dynamic `import(...)`, or top-level `await` is client work and ships (a top-level `fetch('/track')`, `new WebSocket(...)`, `setTimeout(...)`, `someInit()`). A module-scope pure-DATA constructor is the one exception (`const TAGS = new Set([...])`, a compiled `new RegExp(...)`, a parsed `new URL(...)`, a typed array): it produces inert data with no side effect, so it does not force shipping. This is checked structurally as an allowlist of safe top-level forms, not a denylist of global names, so a brand-new browser API is caught automatically with no code change. Code inside a method, `render()`, or an uninvoked function does not count (it does not run at load), nor do these words in rendered template text or a `.fetch` / `.location` member access.
|
|
161
205
|
- A rendered `<slot>`. Light-DOM slots rely on the client projection runtime, and proving a slot is purely native (shadow DOM) is beyond static analysis, so any `<slot>` ships.
|
|
162
206
|
- Being rendered or imported by a component that itself ships (an interactive parent can re-create the child on the client).
|
|
163
207
|
|
|
@@ -205,6 +249,56 @@ which fails if a new `WebComponent` hook is added without teaching the
|
|
|
205
249
|
analyser about it. If you add an interactivity feature to the framework,
|
|
206
250
|
update that file.
|
|
207
251
|
|
|
252
|
+
## Pages and layouts: keep them carriers, out of the network tab
|
|
253
|
+
|
|
254
|
+
The same elision applies to whole routes, and it is what keeps `page.ts`
|
|
255
|
+
and `layout.ts` out of the browser's network tab. A page/layout NEVER
|
|
256
|
+
hydrates: it loads in the browser only to register the components it
|
|
257
|
+
imports (so their tags upgrade). When that registration is its sole
|
|
258
|
+
browser-relevant job, the framework drops the page/layout module and the
|
|
259
|
+
boot script imports those components directly. It is **import-only** (the
|
|
260
|
+
page/layout is dropped but reaches shipping components, #605) or **inert**
|
|
261
|
+
(it reaches none, so zero application JS, #179).
|
|
262
|
+
|
|
263
|
+
A page/layout starts SHIPPING its own module the moment its closure does
|
|
264
|
+
any OTHER client work. Because that is an elision verdict, not a behaviour
|
|
265
|
+
change, it is invisible in `npm test` and easy to introduce by accident
|
|
266
|
+
(a code sample mistaken for real code, #634; a util that touches a client
|
|
267
|
+
global, #619). To keep a route's modules out of the network tab:
|
|
268
|
+
|
|
269
|
+
- Do not give a page/layout its own module-scope client work: no top-level
|
|
270
|
+
call, no `new SomethingNonData()`, no browser-global access
|
|
271
|
+
(`window` / `document` / `customElements`) at module scope, no bare
|
|
272
|
+
side-effect import of a non-component, and no
|
|
273
|
+
`import '@webjsdev/core/client-router'` (routing is automatic, #620). A
|
|
274
|
+
page-template `@event` or inline `<script>` is fine: it is SSR output,
|
|
275
|
+
never module client work (the analyser scans route-module template
|
|
276
|
+
content as inert, #623 / #634).
|
|
277
|
+
- Do not import a client-effecting NON-component util into a page/layout
|
|
278
|
+
(or into a component chain a page reaches). A helper that touches a
|
|
279
|
+
client global or self-executes drags the whole page in. Put client-only
|
|
280
|
+
behaviour inside a component; put server-only work in `.server.{js,ts}`
|
|
281
|
+
(it never enters the client closure); and if a util MIXES a pure helper
|
|
282
|
+
with client-global code (the `cn.ts` shape, #619), split the client part
|
|
283
|
+
into its own module so the pure helper does not pin every importer.
|
|
284
|
+
|
|
285
|
+
Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab
|
|
286
|
+
or the boot `<script type="module">`. If one does, something in its
|
|
287
|
+
closure does client work and is not a component; that is the thing to move
|
|
288
|
+
into a component or a `.server.{js,ts}` file.
|
|
289
|
+
|
|
290
|
+
`webjs doctor` names this for you (#646). Its "Page/layout elision (carrier
|
|
291
|
+
hygiene)" check runs the same elision verdict and, for every page/layout
|
|
292
|
+
that ships whole, prints the FIRST client-effecting blocker by name, for
|
|
293
|
+
example `app/page.ts ships whole. Its first client-effecting blocker is
|
|
294
|
+
lib/track.ts, which references a browser global at module scope, runs code
|
|
295
|
+
at module scope, or has a bare side-effect import and is not a component`.
|
|
296
|
+
It names the first blocker only; if a module has several, it stays shipped
|
|
297
|
+
until each is moved out. It is ADVISORY (a `warn`, never a hard fail): a page
|
|
298
|
+
legitimately MAY ship, and the analyser is biased toward shipping by design,
|
|
299
|
+
so this is a "you may not have intended this" hint. When the module's own
|
|
300
|
+
code is the cause, it prints `ships whole because it <reason>`.
|
|
301
|
+
|
|
208
302
|
### Elision is what keeps a server import off a display-only page (and `webjs check` guards the seam)
|
|
209
303
|
|
|
210
304
|
Elision is also why a page can call a server-only utility and stay
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Server configuration (the `package.json` `"webjs"` block) + observability
|
|
2
2
|
|
|
3
|
-
The full reference for the `package.json` `"webjs"` config block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps) plus the observability surfaces. Moved out of `AGENTS.md` to keep it lean. Env vars live in `agent-docs/built-ins.md`.
|
|
3
|
+
The full reference for the `package.json` `"webjs"` config block (security headers, CSP, redirects, trailing-slash, basePath, allowedOrigins, client-router opt-out, ingress caps) plus the observability surfaces. Moved out of `AGENTS.md` to keep it lean. Env vars live in `agent-docs/built-ins.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -123,6 +123,34 @@ An app served under a sub-path (`example.com/app/`) behind a proxy that does NOT
|
|
|
123
123
|
|
|
124
124
|
Mechanism: `normalizeBasePath` / `readBasePath` / `withBasePath` / `stripBasePath` in `packages/server/src/base-path.js`; the ingress strip is in `dev.js`'s `produce()` (before `applyRedirects`), the importmap-target prefix in `importmap.js` (`setBasePath`), the boot / preload / reload prefix in `ssr.js`.
|
|
125
125
|
|
|
126
|
+
## Cross-origin allowlist: `webjs.allowedOrigins` in package.json (#659)
|
|
127
|
+
|
|
128
|
+
Server-action RPC CSRF is an Origin / `Sec-Fetch-Site` check (the Remix 3 / Go 1.25 model, see `agent-docs/built-ins.md` and the docs-site Security page): a state-changing request passes when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers) the `Origin` host equals the request host. A legitimate CROSS-site caller (a reverse proxy serving the app under several hostnames, an admin panel on a sibling domain) is rejected unless its origin is allowlisted:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{ "webjs": { "allowedOrigins": ["admin.example.com", "https://studio.example.com"] } }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- A bare host (`admin.example.com`) or a full origin (`https://studio.example.com`) are both accepted; only the host is compared.
|
|
135
|
+
- The check honors `x-forwarded-host`, so it works behind a CDN / reverse proxy (the Cloudflare-in-front-of-Railway setup) without extra config.
|
|
136
|
+
- Default `[]` (same-origin only). This is the CSRF allowlist ONLY; it does not configure CORS (use the `cors()` middleware for cross-origin `route.ts` reads).
|
|
137
|
+
|
|
138
|
+
Because the check needs nothing on the page (no token cookie), SSR HTML carries no `Set-Cookie`, so a visitor-identical page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout) is CDN-edge-cacheable. Mechanism: `verifyOrigin` / `readAllowedOrigins` in `packages/server/src/csrf.js`, threaded through `invokeAction` (`actions.js`) from `dev.js`.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Client-router opt-out: `webjs.clientRouter` in package.json (#629)
|
|
143
|
+
|
|
144
|
+
The client router is automatic: it auto-enables in the browser whenever `@webjsdev/core` loads (any page that ships a component), so SPA-style navigation needs no import (#620). `webjs.clientRouter: false` opts the WHOLE app out, for an app that genuinely wants plain full-page (MPA) navigation despite shipping interactive components.
|
|
145
|
+
|
|
146
|
+
```jsonc
|
|
147
|
+
{ "webjs": { "clientRouter": false } } // pure MPA, full-page navigation
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Default `true` (and any value other than the literal `false`), so an existing app is byte-identical. When off, the render path emits a tiny inline `<script>window.__WEBJS_CLIENT_ROUTER__=false</script>` BEFORE the deferred boot module; the core bundle's module-end auto-enable reads that flag and skips, so the components on the page still upgrade and stay interactive while link clicks and form submits fall back to native browser navigation. `disableClientRouter()` (from `@webjsdev/core`) stays the per-page programmatic escape hatch, and `enableClientRouter()` turns it back on.
|
|
151
|
+
|
|
152
|
+
Mechanism: `readClientRouterEnabled` (`packages/server/src/dev.js`) reads the key at boot and on every rebuild and calls `setClientRouterEnabled` (`ssr.js`); `wrapHead` emits the flag; the module-end gate lives in `packages/core/src/router-client.js`.
|
|
153
|
+
|
|
126
154
|
---
|
|
127
155
|
|
|
128
156
|
## Request ingress hardening: body-size limit (413) + server timeouts (on by default)
|
|
@@ -77,14 +77,14 @@ lit form unless the vanilla API is genuinely unavoidable.
|
|
|
77
77
|
|
|
78
78
|
| Vanilla muscle memory | Lit-style webjs form |
|
|
79
79
|
|---|---|
|
|
80
|
-
| `this.getAttribute('x')` / `this.hasAttribute('x')` for own config | a reactive prop: `
|
|
80
|
+
| `this.getAttribute('x')` / `this.hasAttribute('x')` for own config | a reactive prop declared in the factory: `extends WebComponent({ x: String })`, read `this.x` (the prop rides the `x` attribute) |
|
|
81
81
|
| `this.setAttribute('x', v)` / `removeAttribute` to reflect own state | a reactive prop with `reflect: true`, or for non-attribute state a `signal` |
|
|
82
82
|
| `state: true` reactive prop for internal state | a `signal` (instance signal in the constructor, or module-scope) |
|
|
83
83
|
| `this.classList.add/toggle(...)` on self | a `class=${...}` binding in `render()` |
|
|
84
84
|
| `this.innerHTML = ...` / `appendChild` / `document.createElement` | return the markup from `render()` as `` html`...` `` |
|
|
85
85
|
| `this.addEventListener('click', ...)` on own/child elements | a `@click=${...}` binding in the template |
|
|
86
86
|
| `this.querySelector(...)` to reach own rendered DOM | the `ref()` directive + `createRef()`, or read a `<form>` with `new FormData(form)` |
|
|
87
|
-
| manual `observedAttributes` + `attributeChangedCallback` | `
|
|
87
|
+
| manual `observedAttributes` + `attributeChangedCallback` | a factory-declared reactive prop `WebComponent({ ... })` (the framework derives both) |
|
|
88
88
|
| manual `customElements.define('x', C)` | `C.register('x')` |
|
|
89
89
|
|
|
90
90
|
Emitting an event with `this.dispatchEvent(new CustomEvent(...))` is the
|
|
@@ -244,8 +244,8 @@ the event methods (`addEventListener` / `removeEventListener` /
|
|
|
244
244
|
`dispatchEvent`), and `this.attachInternals()` ARE backed by a server shim,
|
|
245
245
|
so reading an attribute in `render()`, wiring a delegated listener in the
|
|
246
246
|
constructor, or reflecting a property during the SSR update cycle all work.
|
|
247
|
-
Reading attributes that drive render through a reactive
|
|
248
|
-
(`
|
|
247
|
+
Reading attributes that drive render through a factory-declared reactive
|
|
248
|
+
property (`WebComponent({ ... })`) is still the idiomatic path, but
|
|
249
249
|
`this.hasAttribute(...)` no longer crashes.
|
|
250
250
|
|
|
251
251
|
Two guards catch the browser-only cases. `webjs check` flags browser
|
|
@@ -278,20 +278,15 @@ connectedCallback() {
|
|
|
278
278
|
|
|
279
279
|
### 5. Class-field initializers for reactive properties
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
accessor.
|
|
281
|
+
In Lit, class-field initializers (like `student: Student = { name: '', email: '' }`) are commonly used. In webjs, the base class installs reactive accessors on `this` inside the constructor via `Object.defineProperty` (to support SSR property hydration and signals). Under modern V8 class-field semantics, a class-field initializer compiles to an assignment after `super()`, which uses `[[Define]]` and overwrites the accessor, silently breaking reactivity. The footgun is still live with factory-declared props, so it stays a gotcha.
|
|
283
282
|
|
|
284
|
-
|
|
285
|
-
// wrong (the initializer overwrites the framework accessor after super())
|
|
286
|
-
class StudentCard extends WebComponent {
|
|
287
|
-
static properties = { student: { type: Object } };
|
|
288
|
-
student: Student = { name: '', email: '' };
|
|
289
|
-
}
|
|
283
|
+
The fix is to declare the prop in the base-class factory `WebComponent({ ... })` (which types it automatically, no `declare` line) and set its default in the constructor after `super()`, never as a class-field initializer:
|
|
290
284
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
285
|
+
```ts
|
|
286
|
+
// set the default in the constructor (fully typed, no declare needed)
|
|
287
|
+
class StudentCard extends WebComponent({
|
|
288
|
+
student: prop<Student>(Object),
|
|
289
|
+
}) {
|
|
295
290
|
constructor() {
|
|
296
291
|
super();
|
|
297
292
|
this.student = { name: '', email: '' };
|
|
@@ -299,17 +294,11 @@ class StudentCard extends WebComponent {
|
|
|
299
294
|
}
|
|
300
295
|
```
|
|
301
296
|
|
|
302
|
-
`webjs check` flags
|
|
303
|
-
AI agents emit the broken form on autopilot. The convention check is
|
|
304
|
-
the safety net, not the primary defense. Authoring code should use
|
|
305
|
-
`declare` plus constructor defaults from the start.
|
|
297
|
+
`webjs check` flags a class-field initializer on a factory-declared prop via the `reactive-props-no-class-field` rule.
|
|
306
298
|
|
|
307
|
-
### 6. The `@property()` decorator
|
|
299
|
+
### 6. The `@property()` decorator and a direct `static properties` block
|
|
308
300
|
|
|
309
|
-
|
|
310
|
-
`static properties = { ... }` plus a matching `declare` for the typed
|
|
311
|
-
accessor, as shown above. Decorators are non-erasable, so they would
|
|
312
|
-
force the framework to depend on a build step.
|
|
301
|
+
The `@property()` decorator is banned by framework invariant 10 (erasable TS): decorators are non-erasable, so they would force the framework to depend on a build step. A direct `static properties = { ... }` block is also gone, and webjs THROWS at runtime if a class body declares one (flagged by the `no-static-properties` rule). The single replacement for both is the declare-free base-class factory `WebComponent({ ... })` (the `prop()` helper carries options), as shown above.
|
|
313
302
|
|
|
314
303
|
## Patterns that produce different visual output
|
|
315
304
|
|
|
@@ -463,8 +452,8 @@ must move with it, use `repeat()` with a stable key.
|
|
|
463
452
|
| `Task` for client-time async | `Task` (no change, that's its job) |
|
|
464
453
|
| `window.X` or `document.X` in constructor or `render()` | Move to `connectedCallback` |
|
|
465
454
|
| Top-level `import` of browser-only library | Dynamic `import()` inside `connectedCallback` |
|
|
466
|
-
| `student: Student = { ... }` field initializer | `
|
|
467
|
-
| `@property()` decorator
|
|
455
|
+
| `student: Student = { ... }` field initializer | Base-class factory `WebComponent({ student: Object })`, default in the constructor after `super()` |
|
|
456
|
+
| `@property()` decorator / `static properties` block | Base-class factory `WebComponent({ ... })` (the `prop()` helper carries options) |
|
|
468
457
|
| `static styles = css` / inline `<style>` with semantic class names in a light-DOM component | Tailwind utilities (the default); or `static shadow = true` for genuinely scoped CSS |
|
|
469
458
|
| Plain `.map()` for an interactive/stateful list | Works (reconciles in place, keeps node identity); use `repeat(items, key, t)` only when the list **reorders** |
|
|
470
459
|
| `willUpdate` for SSR-visible derived state | Works (runs at SSR); keep it a pure derivation |
|
|
@@ -200,6 +200,53 @@ option: `export const POST = route(createPost, { validate })`. A
|
|
|
200
200
|
validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a
|
|
201
201
|
non-envelope return transforms the input.
|
|
202
202
|
|
|
203
|
+
## HTTP verbs, caching, streaming, and cancellation (#488, #489, #490, #492)
|
|
204
|
+
|
|
205
|
+
A `'use server'` action is a POST by default. Reserved sibling exports, read
|
|
206
|
+
statically (the same way a page reads `export const revalidate`), change its
|
|
207
|
+
HTTP semantics WITHOUT changing the call site (you still write `await
|
|
208
|
+
getUser(7)`). One callable function per configured file (a second callable is a
|
|
209
|
+
`webjs check` error).
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// modules/users/queries/get-user.server.ts: a cached, tagged GET read
|
|
213
|
+
'use server';
|
|
214
|
+
export const method = 'GET'; // absent = POST
|
|
215
|
+
export const cache = 60; // seconds, or { maxAge, swr, public }
|
|
216
|
+
export const tags = (id: number) => ['user:' + id];
|
|
217
|
+
export async function getUser(id: number) { return db.query.users.findFirst({ where: { id } }); }
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
// a mutation evicts the tags it touches
|
|
222
|
+
'use server';
|
|
223
|
+
export const invalidates = (id: number) => ['user:' + id];
|
|
224
|
+
export const middleware = [requireAuth]; // #490: async (ctx, next) => result; actionContext()
|
|
225
|
+
export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt,
|
|
229
|
+
and carries `Cache-Control` + a weak `ETag` (304 on `If-None-Match`) +
|
|
230
|
+
`X-Webjs-Tags`. A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body
|
|
231
|
+
(DELETE rides the URL), is CSRF-protected, and on success evicts its
|
|
232
|
+
`invalidates` tags and reports them via `X-Webjs-Invalidate`. A method mismatch
|
|
233
|
+
is a `405` + `Allow`.
|
|
234
|
+
- **SAFETY:** `cache` with `public: true` SHARES one response across all users,
|
|
235
|
+
keyed only by URL + args. Use it ONLY for data identical for every visitor (the
|
|
236
|
+
same rule as a page's `export const revalidate`).
|
|
237
|
+
- **Streaming (#489):** return a `ReadableStream` / async iterable / async
|
|
238
|
+
generator (any verb) and the call site does `for await (const c of await
|
|
239
|
+
streamTokens(8))`. Each chunk is rich-serialized as yielded, back-pressured,
|
|
240
|
+
and the source is cancelled on disconnect. A streamed result is never cached /
|
|
241
|
+
ETagged / seeded.
|
|
242
|
+
- **Cancellation (#492):** read the request `AbortSignal` via `actionSignal()`
|
|
243
|
+
(from `@webjsdev/server`) to stop work on disconnect; a superseded client
|
|
244
|
+
`async render()` auto-aborts the previous in-flight fetch.
|
|
245
|
+
- **SSR seeding (#472):** an action result invoked during a non-streamed SSR
|
|
246
|
+
render is serialized into the page; the stub reads that seed on its first client
|
|
247
|
+
call, so a shipping component does not re-fetch on hydration (consume-once,
|
|
248
|
+
fail-open; opt out with `"webjs": { "seed": false }`).
|
|
249
|
+
|
|
203
250
|
## Add a component
|
|
204
251
|
|
|
205
252
|
```ts
|
|
@@ -217,9 +264,7 @@ A leaf component fetches its own server data into the first paint, co-located, w
|
|
|
217
264
|
|
|
218
265
|
```ts
|
|
219
266
|
// (a) blocking async render: real data in the first paint, the common case
|
|
220
|
-
class UserProfile extends WebComponent {
|
|
221
|
-
static properties = { uid: { type: String } };
|
|
222
|
-
declare uid: string;
|
|
267
|
+
class UserProfile extends WebComponent({ uid: String }) {
|
|
223
268
|
async render() {
|
|
224
269
|
const u = await getUser(this.uid);
|
|
225
270
|
return html`<h3>${u.name}</h3>`;
|
|
@@ -235,9 +280,7 @@ html`
|
|
|
235
280
|
`;
|
|
236
281
|
|
|
237
282
|
// (c) renderFallback() = the CLIENT re-fetch loading state (never the first paint)
|
|
238
|
-
class UserActivity extends WebComponent {
|
|
239
|
-
static properties = { uid: { type: String } };
|
|
240
|
-
declare uid: string;
|
|
283
|
+
class UserActivity extends WebComponent({ uid: String }) {
|
|
241
284
|
renderFallback() { return html`<div class="skeleton h-24"></div>`; }
|
|
242
285
|
async render() {
|
|
243
286
|
const items = await getActivity(this.uid);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Runtime: Node and Bun (and future Deno)
|
|
2
|
+
|
|
3
|
+
webjs runs on **Node 24+** or **Bun**. The app source is identical on either; only
|
|
4
|
+
the listener shell, the type stripper, and a few built-ins differ. The selection
|
|
5
|
+
is a runtime-neutral seam in `startServer` (it picks the `node:http` shell on
|
|
6
|
+
Node and the `Bun.serve` shell on Bun), designed to also host a future
|
|
7
|
+
`Deno.serve` / embedded adapter. Deno is planned, not yet supported. The
|
|
8
|
+
user-facing reference is the docs-site page `/docs/runtime`; keep the two in sync.
|
|
9
|
+
|
|
10
|
+
## Node vs Bun
|
|
11
|
+
|
|
12
|
+
| Area | Node 24+ | Bun |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Install | `npm install` (required) | optional (zero-install via Bun auto-install) |
|
|
15
|
+
| Run | `npm run dev` / `npm run start` | `bun run dev` / `bun run start` |
|
|
16
|
+
| Listener | `node:http` shell | native `Bun.serve` (about 1.9x req/s on the listening path) |
|
|
17
|
+
| TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
|
|
18
|
+
| SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
|
|
19
|
+
| Hot reload | `node --watch` | `bun --hot` |
|
|
20
|
+
| WebSocket | the `ws` library | native `Bun.serve` + the `BunWsAdapter` bridge |
|
|
21
|
+
| 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
|
|
22
|
+
|
|
23
|
+
The bytes the browser fetches are identical across runtimes. The 103 Early Hints
|
|
24
|
+
gap costs only a small first-load latency edge where an edge forwards 103, never
|
|
25
|
+
correctness (the modulepreload hints still ship in the document head).
|
|
26
|
+
|
|
27
|
+
## Scaffolding the runtime
|
|
28
|
+
|
|
29
|
+
`webjs create <name>` defaults to Node. `webjs create <name> --runtime bun` (or
|
|
30
|
+
`bun create webjs <name>`, auto-detected from the invoking package manager,
|
|
31
|
+
#541) emits a Bun-flavored app: `bun.lock`, a pure `oven/bun:1` Dockerfile +
|
|
32
|
+
bun-install CI, bun-command agent docs, and the zero-install bootstrap below.
|
|
33
|
+
`--runtime` is orthogonal to `--template`.
|
|
34
|
+
|
|
35
|
+
## Bun zero-install (#675)
|
|
36
|
+
|
|
37
|
+
A Bun app's `dev` / `start` / `db` scripts run through a generated app-local
|
|
38
|
+
`webjs-bun.mjs` bootstrap under `bun --bun`:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
// webjs-bun.mjs
|
|
42
|
+
await import('@webjsdev/cli/bin/webjs.js');
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun;
|
|
46
|
+
importing the CLI by bare specifier lets Bun auto-install resolve `@webjsdev/*`
|
|
47
|
+
and the app's deps on demand, so a fresh app serves with **no `bun install`**.
|
|
48
|
+
The CLI's `start` is in-process and `dev` re-execs via `process.execPath` (which
|
|
49
|
+
is `bun` here), so the server stays on Bun once the CLI does. `bunx
|
|
50
|
+
@webjsdev/cli` is deliberately NOT used (it runs on Node via the shebang AND
|
|
51
|
+
eager-installs the whole tree). The `start.before` migrate also routes through
|
|
52
|
+
the bootstrap, so the boot-time `webjs db migrate` needs no `webjs` bin in
|
|
53
|
+
`node_modules`. `bun install` stays optional: run it for editor type
|
|
54
|
+
intelligence (no `node_modules` means no local type files) or a pinned offline
|
|
55
|
+
install. The Node-targeted tooling scripts (`test` / `check` / `typecheck`)
|
|
56
|
+
stay plain `webjs` on Node and still expect an install.
|
|
57
|
+
|
|
58
|
+
**Version pinning (#685).** Bun's auto-install fetches `latest` for a bare
|
|
59
|
+
`import 'zod'`, ignoring package.json and `bun.lock` (it consults neither the
|
|
60
|
+
manifest, the lockfile, nor an `onResolve` plugin, verified in #684). webjs
|
|
61
|
+
closes that gap with an `onLoad` transform: it rewrites a declared dep's bare
|
|
62
|
+
specifier to an inline-versioned one (`zod` to `zod@3.22.4`), which Bun's
|
|
63
|
+
auto-install DOES honor. The version is the EXACT one from `bun.lock` when
|
|
64
|
+
present, else an exact `package.json` pin. Bun resolves an exact inline version
|
|
65
|
+
but ENOENTs on a range or dist-tag (`zod@^3`, `zod@latest`), so a dep declared
|
|
66
|
+
as a RANGE without a `bun.lock` is left bare and resolves to latest as before:
|
|
67
|
+
commit a `bun.lock` (or pin exact versions) to get pinned zero-install. The
|
|
68
|
+
rewrite is server-runtime only (it shapes what Bun fetches for SSR and server
|
|
69
|
+
actions). The browser is still served bare specifiers resolved via the importmap
|
|
70
|
+
/ jspm, unchanged. Only declared deps are rewritten (a transitive dep follows
|
|
71
|
+
from its pinned parent's manifest), and it is a no-op when `node_modules` exists
|
|
72
|
+
(Bun uses the installed copy). Default on. Opt out with `WEBJS_PIN=0` or
|
|
73
|
+
`{ "webjs": { "pin": false } }`.
|
|
74
|
+
|
|
75
|
+
**Reproducibility:** dev resolves on demand (now at the pinned versions), and the
|
|
76
|
+
scaffold's Bun Dockerfile still keeps an explicit `bun install` so a prod image
|
|
77
|
+
is immutable and self-contained with no registry fetch at boot.
|
|
78
|
+
|
|
79
|
+
## SQLite busy_timeout (#674)
|
|
80
|
+
|
|
81
|
+
Both `node:sqlite` and `bun:sqlite` default `busy_timeout` to 0, so a contended
|
|
82
|
+
write throws `database is locked` immediately (better-sqlite3, used before the
|
|
83
|
+
built-in drivers, defaulted to 5000ms). The generated connection sets `PRAGMA
|
|
84
|
+
busy_timeout = 5000` + `PRAGMA journal_mode = WAL` on the raw client before
|
|
85
|
+
drizzle wraps it, on both runtime branches.
|
|
86
|
+
|
|
87
|
+
## Future runtimes
|
|
88
|
+
|
|
89
|
+
The listener seam is runtime-neutral, so a `Deno.serve` shell (or an embedded
|
|
90
|
+
adapter) slots in at the same point when added. Edge runtimes with no filesystem
|
|
91
|
+
are a separate, later target. Until then, treat Deno as planned, not supported.
|
|
@@ -234,3 +234,46 @@ More files you write, more discipline required, slight rename cost (2
|
|
|
234
234
|
textual edits when a route folder moves). In exchange: no browser-
|
|
235
235
|
runtime script, no `@theme` block, idiomatic CSS, plain cascade you can
|
|
236
236
|
debug with any tool.
|
|
237
|
+
|
|
238
|
+
## Gotcha: a sticky header flickers on iOS, use `position: fixed`
|
|
239
|
+
|
|
240
|
+
A `position: sticky` header (the common `sticky top-0` pattern) flickers its
|
|
241
|
+
background for one frame on iOS WebKit (every iOS browser, since they all use
|
|
242
|
+
WebKit) during a client-router FORWARD navigation. The scroll-to-top the router
|
|
243
|
+
runs after the content swap drives a sticky stuck-to-static recompute that
|
|
244
|
+
WebKit mis-repaints. It is iOS-only (fine on desktop and Android, and invisible
|
|
245
|
+
in DevTools device emulation, so it shows only on a real device). #610 confirmed
|
|
246
|
+
on-device that neither compositor promotion (`translateZ(0)` / `will-change`)
|
|
247
|
+
nor changing the swap paint timing fixes it.
|
|
248
|
+
|
|
249
|
+
The fix is `position: fixed` instead of sticky. A fixed header is always pinned
|
|
250
|
+
and never does the scroll-relative recompute, so the repaint bug never fires.
|
|
251
|
+
There is no reliable CSS-only fix that KEEPS `position: sticky` (the GPU-promotion
|
|
252
|
+
hacks `translateZ(0)` / `translate3d` / `will-change` / a wrapper element are all
|
|
253
|
+
variants that do NOT reliably work, confirmed on-device for #610). Preserving the
|
|
254
|
+
header across nav is correct and standard (Next.js, Remix, SvelteKit all preserve
|
|
255
|
+
nested layouts); only the `sticky` positioning is the problem.
|
|
256
|
+
|
|
257
|
+
Because fixed leaves normal flow, reserve the header height on the content below.
|
|
258
|
+
Use a single `--header-height` custom property as the source of truth rather than
|
|
259
|
+
a hardcoded number, and keep it exact with a `ResizeObserver` so it tracks
|
|
260
|
+
responsive / dynamic headers (degrades fine with no JS via the default):
|
|
261
|
+
|
|
262
|
+
```css
|
|
263
|
+
:root { --header-h: 56px; } /* sane SSR first-paint default */
|
|
264
|
+
header { position: fixed; inset-inline: 0; top: 0; }
|
|
265
|
+
body { padding-top: var(--header-h); }
|
|
266
|
+
```
|
|
267
|
+
```js
|
|
268
|
+
// refine the default to the header's real height (and on resize)
|
|
269
|
+
const hdr = document.querySelector('header');
|
|
270
|
+
const apply = () => document.documentElement.style.setProperty('--header-h', hdr.offsetHeight + 'px');
|
|
271
|
+
apply();
|
|
272
|
+
new ResizeObserver(apply).observe(hdr);
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The example blog's root layout does exactly this (`examples/blog/app/layout.ts`).
|
|
276
|
+
For an app/dashboard layout, an alternative is an app-shell scroll container (a
|
|
277
|
+
non-scrolling `100dvh` flex column with `<main>` as the internal scroller), which
|
|
278
|
+
needs no offset at all but changes the scroll model (the window no longer
|
|
279
|
+
scrolls, so the iOS URL bar stays visible).
|
|
@@ -116,6 +116,17 @@ runtime-sensitive suite under Bun to catch the long tail of cross-runtime
|
|
|
116
116
|
incompatibilities (a `node:*` API Bun implements differently, a crypto/stream
|
|
117
117
|
edge case, an error-message-format quirk).
|
|
118
118
|
|
|
119
|
+
**Bun parity is part of the definition of done, not an afterthought.** A change
|
|
120
|
+
to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve`
|
|
121
|
+
listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`,
|
|
122
|
+
the TS stripper, auth / session / cors) is NOT done until you have run the Bun
|
|
123
|
+
matrix green AND added or updated a `test/bun/<feature>.mjs` cross-runtime
|
|
124
|
+
assertion for the surface. This is enforced at commit time by
|
|
125
|
+
`.claude/hooks/require-bun-parity-with-runtime-src.sh`, which BLOCKS a commit
|
|
126
|
+
that stages runtime-sensitive `packages/*/src` with no `test/bun/**` test (the
|
|
127
|
+
escape hatch `WEBJS_BUN_VERIFIED=1` is only for when an existing `test/bun`
|
|
128
|
+
script already covers the surface AND you ran it under Bun).
|
|
129
|
+
|
|
119
130
|
- `node scripts/run-bun-tests.js` (needs `bun` on PATH; `BUN=…` overrides) runs
|
|
120
131
|
the `node:test` files under `test/`, `packages/core/test/`, and
|
|
121
132
|
`packages/server/test/` (excluding `browser/`, `e2e/`, the network-bound
|
|
@@ -127,8 +138,10 @@ edge case, an error-message-format quirk).
|
|
|
127
138
|
**skip(env)** (needs Redis / a DOM), and **genuine fail** (a real Bun
|
|
128
139
|
failure, which fails the job). Set `WEBJS_BUN_TESTS=<substr,…>` to scope a
|
|
129
140
|
local run.
|
|
130
|
-
- CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508)
|
|
131
|
-
`test/bun/listener.mjs` (#511, the listener-shell parity)
|
|
141
|
+
- CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508),
|
|
142
|
+
`test/bun/listener.mjs` (#511, the listener-shell parity), and
|
|
143
|
+
`test/bun/timeouts.mjs` (#663, the `Bun.serve` `idleTimeout` wiring; the pure
|
|
144
|
+
ms-to-seconds mapping is unit-tested under the matrix too).
|
|
132
145
|
- Two cross-runtime test scripts also run under BOTH runtimes: `test/bun/smoke.mjs`
|
|
133
146
|
(boot + SSR + TS strip + a server-action RPC) and `test/bun/listener.mjs`
|
|
134
147
|
(`startServer` over a real socket: SSR + route + SSE + WebSocket). Plain assert
|
|
@@ -249,7 +262,7 @@ test exercises the production contract, never a parallel fake).
|
|
|
249
262
|
|
|
250
263
|
```js
|
|
251
264
|
import { createRequestHandler } from '@webjsdev/server';
|
|
252
|
-
import { testRequest,
|
|
265
|
+
import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
|
|
253
266
|
from '@webjsdev/server/testing';
|
|
254
267
|
|
|
255
268
|
const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
|
|
@@ -267,12 +280,13 @@ A bare path (`/about`) is prefixed with a dummy origin (the pipeline only reads
|
|
|
267
280
|
`pathname` + `search`); a full URL string or a pre-built `Request` works too.
|
|
268
281
|
The optional third arg is a standard `RequestInit` (method, headers, body).
|
|
269
282
|
|
|
270
|
-
###
|
|
283
|
+
### The auth/session helpers
|
|
271
284
|
|
|
272
|
-
|
|
273
|
-
`
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
Server-action CSRF is an Origin / `Sec-Fetch-Site` check, so a test needs no
|
|
286
|
+
CSRF setup: `invokeActionForTest` models a same-origin browser POST and passes
|
|
287
|
+
the check automatically, and `rawActionRequest(app, file, fn, args, {
|
|
288
|
+
crossOrigin: true })` models a cross-site request to assert the 403.
|
|
289
|
+
`loginAndGetCookies(handle, { email, password })` drives the
|
|
276
290
|
REAL credentials login through `handle()` (the `createAuth` route handler) and
|
|
277
291
|
captures the genuine signed session `Set-Cookie`, so a follow-up request can hit
|
|
278
292
|
a protected route as the logged-in user:
|
|
@@ -302,8 +316,9 @@ const out = await invokeActionForTest(app, 'modules/posts/actions/create.server.
|
|
|
302
316
|
|
|
303
317
|
`invokeActionForTest` serializes `args` with the webjs serializer (exactly as
|
|
304
318
|
the generated client stub does), POSTs them to the REAL
|
|
305
|
-
`/__webjs/action/<hash>/<fn>` endpoint
|
|
306
|
-
|
|
319
|
+
`/__webjs/action/<hash>/<fn>` endpoint as a same-origin request (so it passes
|
|
320
|
+
the Origin / Sec-Fetch-Site CSRF check), and parses the response with the
|
|
321
|
+
serializer. The action is addressed by the SHA-256
|
|
307
322
|
hash of its `.server.{js,ts}` file path (absolute or appDir-relative) plus the
|
|
308
323
|
function name, the same scheme the stub uses (`actionEndpoint(appDir, file, fn)`
|
|
309
324
|
returns that path if you need it directly).
|
|
@@ -352,8 +367,8 @@ test/
|
|
|
352
367
|
|
|
353
368
|
App-side runners:
|
|
354
369
|
|
|
355
|
-
- `webjs test` →
|
|
356
|
-
- `webjs test --browser` → web-test-runner against `test/**/browser
|
|
370
|
+
- `webjs test` → the runtime's test runner over everything not under `browser/` or `e2e/` (#570): `node --test` on Node, `bun test` on Bun (dispatched on `process.versions.bun`, since `bun --test` is invalid).
|
|
371
|
+
- `webjs test --browser` → web-test-runner against `test/**/browser/**` (the app's resolved `wtr` bin, spawned with the current runtime, no `npx`).
|
|
357
372
|
- `WEBJS_E2E=1 webjs test` adds e2e.
|
|
358
373
|
|
|
359
374
|
App AI agents read this convention through the scaffold's
|
|
@@ -386,7 +386,7 @@ A single plugin with its OWN in-template intelligence (no Lit dependency; the `w
|
|
|
386
386
|
Gives you, inside `` html`` `` templates:
|
|
387
387
|
- **Go-to-definition** from `<my-counter>` to the class registered via `MyCounter.register('my-counter')`, from an attribute / property / event name to its class member, and from a CSS class in `class="…"` to its `` css`` `` rule.
|
|
388
388
|
- **Completions**: reachable custom-element tag names after `<`, and binding-aware attribute completions: `.` offers property names, plain / `?` offer hyphenated attribute names (`maxLength` → `max-length`), `@event` is permissive.
|
|
389
|
-
- **Diagnostics**: attribute / property value type-checks (`<my-counter .count=${expr}>` assignability-checks `typeof expr` against `
|
|
389
|
+
- **Diagnostics**: attribute / property value type-checks (`<my-counter .count=${expr}>` assignability-checks `typeof expr` against the property's type from the `WebComponent({ count: Number })` factory shape, and `@click=${fn}` must be callable), unquoted `@`/`.`/`?` bindings (invariant 4), and expressionless `.prop` bindings.
|
|
390
390
|
- **Hover**: a tag shows its component class; an attribute / property / event shows its declared type.
|
|
391
391
|
|
|
392
392
|
Every feature is gated on import-graph reachability: a tag is recognized only if the file registering it is reachable from the file you're editing. There is deliberately no blanket "unknown tag / attribute" diagnostic (webjs has no element type map, so it would false-positive on third-party custom elements).
|
package/src/mcp-docs.js
CHANGED
|
@@ -365,16 +365,14 @@ const PROMPT_BODIES = {
|
|
|
365
365
|
'Tag MUST contain a hyphen (invariant 3). Event/property/boolean holes are',
|
|
366
366
|
'unquoted (invariant 4). Read `webjs-docs://lit-muscle-memory-gotchas` first:',
|
|
367
367
|
'a class-field initializer that overwrites a reactive accessor breaks reactivity',
|
|
368
|
-
'(
|
|
368
|
+
'(declare reactive props via the `extends WebComponent({ … })` factory).',
|
|
369
369
|
].join('\n'),
|
|
370
370
|
fetch_data_in_component: [
|
|
371
371
|
'Fetch server data IN the component that needs it, with an async render() (#469).',
|
|
372
372
|
'No prop-drilling: SSR awaits the render, so the DATA is in the first paint.',
|
|
373
373
|
'',
|
|
374
374
|
'```ts',
|
|
375
|
-
'class UserProfile extends WebComponent {',
|
|
376
|
-
' static properties = { uid: { type: String } };',
|
|
377
|
-
' declare uid: string;',
|
|
375
|
+
'class UserProfile extends WebComponent({ uid: String }) {',
|
|
378
376
|
' async render() {',
|
|
379
377
|
' const u = await getUser(this.uid); // a `use server` action: real fn at SSR, RPC stub on the client',
|
|
380
378
|
' return html`<h3>${u.name}</h3>`;',
|
package/src/mcp-source.js
CHANGED
|
@@ -48,8 +48,15 @@ const MAX_FILES = 4000;
|
|
|
48
48
|
* reliable. The source dir is `src/`, or `lib/` for the cli. A package that is
|
|
49
49
|
* not installed is skipped (not every app depends on every `@webjsdev/*`).
|
|
50
50
|
*
|
|
51
|
+
* Under Bun ZERO-INSTALL (no `node_modules`, #675) the packages live only in
|
|
52
|
+
* Bun's global install cache (`<bunCacheDir>/@webjsdev/<pkg>@<version>@@@<n>/`,
|
|
53
|
+
* which holds the full source). When the `node_modules` walk finds nothing and a
|
|
54
|
+
* `bunCacheDir` + `readdir` are supplied, fall back to scanning that scope dir
|
|
55
|
+
* for the package's versioned entry (highest version wins, best-effort), so the
|
|
56
|
+
* tool works whether the package is installed OR resolved from the Bun cache.
|
|
57
|
+
*
|
|
51
58
|
* @param {string} cwd
|
|
52
|
-
* @param {{ exists: (p: string) => boolean }} fsDeps
|
|
59
|
+
* @param {{ exists: (p: string) => boolean, readdir?: (d: string) => Array<{ name: string, isDir: boolean }>, bunCacheDir?: string | null }} fsDeps
|
|
53
60
|
* @returns {Array<{ pkg: string, root: string, src: string }>}
|
|
54
61
|
*/
|
|
55
62
|
export function resolveFrameworkRoots(cwd, fsDeps) {
|
|
@@ -69,6 +76,8 @@ export function resolveFrameworkRoots(cwd, fsDeps) {
|
|
|
69
76
|
const cand = join(base, '@webjsdev', pkg);
|
|
70
77
|
if (fsDeps.exists(join(cand, 'package.json'))) { root = cand; break; }
|
|
71
78
|
}
|
|
79
|
+
// Zero-install fallback: no node_modules, so look in Bun's global cache.
|
|
80
|
+
if (!root) root = resolveFromBunCache(pkg, fsDeps);
|
|
72
81
|
if (!root) continue;
|
|
73
82
|
// Most packages keep source in `src/`; the cli keeps it in `lib/`. Use
|
|
74
83
|
// whichever exists so every framework package's source is reachable.
|
|
@@ -82,6 +91,69 @@ export function resolveFrameworkRoots(cwd, fsDeps) {
|
|
|
82
91
|
return out;
|
|
83
92
|
}
|
|
84
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Locate `@webjsdev/<pkg>` in Bun's global install cache (the zero-install
|
|
96
|
+
* fallback). Bun caches a scoped package at `<bunCacheDir>/@webjsdev/<pkg>@<ver>@@@<n>/`
|
|
97
|
+
* (the versioned dir holds the full source; a sibling unversioned `<pkg>/` dir is
|
|
98
|
+
* metadata, skipped). Picks the highest cached version (semver-aware, so
|
|
99
|
+
* `0.10.0` beats `0.9.0`), and returns its path only if it carries a
|
|
100
|
+
* `package.json`. Returns '' when there is no cache dir, no `readdir`, or no
|
|
101
|
+
* matching entry.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} pkg
|
|
104
|
+
* @param {{ exists: (p: string) => boolean, readdir?: (d: string) => Array<{ name: string, isDir: boolean }>, bunCacheDir?: string | null }} fsDeps
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function resolveFromBunCache(pkg, fsDeps) {
|
|
108
|
+
const { bunCacheDir, readdir, exists } = fsDeps;
|
|
109
|
+
if (!bunCacheDir || typeof readdir !== 'function') return '';
|
|
110
|
+
const scopeDir = join(bunCacheDir, '@webjsdev');
|
|
111
|
+
if (!exists(scopeDir)) return '';
|
|
112
|
+
let entries;
|
|
113
|
+
try { entries = readdir(scopeDir); } catch { return ''; }
|
|
114
|
+
const prefix = `${pkg}@`;
|
|
115
|
+
// A cache dir is `<pkg>@<version>@@@<n>`; extract <version> (between the name
|
|
116
|
+
// and the `@@@` cache-key suffix) and sort by it, highest first.
|
|
117
|
+
const versioned = entries
|
|
118
|
+
.filter((e) => e.isDir && e.name.startsWith(prefix) && e.name.includes('@@@'))
|
|
119
|
+
.map((e) => ({ name: e.name, version: e.name.slice(prefix.length).split('@@@')[0] }))
|
|
120
|
+
.sort((a, b) => compareVersions(b.version, a.version));
|
|
121
|
+
for (const { name } of versioned) {
|
|
122
|
+
const cand = join(scopeDir, name);
|
|
123
|
+
if (exists(join(cand, 'package.json'))) return cand;
|
|
124
|
+
}
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compare two semver-ish version strings numerically (so `0.10.0` > `0.9.0`),
|
|
130
|
+
* with a release ranking ABOVE its prerelease (`1.0.0` > `1.0.0-rc.1`). Compares
|
|
131
|
+
* the release core (`major.minor.patch`) segment by segment numerically; on a
|
|
132
|
+
* tie, a version WITHOUT a prerelease tag sorts higher, else the prerelease tags
|
|
133
|
+
* compare lexically. The build suffix (`+...`) is ignored, per semver. Returns
|
|
134
|
+
* negative / 0 / positive like a comparator.
|
|
135
|
+
* @param {string} a
|
|
136
|
+
* @param {string} b
|
|
137
|
+
* @returns {number}
|
|
138
|
+
*/
|
|
139
|
+
function compareVersions(a, b) {
|
|
140
|
+
const parse = (v) => {
|
|
141
|
+
const [core, pre = ''] = v.split('+')[0].split('-');
|
|
142
|
+
return { nums: core.split('.').map(Number), pre };
|
|
143
|
+
};
|
|
144
|
+
const A = parse(a);
|
|
145
|
+
const B = parse(b);
|
|
146
|
+
for (let i = 0; i < Math.max(A.nums.length, B.nums.length); i++) {
|
|
147
|
+
const x = A.nums[i] || 0;
|
|
148
|
+
const y = B.nums[i] || 0;
|
|
149
|
+
if (x !== y) return x - y;
|
|
150
|
+
}
|
|
151
|
+
if (A.pre === B.pre) return 0;
|
|
152
|
+
if (!A.pre) return 1; // a release outranks any prerelease of the same core
|
|
153
|
+
if (!B.pre) return -1;
|
|
154
|
+
return A.pre < B.pre ? -1 : 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
85
157
|
/**
|
|
86
158
|
* Recursively list text-source files under `dir` (absolute paths), skipping
|
|
87
159
|
* `node_modules` / `dist` and bounded by {@link MAX_FILES}.
|
package/src/mcp.js
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createInterface } from 'node:readline';
|
|
24
|
-
import { relative } from 'node:path';
|
|
24
|
+
import { relative, join } from 'node:path';
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
25
26
|
|
|
26
27
|
import {
|
|
27
28
|
resolveDocsLocation,
|
|
@@ -36,6 +37,29 @@ import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
|
|
|
36
37
|
|
|
37
38
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Best-effort path to Bun's global install cache, for the zero-install `source`
|
|
42
|
+
* fallback (#687): under Bun zero-install an app has no `node_modules`, so
|
|
43
|
+
* `@webjsdev/*` lives only in this cache. Prefers `bun pm cache` (authoritative),
|
|
44
|
+
* then `$BUN_INSTALL/install/cache`, then `~/.bun/install/cache`. Returns null
|
|
45
|
+
* when none exists (e.g. Bun is not installed), which leaves the tool on the
|
|
46
|
+
* node_modules path with no behavior change.
|
|
47
|
+
* @param {(p: string) => boolean} exists
|
|
48
|
+
* @returns {string | null}
|
|
49
|
+
*/
|
|
50
|
+
function resolveBunCacheDir(exists) {
|
|
51
|
+
try {
|
|
52
|
+
const out = spawnSync('bun', ['pm', 'cache'], { encoding: 'utf8' });
|
|
53
|
+
const dir = out && out.status === 0 && out.stdout ? out.stdout.trim() : '';
|
|
54
|
+
if (dir && exists(dir)) return dir;
|
|
55
|
+
} catch { /* bun not on PATH */ }
|
|
56
|
+
const candidates = [];
|
|
57
|
+
if (process.env.BUN_INSTALL) candidates.push(join(process.env.BUN_INSTALL, 'install', 'cache'));
|
|
58
|
+
if (process.env.HOME) candidates.push(join(process.env.HOME, '.bun', 'install', 'cache'));
|
|
59
|
+
for (const c of candidates) { if (exists(c)) return c; }
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
// Mirrors packages/server/src/action-config.js. A drift test in
|
|
40
64
|
// packages/mcp/test/mcp.test.mjs asserts these stay in sync with the source.
|
|
41
65
|
/** HTTP verbs an action may declare (mirrors RPC_VERBS in action-config.js). */
|
|
@@ -485,7 +509,7 @@ export async function runMcpServer(opts) {
|
|
|
485
509
|
const { readdirSync, existsSync, realpathSync } = await import('node:fs');
|
|
486
510
|
const readdir = (d) => readdirSync(d, { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() }));
|
|
487
511
|
sourceDeps = {
|
|
488
|
-
roots: resolveFrameworkRoots(cwd, { exists: existsSync }),
|
|
512
|
+
roots: resolveFrameworkRoots(cwd, { exists: existsSync, readdir, bunCacheDir: resolveBunCacheDir(existsSync) }),
|
|
489
513
|
readFile,
|
|
490
514
|
readdir,
|
|
491
515
|
realpath: realpathSync,
|