diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index efa2c38fd..5d08435af 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -53,6 +53,7 @@ Classify the task first, then load the smallest useful reference set. Each refer | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` | | Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` | | Offline support, an asset cache, the opt-in service worker | `references/service-worker.md` | +| Splitting a large file, or how big a module may be | `references/module-structure.md` | | A pattern that feels like Next.js or Lit but might not transfer | `references/muscle-memory-gotchas.md` | Common bundles: diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index e124096b0..dd193731d 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -454,4 +454,4 @@ A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus - HTMLElement / Element: `title`, `id`, `slot`, `role`, `hidden`, `dir`, `lang`, `translate`, `draggable`, `tabIndex`, `className`, `dataset`, `remove`, `closest`, `matches`, `focus`, `blur`, `click`, `append` / `prepend`, `before` / `after`. Rename (`postTitle`, `removeItem`, `handleClick`). - WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete` (#1021: there is no WebJs slot API to override; slots are native). Only override one deliberately, with its exact signature; never repurpose the name for app logic. -Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js`. +Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js` and its sibling `component/` directory, where the class body actually lives. diff --git a/.agents/skills/webjs/references/module-structure.md b/.agents/skills/webjs/references/module-structure.md new file mode 100644 index 000000000..5ffd8b3f9 --- /dev/null +++ b/.agents/skills/webjs/references/module-structure.md @@ -0,0 +1,229 @@ +# Module structure: file size, design principles, and splitting a large module + +Read this before splitting a large source file, and before arguing about how +big a module is allowed to be. + +Two things live here. The first is what "well structured" means in this repo, +which is mostly judgment rather than a number. The second is the mechanical +procedure for barrelling a large module into a directory, which is NOT judgment: +it has a small number of failure modes that are silent, and every one of them +has bitten this codebase already. + +--- + +## Design principles: judgment, not a checker + +SOLID, DRY, and KISS apply here the way they apply anywhere. They are prose +guidance, followed by judgment, and deliberately NOT enforced by `webjs check`. +That split is the same one the rest of the project uses: `webjs check` carries +correctness rules only (code that is wrong to ship), and anything a sensible +project could reasonably do differently stays a convention. + +What they mean in practice, in a buildless framework whose source IS what runs: + +- **Single responsibility** is about what a module OWNS, not how long it is. A + module owns one concern when you can state that concern in a sentence without + the word "and". `router-client/prefetch.js` owns speculative fetching. It is + 584 lines, and it is one responsibility. +- **DRY applies to knowledge, not to text.** Two identical lines that would + change for different reasons are not duplication. A constant that appears in + three places IS, which is why this repo has drift guards that read one copy + and assert it against another (see the guard section below). +- **KISS beats cleverness in a framework more than in an app.** The source is + the documentation surface for the AI agents that use WebJs, and it ships + unbundled to be read. An indirection that saves five lines and costs a reader + a jump is a bad trade here. +- **Dependency direction matters more than dependency inversion.** Modules layer + downward: constants at the bottom, then pure helpers, then orchestration. + Nothing imports upward. This is not architectural taste, it is what keeps ESM + cycles out (see below). Two subsystems are genuinely mutually recursive and + cannot layer, the client router (a navigation fetches, the fetch swaps, the + swap upgrades, an upgraded element navigates) and light-DOM slots (projecting + installs the interceptors, an intercepted mutation re-projects). Those two are + named in `test/architecture/import-cycles.test.mjs`, which fails on any THIRD + cycle, so the rule holds everywhere it can. + +--- + +## File size: target 800, ceiling around 1000 + +**A source module targets 800 lines and should stay around 1000 at the most.** +The ceiling is approximate on purpose. A module at 1040 is fine; one at 1900 +needs either a split or a named exemption (below), and the question to ask at +that size is which responsibility it has picked up rather than how many lines +it has. A barrel is exempt entirely, because its length is a function of how +many names it re-exports. + +**Where this number comes from.** It was set by measuring the frameworks this +project takes its cues from, not by picking a round figure: + +| Module | Lines | +|---|---| +| `lit/packages/lit-html/src/lit-html.ts` | 2303 | +| `lit/packages/reactive-element/src/reactive-element.ts` | 1754 | +| `vite/packages/vite/src/node/optimizer/index.ts` | 1487 | +| `vite/packages/vite/src/node/server/index.ts` | 1447 | + +Every one of them draws its seams by responsibility and lets the orchestration +entry stay large. None of them enforces a line count. + +**A much smaller cap is a bad trade, and this repo has the measurement.** +Splitting ten modules into about ninety produced three bindings that needed +accessors because ESM forbids assigning an imported binding, one dropped import +that threw only inside a deferred callback, one symbol identity swap that +silently broke slot forwarding, and three drift guards that broke or would have +passed vacuously. Four of those six were invisible to `npm test`. Every module +boundary is a place where those failures can happen, so boundaries are worth +adding for cohesion and worth nothing when added to hit a number. + +There is also a runtime cost. In dev the browser fetches core source files +individually rather than the bundle, so N modules is N requests at one more +level of import-graph depth. + +**The ceiling is measured with a RAW line count** (the number `wc -l` prints, +the number you see when you open the file), because that is how #1365 specified +it and because a metric a reader cannot reproduce by looking at the file invites +argument about the metric instead of the module. The tension with dense +documentation is real: this repo's comment style can put a well-factored module +at twice its code size, and a raw ceiling must never become a reason to delete +explanation. The answer to that tension is the exemption list, not a different +metric. + +**There is deliberately NO CI guard for this.** A line-count gate is a proxy +metric that fights cohesion: it reds forever on a generated data table like +`html-entities.js`, and it has to carry an exemption list that rots. So the +ceiling is a REVIEW-TIME check, not a test. Measure it when you split something: + +```sh +find -name '*.js' -exec wc -l {} + | awk '$1 > 1000 && $2 != "total"' +``` + +**A module that genuinely cannot or should not go under the ceiling gets a +NAMED exemption**, argued in the PR that produces it, with its measured size and +its reason. The three exemptions the #1365 split carries show what a valid +reason looks like: + +- **lit parity** (`component/lifecycle.js`): the file tracks lit's + `reactive-element.ts`, which lit keeps WHOLE at 1754 lines, and the project's + standing decision is to keep lit-derived code close to lit rather than + restructure it. +- **mutual recursion** (`render-client/parts.js`): the apply and instance group + calls back into itself, so a real split creates the import cycle the D4 rule + forbids, and the escape (a runtime dispatch registry) is a worse trade. +- **a single closure over shared request state** (`dev/handler.js`): splitting + means threading that state through a context object, a high-risk rewrite of + every app's boot path for zero behaviour gain. + +Note what a valid reason is NOT: "it is mostly comments." If a module is over +the ceiling only because it is well documented, that is a signal the ceiling is +being measured too literally, not grounds for an exemption. Say why the CODE +cannot be split, or split it. + +--- + +## Splitting a module into a barrel plus a directory + +The naming rule, settled once for the whole framework: **the original file keeps +its path and becomes the barrel, and its parts land in a sibling directory named +after it.** So `packages/core/src/slot.js` stays, and its parts go in +`packages/core/src/slot/`. Do NOT rename the barrel to match a public export +subpath. The `package.json` `exports` map, the hand-written `.d.ts` overlays and +their two guard tests, the docs pages that print importmap examples, and every +relative test import all key off the current path. + +### The rule that matters most + +**A split is a MOVE, not a rewrite.** Retyping a function while relocating it is +how a refactor with a green export surface ships behaviour changes. Move the +lines verbatim. The only edits a move should produce are the `export ` keyword +where a declaration now crosses a module boundary, and the generated import +lines. + +### Where mutable module state goes + +A module-scope `let` goes in the module that WRITES it, not the module that +looks like its topical home. ESM import bindings are read-only, so a module +cannot assign a binding it imported. + +When two modules genuinely write the same binding, the owner exposes a +one-statement accessor and the other module calls it: + +```js +// scroll.js owns the counter. +export let restoreGeneration = 0; +export function bumpRestoreGeneration() { restoreGeneration += 1; } + +// navigator.js reads the live binding and calls the accessor to write. +import { restoreGeneration, bumpRestoreGeneration } from './scroll.js'; +``` + +Keep importing the binding itself wherever it is READ. Dropping it from the +import list while a read site survives leaves a free variable, which throws only +when that line executes. In the client router that meant a Back-button scroll +restore silently landing at offset 0, with every node test still green. + +### Cycles and TDZ + +Layer the modules and never import upward. Node tolerates an import cycle, but +reading a `const` or `class` binding during the cycle's evaluation phase throws +a TDZ `ReferenceError` at module load, and in a minified browser bundle that is +a blank page rather than a test failure. + +Where a back edge is unavoidable, resolve it by calling a function at call time +rather than reading a binding at module scope. + +### Symbol identity + +`Symbol('x')` mints a unique value. `Symbol.for('x')` looks one up in the global +registry by string. They are never interchangeable, and substituting one for the +other produces a value that no existing object carries, so every lookup quietly +returns `undefined`. Import the symbol from the module that created it. + +### Drift guards read source files by path + +This repo has guard tests that `readFileSync` a source file and grep it for a +constant, to pin two copies of a value against each other. Barrelling a file +breaks every one of them, and breaks them in two different ways: + +- an `assert.match` fails on its own precondition, which is loud and fine; +- an `assert.doesNotMatch` starts passing **vacuously**, which is silent and is + the reason this is written down. + +After any split, grep the test tree for reads of the file you just barrelled and +point each guard at the barrel PLUS every module beneath it. + +### Verification, in order + +```sh +# 1. Export surface is identical, in BOTH directions. `added` must be empty too: +# a behaviour-preserving split adds no public surface. +node --input-type=module -e " + const before = await import('/tmp/before.js'); + const after = await import('./packages/core/src/.js'); + const A = Object.keys(before).sort(), B = Object.keys(after).sort(); + console.log(JSON.stringify({ + missing: A.filter((k) => !B.includes(k)), + added: B.filter((k) => !A.includes(k)), + })); +" + +# 2. Every code line survived. Normalize away comments and the `export ` prefix, +# then diff. Anything left is a line the split CHANGED, and each one needs a +# reason in the commit message. + +# 3. The module loads at all (catches a TDZ throw introduced by a cycle). +node --input-type=module -e "await import('./packages/core/index-browser.js')" +node --input-type=module -e "await import('./packages/core/index.js')" + +# 4. Rebuild dist BEFORE any e2e or Bun run, which resolve the built bundle and +# would otherwise test the pre-split code and pass vacuously. +node scripts/build-framework-dist.js + +# 5. The browser suite is MANDATORY for a renderer, router, component, or slot +# split. Those defects are post-hydration: the export surface is unchanged, +# the SSR bytes are unchanged, and node tests stay green. +npm test && npm run test:browser +``` + +Step 5 is not optional and not a formality. Of the two silent defects this +codebase has hit from splitting, both were caught by the browser suite alone. diff --git a/.claude/hooks/require-bun-parity-with-runtime-src.sh b/.claude/hooks/require-bun-parity-with-runtime-src.sh index 626240b55..cd1877a0e 100755 --- a/.claude/hooks/require-bun-parity-with-runtime-src.sh +++ b/.claude/hooks/require-bun-parity-with-runtime-src.sh @@ -59,7 +59,7 @@ if [ -z "$staged" ]; then exit 0; fi # require-tests-with-src.sh. Scoped to published-package source. src_runtime=$(printf '%s\n' "$staged" \ | grep -E '^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/' \ - | grep -E 'serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr\.js|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev\.js|stream' \ + | grep -E 'serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr[./]|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev[./]|stream' \ || true) if [ -z "$src_runtime" ]; then exit 0; fi diff --git a/.claude/hooks/require-tests-with-src.sh b/.claude/hooks/require-tests-with-src.sh index a4ba5e40f..4201cfca9 100755 --- a/.claude/hooks/require-tests-with-src.sh +++ b/.claude/hooks/require-tests-with-src.sh @@ -115,7 +115,7 @@ fi # jq objects would be invalid hook output). reminder="" -client_facing=$(printf '%s\n' "$src_touched" | grep -E 'router-client|render-client|component\.js|slot\.js|lazy-loader|websocket-client|client-router|directives' || true) +client_facing=$(printf '%s\n' "$src_touched" | grep -E 'router-client|render-client|component(/|\.js$)|slot(/|\.js$)|lazy-loader|websocket-client|client-router|directives' || true) if [ -n "$client_facing" ]; then list=$(printf '%s' "$client_facing" | tr '\n' ' ') reminder="${reminder}Client/browser-facing source changed ($list). A unit test alone is not sufficient; confirm browser and/or e2e coverage (network probes, navigation, hydration) asserts the real behaviour. " @@ -129,7 +129,7 @@ fi # Kept in sync with require-bun-parity-with-runtime-src.sh (the BLOCKING gate); # this is the matching non-blocking nudge, widened to the request path (csrf / # actions / ssr / dev handler / auth / session / cors), which diverges on Bun too. -runtime_sensitive=$(printf '%s\n' "$src_touched" | grep -E 'serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr\.js|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev\.js|stream' || true) +runtime_sensitive=$(printf '%s\n' "$src_touched" | grep -E 'serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr[./]|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev[./]|stream' || true) if [ -n "$runtime_sensitive" ]; then rlist=$(printf '%s' "$runtime_sensitive" | tr '\n' ' ') reminder="${reminder}Runtime-sensitive source changed ($rlist). webjs runs on Node AND Bun: run \`node scripts/run-bun-tests.js\` (needs bun installed) plus the test/bun/*.mjs scripts under Bun, and treat any divergence as a real framework bug to fix (not a skip). Add a test/bun/.mjs cross-runtime script for a new listener/serializer/streaming surface. See .agents/skills/webjs/references/testing.md. " diff --git a/AGENTS.md b/AGENTS.md index 5fd8496ee..4e05bb76f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ to these `references/`: | `references/built-ins.md` | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | | `references/runtime.md` | Node vs Bun, running the app, deploying, runtime-specific differences | | `references/service-worker.md` | Offline support, an asset cache, the opt-in service worker | +| `references/module-structure.md` | Module size (target 800, ceiling around 1000), SOLID / DRY / KISS as judgment, and the procedure for barrelling a large module into a directory | | `references/muscle-memory-gotchas.md` | **READ FIRST** when writing components or routes. Next.js / Lit patterns that break WebJs, with the webjs-shaped fix for each | Repo-root `framework-dev.md` covers monorepo dev (only when editing WebJs @@ -93,6 +94,37 @@ The full substitution table, the traps in each replacement, and the reasoning be When interactive approval is disabled, never block on questions. Auto-decide: cut the task's worktree from `origin/main` (auto-create `/` per the label scheme); auto-rebase if the parent moved; auto-merge when ready; **delete** feature/fix branches after merge but **keep** long-lived ones (dev, staging, release/*); auto-generate meaningful commit messages; fix failing tests / convention violations rather than asking. Autonomous mode is MORE disciplined, not less, with the same quality bar. +### Module structure and file size + +Design by judgment, not by a line counter. SOLID, DRY, and KISS apply here as +prose guidance, deliberately outside `webjs check`, which carries correctness +rules only. Single responsibility is about what a module OWNS (statable in one +sentence with no "and"), DRY is about knowledge rather than text, and KISS +matters more in a framework than in an app because the source ships unbundled +and IS the documentation surface an agent reads. + +**A source module targets 800 lines and should stay around 1000 at the most.** +The ceiling is approximate: 1040 is fine, 1900 is a signal that a second +responsibility crept in. A barrel is exempt. No CI guard enforces this, because +a line-count gate is a proxy metric that fights cohesion and needs an exemption +list that rots. The number came from measuring the frameworks WebJs takes its +cues from, where `lit-html.ts` is 2303 lines, `reactive-element.ts` is 1754, and +Vite's `server/index.ts` is 1447. All of them draw seams by responsibility and +let the orchestration entry stay large. + +**When you do split a module, it is a MOVE, not a rewrite.** Retyping a function +while relocating it is how a refactor with an unchanged export surface ships +behaviour changes. The original file keeps its path and becomes a barrel over a +sibling directory named after it. Mutable module-scope state goes with its +WRITERS, since an ESM import binding cannot be assigned; when two modules write +it, the owner exposes a one-statement accessor. Never swap `Symbol('x')` for +`Symbol.for('x')`. Re-point any drift guard that reads the barrelled file by +path, because an `assert.doesNotMatch` in one starts passing VACUOUSLY. Rebuild +`packages/core/dist` before e2e or Bun, and run the browser suite: a split's +characteristic defects are post-hydration, so the export surface and the SSR +bytes are both unchanged and node tests stay green. Full procedure and the +verification commands in `references/module-structure.md`. + ### Code workflow (mandatory) Every code change MUST include, automatically: @@ -161,7 +193,7 @@ Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the ## Framework source: where to find it -Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `list_elision` (what the browser never downloads, and why each shipped module ships), `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header), plus a knowledge layer (docs / recipes / framework source). That knowledge layer reads the docs corpus installed in the app itself when there is one, so it is version-matched to the framework you are editing rather than to whenever the server was published, and `init` names the corpus it served and warns when a global install looks stale against the app. +Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. **Each of those four is now a BARREL** (#1365 split ten monoliths into sibling module directories, keeping every original path): the file re-exports, and the code lives one level down in the same-named directory (`server/src/ssr/`, `core/src/render-client/`, `core/src/router-client/`, `server/src/check/`). So the paths above still resolve and still import, but read the directory, not the barrel. The same holds for `server/src/dev.js`, `server/src/vendor.js`, `core/src/render-server.js`, `core/src/component.js`, `core/src/slot.js`, and `cli/lib/doctor.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `list_elision` (what the browser never downloads, and why each shipped module ships), `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header), plus a knowledge layer (docs / recipes / framework source). That knowledge layer reads the docs corpus installed in the app itself when there is one, so it is version-matched to the framework you are editing rather than to whenever the server was published, and `init` names the corpus it served and warns when a global install looks stale against the app. --- diff --git a/framework-dev.md b/framework-dev.md index c143701f0..af939146b 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -4,6 +4,47 @@ Read this only when editing the WebJs monorepo (this repo), not a scaffolded app --- +### Splitting a large module in `packages/` + +The framework's own source is where the 800-line target and the roughly-1000 +ceiling actually bite, since `packages/` is plain `.js` with JSDoc and several +subsystems grew past both. The naming rule is settled for the whole monorepo: +**the original file keeps its path and becomes the barrel, and its parts land in +a sibling directory named after it** (`src/slot.js` stays, parts go in +`src/slot/`). Do not rename a barrel to match its public `exports` subpath. The +`package.json` `exports` map, the hand-written `.d.ts` overlays and the two +guard tests that derive a runtime sibling from an overlay path, the docs pages +that print importmap examples, and every relative test import all key off the +current path. + +A split is a MOVE. Verify it two ways before running anything else: the barrel's +runtime export set must be identical to the pre-split module's in BOTH +directions (`added` empty too, since a behaviour-preserving split adds no public +surface), and every code line must survive byte-identical once comments and the +added `export ` prefix are normalized away. Any line that changed needs a reason +in the commit message. + +Four monorepo-specific traps, each of which has already cost a debugging session +here: + +- **`packages/core/dist` is a symlink into the primary checkout in a linked + worktree.** Building through it clobbers the primary's bundle, and NOT + building means e2e and Bun resolve the pre-split code and pass vacuously. + Replace the symlink with a real directory in the worktree, then + `node scripts/build-framework-dist.js`. +- **A relative `import.meta.url` walk breaks when the file moves one level + deeper.** `locateCoreDir`'s workspace fallback resolved three levels up to + reach `packages/`, and after the move needed four. It silently pointed at a + directory that does not exist, and every `/__webjs/core/*` request 404d. +- **Drift guards read source files by path.** Point each at the barrel PLUS + every module beneath it. An `assert.match` fails loudly, but an + `assert.doesNotMatch` starts passing vacuously. +- **The browser suite is mandatory** for a renderer, router, component, or slot + split. Those defects are post-hydration, so `npm test` stays green and + `webjs elision --verify` still reports byte parity. + +--- + ### Deploying the in-repo apps (Docker image + readiness gate) The two in-repo apps (`website`, which serves the documentation at `/docs` and the component gallery at `/ui`, and `examples/blog`) deploy from ONE image built by the root `Dockerfile`, each run as a separate service with its own `PORT` (compose sets it locally, the platform injects it in prod). `compose.yaml` is local parity for that setup; the platform never reads it. @@ -201,7 +242,7 @@ This replaced a `WEBJS_SKIP_NETWORK_TESTS` gate that could not work: it was opt- Four things to keep in mind when touching this. -**A new vendor test uses the double, not the network.** `withJspmDouble(opts, body)` installs it, clears the vendor caches on both sides, and fails the test on any request the double was not asked to serve. Refusals are RECORDED rather than thrown on purpose: every fetch caller in `packages/server/src/vendor.js` catches, so a throw would be indistinguishable from the CDN being down and would quietly weaken whatever test hit it. The runtime deny answers 503 for the same reason, since that is the shape those call sites classify as transient. +**A new vendor test uses the double, not the network.** `withJspmDouble(opts, body)` installs it, clears the vendor caches on both sides, and fails the test on any request the double was not asked to serve. Refusals are RECORDED rather than thrown on purpose: every fetch caller in the `packages/server/src/vendor/` tree catches (`jspm.js`, `integrity.js`, `resolver.js`, `audit.js`; `vendor.js` itself is now the barrel), so a throw would be indistinguishable from the CDN being down and would quietly weaken whatever test hit it. The runtime deny answers 503 for the same reason, since that is the shape those call sites classify as transient. **The deny is at RUNTIME, and that was learned the hard way.** Both runners preload `test/fixtures/deny-live-hosts.mjs`, which answers 503 for jspm.io and registry.npmjs.org unless `WEBJS_REQUIRE_NETWORK` is set. It needs no parsing, and within the test process it has no blind spots (a spawned child is the exception, below). It covers the transitive callers a source scan structurally cannot see: the app-boot tests reach jspm through `resolveVendorImports` with no `fetch(` anywhere in their own source. A test that depends on a third party now fails on EVERY run rather than only during an outage, which arrives the day it is written instead of months later. @@ -241,7 +282,7 @@ npm runs first; if it fails (auth, network, transient registry error), the GitHu The workflow uses `NPM_TOKEN` (repo secret) and the auto-provisioned `GITHUB_TOKEN`. Free for public repos. -**When `server` or the scaffold consumes a NEW `@webjsdev/core` export, core MUST publish first.** `packages/server/src/dev.js` and `context.js` import core symbols statically (`setAssetUrlProvider`, `setCspNonceProvider`), and `webjs create` emits an app that imports them too. A server published against an older core dies at module load with `does not provide an export named ...`, and a cli published first makes every freshly scaffolded app 500 on every route. Two things force the right order: +**When `server` or the scaffold consumes a NEW `@webjsdev/core` export, core MUST publish first.** `packages/server/src/dev/handler.js` and `context.js` import core symbols statically (`setAssetUrlProvider`, `setCspNonceProvider`), and `webjs create` emits an app that imports them too. A server published against an older core dies at module load with `does not provide an export named ...`, and a cli published first makes every freshly scaffolded app 500 on every route. Two things force the right order: 1. **Give `changelog/core/.md` the EARLIEST `date:` of the batch.** The publish loop sorts by that timestamp ASC, and the tie-break on equal timestamps is filename DESC, which would publish `server` BEFORE `core`. The loop is `set -e` sequential, so core-first is also the fail-safe order: if core's publish fails, nothing after it ships and no skew can reach the registry. 2. **Bump the declared range in the same release PR.** `packages/server/package.json` still declares `"@webjsdev/core": "^0.7.1"`, which every published core satisfies, so npm cannot catch the skew. Raising it to the version that actually carries the new export makes the resolver enforce the coupling permanently, independent of publish order. The scaffold cannot be range-protected (it installs `@latest`), so it relies on the ordering above. @@ -258,4 +299,4 @@ In development, three error sources push a structured error frame to the open ta **A reload is coalesced (#1397), so after a burst of edits the page reloads once the edits settle** rather than once per saved file. Each save produces two reload signals (the in-process rebuild frame, then a changed boot id when the browser reconnects to the process `node --watch` restarted), and acting on every one reloads into a server about to be killed again, which is what leaves the page unstyled. The relay holds the reload until the signals stop for 2 seconds, or at most 5 seconds into a sustained burst. So if a reload looks "missing" right after you saved, wait two seconds before looking for a bug: that is the first thing to check. An error overlay is never held, since it is not a reload. -The overlay client uses `textContent` throughout (never `innerHTML`), so the error content cannot inject markup. It is **strictly dev-only**: `reportDevError` early-returns when `!dev`, `/__webjs/reload.js` 404s in prod, and the prod 500 stays terse (only `error.message`, never the stack or a file path), so no source leaks. An embedding host can observe the same frames via the `onDevError` option on `createRequestHandler` / `startServer`. Mechanism: `buildDevErrorFrame` in `packages/server/src/dev-error.js`, `reportDevError` + the SSE push in `packages/server/src/dev.js`, the SSR-catch hook in `packages/server/src/ssr.js`. +The overlay client uses `textContent` throughout (never `innerHTML`), so the error content cannot inject markup. It is **strictly dev-only**: `reportDevError` early-returns when `!dev`, `/__webjs/reload.js` 404s in prod, and the prod 500 stays terse (only `error.message`, never the stack or a file path), so no source leaks. An embedding host can observe the same frames via the `onDevError` option on `createRequestHandler` / `startServer`. Mechanism: `buildDevErrorFrame` in `packages/server/src/dev-error.js`, `reportDevError` + the SSE push in `packages/server/src/dev/handler.js`, the SSR-catch hook in the `packages/server/src/ssr/` tree. (`dev.js` and `ssr.js` are barrels since #1365; the code is one level down.) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 72c4e2261..7efacf11f 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -181,7 +181,7 @@ README.md npm-facing package readme. | `webjs routes [--json\|--table] [--no-headers]` | Prints the route table to stdout (#975): every page (path, owner file, dynamic params) and every `route.{js,ts}` handler (path, owner file, HTTP methods). Reuses `buildRouteTable` from `@webjsdev/server` (the ONE walker, shared with `webjs types` + the dev server) and the shared `projectRoutes` projector from `@webjsdev/mcp/routes-report`, so `--json` is byte-identical to the MCP `list_routes` tool (the same split as `check --json` / `check-report.js`). Default is a grouped tree; `--table` is aligned KIND/PATH/METHODS/FILE columns and `--no-headers` drops the header row for piping. Read-only. Tests: `test/cli/routes.test.mjs` | | `webjs elision [--json] [--verify] [--routes ]` | `analyzeAppElision()` from `@webjsdev/server` (#1308). Prints the display-only elision verdict: every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it, `own` / `observed` / `closure` / `render` / `import` / `unreadable`, and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every ORPHAN class, one with no registration call or a computed tag, which the scanner cannot see either way. `--json` is byte-identical to the MCP `list_elision` tool (drift-tested). `--verify` boots two `createRequestHandler` instances with `WEBJS_ELIDE` flipped, renders the app's static page corpus through both, and diffs the masked SSR bytes via the shared `maskJsSet` / `staticPageRoutes` leaf: the framework's own differential guard pointed at an arbitrary app. It FORCES the ON side on (the override wins over `webjs.elide`, so an opted-out app still gets a real pair), skips dynamic routes by name (`--routes` adds real paths), skips a nondeterministic route rather than failing it, and exits non-zero on a divergence OR on a corpus where nothing was compared. It reports how many modules elision dropped, so a pass over a corpus with nothing elidable is visibly trivial rather than mistaken for proof | | `webjs mcp` | Delegates to `runMcpServer()` from the standalone `@webjsdev/mcp` package (#415; full surface in `packages/mcp/AGENTS.md`). A read-only MCP stdio server: INTROSPECTION (`list_routes` / `list_actions` / `list_components` / `list_elision` / `check`), KNOWLEDGE (`init` primer, `docs`, `resources`, `prompts`), and a `source` tool. The scaffold's `.claude.json` registers the server directly as `{ "command": "npx", "args": ["@webjsdev/mcp"] }` (mountable in any MCP host, e.g. Cursor `.cursor/mcp.json`); `webjs mcp` stays as a back-compat alias. STDOUT is the JSON-RPC channel (diagnostics go to stderr) | -| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js`. TWO elision checks share ONE `analyzeAppElision()` call so the module graph is built once per run: `ELISION_CARRIERS` (#646, the page/layout carrier advisory) and `ELISION_COMPONENTS` (#1308, the other direction, which PASSES with the elided inventory and warns ONLY on an orphan, the one shape that gets no verdict and has no escape hatch; gateable like any other code via `webjs.doctor.gate`). A project-health checklist over existing signals (Node major, tsconfig `erasableSyntaxOnly`, `.env` drift vs `.env.example`, vendor-pin freshness, the `.gitignore` keeping `.webjs/vendor/` committable (`vendor-gitignore`, moved here from `webjs check` in #461 as a warn since it is a project-config concern, not source correctness), `@webjsdev/*` version coherence (`WEBJS_VERSIONS`, which resolves each declared dep's INSTALLED version through Node's own resolver anchored at the app dir rather than reading `/node_modules//package.json`, so a workspace-hoisted install resolves and the check agrees with `FRAMEWORK_RESOLVE` instead of contradicting it, #1300; the resolve tries `/package.json` FIRST because a bin-only package like `@webjsdev/cli` has no main entry, and falls back through the main entry plus a bounded walk to the package root on `ERR_PACKAGE_PATH_NOT_EXPORTED` because `@webjsdev/server` locks its manifest out of its `exports` map, so neither half alone resolves all four packages. Local to `lib/doctor.js` rather than `getPackageVersion` from `@webjsdev/server`, since doctor must run when the framework does not resolve at all (#954) and that helper returns null for a bin-only package. Before the fix the check warned on every healthy workspace install, which made it ungatable), a framework-resolve probe (#954: `checkFrameworkResolves` + the exported `frameworkResolves` helper WARN when `@webjsdev/core` cannot be resolved FROM the app dir via a directory-relative `createRequire` probe, naming the fresh-git-worktree-without-node_modules cause and the fix; silent PASS when it resolves, so a healthy app is untouched), importmap coherence, git pre-commit hook, and a page/layout elision advisory (#646: `checkElisionCarriers` runs `@webjsdev/server`'s `analyzeAppElision` and WARNS, naming the first client-effecting blocker, for each page/layout that ships whole instead of being elided as a carrier; advisory-only, skipped when elision is off or there is no `app/`), and an unmarked-stylesheet-link advisory (#1095: `checkUnmarkedAssetLinks` scans every `app/**` route module that renders markup (`page` / `layout`, plus the always-shipped `error` / `not-found` / `forbidden` / `unauthorized` / `loading` boundaries and `global-error`, which writes its own ``), skipping `_private` folders the router never routes, for a `` whose href is a STATIC, quoted, root-absolute `/public/...` literal, i.e. one not wrapped in `asset()`, and WARNs with `file:line`, since that url is un-versioned and a deploy cannot bust a CDN's copy. Scoped to `rel="stylesheet"`: a cross-origin sheet must keep its exact url, `rel="icon"` is a legitimate deliberate non-mark (the website leaves its favicons bare so the SEO repo-health tests parse the hrefs literally), `rel="preload"` MUST stay unversioned or its hint could never match the request the CSS `url()` makes, and an `href=${expr}` hole is undecidable from source and is exactly the marked shape. It reads the author's SOURCE and rewrites nothing, deliberately: the automatic form was built and rejected in #1196, and this is the same authoring-time posture Rails and Remix take)). PURE checks render with a `[pass]` / `[off]` / `[warn]` / `[fail]` marker; the exit is non-zero when a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig) OR when a check the app gated `error` reports something (#1257, see below); an ungated warn (drift / staleness) never fails the exit. Each `DoctorResult` carries a stable SCREAMING_SNAKE `code` (#975, the `DOCTOR_CODES` map; e.g. `NODE_VERSION`, `TSCONFIG_ERASABLE`, `IMPORTMAP_COHERENCE`) so an agent branches on the failure KIND, not the message text. `--json` emits `{ results, summary }` where `results` is the raw `DoctorResult[]` (each carrying a `code` + its effective `severity`) and `summary` is `{ pass, warn, fail, off, strict, ok }` (the same array-under-a-key convention as `check --json`). A REJECTED `webjs.doctor` config is the one path that adds a third key, `configErrors`, an array of `{ kind }` entries (`malformed` / `unknown-key` / `unknown-code` / `bad-severity`) with `results` empty because no check ran; `--strict` additionally fails the exit on every REMAINING warning (on top of hard failures and gated errors), so doctor can gate a fully-clean fix loop. The only network touch (pin freshness, plus the importmap-coherence live resolve) is best-effort: a fetch failure is a warn, never a hard fail. The importmap-coherence check (#450) runs `@webjsdev/server`'s `checkImportmapCoherence` IDENTICALLY over the live importmap AND the vendored `.webjs/vendor/importmap.json`, warning when a pinned package needs a newer version of another pinned package than is pinned (the #446 skew class); it reads dependency metadata from the already-installed node_modules manifests (no network of its own) and degrades to "could not verify" when a manifest is unavailable. Tests: `test/cli/doctor.test.mjs` | +| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js` (delegating to `lib/doctor/runner.js`). TWO elision checks share ONE `analyzeAppElision()` call so the module graph is built once per run: `ELISION_CARRIERS` (#646, the page/layout carrier advisory) and `ELISION_COMPONENTS` (#1308, the other direction, which PASSES with the elided inventory and warns ONLY on an orphan, the one shape that gets no verdict and has no escape hatch; gateable like any other code via `webjs.doctor.gate`). A project-health checklist over existing signals (Node major, tsconfig `erasableSyntaxOnly`, `.env` drift vs `.env.example`, vendor-pin freshness, the `.gitignore` keeping `.webjs/vendor/` committable (`vendor-gitignore`, moved here from `webjs check` in #461 as a warn since it is a project-config concern, not source correctness), `@webjsdev/*` version coherence (`WEBJS_VERSIONS`, which resolves each declared dep's INSTALLED version through Node's own resolver anchored at the app dir rather than reading `/node_modules//package.json`, so a workspace-hoisted install resolves and the check agrees with `FRAMEWORK_RESOLVE` instead of contradicting it, #1300; the resolve tries `/package.json` FIRST because a bin-only package like `@webjsdev/cli` has no main entry, and falls back through the main entry plus a bounded walk to the package root on `ERR_PACKAGE_PATH_NOT_EXPORTED` because `@webjsdev/server` locks its manifest out of its `exports` map, so neither half alone resolves all four packages. Local to `lib/doctor.js` rather than `getPackageVersion` from `@webjsdev/server`, since doctor must run when the framework does not resolve at all (#954) and that helper returns null for a bin-only package. Before the fix the check warned on every healthy workspace install, which made it ungatable), a framework-resolve probe (#954: `checkFrameworkResolves` + the exported `frameworkResolves` helper WARN when `@webjsdev/core` cannot be resolved FROM the app dir via a directory-relative `createRequire` probe, naming the fresh-git-worktree-without-node_modules cause and the fix; silent PASS when it resolves, so a healthy app is untouched), importmap coherence, git pre-commit hook, and a page/layout elision advisory (#646: `checkElisionCarriers` runs `@webjsdev/server`'s `analyzeAppElision` and WARNS, naming the first client-effecting blocker, for each page/layout that ships whole instead of being elided as a carrier; advisory-only, skipped when elision is off or there is no `app/`), and an unmarked-stylesheet-link advisory (#1095: `checkUnmarkedAssetLinks` scans every `app/**` route module that renders markup (`page` / `layout`, plus the always-shipped `error` / `not-found` / `forbidden` / `unauthorized` / `loading` boundaries and `global-error`, which writes its own ``), skipping `_private` folders the router never routes, for a `` whose href is a STATIC, quoted, root-absolute `/public/...` literal, i.e. one not wrapped in `asset()`, and WARNs with `file:line`, since that url is un-versioned and a deploy cannot bust a CDN's copy. Scoped to `rel="stylesheet"`: a cross-origin sheet must keep its exact url, `rel="icon"` is a legitimate deliberate non-mark (the website leaves its favicons bare so the SEO repo-health tests parse the hrefs literally), `rel="preload"` MUST stay unversioned or its hint could never match the request the CSS `url()` makes, and an `href=${expr}` hole is undecidable from source and is exactly the marked shape. It reads the author's SOURCE and rewrites nothing, deliberately: the automatic form was built and rejected in #1196, and this is the same authoring-time posture Rails and Remix take)). PURE checks render with a `[pass]` / `[off]` / `[warn]` / `[fail]` marker; the exit is non-zero when a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig) OR when a check the app gated `error` reports something (#1257, see below); an ungated warn (drift / staleness) never fails the exit. Each `DoctorResult` carries a stable SCREAMING_SNAKE `code` (#975, the `DOCTOR_CODES` map; e.g. `NODE_VERSION`, `TSCONFIG_ERASABLE`, `IMPORTMAP_COHERENCE`) so an agent branches on the failure KIND, not the message text. `--json` emits `{ results, summary }` where `results` is the raw `DoctorResult[]` (each carrying a `code` + its effective `severity`) and `summary` is `{ pass, warn, fail, off, strict, ok }` (the same array-under-a-key convention as `check --json`). A REJECTED `webjs.doctor` config is the one path that adds a third key, `configErrors`, an array of `{ kind }` entries (`malformed` / `unknown-key` / `unknown-code` / `bad-severity`) with `results` empty because no check ran; `--strict` additionally fails the exit on every REMAINING warning (on top of hard failures and gated errors), so doctor can gate a fully-clean fix loop. The only network touch (pin freshness, plus the importmap-coherence live resolve) is best-effort: a fetch failure is a warn, never a hard fail. The importmap-coherence check (#450) runs `@webjsdev/server`'s `checkImportmapCoherence` IDENTICALLY over the live importmap AND the vendored `.webjs/vendor/importmap.json`, warning when a pinned package needs a newer version of another pinned package than is pinned (the #446 skew class); it reads dependency metadata from the already-installed node_modules manifests (no network of its own) and degrades to "could not verify" when a manifest is unavailable. Tests: `test/cli/doctor.test.mjs` | | `webjs types` | `generateRouteTypes()` from `@webjsdev/server`, writes `.webjs/routes.d.ts` (typed `Route` union + per-route params, #258). Also auto-emitted at `webjs dev` startup | | `webjs typecheck [tsc args]` | Resolves the project's own `typescript/bin/tsc` (via `createRequire` from the app cwd) and spawns it with `--noEmit`, passing extra args through. Exits non-zero on a type error (a CI gate). A clear message + non-zero exit when typescript is not installed (#265). The framework runs the standard compiler, it does not embed one | | `webjs create [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `` is validated by `lib/app-name.js` BEFORE any file is written (#1066; npm package-name rules minus the lowercase-only clause, which never protected anything and which `webjs create MyApp` relied on), at all three entries (this bin, `scaffoldApp()`, and the `create-webjs` wrapper). `--runtime bun` (or `bun create webjs`, auto-detected) emits a Bun-flavored app (#541): `dev`/`start` scripts force `bun --bun`, `bun.lock`, a pure `oven/bun:1` Dockerfile + bun-install CI, and bun-command agent docs. Orthogonal to `--template` (invariant 1 stays exactly 3 templates). | @@ -190,7 +190,7 @@ README.md npm-facing package readme. | `webjs version` | Prints the installed `@webjsdev/cli` version (#975, `readCliVersion()` reads the package's own package.json). Also reachable as the top-level `webjs --version` / `-v` flag, handled at the top of `main()` before the Node preflight so it works on an old Node. Tests: `test/cli/help.test.mjs` | | `webjs help [command]` | Bare: the full USAGE banner. `webjs help ` prints that command's usage line, a one-line summary, an **Options** table (each flag + a universal `-h, --help` row, matching the Remix CLI's per-command Options section), and an Examples block from the `HELP` map in `bin/webjs.js` (#975), so an agent reads the exact invocation instead of guessing flags. An unknown help topic prints an error + the banner and **exits 1** (`printCommandHelp` returns false). The `--help` / `-h` FLAG forms are equivalent and handled at the top of `main()` (before the Node preflight, so they work on an old Node): `webjs --help` / `-h` prints the banner; `webjs --help` / `-h` prints that command's help and short-circuits the body. Commands that forward args to an external CLI (`HELP_FLAG_PASSTHROUGH` = `typecheck` to tsc, `db` to drizzle-kit, `ui` to `@webjsdev/ui`) are excluded so the wrapped tool's own `--help` reaches it; an unrecognised command is not intercepted either, so it hits the Unknown-command error (exit 1). Tests: `test/cli/help.test.mjs` | -**Per-check severity is CONFIG, not a flag (#1257), which is what lets CI gate doctor.** `--strict` is unusable in CI on its own, because four checks are environment-shaped and fatal under it (`GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, `FRAMEWORK_RESOLVE` is environment-dependent). So an app declares its policy in `package.json` under `"webjs": { "doctor": { "gate": { "": "off" | "warn" | "error" } } }`, the same three-level scale ESLint uses and `eslint-plugin-next` ships as a rule-id-keyed map. `readDoctorPolicy(appDir)` reads it (PURE, returns `{ gate, unknownCodes, badSeverities, malformed, unknownKeys }`) and `applyDoctorPolicy(results, gate)` folds it over the results (PURE, returns a new array), both in `lib/doctor.js`; the CHECKS stay policy-unaware and the bin composes the two. Four rules make it safe. **A code with no entry keeps its default** (`error` for a hard fail, `warn` otherwise), so an app with no `webjs.doctor` block produces byte-identical counts and the `failing = fail > 0 || (strict && warn > 0)` formula is untouched. **`severity` on a result is the EFFECTIVE level, not the declared one**, so a PASSING check reports `pass` even when its code is gated `error` and `results.some((r) => r.severity === 'error')` has no false positive. **A `bestEffort` result is CAPPED at `warn`**, so the four could-not-check branches (the two vendor-pin ones, the two importmap-coherence ones) can never be escalated and a jspm or npm outage cannot red the required job. **A malformed gate exits 1 naming the offender, without running the checks**, covering the wrong SHAPE (a non-object `doctor` or `gate`, or a misspelled sibling like `gates`) as well as an unknown code or severity, because any of them silently ignored would leave CI un-gated while looking gated, which is strictly worse than no gate since nobody goes looking. The JSON Schema catches these in an editor, and since #1300 it also runs at BOOT (`webjs-config-validate.js` in `@webjsdev/server`, called from `createRequestHandler`), but neither reach can be the enforcement here, and the boot one does not even apply. That check validates TOP-LEVEL keys only and never descends, so it says nothing at all about anything under `webjs.doctor`, whose schema type is `object`. Even if it did descend it would be the wrong tool: it warns where this gate must fail CLOSED, it runs in the server, which this CLI must work without (#954), and the schema can only express `propertyNames: { pattern: "^[A-Z][A-Z0-9_]*$" }`, so it cannot catch a well-shaped but wrong code such as `NODE_VERSIONS`, which `readDoctorPolicy` catches by checking the real `DOCTOR_CODES` set. Do not collapse the two. `off` is uniform and silences any code, the two hard-fail checks included, matching ESLint, where any rule can be turned off. The repo's own `conventions` CI job runs doctor over both in-repo apps, and `website` + `examples/blog` gate `UNMARKED_ASSET_LINKS` to `error`; the scaffold emits the same gate and its `ci.yml` runs `npm run doctor`. The config key rides the three-surface lockstep in `packages/server/AGENTS.md` (JSON Schema + `WebjsConfig` type + reader, plus the `KNOWN_KEYS` drift test), like `dev` / `start`. That file holds the one canonical reader inventory; do not restate it here. +**Per-check severity is CONFIG, not a flag (#1257), which is what lets CI gate doctor.** `--strict` is unusable in CI on its own, because four checks are environment-shaped and fatal under it (`GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, `FRAMEWORK_RESOLVE` is environment-dependent). So an app declares its policy in `package.json` under `"webjs": { "doctor": { "gate": { "": "off" | "warn" | "error" } } }`, the same three-level scale ESLint uses and `eslint-plugin-next` ships as a rule-id-keyed map. `readDoctorPolicy(appDir)` reads it (PURE, returns `{ gate, unknownCodes, badSeverities, malformed, unknownKeys }`) and `applyDoctorPolicy(results, gate)` folds it over the results (PURE, returns a new array), both in `lib/doctor/policy.js` (re-exported by `lib/doctor.js`); the CHECKS stay policy-unaware and the bin composes the two. Four rules make it safe. **A code with no entry keeps its default** (`error` for a hard fail, `warn` otherwise), so an app with no `webjs.doctor` block produces byte-identical counts and the `failing = fail > 0 || (strict && warn > 0)` formula is untouched. **`severity` on a result is the EFFECTIVE level, not the declared one**, so a PASSING check reports `pass` even when its code is gated `error` and `results.some((r) => r.severity === 'error')` has no false positive. **A `bestEffort` result is CAPPED at `warn`**, so the four could-not-check branches (the two vendor-pin ones, the two importmap-coherence ones) can never be escalated and a jspm or npm outage cannot red the required job. **A malformed gate exits 1 naming the offender, without running the checks**, covering the wrong SHAPE (a non-object `doctor` or `gate`, or a misspelled sibling like `gates`) as well as an unknown code or severity, because any of them silently ignored would leave CI un-gated while looking gated, which is strictly worse than no gate since nobody goes looking. The JSON Schema catches these in an editor, and since #1300 it also runs at BOOT (`webjs-config-validate.js` in `@webjsdev/server`, called from `createRequestHandler`), but neither reach can be the enforcement here, and the boot one does not even apply. That check validates TOP-LEVEL keys only and never descends, so it says nothing at all about anything under `webjs.doctor`, whose schema type is `object`. Even if it did descend it would be the wrong tool: it warns where this gate must fail CLOSED, it runs in the server, which this CLI must work without (#954), and the schema can only express `propertyNames: { pattern: "^[A-Z][A-Z0-9_]*$" }`, so it cannot catch a well-shaped but wrong code such as `NODE_VERSIONS`, which `readDoctorPolicy` catches by checking the real `DOCTOR_CODES` set. Do not collapse the two. `off` is uniform and silences any code, the two hard-fail checks included, matching ESLint, where any rule can be turned off. The repo's own `conventions` CI job runs doctor over both in-repo apps, and `website` + `examples/blog` gate `UNMARKED_ASSET_LINKS` to `error`; the scaffold emits the same gate and its `ci.yml` runs `npm run doctor`. The config key rides the three-surface lockstep in `packages/server/AGENTS.md` (JSON Schema + `WebjsConfig` type + reader, plus the `KNOWN_KEYS` drift test), like `dev` / `start`. That file holds the one canonical reader inventory; do not restate it here. ## UI subcommand: proxies to `@webjsdev/ui` diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js index 2d0502aed..fd04a9672 100644 --- a/packages/cli/lib/doctor.js +++ b/packages/cli/lib/doctor.js @@ -50,1637 +50,8 @@ * the blunt "every warning is fatal" switch, layered on top. */ -import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { dirname, join, relative } from 'node:path'; -import { createRequire } from 'node:module'; -import { checkNodeInline } from './node-preflight.js'; - -/** - * `status` is what the CHECK found and never depends on config. `severity` is - * the EFFECTIVE level the result contributes after the app's gate is applied, - * attached by `applyDoctorPolicy` (the checks never set it). `bestEffort` marks - * a result that reports "could not check" rather than a real finding, which is - * the one thing a gate can never escalate. - * @typedef {'pass' | 'warn' | 'fail'} DoctorStatus - * @typedef {'off' | 'warn' | 'error'} DoctorSeverity a level a gate entry may DECLARE - * @typedef {'pass' | DoctorSeverity} DoctorLevel the EFFECTIVE level of a result - * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string, bestEffort?: boolean, severity?: DoctorLevel }} DoctorResult - */ - -/** - * The severity levels a `webjs.doctor.gate` entry may name, mirroring ESLint's - * three-level scale (its `off` / `warn` / `error`, which Next.js's - * `eslint-plugin-next` uses verbatim as a rule-id-keyed map). `off` is uniform: - * it silences ANY code, the two hard-fail checks included, exactly as ESLint - * lets any rule be turned off. - * @type {DoctorSeverity[]} - */ -export const DOCTOR_SEVERITIES = ['off', 'warn', 'error']; - -/** - * Stable machine-readable code per check (#975), so an agent consuming - * `webjs doctor --json` branches on the failure KIND, not the human message - * text (which is free to change). The `name` stays the display identity (some - * are kebab-case, two are prose); the `code` is the durable contract, a - * SCREAMING_SNAKE_CASE constant that never changes for a given check. Attached - * centrally in `runDoctorChecks` so every check function stays focused on its - * own logic. Mirrors Remix's `DoctorFindingCode` enum (its `doctor/types.ts`). - * - * Keyed by each check's `name`. A missing entry falls back to a name-derived - * code (see `codeForName`), but every shipped check is listed here explicitly - * and a drift test asserts each result carries one of these codes. - * @type {Record} - */ -export const DOCTOR_CODES = { - 'node-version': 'NODE_VERSION', - 'tsconfig-erasable': 'TSCONFIG_ERASABLE', - 'env-drift': 'ENV_DRIFT', - 'vendor-pin': 'VENDOR_PIN', - 'vendor-gitignore': 'VENDOR_GITIGNORE', - 'webjs-versions': 'WEBJS_VERSIONS', - 'framework-resolve': 'FRAMEWORK_RESOLVE', - 'importmap-coherence': 'IMPORTMAP_COHERENCE', - 'git-hook': 'GIT_HOOK', - 'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS', - 'Component elision (what the browser drops)': 'ELISION_COMPONENTS', - 'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS', - 'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS', -}; - -/** - * The stable code for a check name: the explicit `DOCTOR_CODES` entry, else a - * best-effort derivation (uppercased, non-alphanumerics collapsed to `_`) so a - * newly-added check that forgets its map entry still gets a non-empty code. - * @param {string} name - * @returns {string} - */ -export function codeForName(name) { - return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, ''); -} - -/** - * @typedef {{ gate: Record, unknownCodes: string[], badSeverities: Array<{ code: string, value: unknown }>, malformed: Array<{ path: string, value: unknown }>, unknownKeys: string[] }} DoctorPolicy - */ - -/** A plain JSON object (not null, not an array), the only shape the gate accepts. */ -function isPlainObject(v) { - return !!v && typeof v === 'object' && !Array.isArray(v); -} - -/** - * Read the app's per-check severity policy out of `package.json` - * `webjs.doctor.gate` (#1257). PURE: it reads one file and returns data, and - * the caller (the CLI) decides what to do about a problem. - * - * `gate` keeps only WELL-FORMED entries, so a caller can fold it over the - * results without re-validating. Everything rejected is reported separately: - * a key that is not a value of `DOCTOR_CODES` lands in `unknownCodes`, a value - * outside `DOCTOR_SEVERITIES` in `badSeverities`, a wrong SHAPE (a non-object - * `doctor` or `gate`) in `malformed`, and a misspelled sibling of `gate` such - * as `gates` in `unknownKeys`. All four are surfaced as a hard error by the - * CLI rather than skipped. - * - * The shape check matters as much as the per-entry one, and is the easier half - * to leave out. A gate that FAILS OPEN is the one outcome this mechanism cannot - * afford: `"gate": "error"` or a misspelled `"gates": {...}` would leave CI - * un-gated while the package.json looks gated, which is strictly worse than - * having no gate at all, since nobody goes looking. The JSON Schema catches - * these in an editor, but it is editor-only, so it can never be the enforcement. - * - * A missing package.json, a missing block, or unparseable JSON is an EMPTY - * policy with no problems: an app that declares nothing behaves exactly as it - * did before the gate existed. Unparseable JSON in particular is deliberately - * not an error here, since `checkWebjsVersions` already reports that condition - * and doctor must never crash on a broken app file. - * - * @param {string} appDir - * @returns {DoctorPolicy} - */ -export function readDoctorPolicy(appDir) { - /** @type {DoctorPolicy} */ - const empty = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] }; - let raw; - try { - raw = readFileSync(join(appDir, 'package.json'), 'utf8'); - } catch { - return empty; - } - let pkg; - try { - pkg = JSON.parse(raw); - } catch { - return empty; - } - const doctor = pkg?.webjs?.doctor; - if (doctor === undefined) return empty; - if (!isPlainObject(doctor)) return { ...empty, malformed: [{ path: 'webjs.doctor', value: doctor }] }; - - /** @type {DoctorPolicy} */ - const policy = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] }; - // A misspelled sibling (`gates`) would otherwise be dropped in silence, which - // is the fail-open case. `gate` is the only key this block accepts. - for (const key of Object.keys(doctor)) { - if (key !== 'gate') policy.unknownKeys.push(`webjs.doctor.${key}`); - } - const declared = doctor.gate; - if (declared !== undefined && !isPlainObject(declared)) { - policy.malformed.push({ path: 'webjs.doctor.gate', value: declared }); - } - if (!isPlainObject(declared)) return policy; - - const known = new Set(Object.values(DOCTOR_CODES)); - for (const [code, value] of Object.entries(declared)) { - if (!known.has(code)) { - policy.unknownCodes.push(code); - continue; - } - if (typeof value !== 'string' || !DOCTOR_SEVERITIES.includes(/** @type {DoctorSeverity} */ (value))) { - policy.badSeverities.push({ code, value }); - continue; - } - policy.gate[code] = /** @type {DoctorSeverity} */ (value); - } - return policy; -} - -/** - * Fold a severity `gate` over check results, returning a NEW array whose - * results each carry the EFFECTIVE level they contribute (#1257). PURE: the - * input array and its results are never mutated. - * - * `severity` is the effective level, not the declared one, which is why a - * PASSING check reports `'pass'` even when its code is gated `error`. A rule - * that did not fire contributes nothing, the same way ESLint puts severity on a - * message rather than on a rule that stayed quiet. It also keeps the obvious - * one-liner honest: `results.some((r) => r.severity === 'error')` is exactly - * "something fatal was found", with no passing-check false positive. - * - * The gate's one hard limit is `bestEffort`: a result that could not check - * (a toolchain that would not load, a network that was unreachable) is CLAMPED - * to `warn` however loudly the gate declares its code. That is what lets this - * repo's required CI job run a check whose live resolve touches jspm without - * an outage there ever redding an unrelated pull request. - * - * @param {DoctorResult[]} results - * @param {Record} [gate] well-formed entries only (see readDoctorPolicy) - * @returns {DoctorResult[]} - */ -export function applyDoctorPolicy(results, gate = {}) { - return results.map((r) => { - if (r.status === 'pass') return { ...r, severity: /** @type {DoctorLevel} */ ('pass') }; - const declared = gate[r.code]; - const fallback = r.status === 'fail' ? 'error' : 'warn'; - let severity = /** @type {DoctorSeverity} */ (declared || fallback); - if (r.bestEffort && severity === 'error') severity = 'warn'; - return { ...r, severity }; - }); -} - -/** - * Read the CLI package's own `engines.node` so the required Node major lives in - * one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`. - * @param {string} cliDir directory of THIS file's package (lib/ -> package root) - * @returns {Promise} - */ -async function readEngines(cliDir) { - try { - const pkg = JSON.parse(await readFile(join(cliDir, '..', 'package.json'), 'utf8')); - return pkg?.engines?.node || '>=24.0.0'; - } catch { - return '>=24.0.0'; - } -} - -/** - * Strip `//` line comments, block comments, and trailing commas from a JSONC - * string so a tsconfig (which permits all three) parses with `JSON.parse`. - * Deliberately simple: it does not honor comment-looking sequences inside - * string values, which is acceptable for a tsconfig (paths rarely contain `//` - * or block-comment markers, and the worst case is a parse failure the caller - * already degrades to a WARN). - * @param {string} text - * @returns {string} - */ -function stripJsonc(text) { - let out = ''; - let inString = false; - let stringQuote = ''; - for (let i = 0; i < text.length; i++) { - const ch = text[i]; - const next = text[i + 1]; - if (inString) { - out += ch; - if (ch === '\\') { - // Copy the escaped char verbatim so an escaped quote does not end the string. - out += text[i + 1] || ''; - i++; - } else if (ch === stringQuote) { - inString = false; - } - continue; - } - if (ch === '"' || ch === "'") { - inString = true; - stringQuote = ch; - out += ch; - continue; - } - if (ch === '/' && next === '/') { - while (i < text.length && text[i] !== '\n') i++; - out += '\n'; - continue; - } - if (ch === '/' && next === '*') { - i += 2; - while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; - i++; // land on the '/' - continue; - } - out += ch; - } - // Drop trailing commas before } or ]. - return out.replace(/,(\s*[}\]])/g, '$1'); -} - -/** - * Parse a `.env`-style file into the SET of KEY names it declares. A simple - * `KEY=value` line parse: comments (`#`) and blank lines are skipped, and only - * the key before the first `=` is taken (the value is irrelevant for drift). - * @param {string} text - * @returns {Set} - */ -function parseEnvKeys(text) { - const keys = new Set(); - for (const raw of text.split(/\r?\n/)) { - const line = raw.trim(); - if (!line || line.startsWith('#')) continue; - const eq = line.indexOf('='); - if (eq <= 0) continue; - let key = line.slice(0, eq).trim(); - // Tolerate a leading `export ` (a common .env.example convention). - if (key.startsWith('export ')) key = key.slice('export '.length).trim(); - if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) keys.add(key); - } - return keys; -} - -/** - * CHECK 1, Node version. HARD-FAIL when the running major is below the required - * major (the strip-types + recursive fs.watch floor). `opts.nodeVersion` lets a - * test inject the running version so the fail case is assertable without being - * on old Node. - * @param {string} cliDir - * @param {{ nodeVersion?: string }} opts - * @returns {Promise} - */ -async function checkNode(cliDir, opts) { - const engines = await readEngines(cliDir); - const current = opts.nodeVersion || process.versions.node; - const r = checkNodeInline(current, engines); - if (r.ok) { - return { - name: 'node-version', - status: 'pass', - message: `Node ${r.current} satisfies the required Node ${r.requiredMajor}+.`, - }; - } - return { - name: 'node-version', - status: 'fail', - message: - `Node ${r.current} is below the required Node ${r.requiredMajor}+. ` + - `webjs is buildless and relies on Node ${r.requiredMajor}'s built-in TypeScript ` + - `strip and recursive fs.watch.`, - fix: `Upgrade to Node ${r.requiredMajor}+ (see https://nodejs.org).`, - }; -} - -/** - * CHECK 2, tsconfig erasableSyntaxOnly. PASS when `true`; WARN when no tsconfig - * (a JS-only app legitimately has none) or the file is unparseable; HARD-FAIL - * when the file EXISTS but the flag is missing/false (non-erasable TS 500s at - * strip time). - * @param {string} appDir - * @returns {Promise} - */ -async function checkTsconfig(appDir) { - const path = join(appDir, 'tsconfig.json'); - if (!existsSync(path)) { - return { - name: 'tsconfig-erasable', - status: 'warn', - message: 'No tsconfig.json found. A JS-only app needs none; a TypeScript app requires one.', - fix: 'If this app uses TypeScript, add a tsconfig.json with "erasableSyntaxOnly": true.', - }; - } - let parsed; - try { - parsed = JSON.parse(stripJsonc(await readFile(path, 'utf8'))); - } catch { - return { - name: 'tsconfig-erasable', - status: 'warn', - message: 'tsconfig.json could not be parsed (even after stripping comments + trailing commas).', - fix: 'Fix the tsconfig.json syntax, then ensure "compilerOptions.erasableSyntaxOnly": true.', - }; - } - const flag = parsed?.compilerOptions?.erasableSyntaxOnly; - if (flag === true) { - return { - name: 'tsconfig-erasable', - status: 'pass', - message: 'tsconfig.json sets "erasableSyntaxOnly": true.', - }; - } - return { - name: 'tsconfig-erasable', - status: 'fail', - message: - 'tsconfig.json is missing "compilerOptions.erasableSyntaxOnly": true. ' + - 'Non-erasable TypeScript (enum, namespace, parameter properties, ...) 500s at strip time.', - fix: 'Set "compilerOptions": { "erasableSyntaxOnly": true } in tsconfig.json.', - }; -} - -/** - * CHECK 3, .env presence + drift vs .env.example. WARN-level only (a missing - * env var is the app's runtime problem, not a toolchain crash). When no - * `.env.example`, PASS (nothing to compare). When `.env.example` exists but - * `.env` is absent, WARN to copy it. Otherwise WARN listing any example key - * missing from `.env`, else PASS. - * @param {string} appDir - * @returns {Promise} - */ -async function checkEnv(appDir) { - const examplePath = join(appDir, '.env.example'); - if (!existsSync(examplePath)) { - return { - name: 'env-drift', - status: 'pass', - message: 'No .env.example to compare against.', - }; - } - const exampleKeys = parseEnvKeys(await readFile(examplePath, 'utf8')); - const envPath = join(appDir, '.env'); - if (!existsSync(envPath)) { - return { - name: 'env-drift', - status: 'warn', - message: '.env.example exists but .env does not.', - fix: 'Copy it: cp .env.example .env (then fill in the values).', - }; - } - const envKeys = parseEnvKeys(await readFile(envPath, 'utf8')); - const missing = [...exampleKeys].filter((k) => !envKeys.has(k)); - if (missing.length === 0) { - return { - name: 'env-drift', - status: 'pass', - message: `.env has all ${exampleKeys.size} key(s) declared in .env.example.`, - }; - } - return { - name: 'env-drift', - status: 'warn', - message: `.env is missing ${missing.length} key(s) from .env.example: ${missing.join(', ')}.`, - fix: 'Add the missing key(s) to .env (see .env.example for the expected names).', - }; -} - -/** - * CHECK 4, vendor pin freshness. Applies ONLY when a pin file exists. PASS/skip - * for an unpinned app (it resolves live, which is fine in dev). BEST-EFFORT + - * NETWORK-TOLERANT: any error (network, timeout) is a WARN "could not check", - * never a hard fail and never a throw. PASS when all pins current, WARN listing - * outdated packages otherwise. - * - * The vendor functions are injected via `opts.vendor` so a test can supply a - * stub without a real network call; absent the override, they are dynamically - * imported from `@webjsdev/server`. - * @param {string} appDir - * @param {{ vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise> } }} opts - * @returns {Promise} - */ -async function checkVendorPin(appDir, opts) { - let vendor = opts.vendor; - if (!vendor) { - try { - const mod = await import('@webjsdev/server'); - vendor = { hasVendorPin: mod.hasVendorPin, findOutdated: mod.findOutdated }; - } catch { - return { - name: 'vendor-pin', - status: 'warn', - // "Could not check", not a finding: never escalatable by a gate. - bestEffort: true, - message: 'Could not load the vendor toolchain to check pin freshness.', - fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.', - }; - } - } - let pinned = false; - try { - pinned = vendor.hasVendorPin(appDir); - } catch { - pinned = false; - } - if (!pinned) { - return { - name: 'vendor-pin', - status: 'pass', - message: 'No vendor pin file; the app resolves vendor imports live (fine in dev).', - }; - } - let outdated; - try { - outdated = await vendor.findOutdated(appDir); - } catch { - // findOutdated is built to swallow fetch errors and return [], but guard - // anyway: a network check must NEVER throw out of doctor. - return { - name: 'vendor-pin', - status: 'warn', - bestEffort: true, - message: 'Could not check pin freshness (network unreachable or registry error).', - fix: 'Re-run `webjs doctor` when connectivity is back, or run `webjs vendor outdated`.', - }; - } - if (!Array.isArray(outdated) || outdated.length === 0) { - return { - name: 'vendor-pin', - status: 'pass', - message: 'All vendor pins are current.', - }; - } - const list = outdated.map((o) => `${o.pkg} (${o.current} -> ${o.latest})`).join(', '); - return { - name: 'vendor-pin', - status: 'warn', - message: `${outdated.length} pinned package(s) are outdated: ${list}.`, - fix: 'Run `webjs vendor update` to re-pin to the latest versions.', - }; -} - -/** - * CHECK: the `.gitignore` does not swallow the committed vendor pin. The pattern - * for `.webjs/vendor/` is subtle: a bare `.webjs/` line excludes the directory - * entirely and git cannot re-include children of an excluded parent, so a - * `!.webjs/vendor/` exception silently does nothing and `webjs vendor pin` - * output never gets committed. The correct pattern is the depth-robust - * contents-glob form (see the fix text below / VENDOR_GITIGNORE_LINES in - * vendor.js): a globstar-prefixed `.webjs/*` plus the matching vendor - * negations, which ignores transient `.webjs` output at any depth while - * keeping the committed vendor pin tracked. - * - * This was a `webjs check` rule, but inspecting `.gitignore` is a project-config - * concern (like `tsconfig-erasable`), not source-code correctness, and vendoring - * is optional, so a doctor WARN fits the domain and severity better than a CI - * hard-fail (#461). It lives next to `vendor-pin` (same family). - * - * PASS/skip when the dir is not a git repo or has no `.gitignore` (the user has - * not opted into version control yet). Probes two representative paths via - * `git check-ignore` with the inherited GIT_* env stripped so `cwd` is the sole - * authority on which repo + .gitignore stack is consulted (a pre-commit hook - * from a linked worktree exports GIT_WORK_TREE, which would otherwise override - * cwd-based discovery). - * - * @param {string} appDir - * @returns {Promise} - */ -async function checkVendorGitignore(appDir) { - const hasGit = existsSync(join(appDir, '.git')); - const hasGitignore = existsSync(join(appDir, '.gitignore')); - if (!hasGit || !hasGitignore) { - return { - name: 'vendor-gitignore', - status: 'pass', - message: 'Not a git checkout with a .gitignore; nothing to verify.', - }; - } - const { spawnSync } = await import('node:child_process'); - const { - GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp, - ...gitEnv - } = process.env; - // Check two representative paths: the pin manifest AND a sample downloaded - // bundle. A `.gitignore` that allows the manifest but blocks bundles (e.g. - // `*.js` higher up) would still break `webjs vendor pin --download`. - // `git check-ignore -q` exits 0 when the path is ignored, 1 when not. - const probes = [ - '.webjs/vendor/importmap.json', - '.webjs/vendor/sample-pkg@1.0.0.js', - ]; - for (const probe of probes) { - const result = spawnSync('git', ['check-ignore', '-q', probe], { - cwd: appDir, - stdio: 'pipe', - env: gitEnv, - }); - if (result.status === 0) { - return { - name: 'vendor-gitignore', - status: 'warn', - message: - `${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ that MUST be committed for a production deploy to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second cause is a broader rule (e.g. \`*.js\` at root) hiding bundle files added by \`webjs vendor pin --download\`.`, - fix: - 'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' + - ' **/.webjs/*\n' + - ' !**/.webjs/vendor/\n' + - ' !**/.webjs/vendor/**\n' + - 'The `**/` prefix ignores `.webjs/` at any depth (so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`) while still re-including the committed vendor pin. ' + - 'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).', - }; - } - } - return { - name: 'vendor-gitignore', - status: 'pass', - message: 'The .gitignore keeps .webjs/vendor/ committable.', - }; -} - -/** - * Compare an installed version against a semver range PRAGMATICALLY (no semver - * dependency). Supports the common scaffold shapes: `latest` / `*` / `workspace:*` - * (any installed version satisfies), an exact `1.2.3`, and a caret `^1.2.3` - * (installed must be >= the floor AND share the same major, with major 0 also - * pinning the minor, matching npm caret semantics). An unrecognized range is - * treated as "cannot statically verify" (returns null), so the caller does not - * warn on a shape it does not understand. - * @param {string} installed - * @param {string} range - * @returns {boolean | null} - */ -function satisfiesRange(installed, range) { - if (!installed) return null; - const r = String(range).trim(); - if (r === 'latest' || r === '*' || r === '' || r.startsWith('workspace:')) return true; - const parse = (v) => { - const m = String(v).match(/(\d+)\.(\d+)\.(\d+)/); - return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; - }; - const inst = parse(installed); - if (!inst) return null; - if (/^\d+\.\d+\.\d+$/.test(r)) { - const exact = parse(r); - return exact ? inst[0] === exact[0] && inst[1] === exact[1] && inst[2] === exact[2] : null; - } - if (r.startsWith('^')) { - const floor = parse(r); - if (!floor) return null; - if (inst[0] !== floor[0]) return false; - // For 0.x, caret pins the minor too (^0.7.0 allows 0.7.x, not 0.8.0). - if (floor[0] === 0 && inst[1] !== floor[1]) return false; - const cmp = - inst[0] !== floor[0] ? inst[0] - floor[0] : - inst[1] !== floor[1] ? inst[1] - floor[1] : - inst[2] - floor[2]; - return cmp >= 0; - } - return null; -} - -/** - * Read the declared dependency ranges of an INSTALLED package from - * `node_modules//package.json`, for the importmap-coherence check. This - * is the "already-resolved metadata, no network" path the issue calls for: the - * package is on disk (it was installed for the importmap to pin it), so its - * manifest is a local read. Returns null on any failure (not installed, - * unreadable, unparseable), which the coherence check treats as "could not - * verify" rather than a conflict. - * - * @param {string} appDir - * @returns {(pkg: string) => Promise<{ dependencies?: Record, peerDependencies?: Record } | null>} - */ -function makeInstalledManifestReader(appDir) { - return async (pkg) => { - const manifestPath = join(appDir, 'node_modules', pkg, 'package.json'); - if (!existsSync(manifestPath)) return null; - try { - const parsed = JSON.parse(await readFile(manifestPath, 'utf8')); - return { - dependencies: parsed.dependencies || {}, - peerDependencies: parsed.peerDependencies || {}, - }; - } catch { - return null; - } - }; -} - -/** - * Format a coherence conflict list into a single human-readable warning line - * naming each conflicting pair, the required range, and the pinned version. - * @param {Array<{ pkg: string, version: string, dependsOn: string, kind: string, requiredRange: string, pinnedVersion: string }>} conflicts - * @returns {string} - */ -function formatConflicts(conflicts) { - return conflicts - .map( - (c) => - `${c.pkg}@${c.version} needs ${c.dependsOn} ${c.kind === 'peerDependency' ? '(peer) ' : ''}${c.requiredRange} but the importmap pins ${c.dependsOn}@${c.pinnedVersion}`, - ) - .join('; '); -} - -/** - * CHECK 7, importmap coherence (issue #450). Defense-in-depth that catches an - * INCOHERENT client dependency graph in the produced importmap, regardless of - * how the incoherence arose (a hand-edited pin file, a partial vendor pin, or - * the #446 resolution skew). For each resolved package, it checks that the - * version actually pinned for every OTHER resolved package it depends on - * satisfies the declared range; a miss warns naming both packages, the range, - * and the pinned version. - * - * Runs the SAME check over BOTH inputs and produces the same verdict for the - * same dep set (the parity invariant): the live importmap (resolved the way the - * server resolves it at runtime) AND the vendored `.webjs/vendor/importmap.json`. - * A vendored importmap is a freeze of the runtime-resolved graph, so a coherent - * runtime graph that gets vendored stays coherent. - * - * WARN-only and BEST-EFFORT: it never hard-fails (a runtime incoherence is the - * app's concern, not a broken toolchain), and it degrades to a soft - * "could not verify" whenever metadata or a live resolve is unavailable rather - * than failing closed. Dependency metadata is read from the already-installed - * `node_modules` manifests, no network call of its own; the only network touch - * is the live importmap resolve, which is wrapped so any failure degrades. - * - * The vendor functions + manifest reader are injectable via `opts.coherence` - * so a test can drive every branch without a network call. - * - * @param {string} appDir - * @param {{ coherence?: { - * liveImports?: () => Promise | null>, - * vendoredImports?: () => Promise | null>, - * getManifest?: (pkg: string, version: string) => Promise, - * check?: (imports: Record, o: { getManifest: any }) => Promise<{ conflicts: any[], unverified: any[], checked: number }>, - * } }} opts - * @returns {Promise} - */ -async function checkImportmapCoherence(appDir, opts) { - let inj = opts.coherence; - // Resolve the real vendor toolchain unless a test injected stubs. Both the - // importmap sources and the coherence-check function come from - // @webjsdev/server, so a missing install degrades to a WARN, never a throw. - if (!inj || !inj.check || !inj.liveImports || !inj.vendoredImports || !inj.getManifest) { - let mod; - try { - mod = await import('@webjsdev/server'); - } catch { - return { - name: 'importmap-coherence', - status: 'warn', - bestEffort: true, - message: 'Could not load the vendor toolchain to check importmap coherence.', - fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.', - }; - } - const real = { - check: mod.checkImportmapCoherence, - // Hoist-aware manifest read from the already-installed node_modules (no - // network of its own), so a monorepo-hoisted dep still resolves. Falls - // back to the local app/node_modules read if the server build predates - // getPackageManifest. - getManifest: typeof mod.getPackageManifest === 'function' - ? (pkg) => mod.getPackageManifest(pkg, appDir) - : makeInstalledManifestReader(appDir), - // Live importmap: resolve vendor imports the way the server does on the - // first request (prefers the pin file, else a live jspm.io resolve). - liveImports: async () => { - try { - const resolved = await mod.resolveVendorImports(appDir, () => mod.scanBareImports(appDir)); - return resolved && resolved.imports ? resolved.imports : {}; - } catch { - return null; - } - }, - // Vendored importmap: the committed pin file, no network. - vendoredImports: async () => { - try { - const pin = await mod.readPinFile(appDir); - return pin && pin.imports ? pin.imports : null; - } catch { - return null; - } - }, - }; - inj = { ...real, ...(inj || {}) }; - } - - // Gather both importmaps. Either may be absent (no pin file, or a live - // resolve that failed / found no vendor imports); the check runs over - // whichever exist, identically. - let live = null; - let vendored = null; - try { live = await inj.liveImports(); } catch { live = null; } - try { vendored = await inj.vendoredImports(); } catch { vendored = null; } - - const liveHas = live && Object.keys(live).length > 0; - const vendoredHas = vendored && Object.keys(vendored).length > 0; - if (!liveHas && !vendoredHas) { - return { - name: 'importmap-coherence', - status: 'pass', - message: 'No vendor importmap to check (the app imports no npm packages on the client).', - }; - } - - // Run the IDENTICAL check over each available importmap. The function is - // pure in (imports, getManifest), so the same pinned dep set produces the - // same verdict whichever input it came from (the runtime-vs-vendored parity - // invariant). Aggregate the conflicts; dedupe identical ones so a package - // pinned the same way in both maps is reported once. - /** @type {Map} */ - const conflictsByKey = new Map(); - let anyChecked = 0; - let anyUnverified = 0; - for (const imports of [liveHas ? live : null, vendoredHas ? vendored : null]) { - if (!imports) continue; - let report; - try { - report = await inj.check(imports, { getManifest: inj.getManifest }); - } catch { - // A check that threw is a "could not verify", never a doctor crash. - anyUnverified++; - continue; - } - anyChecked += report.checked || 0; - anyUnverified += (report.unverified || []).length; - for (const c of report.conflicts || []) { - conflictsByKey.set(`${c.pkg}@${c.version}->${c.dependsOn}@${c.pinnedVersion}`, c); - } - } - - const conflicts = [...conflictsByKey.values()]; - if (conflicts.length > 0) { - return { - name: 'importmap-coherence', - status: 'warn', - message: `Incoherent client dependency graph in the importmap: ${formatConflicts(conflicts)}.`, - fix: 'Align the pinned versions: re-run `webjs vendor pin` to re-resolve a coherent set, or bump the lagging package in package.json and reinstall so the importmap pins a version satisfying every dependent.', - }; - } - if (anyChecked === 0 && anyUnverified > 0) { - return { - name: 'importmap-coherence', - status: 'warn', - bestEffort: true, - message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).', - fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.', - }; - } - return { - name: 'importmap-coherence', - status: 'pass', - message: 'The importmap dependency graph is coherent (every pinned package satisfies its dependents\' declared ranges).', - }; -} - -/** - * Read a dependency's INSTALLED version as resolved FROM `appDir`, or null when - * it does not resolve there at all. - * - * Node's own resolver is the ground truth here, not a directory read. The check - * this serves asks "would this app resolve this dependency at runtime, and at - * what version", and Node's resolution algorithm IS that question's definition, - * so anything re-implementing it can only be a worse approximation. Asking Node - * handles workspace hoisting (the bug this fixes: under npm workspaces the - * `@webjsdev/*` deps hoist to the ROOT node_modules, so an app subdirectory has - * no local copy and a per-app `node_modules//package.json` read reported - * every declared dep missing on a healthy install), symlinked workspace links, - * nested non-hoisted trees, and `package.json` `imports`, for free and for ever. - * - * The direct `/package.json` resolve is attempted FIRST because a package - * may declare no main entry at all: `@webjsdev/cli` is bin-only (no `main`, no - * `exports`), so `require.resolve('@webjsdev/cli')` throws MODULE_NOT_FOUND. - * The ERR_PACKAGE_PATH_NOT_EXPORTED fallback exists because a package may lock - * its manifest out of its `exports` map: `@webjsdev/server` exports only `.`, - * `./check`, `./testing`, and `./webjs-config.schema.json`, so the direct - * manifest resolve is refused and the main entry plus a bounded walk up to the - * package root is the way in. Neither strategy alone resolves all four - * `@webjsdev/*` packages; both halves are required. - * - * Local rather than `getPackageVersion` from `@webjsdev/server` for two reasons. - * Doctor must stay usable when the framework does not resolve from the app dir - * at all, which is the #954 fresh-worktree case doctor exists to diagnose, so - * this check cannot import the server (the same argument `frameworkResolves` - * below already follows). And `getPackageVersion` resolves the main entry only, - * so it returns null for a bin-only package, which would leave `@webjsdev/cli` - * reported missing: the same false positive with more machinery. - * - * Pinned by the workspace, bin-only, and exports-locked fixtures in - * `test/cli/doctor.test.mjs`. - * @param {string} dep package name, e.g. `@webjsdev/server` - * @param {string} appDir directory to anchor resolution at - * @returns {Promise} the installed version, or null when unresolvable - */ -async function readInstalledVersion(dep, appDir) { - // The base file need not exist; createRequire only uses it to anchor the - // node_modules lookup at appDir. - const require = createRequire(join(appDir, '__webjs_resolve_probe__.js')); - let manifestPath = null; - try { - manifestPath = require.resolve(dep + '/package.json'); - } catch (err) { - if (err?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') return null; - let entry; - try { - entry = require.resolve(dep); - } catch { - return null; - } - let dir = dirname(entry); - for (let i = 0; i < 12; i++) { - const candidate = join(dir, 'package.json'); - if (existsSync(candidate)) { - manifestPath = candidate; - break; - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - if (!manifestPath) return null; - } - try { - return JSON.parse(await readFile(manifestPath, 'utf8')).version || null; - } catch { - return null; - } -} - -/** - * CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is - * not a crash). Reads the app package.json `@webjsdev/*` ranges across - * dependencies + devDependencies, then for each resolves the INSTALLED version - * through Node's own resolver anchored at the app dir (see - * `readInstalledVersion`, which is why a workspace-hoisted install resolves) - * and checks it satisfies the declared range. PASS when every @webjsdev dep is - * present + satisfied; WARN on a missing install or a range drift. - * @param {string} appDir - * @returns {Promise} - */ -async function checkWebjsVersions(appDir) { - const pkgPath = join(appDir, 'package.json'); - if (!existsSync(pkgPath)) { - return { - name: 'webjs-versions', - status: 'warn', - message: 'No package.json found in this directory.', - fix: 'Run `webjs doctor` from the app root (where package.json lives).', - }; - } - let pkg; - try { - pkg = JSON.parse(await readFile(pkgPath, 'utf8')); - } catch { - return { - name: 'webjs-versions', - status: 'warn', - message: 'package.json could not be parsed.', - fix: 'Fix the package.json syntax.', - }; - } - const ranges = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; - const webjsDeps = Object.keys(ranges).filter((n) => n.startsWith('@webjsdev/')); - if (webjsDeps.length === 0) { - return { - name: 'webjs-versions', - status: 'warn', - message: 'No @webjsdev/* dependencies declared in package.json.', - fix: 'A webjs app depends on @webjsdev/core + @webjsdev/server (+ @webjsdev/cli).', - }; - } - const missing = []; - const drift = []; - for (const dep of webjsDeps) { - const installedVersion = await readInstalledVersion(dep, appDir); - if (!installedVersion) { - missing.push(dep); - continue; - } - const ok = satisfiesRange(installedVersion, ranges[dep]); - // null = a range shape we cannot statically verify; do not warn on it. - if (ok === false) drift.push(`${dep}@${installedVersion} does not satisfy "${ranges[dep]}"`); - } - if (missing.length > 0) { - return { - name: 'webjs-versions', - status: 'warn', - message: `${missing.length} @webjsdev/* dependency not installed: ${missing.join(', ')}.`, - fix: 'Run `npm install` to install the declared dependencies.', - }; - } - if (drift.length > 0) { - return { - name: 'webjs-versions', - status: 'warn', - message: `@webjsdev version drift: ${drift.join('; ')}.`, - fix: 'Run `npm install` to reconcile node_modules with the declared ranges.', - }; - } - return { - name: 'webjs-versions', - status: 'pass', - message: `All ${webjsDeps.length} @webjsdev/* dependency satisfy their declared ranges.`, - }; -} - -/** - * CHECK 6 (optional), git pre-commit hook installed + executable. WARN when the - * repo is a git checkout but `.git/hooks/pre-commit` is absent or - * non-executable, since the test-gate / changelog hook would not fire. PASS when - * present + executable, or skip (PASS) when this is not a git checkout at all - * (an exported tarball, a non-repo dir). Respects a configured `core.hooksPath` - * is OUT of scope here: the common scaffold installs into `.git/hooks`, so this - * checks the default location and a configured path is the user's own concern. - * @param {string} appDir - * @returns {DoctorResult} - */ -function checkGitHook(appDir) { - const gitDir = join(appDir, '.git'); - if (!existsSync(gitDir)) { - return { - name: 'git-hook', - status: 'pass', - message: 'Not a git checkout; no pre-commit hook expected.', - }; - } - const hook = join(gitDir, 'hooks', 'pre-commit'); - if (!existsSync(hook)) { - return { - name: 'git-hook', - status: 'warn', - message: 'No .git/hooks/pre-commit hook installed.', - fix: 'Install the project hooks (e.g. `npm install` runs the prepare step that wires them).', - }; - } - let executable = false; - try { - // Owner-execute bit. On a checkout without exec bits (some Windows / CI - // setups) the hook will not run, so flag it. - executable = (statSync(hook).mode & 0o100) !== 0; - } catch { - executable = false; - } - if (!executable) { - return { - name: 'git-hook', - status: 'warn', - message: '.git/hooks/pre-commit exists but is not executable.', - fix: 'chmod +x .git/hooks/pre-commit', - }; - } - return { - name: 'git-hook', - status: 'pass', - message: '.git/hooks/pre-commit is installed and executable.', - }; -} - -/** - * Run every doctor check against `appDir` and return the results. PURE: no - * printing, no `process.exit`; the CLI renders + decides the exit code. - * - * @param {string} appDir the app directory to check (usually `process.cwd()`) - * @param {{ - * nodeVersion?: string, - * cliDir?: string, - * vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise> }, - * }} [opts] test-injection seams: - * - `nodeVersion`: override the running Node version (asserts the fail case - * without being on old Node); - * - `cliDir`: directory of the CLI package whose `engines.node` sources the - * required major (defaults to THIS module's package); - * - `vendor`: inject the `{ hasVendorPin, findOutdated }` pair so the pin check - * runs against a stub instead of a real network call. - * - `coherence`: inject `{ liveImports, vendoredImports, getManifest, check }` - * so the importmap-coherence check runs against stub importmaps + metadata - * instead of a real live resolve / node_modules read. - * @returns {Promise} - */ -/** - * Advisory (#646): name why a page/layout SHIPS its module to the browser - * instead of being elided. A page/layout that is a pure carrier (import-only - * #605 / inert #179) stays out of the browser; one that ships whole is pinned - * by a specific client-effecting NON-component on a component-free path from it, #963 (a util touching - * a client global, a module-scope side effect, a bare side-effect import) or by - * its own client work. This turns that invisible #605/#179 regression into a - * named line. WARN only: a page legitimately MAY ship, and the analyser is - * biased toward shipping by design (server AGENTS invariant 7), so this is a - * "you may not have intended this" hint, never a hard fail. - * @param {Promise} elisionPromise the ONE shared report (#1308) - * @returns {Promise} - */ -async function checkElisionCarriers(elisionPromise) { - const name = 'Page/layout elision (carrier hygiene)'; - const report = await elisionPromise; - if (!report) { - // Analysis unavailable (no app, malformed, server import failed): no advice. - return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' }; - } - if (!report.analysed) { - return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' }; - } - // Paths and reasons arrive app-relative from `analyzeAppElision` (#1308). - const shipped = report.routeModules.filter((r) => r.verdict === 'shipped'); - if (shipped.length === 0) { - return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' }; - } - // Name the FIRST client-effecting blocker (there may be more than one; the - // module stays shipped until every such blocker is moved out). - const lines = shipped.map(({ file, blocker, reason }) => - blocker - ? `${file} ships whole. Its first client-effecting blocker is ${blocker}, which ${reason} and is not a component` - : `${file} ships whole because it ${reason}`, - ); - return { - name, - status: 'warn', - message: - `${shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` + - lines.map((l) => ` ${l}`).join('\n'), - fix: 'Move the client work out of the page/layout closure (into a component, or a .server module reached through an action) so the carrier can be elided, or accept that it ships. See references/components.md in the skill.', - }; -} - -/** - * The OTHER direction of the elision verdict (#1308): which COMPONENT modules - * the browser never downloads. `checkElisionCarriers` above reports the benign - * over-ship direction; this one reports what was DROPPED, which is where a - * wrong verdict silently costs an app its interactivity. - * - * Pass-only except for orphans, deliberately. An elided component is the - * DESIRED outcome, so warning on one would fire on every healthy app and train - * the reader to skip doctor output. The passing message carries the elided - * inventory instead, which makes it the discovery surface, while `webjs - * elision` is the detail surface. The one always-wrong condition is an ORPHAN: - * a `class X extends WebComponent` with no literal-tag registration is - * invisible to the scanner, so it gets no verdict at all and `static - * interactive = true` cannot rescue it (nothing consults the component - * analyser for a component the scanner never saw). Never `fail`: - * an app that wants an orphan to break CI gates `ELISION_COMPONENTS` to - * `error` via `webjs.doctor.gate`. - * - * @param {Promise} elisionPromise the ONE shared report - * @returns {Promise} - */ -async function checkElisionComponents(elisionPromise) { - const name = 'Component elision (what the browser drops)'; - const report = await elisionPromise; - const notAnalysed = { name, status: /** @type {const} */ ('pass'), message: 'not analysed (no routable app or analysis unavailable)' }; - if (!report) return notAnalysed; - if (!report.analysed) { - return report.skipped === 'elide-off' - ? { name, status: 'pass', message: 'elision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships' } - : notAnalysed; - } - if (report.orphans.length > 0) { - const lines = report.orphans.map(({ file, className }) => - `${className} in ${file} is never registered with a literal tag`, - ); - return { - name, - status: 'warn', - message: - `${report.orphans.length} component class(es) get NO elision verdict:\n` + - lines.map((l) => ` ${l}`).join('\n') + - '\n Either it has no registration call at all, or it registers a computed tag. The component ' - + 'scanner matches only a literal tag, so either way it never sees the class: no elision verdict, no ' - + 'registry entry, no preload hint, and `static interactive = true` cannot rescue it. With no ' - + 'registration call the element never upgrades at all; with a computed tag it upgrades only while ' - + 'its module still reaches the browser through an importer that ships.', - fix: 'Register it with a literal tag, Class.register(\'my-tag\') (invariant 3 already requires one), or delete the class if nothing uses it.', - }; - } - const elided = report.components.filter((c) => c.verdict === 'elided'); - const tags = elided.flatMap((c) => c.tags); - const shown = tags.slice(0, 8).join(', '); - const tail = tags.length > 8 ? `, +${tags.length - 8} more` : ''; - return { - name, - status: 'pass', - message: - `${report.summary.elided} of ${report.summary.components} component module(s) are elided (never downloaded)` + - (tags.length ? `: ${shown}${tail}` : '') + - '. Run `webjs elision` for the full verdict.', - }; -} - -// Directories never worth walking for the CSS-freshness advisory (mirrors -// dev-regenerate's IGNORE_DIRS): build output, deps, VCS + framework caches. -const FRESHNESS_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']); - -/** - * Newest mtime (ms) of any FILE under a path (a file's own, or the max over the - * files in a directory tree, skipping dependencies / dotfiles). Directory-node - * mtimes are NOT counted, matching dev-regenerate's walker: a content edit only - * shows through the file mtime, and a directory mtime is a flaky moving target. - * A missing path is 0. Best-effort: never throws. - * @param {string} abs - * @returns {number} - */ -function newestMtimeMs(abs) { - let st; - try { st = statSync(abs); } catch { return 0; } - if (!st.isDirectory()) return st.mtimeMs; - let newest = 0; - let entries; - try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return newest; } - for (const e of entries) { - if (e.name.startsWith('.') || FRESHNESS_IGNORE.has(e.name)) continue; - // Skip symlinks: following one can cycle into unbounded recursion (a stack - // overflow here) or escape into node_modules. Same tradeoff as the server - // walker in dev-regenerate.js. - if (e.isSymbolicLink()) continue; - const m = newestMtimeMs(join(abs, e.name)); - if (m > newest) newest = m; - } - return newest; -} - -/** - * ADVISORY: a declared `webjs.dev.regenerate` output is STALE on disk (a source - * is newer than the committed/built output). In DEV the framework recompiles it - * on request (#967), so this never bites locally, but the check is the explicit - * dev/prod PARITY backstop: it catches a stale `public/tailwind.css` that would - * be served as-is by `webjs start` (prod does NOT recompile on request) or - * committed into the repo. WARN-level: the fix is a one-line rebuild, and a - * missing output (a fresh clone before the first `css:build`) is not this app's - * bug to hard-fail on. - * @param {string} appDir - * @returns {Promise} - */ -async function checkStaticAssetFreshness(appDir) { - const name = 'Static build outputs (dev.regenerate freshness)'; - let pkg; - try { - pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8')); - } catch { - return { name, status: 'pass', message: 'no package.json to analyse' }; - } - const rules = pkg && pkg.webjs && pkg.webjs.dev ? pkg.webjs.dev.regenerate : null; - if (!Array.isArray(rules) || rules.length === 0) { - return { name, status: 'pass', message: 'no webjs.dev.regenerate rules declared' }; - } - const stale = []; - for (const rule of rules) { - if (!rule || typeof rule.output !== 'string') continue; - const output = rule.output.replace(/^\/+/, ''); - const outMtime = newestMtimeMs(join(appDir, output)); - if (outMtime === 0) continue; // missing output: not a staleness fail (built on first boot) - let newestSrc = 0; - for (const inp of Array.isArray(rule.inputs) ? rule.inputs : []) { - const m = newestMtimeMs(join(appDir, inp)); - if (m > newestSrc) newestSrc = m; - } - if (newestSrc > outMtime) stale.push({ output, command: rule.command }); - } - if (stale.length === 0) { - return { name, status: 'pass', message: 'every declared build output is up to date with its sources' }; - } - return { - name, - status: 'warn', - message: - `${stale.length} static build output(s) are older than a source file:\n` + - stale.map((s) => ` ${s.output} (rebuild: ${s.command})`).join('\n') + - '\n In dev the framework recompiles these on request, so this only bites a `webjs start` (prod) or a committed stale file.', - fix: 'Rebuild the output(s) with the command shown (e.g. `npm run css:build`) before deploying or committing. `webjs dev` regenerates them on request automatically.', - }; -} - -// Directories the route-module walk never descends into (deps, VCS, framework -// and build caches). Mirrors FRESHNESS_IGNORE; kept separate so either walk can -// change its exclusions without silently moving the other. -const ROUTE_WALK_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']); - -/** - * A route module that renders markup on the server, which is where `asset()` - * belongs. Page and layout are the common case, but the BOUNDARY modules matter - * too and are easy to miss: `error` / `not-found` / `forbidden` / `unauthorized` - * / `loading` are always shipped and never elided, and `global-error` renders - * its OWN `` and is returned verbatim with no framework - * head splice, which makes it the likeliest place outside the root layout for - * an author to hand-write a stylesheet link. - * @type {RegExp} - */ -const ROUTE_MODULE_RE = - /^(?:page|layout|error|not-found|forbidden|unauthorized|loading)\.(?:js|ts|mjs|mts)$/; - -/** - * The two boundary stems `router.js` registers ONLY at the app root (both are - * guarded by `dir === '.'` there). A nested `app/admin/global-error.ts` is never - * in the route table and never renders, so scanning one would advise on dead - * code, the same defect the `_private` skip exists to avoid. - * @type {RegExp} - */ -const ROOT_ONLY_MODULE_RE = /^(?:global-error|global-not-found)\.(?:js|ts|mjs|mts)$/; - -/** - * One whole `` tag. QUOTE-AWARE (`(?:[^>"']|"[^"]*"|'[^']*')*`), the - * same shape `ssr.js`'s hoist scanner uses, so a `>` inside a quoted attribute - * value cannot terminate the tag early. - * @type {RegExp} - */ -const LINK_TAG_RE = /"']|"[^"]*"|'[^']*')*>/gi; - -/** - * One attribute inside a tag: a name, then optionally `=` and a double-quoted, - * single-quoted, or unquoted value. Matching attributes as WHOLE units is what - * makes the scan correct, because each quoted value is consumed in one step and - * can therefore never be re-scanned as if it contained an attribute of its own. - * @type {RegExp} - */ -const ATTR_RE = /([a-zA-Z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; - -/** - * Parse a tag's attributes into a lowercased-name map. The value is `null` for a - * valueless attribute and carries a `quoted` flag, since this check treats an - * UNQUOTED href (a template hole) as undecidable rather than as a path. - * @param {string} tag - * @returns {Map} - */ -function parseTagAttrs(tag) { - /** @type {Map} */ - const attrs = new Map(); - // Skip the tag name itself so `link` is not read as an attribute. - const body = tag.replace(/^<[a-zA-Z_:][-\w:.]*/, ''); - ATTR_RE.lastIndex = 0; - for (const m of body.matchAll(ATTR_RE)) { - const name = m[1].toLowerCase(); - if (attrs.has(name)) continue; // first wins, as in HTML parsing - const quoted = m[2] !== undefined || m[3] !== undefined; - const value = m[2] ?? m[3] ?? m[4] ?? null; - attrs.set(name, { value, quoted }); - } - return attrs; -} - -/** - * Whether a parsed `` is an unmarked stylesheet, and if so its href. - * - * Attribute PARSING rather than a lookahead over the raw tag is load-bearing, - * not tidiness. A scan that merely looks ahead for `rel=…stylesheet` anywhere in - * the tag matches the string inside ANOTHER attribute's value, which flags the - * two shapes this check most needs to leave alone: the canonical async-CSS - * `` - * (where the advised `asset()` fix would actively BREAK the preload, since the - * versioned hint could then never match the unversioned request), and a - * `data-rel="stylesheet"` sitting on a `rel="icon"`. Reading real attributes - * makes `rel` mean the `rel` attribute and nothing else. - * - * Returns the href only when every condition holds: - * - `rel` is a token list CONTAINING `stylesheet` (so `rel="preload"` with an - * onload swap, and `rel="icon"`, are both out). - * - `href` is QUOTED. An unquoted value is a template hole - * (`href=${asset('/public/app.css')}`), undecidable from source, and is - * exactly the shape the marked form uses. - * - the path is under `/public/` (after the app's `webjs.basePath` is - * stripped, since under a sub-path deploy the author writes the prefix - * themselves and `resolveAssetUrl` strips it before its own `public/` gate). - * - `resolveAssetUrl` would actually fingerprint it. It returns a path - * carrying a QUERY or a `..` unchanged, so wrapping one in `asset()` is a - * runtime NO-OP: the author does the work and the url they ship is - * byte-identical. Advising it would be advising a change that buys nothing. - * A hand-rolled `?v=` cache-buster is exactly what an author who has not - * adopted `asset()` is most likely to have written, so this is the common - * case, not a corner. (The warning itself would clear, since this check - * reads the SOURCE shape and a wrapped href is an unquoted hole. Clearing a - * warning without improving the caching is the outcome to avoid.) - * - * @param {string} tag - * @param {string} basePath the app's normalized `webjs.basePath` (`''` at root) - * @returns {string | null} - */ -function unmarkedStylesheetHref(tag, basePath = '') { - const attrs = parseTagAttrs(tag); - const rel = attrs.get('rel'); - if (!rel || !rel.value) return null; - if (!rel.value.toLowerCase().split(/\s+/).includes('stylesheet')) return null; - const href = attrs.get('href'); - if (!href || !href.quoted || !href.value) return null; - const url = href.value; - if (url[0] !== '/' || url[1] === '/') return null; - // Mirror `resolveAssetUrl`'s refusals IN ITS ORDER, so every flagged href is - // one `asset()` can actually fingerprint. It strips the base path, cuts at - // `?` / `#`, DECODES, and only then tests `..` and the `public/` prefix. - // Testing the raw value instead disagrees at both ends: `/public/%2e%2e/x` - // would be flagged although wrapping it changes nothing, and - // `/%70ublic/app.css` would be skipped although `asset()` fingerprints it. - let probe = url; - if (basePath && probe.startsWith(basePath + '/')) probe = probe.slice(basePath.length); - const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1); - let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length); - try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ } - if (decoded.includes('..') || !decoded.startsWith('/public/')) return null; - // A query is refused outright (an author query may carry meaning we do not - // own, so `resolveAssetUrl` returns the url untouched); a `#fragment` is not, - // since it is split off and preserved. - const beforeFragment = url.indexOf('#') === -1 ? url : url.slice(0, url.indexOf('#')); - if (beforeFragment.includes('?')) return null; - return url; -} - -/** - * The app's `webjs.basePath`, normalized to `''` (root mount) or `/segment…`. - * - * A faithful port of `normalizeBasePath` (`packages/server/src/base-path.js`), - * which is the source of truth: it trims, PREPENDS the leading slash (so the - * documented `"myapp"`, `"/myapp"` and `"/myapp/"` all normalize alike), and - * fails safe to `''` on a value that is not a plain same-origin prefix. Reading - * only `startsWith('/')` would leave this check inert for an app configured - * `"myapp"`, which is exactly the silently-inert case it exists to close. - * - * Ported rather than imported because that helper is not on `@webjsdev/server`'s - * public surface, and because doctor must stay usable when the framework does - * not resolve from the app dir at all (the #954 fresh-worktree case this same - * command exists to diagnose). The port is intentional and stays. What makes it - * safe is that the drift is tested rather than trusted. - * - * `test/cli/base-path-parity.test.mjs` feeds one input table through BOTH this - * function and the server's `readBasePath`, asserting they agree with each other - * and with the expected value. Change either side without the other and it reds. - * So edit this body only alongside `packages/server/src/base-path.js`, and run - * that test. (`test/cli/doctor.test.mjs` covers the check that consumes this, - * not the normalization forms themselves.) - * @param {string} appDir - * @returns {Promise} - */ -export async function readAppBasePath(appDir) { - let raw; - try { - const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8')); - raw = pkg?.webjs?.basePath; - } catch { - return ''; - } - if (typeof raw !== 'string') return ''; - let v = raw.trim(); - if (v === '' || v === '/') return ''; - // Not a plain same-origin path prefix: fail safe to no base path. - if (v.includes('..') || v.includes('://') || v.includes('\\') || /\s/.test(v)) return ''; - // A network-path reference (`//host`) is rejected BEFORE leading slashes are - // collapsed, since collapsing would turn an origin escape into `/host`. - if (v.startsWith('//')) return ''; - v = ('/' + v.replace(/^\/+/, '')).replace(/\/+$/, ''); - return v === '' || v === '/' ? '' : v; -} - -/** - * Whether the `` tag at `idx` is commented out, so dead markup is never - * reported as a live finding. - * - * A DELIMITED comment is decided by an unclosed opener behind the tag. Neither - * `')) return true; - if (before.lastIndexOf('/*') > before.lastIndexOf('*/')) return true; - const lineStart = before.lastIndexOf('\n') + 1; - return before.slice(lineStart).trimStart().startsWith('//'); -} - -/** - * Collect every `app/**` route module that renders markup, depth-first. - * Best-effort: an unreadable directory contributes nothing rather than throwing. - * @param {string} dir - * @param {string[]} [out] - * @returns {string[]} - */ -function collectRouteModules(dir, root = dir, out = []) { - let entries; - try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } - for (const e of entries) { - if (e.name.startsWith('.') || ROUTE_WALK_IGNORE.has(e.name)) continue; - if (e.isSymbolicLink()) continue; // never follow: can cycle or escape into deps - // `_`-prefixed folders are PRIVATE: `router.js` drops any route whose - // directory has such a segment, so markup under one is never routed and - // never rendered. Advising on it would be advice about dead code. - if (e.isDirectory() && e.name.startsWith('_')) continue; - const abs = join(dir, e.name); - if (e.isDirectory()) collectRouteModules(abs, root, out); - else if (ROUTE_MODULE_RE.test(e.name)) out.push(abs); - else if (dir === root && ROOT_ONLY_MODULE_RE.test(e.name)) out.push(abs); - } - return out; -} - -/** - * ADVISORY (#1095): a route module hand-writes a `` without `asset()`, so the url is un-versioned and a deploy - * cannot bust a CDN's copy of it. - * - * The failure this names was caught in production on webjs.dev: the edge served - * a `public/tailwind.css` built BEFORE the deploy (`cf-cache-status: HIT`, - * `max-age=14400`) against post-deploy HTML, so the new page rendered with its - * content edge to edge and its grid collapsed, because the cached css was - * missing the arbitrary-value utilities that page introduced. It is invisible - * while a deploy only restyles existing classes and maximally visible the moment - * one adds a page using new utilities. - * - * Why this is an ADVISORY over the author's SOURCE rather than a rewrite of the - * framework's OUTPUT. The first attempt at the automatic form (#1196) matched - * urls in the assembled HTML, and two deep-review rounds found six major - * defects, five of them one bug: at that layer framework output and author data - * are indistinguishable, so the matcher kept editing things it did not own. That - * is why `asset()` (#1194) is opt-in, and it is what Rails (a - * `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url - * from the build graph, surfaced through `links()`) both do: take the - * fingerprint from an authoritative source at the point the url is PRODUCED, and - * never rewrite a rendered document. The gap `asset()` leaves is purely - * ergonomic. In Rails the helper is the only idiomatic way to write the tag, so - * forgetting it is nearly impossible; in WebJs the `` is hand-written HTML, - * so it is easy to omit. This check closes exactly that gap, at authoring time, - * where the author's meaning is unambiguous and nothing is rewritten. - * - * Scoped to `rel="stylesheet"` on purpose. An icon is a legitimate deliberate - * NON-mark (the website leaves its favicons bare so the SEO repo-health tests - * can parse the hrefs literally), and a `rel="preload"` must NOT be marked at - * all, since its versioned hint could never match the unversioned request a CSS - * `url()` actually makes. Flagging either would nag about a correct choice. - * - * WARN only: an un-versioned stylesheet still SERVES correctly, it just caches - * badly, and an app fronted by no CDN may not care. - * @param {string} appDir - * @returns {Promise} - */ -async function checkUnmarkedAssetLinks(appDir) { - const name = 'Asset urls (unmarked stylesheet links)'; - const routeDir = join(appDir, 'app'); - if (!existsSync(routeDir)) { - return { name, status: 'pass', message: 'no app/ directory to analyse' }; - } - const basePath = await readAppBasePath(appDir); - const findings = []; - for (const file of collectRouteModules(routeDir)) { - let src; - try { src = await readFile(file, 'utf8'); } catch { continue; } - // Cheap bail before any tag scanning. Case-INSENSITIVE to match the tag - // regex: a file whose only link tag is written `` must still be - // scanned, or the scanner's own case-insensitivity is unreachable exactly - // where it is needed. - if (!/ relative(appDir, f) || f; - return { - name, - status: 'warn', - message: - `${findings.length} stylesheet link(s) are served at an un-versioned url, so a deploy cannot bust a cached copy:\n` + - findings.map((f) => ` ${rel(f.file)}:${f.line} href="${f.href}"`).join('\n'), - fix: - "Wrap the path in asset(): `import { asset } from '@webjsdev/core'` then " - + '``. It appends a content hash in prod ' - + '(the framework then serves that url immutable for a year) and is a no-op in dev and in the browser. ' - + 'Call it inside the render function, not at module scope.', - }; -} - -/** - * Probe whether `@webjsdev/core` resolves from `appDir`. Node resolution is - * directory-relative, so this must probe FROM the app (not the CLI's own - * location, which resolves the framework fine from a global install even when - * the app cannot). A no-op-cheap resolve, no I/O beyond what Node's resolver - * does, no network. Returns true when the framework resolves, false otherwise. - * @param {string} appDir - * @returns {boolean} - */ -export function frameworkResolves(appDir) { - try { - // The base file need not exist; createRequire only uses it to anchor the - // node_modules lookup at appDir. - const require = createRequire(join(appDir, '__webjs_resolve_probe__.js')); - require.resolve('@webjsdev/core'); - return true; - } catch { - return false; - } -} - -/** - * CHECK 8, framework resolvability (#954). WARN when `@webjsdev/core` cannot be - * resolved FROM the app directory, which is the fresh-git-worktree trap: a - * worktree does not copy `node_modules`, so a plain `webjs dev` there dies at - * SSR with a raw `ERR_MODULE_NOT_FOUND: Cannot find package '@webjsdev/core'` - * whose remedy is not obvious. Silent PASS when the framework resolves (the - * common case), so this never slows a healthy app. WARN (not a hard fail): it - * is a setup/environment concern, the same tier as the version-coherence check. - * @param {string} appDir - * @returns {DoctorResult} - */ -export function checkFrameworkResolves(appDir) { - const name = 'framework-resolve'; - if (frameworkResolves(appDir)) { - return { name, status: 'pass', message: '@webjsdev/core resolves from the app directory.' }; - } - const hasNodeModules = existsSync(join(appDir, 'node_modules')); - // A git worktree checks out `.git` as a FILE (a gitdir pointer), not a - // directory. That, plus a missing node_modules, is the exact #954 cause. - let isWorktree = false; - try { - isWorktree = statSync(join(appDir, '.git')).isFile(); - } catch { - isWorktree = false; - } - if (isWorktree && !hasNodeModules) { - return { - name, - status: 'warn', - message: - '@webjsdev/core cannot be resolved from this directory, and this is a git worktree with no ' + - 'node_modules. Git worktrees do not copy node_modules, so the framework is unresolvable here ' + - 'and `webjs dev` / `webjs start` would fail at SSR with a raw ERR_MODULE_NOT_FOUND.', - fix: - 'Install dependencies in this worktree (`npm install`), or symlink node_modules from the ' + - 'primary checkout (`ln -s ..//node_modules node_modules`).', - }; - } - if (!hasNodeModules) { - return { - name, - status: 'warn', - message: '@webjsdev/core cannot be resolved from this directory (no node_modules present).', - fix: 'Run `npm install` in the app directory so the framework resolves.', - }; - } - return { - name, - status: 'warn', - message: - '@webjsdev/core cannot be resolved from this directory even though node_modules exists ' + - '(a partial or corrupted install).', - fix: 'Reinstall dependencies (`npm install`, or remove node_modules and reinstall).', - }; -} - -export async function runDoctorChecks(appDir, opts = {}) { - const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname; - // ONE elision report for BOTH elision checks (#1308). Started before the - // batch and awaited inside each check, so the module graph is built once per - // doctor run and the two checks still run in parallel with everything else. - // Fails soft to null, exactly as the carrier check's own try/catch did. - const elision = (async () => { - try { - const { analyzeAppElision } = await import('@webjsdev/server'); - return await analyzeAppElision(appDir); - } catch { return null; } - })(); - const results = await Promise.all([ - checkNode(cliDir, opts), - checkTsconfig(appDir), - checkEnv(appDir), - checkVendorPin(appDir, opts), - checkVendorGitignore(appDir), - checkWebjsVersions(appDir), - Promise.resolve(checkFrameworkResolves(appDir)), - checkImportmapCoherence(appDir, opts), - Promise.resolve(checkGitHook(appDir)), - checkElisionCarriers(elision), - checkElisionComponents(elision), - checkStaticAssetFreshness(appDir), - checkUnmarkedAssetLinks(appDir), - ]); - // Attach the stable machine code to every result (#975). Centralized here so - // each check function stays free of the code-contract concern. - for (const r of results) r.code = codeForName(r.name); - return results; -} +export { DOCTOR_SEVERITIES, DOCTOR_CODES, codeForName } from './doctor/codes.js'; +export { readDoctorPolicy, applyDoctorPolicy } from './doctor/policy.js'; +export { readAppBasePath } from './doctor/route-modules.js'; +export { frameworkResolves, checkFrameworkResolves } from './doctor/probes/framework-resolves.js'; +export { runDoctorChecks } from './doctor/runner.js'; diff --git a/packages/cli/lib/doctor/codes.js b/packages/cli/lib/doctor/codes.js new file mode 100644 index 000000000..c29caeb71 --- /dev/null +++ b/packages/cli/lib/doctor/codes.js @@ -0,0 +1,66 @@ +/** + * `status` is what the CHECK found and never depends on config. `severity` is + * the EFFECTIVE level the result contributes after the app's gate is applied, + * attached by `applyDoctorPolicy` (the checks never set it). `bestEffort` marks + * a result that reports "could not check" rather than a real finding, which is + * the one thing a gate can never escalate. + * @typedef {'pass' | 'warn' | 'fail'} DoctorStatus + * @typedef {'off' | 'warn' | 'error'} DoctorSeverity a level a gate entry may DECLARE + * @typedef {'pass' | DoctorSeverity} DoctorLevel the EFFECTIVE level of a result + * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string, bestEffort?: boolean, severity?: DoctorLevel }} DoctorResult + */ + +/** + * The severity levels a `webjs.doctor.gate` entry may name, mirroring ESLint's + * three-level scale (its `off` / `warn` / `error`, which Next.js's + * `eslint-plugin-next` uses verbatim as a rule-id-keyed map). `off` is uniform: + * it silences ANY code, the two hard-fail checks included, exactly as ESLint + * lets any rule be turned off. + * @type {DoctorSeverity[]} + */ +export const DOCTOR_SEVERITIES = ['off', 'warn', 'error']; + +/** + * Stable machine-readable code per check (#975), so an agent consuming + * `webjs doctor --json` branches on the failure KIND, not the human message + * text (which is free to change). The `name` stays the display identity (some + * are kebab-case, two are prose); the `code` is the durable contract, a + * SCREAMING_SNAKE_CASE constant that never changes for a given check. Attached + * centrally in `runDoctorChecks` so every check function stays focused on its + * own logic. Mirrors Remix's `DoctorFindingCode` enum (its `doctor/types.ts`). + * + * Keyed by each check's `name`. A missing entry falls back to a name-derived + * code (see `codeForName`), but every shipped check is listed here explicitly + * and a drift test asserts each result carries one of these codes. + * @type {Record} + */ +export const DOCTOR_CODES = { + 'node-version': 'NODE_VERSION', + 'tsconfig-erasable': 'TSCONFIG_ERASABLE', + 'env-drift': 'ENV_DRIFT', + 'vendor-pin': 'VENDOR_PIN', + 'vendor-gitignore': 'VENDOR_GITIGNORE', + 'webjs-versions': 'WEBJS_VERSIONS', + 'framework-resolve': 'FRAMEWORK_RESOLVE', + 'importmap-coherence': 'IMPORTMAP_COHERENCE', + 'git-hook': 'GIT_HOOK', + 'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS', + 'Component elision (what the browser drops)': 'ELISION_COMPONENTS', + 'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS', + 'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS', +}; + +/** + * The stable code for a check name: the explicit `DOCTOR_CODES` entry, else a + * best-effort derivation (uppercased, non-alphanumerics collapsed to `_`) so a + * newly-added check that forgets its map entry still gets a non-empty code. + * @param {string} name + * @returns {string} + */ +export function codeForName(name) { + return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, ''); +} + +/** + * @typedef {{ gate: Record, unknownCodes: string[], badSeverities: Array<{ code: string, value: unknown }>, malformed: Array<{ path: string, value: unknown }>, unknownKeys: string[] }} DoctorPolicy + */ diff --git a/packages/cli/lib/doctor/manifest.js b/packages/cli/lib/doctor/manifest.js new file mode 100644 index 000000000..467f5470a --- /dev/null +++ b/packages/cli/lib/doctor/manifest.js @@ -0,0 +1,161 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { createRequire } from 'node:module'; + +/** + * Compare an installed version against a semver range PRAGMATICALLY (no semver + * dependency). Supports the common scaffold shapes: `latest` / `*` / `workspace:*` + * (any installed version satisfies), an exact `1.2.3`, and a caret `^1.2.3` + * (installed must be >= the floor AND share the same major, with major 0 also + * pinning the minor, matching npm caret semantics). An unrecognized range is + * treated as "cannot statically verify" (returns null), so the caller does not + * warn on a shape it does not understand. + * @param {string} installed + * @param {string} range + * @returns {boolean | null} + */ +export function satisfiesRange(installed, range) { + if (!installed) return null; + const r = String(range).trim(); + if (r === 'latest' || r === '*' || r === '' || r.startsWith('workspace:')) return true; + const parse = (v) => { + const m = String(v).match(/(\d+)\.(\d+)\.(\d+)/); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; + }; + const inst = parse(installed); + if (!inst) return null; + if (/^\d+\.\d+\.\d+$/.test(r)) { + const exact = parse(r); + return exact ? inst[0] === exact[0] && inst[1] === exact[1] && inst[2] === exact[2] : null; + } + if (r.startsWith('^')) { + const floor = parse(r); + if (!floor) return null; + if (inst[0] !== floor[0]) return false; + // For 0.x, caret pins the minor too (^0.7.0 allows 0.7.x, not 0.8.0). + if (floor[0] === 0 && inst[1] !== floor[1]) return false; + const cmp = + inst[0] !== floor[0] ? inst[0] - floor[0] : + inst[1] !== floor[1] ? inst[1] - floor[1] : + inst[2] - floor[2]; + return cmp >= 0; + } + return null; +} + +/** + * Read the declared dependency ranges of an INSTALLED package from + * `node_modules//package.json`, for the importmap-coherence check. This + * is the "already-resolved metadata, no network" path the issue calls for: the + * package is on disk (it was installed for the importmap to pin it), so its + * manifest is a local read. Returns null on any failure (not installed, + * unreadable, unparseable), which the coherence check treats as "could not + * verify" rather than a conflict. + * + * @param {string} appDir + * @returns {(pkg: string) => Promise<{ dependencies?: Record, peerDependencies?: Record } | null>} + */ +export function makeInstalledManifestReader(appDir) { + return async (pkg) => { + const manifestPath = join(appDir, 'node_modules', pkg, 'package.json'); + if (!existsSync(manifestPath)) return null; + try { + const parsed = JSON.parse(await readFile(manifestPath, 'utf8')); + return { + dependencies: parsed.dependencies || {}, + peerDependencies: parsed.peerDependencies || {}, + }; + } catch { + return null; + } + }; +} + +/** + * Format a coherence conflict list into a single human-readable warning line + * naming each conflicting pair, the required range, and the pinned version. + * @param {Array<{ pkg: string, version: string, dependsOn: string, kind: string, requiredRange: string, pinnedVersion: string }>} conflicts + * @returns {string} + */ +export function formatConflicts(conflicts) { + return conflicts + .map( + (c) => + `${c.pkg}@${c.version} needs ${c.dependsOn} ${c.kind === 'peerDependency' ? '(peer) ' : ''}${c.requiredRange} but the importmap pins ${c.dependsOn}@${c.pinnedVersion}`, + ) + .join('; '); +} + +/** + * Read a dependency's INSTALLED version as resolved FROM `appDir`, or null when + * it does not resolve there at all. + * + * Node's own resolver is the ground truth here, not a directory read. The check + * this serves asks "would this app resolve this dependency at runtime, and at + * what version", and Node's resolution algorithm IS that question's definition, + * so anything re-implementing it can only be a worse approximation. Asking Node + * handles workspace hoisting (the bug this fixes: under npm workspaces the + * `@webjsdev/*` deps hoist to the ROOT node_modules, so an app subdirectory has + * no local copy and a per-app `node_modules//package.json` read reported + * every declared dep missing on a healthy install), symlinked workspace links, + * nested non-hoisted trees, and `package.json` `imports`, for free and for ever. + * + * The direct `/package.json` resolve is attempted FIRST because a package + * may declare no main entry at all: `@webjsdev/cli` is bin-only (no `main`, no + * `exports`), so `require.resolve('@webjsdev/cli')` throws MODULE_NOT_FOUND. + * The ERR_PACKAGE_PATH_NOT_EXPORTED fallback exists because a package may lock + * its manifest out of its `exports` map: `@webjsdev/server` exports only `.`, + * `./check`, `./testing`, and `./webjs-config.schema.json`, so the direct + * manifest resolve is refused and the main entry plus a bounded walk up to the + * package root is the way in. Neither strategy alone resolves all four + * `@webjsdev/*` packages; both halves are required. + * + * Local rather than `getPackageVersion` from `@webjsdev/server` for two reasons. + * Doctor must stay usable when the framework does not resolve from the app dir + * at all, which is the #954 fresh-worktree case doctor exists to diagnose, so + * this check cannot import the server (the same argument `frameworkResolves` + * below already follows). And `getPackageVersion` resolves the main entry only, + * so it returns null for a bin-only package, which would leave `@webjsdev/cli` + * reported missing: the same false positive with more machinery. + * + * Pinned by the workspace, bin-only, and exports-locked fixtures in + * `test/cli/doctor.test.mjs`. + * @param {string} dep package name, e.g. `@webjsdev/server` + * @param {string} appDir directory to anchor resolution at + * @returns {Promise} the installed version, or null when unresolvable + */ +export async function readInstalledVersion(dep, appDir) { + // The base file need not exist; createRequire only uses it to anchor the + // node_modules lookup at appDir. + const require = createRequire(join(appDir, '__webjs_resolve_probe__.js')); + let manifestPath = null; + try { + manifestPath = require.resolve(dep + '/package.json'); + } catch (err) { + if (err?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') return null; + let entry; + try { + entry = require.resolve(dep); + } catch { + return null; + } + let dir = dirname(entry); + for (let i = 0; i < 12; i++) { + const candidate = join(dir, 'package.json'); + if (existsSync(candidate)) { + manifestPath = candidate; + break; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + if (!manifestPath) return null; + } + try { + return JSON.parse(await readFile(manifestPath, 'utf8')).version || null; + } catch { + return null; + } +} diff --git a/packages/cli/lib/doctor/policy.js b/packages/cli/lib/doctor/policy.js new file mode 100644 index 000000000..9d819d99e --- /dev/null +++ b/packages/cli/lib/doctor/policy.js @@ -0,0 +1,124 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DOCTOR_CODES, DOCTOR_SEVERITIES } from './codes.js'; + +/** + * @typedef {import('./codes.js').DoctorSeverity} DoctorSeverity + * @typedef {import('./codes.js').DoctorLevel} DoctorLevel + * @typedef {import('./codes.js').DoctorResult} DoctorResult + * @typedef {{ gate: Record, unknownCodes: string[], badSeverities: Array<{ code: string, value: unknown }>, malformed: Array<{ path: string, value: unknown }>, unknownKeys: string[] }} DoctorPolicy + */ + +/** A plain JSON object (not null, not an array), the only shape the gate accepts. */ +function isPlainObject(v) { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** + * Read the app's per-check severity policy out of `package.json` + * `webjs.doctor.gate` (#1257). PURE: it reads one file and returns data, and + * the caller (the CLI) decides what to do about a problem. + * + * `gate` keeps only WELL-FORMED entries, so a caller can fold it over the + * results without re-validating. Everything rejected is reported separately: + * a key that is not a value of `DOCTOR_CODES` lands in `unknownCodes`, a value + * outside `DOCTOR_SEVERITIES` in `badSeverities`, a wrong SHAPE (a non-object + * `doctor` or `gate`) in `malformed`, and a misspelled sibling of `gate` such + * as `gates` in `unknownKeys`. All four are surfaced as a hard error by the + * CLI rather than skipped. + * + * The shape check matters as much as the per-entry one, and is the easier half + * to leave out. A gate that FAILS OPEN is the one outcome this mechanism cannot + * afford: `"gate": "error"` or a misspelled `"gates": {...}` would leave CI + * un-gated while the package.json looks gated, which is strictly worse than + * having no gate at all, since nobody goes looking. The JSON Schema catches + * these in an editor, but it is editor-only, so it can never be the enforcement. + * + * A missing package.json, a missing block, or unparseable JSON is an EMPTY + * policy with no problems: an app that declares nothing behaves exactly as it + * did before the gate existed. Unparseable JSON in particular is deliberately + * not an error here, since `checkWebjsVersions` already reports that condition + * and doctor must never crash on a broken app file. + * + * @param {string} appDir + * @returns {DoctorPolicy} + */ +export function readDoctorPolicy(appDir) { + /** @type {DoctorPolicy} */ + const empty = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] }; + let raw; + try { + raw = readFileSync(join(appDir, 'package.json'), 'utf8'); + } catch { + return empty; + } + let pkg; + try { + pkg = JSON.parse(raw); + } catch { + return empty; + } + const doctor = pkg?.webjs?.doctor; + if (doctor === undefined) return empty; + if (!isPlainObject(doctor)) return { ...empty, malformed: [{ path: 'webjs.doctor', value: doctor }] }; + + /** @type {DoctorPolicy} */ + const policy = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] }; + // A misspelled sibling (`gates`) would otherwise be dropped in silence, which + // is the fail-open case. `gate` is the only key this block accepts. + for (const key of Object.keys(doctor)) { + if (key !== 'gate') policy.unknownKeys.push(`webjs.doctor.${key}`); + } + const declared = doctor.gate; + if (declared !== undefined && !isPlainObject(declared)) { + policy.malformed.push({ path: 'webjs.doctor.gate', value: declared }); + } + if (!isPlainObject(declared)) return policy; + + const known = new Set(Object.values(DOCTOR_CODES)); + for (const [code, value] of Object.entries(declared)) { + if (!known.has(code)) { + policy.unknownCodes.push(code); + continue; + } + if (typeof value !== 'string' || !DOCTOR_SEVERITIES.includes(/** @type {DoctorSeverity} */ (value))) { + policy.badSeverities.push({ code, value }); + continue; + } + policy.gate[code] = /** @type {DoctorSeverity} */ (value); + } + return policy; +} + +/** + * Fold a severity `gate` over check results, returning a NEW array whose + * results each carry the EFFECTIVE level they contribute (#1257). PURE: the + * input array and its results are never mutated. + * + * `severity` is the effective level, not the declared one, which is why a + * PASSING check reports `'pass'` even when its code is gated `error`. A rule + * that did not fire contributes nothing, the same way ESLint puts severity on a + * message rather than on a rule that stayed quiet. It also keeps the obvious + * one-liner honest: `results.some((r) => r.severity === 'error')` is exactly + * "something fatal was found", with no passing-check false positive. + * + * The gate's one hard limit is `bestEffort`: a result that could not check + * (a toolchain that would not load, a network that was unreachable) is CLAMPED + * to `warn` however loudly the gate declares its code. That is what lets this + * repo's required CI job run a check whose live resolve touches jspm without + * an outage there ever redding an unrelated pull request. + * + * @param {DoctorResult[]} results + * @param {Record} [gate] well-formed entries only (see readDoctorPolicy) + * @returns {DoctorResult[]} + */ +export function applyDoctorPolicy(results, gate = {}) { + return results.map((r) => { + if (r.status === 'pass') return { ...r, severity: /** @type {DoctorLevel} */ ('pass') }; + const declared = gate[r.code]; + const fallback = r.status === 'fail' ? 'error' : 'warn'; + let severity = /** @type {DoctorSeverity} */ (declared || fallback); + if (r.bestEffort && severity === 'error') severity = 'warn'; + return { ...r, severity }; + }); +} diff --git a/packages/cli/lib/doctor/probes/elision.js b/packages/cli/lib/doctor/probes/elision.js new file mode 100644 index 000000000..4549504a9 --- /dev/null +++ b/packages/cli/lib/doctor/probes/elision.js @@ -0,0 +1,111 @@ +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * Advisory (#646): name why a page/layout SHIPS its module to the browser + * instead of being elided. A page/layout that is a pure carrier (import-only + * #605 / inert #179) stays out of the browser; one that ships whole is pinned + * by a specific client-effecting NON-component on a component-free path from it, #963 (a util touching + * a client global, a module-scope side effect, a bare side-effect import) or by + * its own client work. This turns that invisible #605/#179 regression into a + * named line. WARN only: a page legitimately MAY ship, and the analyser is + * biased toward shipping by design (server AGENTS invariant 7), so this is a + * "you may not have intended this" hint, never a hard fail. + * @param {Promise} elisionPromise the ONE shared report (#1308) + * @returns {Promise} + */ +export async function checkElisionCarriers(elisionPromise) { + const name = 'Page/layout elision (carrier hygiene)'; + const report = await elisionPromise; + if (!report) { + // Analysis unavailable (no app, malformed, server import failed): no advice. + return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' }; + } + if (!report.analysed) { + return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' }; + } + // Paths and reasons arrive app-relative from `analyzeAppElision` (#1308). + const shipped = report.routeModules.filter((r) => r.verdict === 'shipped'); + if (shipped.length === 0) { + return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' }; + } + // Name the FIRST client-effecting blocker (there may be more than one; the + // module stays shipped until every such blocker is moved out). + const lines = shipped.map(({ file, blocker, reason }) => + blocker + ? `${file} ships whole. Its first client-effecting blocker is ${blocker}, which ${reason} and is not a component` + : `${file} ships whole because it ${reason}`, + ); + return { + name, + status: 'warn', + message: + `${shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` + + lines.map((l) => ` ${l}`).join('\n'), + fix: 'Move the client work out of the page/layout closure (into a component, or a .server module reached through an action) so the carrier can be elided, or accept that it ships. See references/components.md in the skill.', + }; +} + +/** + * The OTHER direction of the elision verdict (#1308): which COMPONENT modules + * the browser never downloads. `checkElisionCarriers` above reports the benign + * over-ship direction; this one reports what was DROPPED, which is where a + * wrong verdict silently costs an app its interactivity. + * + * Pass-only except for orphans, deliberately. An elided component is the + * DESIRED outcome, so warning on one would fire on every healthy app and train + * the reader to skip doctor output. The passing message carries the elided + * inventory instead, which makes it the discovery surface, while `webjs + * elision` is the detail surface. The one always-wrong condition is an ORPHAN: + * a `class X extends WebComponent` with no literal-tag registration is + * invisible to the scanner, so it gets no verdict at all and `static + * interactive = true` cannot rescue it (nothing consults the component + * analyser for a component the scanner never saw). Never `fail`: + * an app that wants an orphan to break CI gates `ELISION_COMPONENTS` to + * `error` via `webjs.doctor.gate`. + * + * @param {Promise} elisionPromise the ONE shared report + * @returns {Promise} + */ +export async function checkElisionComponents(elisionPromise) { + const name = 'Component elision (what the browser drops)'; + const report = await elisionPromise; + const notAnalysed = { name, status: /** @type {const} */ ('pass'), message: 'not analysed (no routable app or analysis unavailable)' }; + if (!report) return notAnalysed; + if (!report.analysed) { + return report.skipped === 'elide-off' + ? { name, status: 'pass', message: 'elision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships' } + : notAnalysed; + } + if (report.orphans.length > 0) { + const lines = report.orphans.map(({ file, className }) => + `${className} in ${file} is never registered with a literal tag`, + ); + return { + name, + status: 'warn', + message: + `${report.orphans.length} component class(es) get NO elision verdict:\n` + + lines.map((l) => ` ${l}`).join('\n') + + '\n Either it has no registration call at all, or it registers a computed tag. The component ' + + 'scanner matches only a literal tag, so either way it never sees the class: no elision verdict, no ' + + 'registry entry, no preload hint, and `static interactive = true` cannot rescue it. With no ' + + 'registration call the element never upgrades at all; with a computed tag it upgrades only while ' + + 'its module still reaches the browser through an importer that ships.', + fix: 'Register it with a literal tag, Class.register(\'my-tag\') (invariant 3 already requires one), or delete the class if nothing uses it.', + }; + } + const elided = report.components.filter((c) => c.verdict === 'elided'); + const tags = elided.flatMap((c) => c.tags); + const shown = tags.slice(0, 8).join(', '); + const tail = tags.length > 8 ? `, +${tags.length - 8} more` : ''; + return { + name, + status: 'pass', + message: + `${report.summary.elided} of ${report.summary.components} component module(s) are elided (never downloaded)` + + (tags.length ? `: ${shown}${tail}` : '') + + '. Run `webjs elision` for the full verdict.', + }; +} diff --git a/packages/cli/lib/doctor/probes/env.js b/packages/cli/lib/doctor/probes/env.js new file mode 100644 index 000000000..b6e2a6cda --- /dev/null +++ b/packages/cli/lib/doctor/probes/env.js @@ -0,0 +1,53 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { parseEnvKeys } from '../util.js'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * CHECK 3, .env presence + drift vs .env.example. WARN-level only (a missing + * env var is the app's runtime problem, not a toolchain crash). When no + * `.env.example`, PASS (nothing to compare). When `.env.example` exists but + * `.env` is absent, WARN to copy it. Otherwise WARN listing any example key + * missing from `.env`, else PASS. + * @param {string} appDir + * @returns {Promise} + */ +export async function checkEnv(appDir) { + const examplePath = join(appDir, '.env.example'); + if (!existsSync(examplePath)) { + return { + name: 'env-drift', + status: 'pass', + message: 'No .env.example to compare against.', + }; + } + const exampleKeys = parseEnvKeys(await readFile(examplePath, 'utf8')); + const envPath = join(appDir, '.env'); + if (!existsSync(envPath)) { + return { + name: 'env-drift', + status: 'warn', + message: '.env.example exists but .env does not.', + fix: 'Copy it: cp .env.example .env (then fill in the values).', + }; + } + const envKeys = parseEnvKeys(await readFile(envPath, 'utf8')); + const missing = [...exampleKeys].filter((k) => !envKeys.has(k)); + if (missing.length === 0) { + return { + name: 'env-drift', + status: 'pass', + message: `.env has all ${exampleKeys.size} key(s) declared in .env.example.`, + }; + } + return { + name: 'env-drift', + status: 'warn', + message: `.env is missing ${missing.length} key(s) from .env.example: ${missing.join(', ')}.`, + fix: 'Add the missing key(s) to .env (see .env.example for the expected names).', + }; +} diff --git a/packages/cli/lib/doctor/probes/framework-resolves.js b/packages/cli/lib/doctor/probes/framework-resolves.js new file mode 100644 index 000000000..7b99aa08e --- /dev/null +++ b/packages/cli/lib/doctor/probes/framework-resolves.js @@ -0,0 +1,84 @@ +import { existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * Probe whether `@webjsdev/core` resolves from `appDir`. Node resolution is + * directory-relative, so this must probe FROM the app (not the CLI's own + * location, which resolves the framework fine from a global install even when + * the app cannot). A no-op-cheap resolve, no I/O beyond what Node's resolver + * does, no network. Returns true when the framework resolves, false otherwise. + * @param {string} appDir + * @returns {boolean} + */ +export function frameworkResolves(appDir) { + try { + // The base file need not exist; createRequire only uses it to anchor the + // node_modules lookup at appDir. + const require = createRequire(join(appDir, '__webjs_resolve_probe__.js')); + require.resolve('@webjsdev/core'); + return true; + } catch { + return false; + } +} + +/** + * CHECK 8, framework resolvability (#954). WARN when `@webjsdev/core` cannot be + * resolved FROM the app directory, which is the fresh-git-worktree trap: a + * worktree does not copy `node_modules`, so a plain `webjs dev` there dies at + * SSR with a raw `ERR_MODULE_NOT_FOUND: Cannot find package '@webjsdev/core'` + * whose remedy is not obvious. Silent PASS when the framework resolves (the + * common case), so this never slows a healthy app. WARN (not a hard fail): it + * is a setup/environment concern, the same tier as the version-coherence check. + * @param {string} appDir + * @returns {DoctorResult} + */ +export function checkFrameworkResolves(appDir) { + const name = 'framework-resolve'; + if (frameworkResolves(appDir)) { + return { name, status: 'pass', message: '@webjsdev/core resolves from the app directory.' }; + } + const hasNodeModules = existsSync(join(appDir, 'node_modules')); + // A git worktree checks out `.git` as a FILE (a gitdir pointer), not a + // directory. That, plus a missing node_modules, is the exact #954 cause. + let isWorktree = false; + try { + isWorktree = statSync(join(appDir, '.git')).isFile(); + } catch { + isWorktree = false; + } + if (isWorktree && !hasNodeModules) { + return { + name, + status: 'warn', + message: + '@webjsdev/core cannot be resolved from this directory, and this is a git worktree with no ' + + 'node_modules. Git worktrees do not copy node_modules, so the framework is unresolvable here ' + + 'and `webjs dev` / `webjs start` would fail at SSR with a raw ERR_MODULE_NOT_FOUND.', + fix: + 'Install dependencies in this worktree (`npm install`), or symlink node_modules from the ' + + 'primary checkout (`ln -s ..//node_modules node_modules`).', + }; + } + if (!hasNodeModules) { + return { + name, + status: 'warn', + message: '@webjsdev/core cannot be resolved from this directory (no node_modules present).', + fix: 'Run `npm install` in the app directory so the framework resolves.', + }; + } + return { + name, + status: 'warn', + message: + '@webjsdev/core cannot be resolved from this directory even though node_modules exists ' + + '(a partial or corrupted install).', + fix: 'Reinstall dependencies (`npm install`, or remove node_modules and reinstall).', + }; +} diff --git a/packages/cli/lib/doctor/probes/git-hook.js b/packages/cli/lib/doctor/probes/git-hook.js new file mode 100644 index 000000000..414a56581 --- /dev/null +++ b/packages/cli/lib/doctor/probes/git-hook.js @@ -0,0 +1,58 @@ +import { existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * CHECK 6 (optional), git pre-commit hook installed + executable. WARN when the + * repo is a git checkout but `.git/hooks/pre-commit` is absent or + * non-executable, since the test-gate / changelog hook would not fire. PASS when + * present + executable, or skip (PASS) when this is not a git checkout at all + * (an exported tarball, a non-repo dir). Respects a configured `core.hooksPath` + * is OUT of scope here: the common scaffold installs into `.git/hooks`, so this + * checks the default location and a configured path is the user's own concern. + * @param {string} appDir + * @returns {DoctorResult} + */ +export function checkGitHook(appDir) { + const gitDir = join(appDir, '.git'); + if (!existsSync(gitDir)) { + return { + name: 'git-hook', + status: 'pass', + message: 'Not a git checkout; no pre-commit hook expected.', + }; + } + const hook = join(gitDir, 'hooks', 'pre-commit'); + if (!existsSync(hook)) { + return { + name: 'git-hook', + status: 'warn', + message: 'No .git/hooks/pre-commit hook installed.', + fix: 'Install the project hooks (e.g. `npm install` runs the prepare step that wires them).', + }; + } + let executable = false; + try { + // Owner-execute bit. On a checkout without exec bits (some Windows / CI + // setups) the hook will not run, so flag it. + executable = (statSync(hook).mode & 0o100) !== 0; + } catch { + executable = false; + } + if (!executable) { + return { + name: 'git-hook', + status: 'warn', + message: '.git/hooks/pre-commit exists but is not executable.', + fix: 'chmod +x .git/hooks/pre-commit', + }; + } + return { + name: 'git-hook', + status: 'pass', + message: '.git/hooks/pre-commit is installed and executable.', + }; +} diff --git a/packages/cli/lib/doctor/probes/importmap-coherence.js b/packages/cli/lib/doctor/probes/importmap-coherence.js new file mode 100644 index 000000000..d9dbd9cdb --- /dev/null +++ b/packages/cli/lib/doctor/probes/importmap-coherence.js @@ -0,0 +1,158 @@ +import { formatConflicts, makeInstalledManifestReader } from '../manifest.js'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * CHECK 7, importmap coherence (issue #450). Defense-in-depth that catches an + * INCOHERENT client dependency graph in the produced importmap, regardless of + * how the incoherence arose (a hand-edited pin file, a partial vendor pin, or + * the #446 resolution skew). For each resolved package, it checks that the + * version actually pinned for every OTHER resolved package it depends on + * satisfies the declared range; a miss warns naming both packages, the range, + * and the pinned version. + * + * Runs the SAME check over BOTH inputs and produces the same verdict for the + * same dep set (the parity invariant): the live importmap (resolved the way the + * server resolves it at runtime) AND the vendored `.webjs/vendor/importmap.json`. + * A vendored importmap is a freeze of the runtime-resolved graph, so a coherent + * runtime graph that gets vendored stays coherent. + * + * WARN-only and BEST-EFFORT: it never hard-fails (a runtime incoherence is the + * app's concern, not a broken toolchain), and it degrades to a soft + * "could not verify" whenever metadata or a live resolve is unavailable rather + * than failing closed. Dependency metadata is read from the already-installed + * `node_modules` manifests, no network call of its own; the only network touch + * is the live importmap resolve, which is wrapped so any failure degrades. + * + * The vendor functions + manifest reader are injectable via `opts.coherence` + * so a test can drive every branch without a network call. + * + * @param {string} appDir + * @param {{ coherence?: { + * liveImports?: () => Promise | null>, + * vendoredImports?: () => Promise | null>, + * getManifest?: (pkg: string, version: string) => Promise, + * check?: (imports: Record, o: { getManifest: any }) => Promise<{ conflicts: any[], unverified: any[], checked: number }>, + * } }} opts + * @returns {Promise} + */ +export async function checkImportmapCoherence(appDir, opts) { + let inj = opts.coherence; + // Resolve the real vendor toolchain unless a test injected stubs. Both the + // importmap sources and the coherence-check function come from + // @webjsdev/server, so a missing install degrades to a WARN, never a throw. + if (!inj || !inj.check || !inj.liveImports || !inj.vendoredImports || !inj.getManifest) { + let mod; + try { + mod = await import('@webjsdev/server'); + } catch { + return { + name: 'importmap-coherence', + status: 'warn', + bestEffort: true, + message: 'Could not load the vendor toolchain to check importmap coherence.', + fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.', + }; + } + const real = { + check: mod.checkImportmapCoherence, + // Hoist-aware manifest read from the already-installed node_modules (no + // network of its own), so a monorepo-hoisted dep still resolves. Falls + // back to the local app/node_modules read if the server build predates + // getPackageManifest. + getManifest: typeof mod.getPackageManifest === 'function' + ? (pkg) => mod.getPackageManifest(pkg, appDir) + : makeInstalledManifestReader(appDir), + // Live importmap: resolve vendor imports the way the server does on the + // first request (prefers the pin file, else a live jspm.io resolve). + liveImports: async () => { + try { + const resolved = await mod.resolveVendorImports(appDir, () => mod.scanBareImports(appDir)); + return resolved && resolved.imports ? resolved.imports : {}; + } catch { + return null; + } + }, + // Vendored importmap: the committed pin file, no network. + vendoredImports: async () => { + try { + const pin = await mod.readPinFile(appDir); + return pin && pin.imports ? pin.imports : null; + } catch { + return null; + } + }, + }; + inj = { ...real, ...(inj || {}) }; + } + + // Gather both importmaps. Either may be absent (no pin file, or a live + // resolve that failed / found no vendor imports); the check runs over + // whichever exist, identically. + let live = null; + let vendored = null; + try { live = await inj.liveImports(); } catch { live = null; } + try { vendored = await inj.vendoredImports(); } catch { vendored = null; } + + const liveHas = live && Object.keys(live).length > 0; + const vendoredHas = vendored && Object.keys(vendored).length > 0; + if (!liveHas && !vendoredHas) { + return { + name: 'importmap-coherence', + status: 'pass', + message: 'No vendor importmap to check (the app imports no npm packages on the client).', + }; + } + + // Run the IDENTICAL check over each available importmap. The function is + // pure in (imports, getManifest), so the same pinned dep set produces the + // same verdict whichever input it came from (the runtime-vs-vendored parity + // invariant). Aggregate the conflicts; dedupe identical ones so a package + // pinned the same way in both maps is reported once. + /** @type {Map} */ + const conflictsByKey = new Map(); + let anyChecked = 0; + let anyUnverified = 0; + for (const imports of [liveHas ? live : null, vendoredHas ? vendored : null]) { + if (!imports) continue; + let report; + try { + report = await inj.check(imports, { getManifest: inj.getManifest }); + } catch { + // A check that threw is a "could not verify", never a doctor crash. + anyUnverified++; + continue; + } + anyChecked += report.checked || 0; + anyUnverified += (report.unverified || []).length; + for (const c of report.conflicts || []) { + conflictsByKey.set(`${c.pkg}@${c.version}->${c.dependsOn}@${c.pinnedVersion}`, c); + } + } + + const conflicts = [...conflictsByKey.values()]; + if (conflicts.length > 0) { + return { + name: 'importmap-coherence', + status: 'warn', + message: `Incoherent client dependency graph in the importmap: ${formatConflicts(conflicts)}.`, + fix: 'Align the pinned versions: re-run `webjs vendor pin` to re-resolve a coherent set, or bump the lagging package in package.json and reinstall so the importmap pins a version satisfying every dependent.', + }; + } + if (anyChecked === 0 && anyUnverified > 0) { + return { + name: 'importmap-coherence', + status: 'warn', + bestEffort: true, + message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).', + fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.', + }; + } + return { + name: 'importmap-coherence', + status: 'pass', + message: 'The importmap dependency graph is coherent (every pinned package satisfies its dependents\' declared ranges).', + }; +} diff --git a/packages/cli/lib/doctor/probes/node.js b/packages/cli/lib/doctor/probes/node.js new file mode 100644 index 000000000..81dd9eb94 --- /dev/null +++ b/packages/cli/lib/doctor/probes/node.js @@ -0,0 +1,37 @@ +import { checkNodeInline } from '../../node-preflight.js'; +import { readEngines } from '../util.js'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * CHECK 1, Node version. HARD-FAIL when the running major is below the required + * major (the strip-types + recursive fs.watch floor). `opts.nodeVersion` lets a + * test inject the running version so the fail case is assertable without being + * on old Node. + * @param {string} cliDir + * @param {{ nodeVersion?: string }} opts + * @returns {Promise} + */ +export async function checkNode(cliDir, opts) { + const engines = await readEngines(cliDir); + const current = opts.nodeVersion || process.versions.node; + const r = checkNodeInline(current, engines); + if (r.ok) { + return { + name: 'node-version', + status: 'pass', + message: `Node ${r.current} satisfies the required Node ${r.requiredMajor}+.`, + }; + } + return { + name: 'node-version', + status: 'fail', + message: + `Node ${r.current} is below the required Node ${r.requiredMajor}+. ` + + `webjs is buildless and relies on Node ${r.requiredMajor}'s built-in TypeScript ` + + `strip and recursive fs.watch.`, + fix: `Upgrade to Node ${r.requiredMajor}+ (see https://nodejs.org).`, + }; +} diff --git a/packages/cli/lib/doctor/probes/static-asset-freshness.js b/packages/cli/lib/doctor/probes/static-asset-freshness.js new file mode 100644 index 000000000..2cdbdfab9 --- /dev/null +++ b/packages/cli/lib/doctor/probes/static-asset-freshness.js @@ -0,0 +1,58 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { newestMtimeMs } from '../util.js'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * ADVISORY: a declared `webjs.dev.regenerate` output is STALE on disk (a source + * is newer than the committed/built output). In DEV the framework recompiles it + * on request (#967), so this never bites locally, but the check is the explicit + * dev/prod PARITY backstop: it catches a stale `public/tailwind.css` that would + * be served as-is by `webjs start` (prod does NOT recompile on request) or + * committed into the repo. WARN-level: the fix is a one-line rebuild, and a + * missing output (a fresh clone before the first `css:build`) is not this app's + * bug to hard-fail on. + * @param {string} appDir + * @returns {Promise} + */ +export async function checkStaticAssetFreshness(appDir) { + const name = 'Static build outputs (dev.regenerate freshness)'; + let pkg; + try { + pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8')); + } catch { + return { name, status: 'pass', message: 'no package.json to analyse' }; + } + const rules = pkg && pkg.webjs && pkg.webjs.dev ? pkg.webjs.dev.regenerate : null; + if (!Array.isArray(rules) || rules.length === 0) { + return { name, status: 'pass', message: 'no webjs.dev.regenerate rules declared' }; + } + const stale = []; + for (const rule of rules) { + if (!rule || typeof rule.output !== 'string') continue; + const output = rule.output.replace(/^\/+/, ''); + const outMtime = newestMtimeMs(join(appDir, output)); + if (outMtime === 0) continue; // missing output: not a staleness fail (built on first boot) + let newestSrc = 0; + for (const inp of Array.isArray(rule.inputs) ? rule.inputs : []) { + const m = newestMtimeMs(join(appDir, inp)); + if (m > newestSrc) newestSrc = m; + } + if (newestSrc > outMtime) stale.push({ output, command: rule.command }); + } + if (stale.length === 0) { + return { name, status: 'pass', message: 'every declared build output is up to date with its sources' }; + } + return { + name, + status: 'warn', + message: + `${stale.length} static build output(s) are older than a source file:\n` + + stale.map((s) => ` ${s.output} (rebuild: ${s.command})`).join('\n') + + '\n In dev the framework recompiles these on request, so this only bites a `webjs start` (prod) or a committed stale file.', + fix: 'Rebuild the output(s) with the command shown (e.g. `npm run css:build`) before deploying or committing. `webjs dev` regenerates them on request automatically.', + }; +} diff --git a/packages/cli/lib/doctor/probes/tsconfig.js b/packages/cli/lib/doctor/probes/tsconfig.js new file mode 100644 index 000000000..07644a651 --- /dev/null +++ b/packages/cli/lib/doctor/probes/tsconfig.js @@ -0,0 +1,55 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { stripJsonc } from '../util.js'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * CHECK 2, tsconfig erasableSyntaxOnly. PASS when `true`; WARN when no tsconfig + * (a JS-only app legitimately has none) or the file is unparseable; HARD-FAIL + * when the file EXISTS but the flag is missing/false (non-erasable TS 500s at + * strip time). + * @param {string} appDir + * @returns {Promise} + */ +export async function checkTsconfig(appDir) { + const path = join(appDir, 'tsconfig.json'); + if (!existsSync(path)) { + return { + name: 'tsconfig-erasable', + status: 'warn', + message: 'No tsconfig.json found. A JS-only app needs none; a TypeScript app requires one.', + fix: 'If this app uses TypeScript, add a tsconfig.json with "erasableSyntaxOnly": true.', + }; + } + let parsed; + try { + parsed = JSON.parse(stripJsonc(await readFile(path, 'utf8'))); + } catch { + return { + name: 'tsconfig-erasable', + status: 'warn', + message: 'tsconfig.json could not be parsed (even after stripping comments + trailing commas).', + fix: 'Fix the tsconfig.json syntax, then ensure "compilerOptions.erasableSyntaxOnly": true.', + }; + } + const flag = parsed?.compilerOptions?.erasableSyntaxOnly; + if (flag === true) { + return { + name: 'tsconfig-erasable', + status: 'pass', + message: 'tsconfig.json sets "erasableSyntaxOnly": true.', + }; + } + return { + name: 'tsconfig-erasable', + status: 'fail', + message: + 'tsconfig.json is missing "compilerOptions.erasableSyntaxOnly": true. ' + + 'Non-erasable TypeScript (enum, namespace, parameter properties, ...) 500s at strip time.', + fix: 'Set "compilerOptions": { "erasableSyntaxOnly": true } in tsconfig.json.', + }; +} diff --git a/packages/cli/lib/doctor/probes/unmarked-asset-links.js b/packages/cli/lib/doctor/probes/unmarked-asset-links.js new file mode 100644 index 000000000..ac625263f --- /dev/null +++ b/packages/cli/lib/doctor/probes/unmarked-asset-links.js @@ -0,0 +1,199 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { isCommentedOut } from '../util.js'; +import { collectRouteModules, readAppBasePath } from '../route-modules.js'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * One whole `` tag. QUOTE-AWARE (`(?:[^>"']|"[^"]*"|'[^']*')*`), the + * same shape `ssr.js`'s hoist scanner uses, so a `>` inside a quoted attribute + * value cannot terminate the tag early. + * @type {RegExp} + */ +const LINK_TAG_RE = /"']|"[^"]*"|'[^']*')*>/gi; + +/** + * One attribute inside a tag: a name, then optionally `=` and a double-quoted, + * single-quoted, or unquoted value. Matching attributes as WHOLE units is what + * makes the scan correct, because each quoted value is consumed in one step and + * can therefore never be re-scanned as if it contained an attribute of its own. + * @type {RegExp} + */ +const ATTR_RE = /([a-zA-Z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; + +/** + * Parse a tag's attributes into a lowercased-name map. The value is `null` for a + * valueless attribute and carries a `quoted` flag, since this check treats an + * UNQUOTED href (a template hole) as undecidable rather than as a path. + * @param {string} tag + * @returns {Map} + */ +function parseTagAttrs(tag) { + /** @type {Map} */ + const attrs = new Map(); + // Skip the tag name itself so `link` is not read as an attribute. + const body = tag.replace(/^<[a-zA-Z_:][-\w:.]*/, ''); + ATTR_RE.lastIndex = 0; + for (const m of body.matchAll(ATTR_RE)) { + const name = m[1].toLowerCase(); + if (attrs.has(name)) continue; // first wins, as in HTML parsing + const quoted = m[2] !== undefined || m[3] !== undefined; + const value = m[2] ?? m[3] ?? m[4] ?? null; + attrs.set(name, { value, quoted }); + } + return attrs; +} + +/** + * Whether a parsed `` is an unmarked stylesheet, and if so its href. + * + * Attribute PARSING rather than a lookahead over the raw tag is load-bearing, + * not tidiness. A scan that merely looks ahead for `rel=…stylesheet` anywhere in + * the tag matches the string inside ANOTHER attribute's value, which flags the + * two shapes this check most needs to leave alone: the canonical async-CSS + * `` + * (where the advised `asset()` fix would actively BREAK the preload, since the + * versioned hint could then never match the unversioned request), and a + * `data-rel="stylesheet"` sitting on a `rel="icon"`. Reading real attributes + * makes `rel` mean the `rel` attribute and nothing else. + * + * Returns the href only when every condition holds: + * - `rel` is a token list CONTAINING `stylesheet` (so `rel="preload"` with an + * onload swap, and `rel="icon"`, are both out). + * - `href` is QUOTED. An unquoted value is a template hole + * (`href=${asset('/public/app.css')}`), undecidable from source, and is + * exactly the shape the marked form uses. + * - the path is under `/public/` (after the app's `webjs.basePath` is + * stripped, since under a sub-path deploy the author writes the prefix + * themselves and `resolveAssetUrl` strips it before its own `public/` gate). + * - `resolveAssetUrl` would actually fingerprint it. It returns a path + * carrying a QUERY or a `..` unchanged, so wrapping one in `asset()` is a + * runtime NO-OP: the author does the work and the url they ship is + * byte-identical. Advising it would be advising a change that buys nothing. + * A hand-rolled `?v=` cache-buster is exactly what an author who has not + * adopted `asset()` is most likely to have written, so this is the common + * case, not a corner. (The warning itself would clear, since this check + * reads the SOURCE shape and a wrapped href is an unquoted hole. Clearing a + * warning without improving the caching is the outcome to avoid.) + * + * @param {string} tag + * @param {string} basePath the app's normalized `webjs.basePath` (`''` at root) + * @returns {string | null} + */ +function unmarkedStylesheetHref(tag, basePath = '') { + const attrs = parseTagAttrs(tag); + const rel = attrs.get('rel'); + if (!rel || !rel.value) return null; + if (!rel.value.toLowerCase().split(/\s+/).includes('stylesheet')) return null; + const href = attrs.get('href'); + if (!href || !href.quoted || !href.value) return null; + const url = href.value; + if (url[0] !== '/' || url[1] === '/') return null; + // Mirror `resolveAssetUrl`'s refusals IN ITS ORDER, so every flagged href is + // one `asset()` can actually fingerprint. It strips the base path, cuts at + // `?` / `#`, DECODES, and only then tests `..` and the `public/` prefix. + // Testing the raw value instead disagrees at both ends: `/public/%2e%2e/x` + // would be flagged although wrapping it changes nothing, and + // `/%70ublic/app.css` would be skipped although `asset()` fingerprints it. + let probe = url; + if (basePath && probe.startsWith(basePath + '/')) probe = probe.slice(basePath.length); + const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1); + let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length); + try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ } + if (decoded.includes('..') || !decoded.startsWith('/public/')) return null; + // A query is refused outright (an author query may carry meaning we do not + // own, so `resolveAssetUrl` returns the url untouched); a `#fragment` is not, + // since it is split off and preserved. + const beforeFragment = url.indexOf('#') === -1 ? url : url.slice(0, url.indexOf('#')); + if (beforeFragment.includes('?')) return null; + return url; +} + +/** + * ADVISORY (#1095): a route module hand-writes a `` without `asset()`, so the url is un-versioned and a deploy + * cannot bust a CDN's copy of it. + * + * The failure this names was caught in production on webjs.dev: the edge served + * a `public/tailwind.css` built BEFORE the deploy (`cf-cache-status: HIT`, + * `max-age=14400`) against post-deploy HTML, so the new page rendered with its + * content edge to edge and its grid collapsed, because the cached css was + * missing the arbitrary-value utilities that page introduced. It is invisible + * while a deploy only restyles existing classes and maximally visible the moment + * one adds a page using new utilities. + * + * Why this is an ADVISORY over the author's SOURCE rather than a rewrite of the + * framework's OUTPUT. The first attempt at the automatic form (#1196) matched + * urls in the assembled HTML, and two deep-review rounds found six major + * defects, five of them one bug: at that layer framework output and author data + * are indistinguishable, so the matcher kept editing things it did not own. That + * is why `asset()` (#1194) is opt-in, and it is what Rails (a + * `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url + * from the build graph, surfaced through `links()`) both do: take the + * fingerprint from an authoritative source at the point the url is PRODUCED, and + * never rewrite a rendered document. The gap `asset()` leaves is purely + * ergonomic. In Rails the helper is the only idiomatic way to write the tag, so + * forgetting it is nearly impossible; in WebJs the `` is hand-written HTML, + * so it is easy to omit. This check closes exactly that gap, at authoring time, + * where the author's meaning is unambiguous and nothing is rewritten. + * + * Scoped to `rel="stylesheet"` on purpose. An icon is a legitimate deliberate + * NON-mark (the website leaves its favicons bare so the SEO repo-health tests + * can parse the hrefs literally), and a `rel="preload"` must NOT be marked at + * all, since its versioned hint could never match the unversioned request a CSS + * `url()` actually makes. Flagging either would nag about a correct choice. + * + * WARN only: an un-versioned stylesheet still SERVES correctly, it just caches + * badly, and an app fronted by no CDN may not care. + * @param {string} appDir + * @returns {Promise} + */ +export async function checkUnmarkedAssetLinks(appDir) { + const name = 'Asset urls (unmarked stylesheet links)'; + const routeDir = join(appDir, 'app'); + if (!existsSync(routeDir)) { + return { name, status: 'pass', message: 'no app/ directory to analyse' }; + } + const basePath = await readAppBasePath(appDir); + const findings = []; + for (const file of collectRouteModules(routeDir)) { + let src; + try { src = await readFile(file, 'utf8'); } catch { continue; } + // Cheap bail before any tag scanning. Case-INSENSITIVE to match the tag + // regex: a file whose only link tag is written `` must still be + // scanned, or the scanner's own case-insensitivity is unreachable exactly + // where it is needed. + if (!/ relative(appDir, f) || f; + return { + name, + status: 'warn', + message: + `${findings.length} stylesheet link(s) are served at an un-versioned url, so a deploy cannot bust a cached copy:\n` + + findings.map((f) => ` ${rel(f.file)}:${f.line} href="${f.href}"`).join('\n'), + fix: + "Wrap the path in asset(): `import { asset } from '@webjsdev/core'` then " + + '``. It appends a content hash in prod ' + + '(the framework then serves that url immutable for a year) and is a no-op in dev and in the browser. ' + + 'Call it inside the render function, not at module scope.', + }; +} diff --git a/packages/cli/lib/doctor/probes/vendor-gitignore.js b/packages/cli/lib/doctor/probes/vendor-gitignore.js new file mode 100644 index 000000000..417b53675 --- /dev/null +++ b/packages/cli/lib/doctor/probes/vendor-gitignore.js @@ -0,0 +1,84 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * CHECK: the `.gitignore` does not swallow the committed vendor pin. The pattern + * for `.webjs/vendor/` is subtle: a bare `.webjs/` line excludes the directory + * entirely and git cannot re-include children of an excluded parent, so a + * `!.webjs/vendor/` exception silently does nothing and `webjs vendor pin` + * output never gets committed. The correct pattern is the depth-robust + * contents-glob form (see the fix text below / VENDOR_GITIGNORE_LINES in + * vendor.js): a globstar-prefixed `.webjs/*` plus the matching vendor + * negations, which ignores transient `.webjs` output at any depth while + * keeping the committed vendor pin tracked. + * + * This was a `webjs check` rule, but inspecting `.gitignore` is a project-config + * concern (like `tsconfig-erasable`), not source-code correctness, and vendoring + * is optional, so a doctor WARN fits the domain and severity better than a CI + * hard-fail (#461). It lives next to `vendor-pin` (same family). + * + * PASS/skip when the dir is not a git repo or has no `.gitignore` (the user has + * not opted into version control yet). Probes two representative paths via + * `git check-ignore` with the inherited GIT_* env stripped so `cwd` is the sole + * authority on which repo + .gitignore stack is consulted (a pre-commit hook + * from a linked worktree exports GIT_WORK_TREE, which would otherwise override + * cwd-based discovery). + * + * @param {string} appDir + * @returns {Promise} + */ +export async function checkVendorGitignore(appDir) { + const hasGit = existsSync(join(appDir, '.git')); + const hasGitignore = existsSync(join(appDir, '.gitignore')); + if (!hasGit || !hasGitignore) { + return { + name: 'vendor-gitignore', + status: 'pass', + message: 'Not a git checkout with a .gitignore; nothing to verify.', + }; + } + const { spawnSync } = await import('node:child_process'); + const { + GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp, + ...gitEnv + } = process.env; + // Check two representative paths: the pin manifest AND a sample downloaded + // bundle. A `.gitignore` that allows the manifest but blocks bundles (e.g. + // `*.js` higher up) would still break `webjs vendor pin --download`. + // `git check-ignore -q` exits 0 when the path is ignored, 1 when not. + const probes = [ + '.webjs/vendor/importmap.json', + '.webjs/vendor/sample-pkg@1.0.0.js', + ]; + for (const probe of probes) { + const result = spawnSync('git', ['check-ignore', '-q', probe], { + cwd: appDir, + stdio: 'pipe', + env: gitEnv, + }); + if (result.status === 0) { + return { + name: 'vendor-gitignore', + status: 'warn', + message: + `${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ that MUST be committed for a production deploy to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second cause is a broader rule (e.g. \`*.js\` at root) hiding bundle files added by \`webjs vendor pin --download\`.`, + fix: + 'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' + + ' **/.webjs/*\n' + + ' !**/.webjs/vendor/\n' + + ' !**/.webjs/vendor/**\n' + + 'The `**/` prefix ignores `.webjs/` at any depth (so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`) while still re-including the committed vendor pin. ' + + 'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).', + }; + } + } + return { + name: 'vendor-gitignore', + status: 'pass', + message: 'The .gitignore keeps .webjs/vendor/ committable.', + }; +} diff --git a/packages/cli/lib/doctor/probes/vendor-pin.js b/packages/cli/lib/doctor/probes/vendor-pin.js new file mode 100644 index 000000000..0451a7c02 --- /dev/null +++ b/packages/cli/lib/doctor/probes/vendor-pin.js @@ -0,0 +1,77 @@ +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * CHECK 4, vendor pin freshness. Applies ONLY when a pin file exists. PASS/skip + * for an unpinned app (it resolves live, which is fine in dev). BEST-EFFORT + + * NETWORK-TOLERANT: any error (network, timeout) is a WARN "could not check", + * never a hard fail and never a throw. PASS when all pins current, WARN listing + * outdated packages otherwise. + * + * The vendor functions are injected via `opts.vendor` so a test can supply a + * stub without a real network call; absent the override, they are dynamically + * imported from `@webjsdev/server`. + * @param {string} appDir + * @param {{ vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise> } }} opts + * @returns {Promise} + */ +export async function checkVendorPin(appDir, opts) { + let vendor = opts.vendor; + if (!vendor) { + try { + const mod = await import('@webjsdev/server'); + vendor = { hasVendorPin: mod.hasVendorPin, findOutdated: mod.findOutdated }; + } catch { + return { + name: 'vendor-pin', + status: 'warn', + // "Could not check", not a finding: never escalatable by a gate. + bestEffort: true, + message: 'Could not load the vendor toolchain to check pin freshness.', + fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.', + }; + } + } + let pinned = false; + try { + pinned = vendor.hasVendorPin(appDir); + } catch { + pinned = false; + } + if (!pinned) { + return { + name: 'vendor-pin', + status: 'pass', + message: 'No vendor pin file; the app resolves vendor imports live (fine in dev).', + }; + } + let outdated; + try { + outdated = await vendor.findOutdated(appDir); + } catch { + // findOutdated is built to swallow fetch errors and return [], but guard + // anyway: a network check must NEVER throw out of doctor. + return { + name: 'vendor-pin', + status: 'warn', + bestEffort: true, + message: 'Could not check pin freshness (network unreachable or registry error).', + fix: 'Re-run `webjs doctor` when connectivity is back, or run `webjs vendor outdated`.', + }; + } + if (!Array.isArray(outdated) || outdated.length === 0) { + return { + name: 'vendor-pin', + status: 'pass', + message: 'All vendor pins are current.', + }; + } + const list = outdated.map((o) => `${o.pkg} (${o.current} -> ${o.latest})`).join(', '); + return { + name: 'vendor-pin', + status: 'warn', + message: `${outdated.length} pinned package(s) are outdated: ${list}.`, + fix: 'Run `webjs vendor update` to re-pin to the latest versions.', + }; +} diff --git a/packages/cli/lib/doctor/probes/webjs-versions.js b/packages/cli/lib/doctor/probes/webjs-versions.js new file mode 100644 index 000000000..85826544e --- /dev/null +++ b/packages/cli/lib/doctor/probes/webjs-versions.js @@ -0,0 +1,85 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { satisfiesRange, readInstalledVersion } from '../manifest.js'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** + * CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is + * not a crash). Reads the app package.json `@webjsdev/*` ranges across + * dependencies + devDependencies, then for each resolves the INSTALLED version + * through Node's own resolver anchored at the app dir (see + * `readInstalledVersion`, which is why a workspace-hoisted install resolves) + * and checks it satisfies the declared range. PASS when every @webjsdev dep is + * present + satisfied; WARN on a missing install or a range drift. + * @param {string} appDir + * @returns {Promise} + */ +export async function checkWebjsVersions(appDir) { + const pkgPath = join(appDir, 'package.json'); + if (!existsSync(pkgPath)) { + return { + name: 'webjs-versions', + status: 'warn', + message: 'No package.json found in this directory.', + fix: 'Run `webjs doctor` from the app root (where package.json lives).', + }; + } + let pkg; + try { + pkg = JSON.parse(await readFile(pkgPath, 'utf8')); + } catch { + return { + name: 'webjs-versions', + status: 'warn', + message: 'package.json could not be parsed.', + fix: 'Fix the package.json syntax.', + }; + } + const ranges = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; + const webjsDeps = Object.keys(ranges).filter((n) => n.startsWith('@webjsdev/')); + if (webjsDeps.length === 0) { + return { + name: 'webjs-versions', + status: 'warn', + message: 'No @webjsdev/* dependencies declared in package.json.', + fix: 'A webjs app depends on @webjsdev/core + @webjsdev/server (+ @webjsdev/cli).', + }; + } + const missing = []; + const drift = []; + for (const dep of webjsDeps) { + const installedVersion = await readInstalledVersion(dep, appDir); + if (!installedVersion) { + missing.push(dep); + continue; + } + const ok = satisfiesRange(installedVersion, ranges[dep]); + // null = a range shape we cannot statically verify; do not warn on it. + if (ok === false) drift.push(`${dep}@${installedVersion} does not satisfy "${ranges[dep]}"`); + } + if (missing.length > 0) { + return { + name: 'webjs-versions', + status: 'warn', + message: `${missing.length} @webjsdev/* dependency not installed: ${missing.join(', ')}.`, + fix: 'Run `npm install` to install the declared dependencies.', + }; + } + if (drift.length > 0) { + return { + name: 'webjs-versions', + status: 'warn', + message: `@webjsdev version drift: ${drift.join('; ')}.`, + fix: 'Run `npm install` to reconcile node_modules with the declared ranges.', + }; + } + return { + name: 'webjs-versions', + status: 'pass', + message: `All ${webjsDeps.length} @webjsdev/* dependency satisfy their declared ranges.`, + }; +} diff --git a/packages/cli/lib/doctor/route-modules.js b/packages/cli/lib/doctor/route-modules.js new file mode 100644 index 000000000..3fbbd7129 --- /dev/null +++ b/packages/cli/lib/doctor/route-modules.js @@ -0,0 +1,100 @@ +import { readdirSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +// Directories the route-module walk never descends into (deps, VCS, framework +// and build caches). Mirrors FRESHNESS_IGNORE; kept separate so either walk can +// change its exclusions without silently moving the other. +export const ROUTE_WALK_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']); + +/** + * A route module that renders markup on the server, which is where `asset()` + * belongs. Page and layout are the common case, but the BOUNDARY modules matter + * too and are easy to miss: `error` / `not-found` / `forbidden` / `unauthorized` + * / `loading` are always shipped and never elided, and `global-error` renders + * its OWN `` and is returned verbatim with no framework + * head splice, which makes it the likeliest place outside the root layout for + * an author to hand-write a stylesheet link. + * @type {RegExp} + */ +export const ROUTE_MODULE_RE = + /^(?:page|layout|error|not-found|forbidden|unauthorized|loading)\.(?:js|ts|mjs|mts)$/; + +/** + * The two boundary stems `router.js` registers ONLY at the app root (both are + * guarded by `dir === '.'` there). A nested `app/admin/global-error.ts` is never + * in the route table and never renders, so scanning one would advise on dead + * code, the same defect the `_private` skip exists to avoid. + * @type {RegExp} + */ +export const ROOT_ONLY_MODULE_RE = /^(?:global-error|global-not-found)\.(?:js|ts|mjs|mts)$/; + +/** + * The app's `webjs.basePath`, normalized to `''` (root mount) or `/segment…`. + * + * A faithful port of `normalizeBasePath` (`packages/server/src/base-path.js`), + * which is the source of truth: it trims, PREPENDS the leading slash (so the + * documented `"myapp"`, `"/myapp"` and `"/myapp/"` all normalize alike), and + * fails safe to `''` on a value that is not a plain same-origin prefix. Reading + * only `startsWith('/')` would leave this check inert for an app configured + * `"myapp"`, which is exactly the silently-inert case it exists to close. + * + * Ported rather than imported because that helper is not on `@webjsdev/server`'s + * public surface, and because doctor must stay usable when the framework does + * not resolve from the app dir at all (the #954 fresh-worktree case this same + * command exists to diagnose). The port is intentional and stays. What makes it + * safe is that the drift is tested rather than trusted. + * + * `test/cli/base-path-parity.test.mjs` feeds one input table through BOTH this + * function and the server's `readBasePath`, asserting they agree with each other + * and with the expected value. Change either side without the other and it reds. + * So edit this body only alongside `packages/server/src/base-path.js`, and run + * that test. (`test/cli/doctor.test.mjs` covers the check that consumes this, + * not the normalization forms themselves.) + * @param {string} appDir + * @returns {Promise} + */ +export async function readAppBasePath(appDir) { + let raw; + try { + const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8')); + raw = pkg?.webjs?.basePath; + } catch { + return ''; + } + if (typeof raw !== 'string') return ''; + let v = raw.trim(); + if (v === '' || v === '/') return ''; + // Not a plain same-origin path prefix: fail safe to no base path. + if (v.includes('..') || v.includes('://') || v.includes('\\') || /\s/.test(v)) return ''; + // A network-path reference (`//host`) is rejected BEFORE leading slashes are + // collapsed, since collapsing would turn an origin escape into `/host`. + if (v.startsWith('//')) return ''; + v = ('/' + v.replace(/^\/+/, '')).replace(/\/+$/, ''); + return v === '' || v === '/' ? '' : v; +} + +/** + * Collect every `app/**` route module that renders markup, depth-first. + * Best-effort: an unreadable directory contributes nothing rather than throwing. + * @param {string} dir + * @param {string[]} [out] + * @returns {string[]} + */ +export function collectRouteModules(dir, root = dir, out = []) { + let entries; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + if (e.name.startsWith('.') || ROUTE_WALK_IGNORE.has(e.name)) continue; + if (e.isSymbolicLink()) continue; // never follow: can cycle or escape into deps + // `_`-prefixed folders are PRIVATE: `router.js` drops any route whose + // directory has such a segment, so markup under one is never routed and + // never rendered. Advising on it would be advice about dead code. + if (e.isDirectory() && e.name.startsWith('_')) continue; + const abs = join(dir, e.name); + if (e.isDirectory()) collectRouteModules(abs, root, out); + else if (ROUTE_MODULE_RE.test(e.name)) out.push(abs); + else if (dir === root && ROOT_ONLY_MODULE_RE.test(e.name)) out.push(abs); + } + return out; +} diff --git a/packages/cli/lib/doctor/runner.js b/packages/cli/lib/doctor/runner.js new file mode 100644 index 000000000..c19f86a1f --- /dev/null +++ b/packages/cli/lib/doctor/runner.js @@ -0,0 +1,71 @@ +import { codeForName } from './codes.js'; +import { checkNode } from './probes/node.js'; +import { checkTsconfig } from './probes/tsconfig.js'; +import { checkEnv } from './probes/env.js'; +import { checkVendorPin } from './probes/vendor-pin.js'; +import { checkVendorGitignore } from './probes/vendor-gitignore.js'; +import { checkImportmapCoherence } from './probes/importmap-coherence.js'; +import { checkWebjsVersions } from './probes/webjs-versions.js'; +import { checkGitHook } from './probes/git-hook.js'; +import { checkElisionCarriers, checkElisionComponents } from './probes/elision.js'; +import { checkStaticAssetFreshness } from './probes/static-asset-freshness.js'; +import { checkUnmarkedAssetLinks } from './probes/unmarked-asset-links.js'; +import { checkFrameworkResolves } from './probes/framework-resolves.js'; + +/** + * @typedef {import('./codes.js').DoctorResult} DoctorResult + */ + +/** + * Run every doctor check against `appDir` and return the results. PURE: no + * printing, no `process.exit`; the CLI renders + decides the exit code. + * + * @param {string} appDir the app directory to check (usually `process.cwd()`) + * @param {{ + * nodeVersion?: string, + * cliDir?: string, + * vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise> }, + * }} [opts] test-injection seams: + * - `nodeVersion`: override the running Node version (asserts the fail case + * without being on old Node); + * - `cliDir`: directory of the CLI package whose `engines.node` sources the + * required major (defaults to THIS module's package); + * - `vendor`: inject the `{ hasVendorPin, findOutdated }` pair so the pin check + * runs against a stub instead of a real network call. + * - `coherence`: inject `{ liveImports, vendoredImports, getManifest, check }` + * so the importmap-coherence check runs against stub importmaps + metadata + * instead of a real live resolve / node_modules read. + * @returns {Promise} + */ +export async function runDoctorChecks(appDir, opts = {}) { + const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname; + // ONE elision report for BOTH elision checks (#1308). Started before the + // batch and awaited inside each check, so the module graph is built once per + // doctor run and the two checks still run in parallel with everything else. + // Fails soft to null, exactly as the carrier check's own try/catch did. + const elision = (async () => { + try { + const { analyzeAppElision } = await import('@webjsdev/server'); + return await analyzeAppElision(appDir); + } catch { return null; } + })(); + const results = await Promise.all([ + checkNode(cliDir, opts), + checkTsconfig(appDir), + checkEnv(appDir), + checkVendorPin(appDir, opts), + checkVendorGitignore(appDir), + checkWebjsVersions(appDir), + Promise.resolve(checkFrameworkResolves(appDir)), + checkImportmapCoherence(appDir, opts), + Promise.resolve(checkGitHook(appDir)), + checkElisionCarriers(elision), + checkElisionComponents(elision), + checkStaticAssetFreshness(appDir), + checkUnmarkedAssetLinks(appDir), + ]); + // Attach the stable machine code to every result (#975). Centralized here so + // each check function stays free of the code-contract concern. + for (const r of results) r.code = codeForName(r.name); + return results; +} diff --git a/packages/cli/lib/doctor/util.js b/packages/cli/lib/doctor/util.js new file mode 100644 index 000000000..cac020aeb --- /dev/null +++ b/packages/cli/lib/doctor/util.js @@ -0,0 +1,160 @@ +import { statSync, readdirSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * Read the CLI package's own `engines.node` so the required Node major lives in + * one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`. + * @param {string} cliDir directory of THIS file's package (lib/ -> package root) + * @returns {Promise} + */ +export async function readEngines(cliDir) { + try { + const pkg = JSON.parse(await readFile(join(cliDir, '..', 'package.json'), 'utf8')); + return pkg?.engines?.node || '>=24.0.0'; + } catch { + return '>=24.0.0'; + } +} + +/** + * Strip `//` line comments, block comments, and trailing commas from a JSONC + * string so a tsconfig (which permits all three) parses with `JSON.parse`. + * Deliberately simple: it does not honor comment-looking sequences inside + * string values, which is acceptable for a tsconfig (paths rarely contain `//` + * or block-comment markers, and the worst case is a parse failure the caller + * already degrades to a WARN). + * @param {string} text + * @returns {string} + */ +export function stripJsonc(text) { + let out = ''; + let inString = false; + let stringQuote = ''; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = text[i + 1]; + if (inString) { + out += ch; + if (ch === '\\') { + // Copy the escaped char verbatim so an escaped quote does not end the string. + out += text[i + 1] || ''; + i++; + } else if (ch === stringQuote) { + inString = false; + } + continue; + } + if (ch === '"' || ch === "'") { + inString = true; + stringQuote = ch; + out += ch; + continue; + } + if (ch === '/' && next === '/') { + while (i < text.length && text[i] !== '\n') i++; + out += '\n'; + continue; + } + if (ch === '/' && next === '*') { + i += 2; + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + i++; // land on the '/' + continue; + } + out += ch; + } + // Drop trailing commas before } or ]. + return out.replace(/,(\s*[}\]])/g, '$1'); +} + +/** + * Parse a `.env`-style file into the SET of KEY names it declares. A simple + * `KEY=value` line parse: comments (`#`) and blank lines are skipped, and only + * the key before the first `=` is taken (the value is irrelevant for drift). + * @param {string} text + * @returns {Set} + */ +export function parseEnvKeys(text) { + const keys = new Set(); + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + let key = line.slice(0, eq).trim(); + // Tolerate a leading `export ` (a common .env.example convention). + if (key.startsWith('export ')) key = key.slice('export '.length).trim(); + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) keys.add(key); + } + return keys; +} + +// Directories never worth walking for the CSS-freshness advisory (mirrors +// dev-regenerate's IGNORE_DIRS): build output, deps, VCS + framework caches. +const FRESHNESS_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']); + +/** + * Newest mtime (ms) of any FILE under a path (a file's own, or the max over the + * files in a directory tree, skipping dependencies / dotfiles). Directory-node + * mtimes are NOT counted, matching dev-regenerate's walker: a content edit only + * shows through the file mtime, and a directory mtime is a flaky moving target. + * A missing path is 0. Best-effort: never throws. + * @param {string} abs + * @returns {number} + */ +export function newestMtimeMs(abs) { + let st; + try { st = statSync(abs); } catch { return 0; } + if (!st.isDirectory()) return st.mtimeMs; + let newest = 0; + let entries; + try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return newest; } + for (const e of entries) { + if (e.name.startsWith('.') || FRESHNESS_IGNORE.has(e.name)) continue; + // Skip symlinks: following one can cycle into unbounded recursion (a stack + // overflow here) or escape into node_modules. Same tradeoff as the server + // walker in dev-regenerate.js. + if (e.isSymbolicLink()) continue; + const m = newestMtimeMs(join(abs, e.name)); + if (m > newest) newest = m; + } + return newest; +} + +/** + * Whether the `` tag at `idx` is commented out, so dead markup is never + * reported as a live finding. + * + * A DELIMITED comment is decided by an unclosed opener behind the tag. Neither + * `')) return true; + if (before.lastIndexOf('/*') > before.lastIndexOf('*/')) return true; + const lineStart = before.lastIndexOf('\n') + 1; + return before.slice(lineStart).trimStart().startsWith('//'); +} diff --git a/packages/cli/test/dev-supervisor/dev-supervisor.test.js b/packages/cli/test/dev-supervisor/dev-supervisor.test.js index 31bc00689..53040ba6f 100644 --- a/packages/cli/test/dev-supervisor/dev-supervisor.test.js +++ b/packages/cli/test/dev-supervisor/dev-supervisor.test.js @@ -15,7 +15,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -95,10 +95,19 @@ test('the watched middleware extensions match the ones the server loads', () => // the dev server when edited, which is the quiet half of the bug where a // root `middleware.ts` was loaded by neither. Read from the server source // rather than restated, so the two cannot drift apart silently. - const src = readFileSync( - join(dirname(fileURLToPath(import.meta.url)), '../../../server/src/dev.js'), - 'utf8', - ); + // `dev.js` is a barrel over `dev/` now, so read the barrel AND every module + // beneath it. Reading only the barrel would leave this guard unable to find + // the declaration at all, which is a silent pass into a vacuous assertion + // rather than the drift check it is meant to be. + const serverSrc = join(dirname(fileURLToPath(import.meta.url)), '../../../server/src'); + const devDir = join(serverSrc, 'dev'); + const files = [join(serverSrc, 'dev.js')]; + if (existsSync(devDir)) { + for (const e of readdirSync(devDir, { withFileTypes: true })) { + if (e.isFile() && e.name.endsWith('.js')) files.push(join(devDir, e.name)); + } + } + const src = files.map((f) => readFileSync(f, 'utf8')).join('\n'); const m = src.match(/const ROOT_MIDDLEWARE_FILES = \[([^\]]+)\]/); assert.ok(m, 'the server declares its root-middleware candidates in one place'); const serverExts = m[1].match(/'([^']+)'/g).map((q) => q.slice(1, -1)); diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index adea706d8..263c4b6db 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -28,9 +28,9 @@ the same output in all three. |---|---| | `html.js` | `` html`` `` tagged-template → `TemplateResult`, plus `MARKER` and `isTemplate` | | `css.js` | `` css`` `` → `CSSResult`, `adoptStyles`, `stylesToString` | -| `component.js` | `WebComponent` base class: lifecycle, properties, reactive accessors, light-vs-shadow DOM, scheduling, slot host wiring. On the server the base is a DOM shim (attribute methods backed by a Map, no-op events, inert `attachInternals`, `closest()` over the SSR ancestor chain, and the host IDL reflections `dataset` / `className` / `hidden` / `id` / `title` / `slot` / `role` / `tabIndex` / `aria*`); `performServerUpdate` runs the pre-render lifecycle (`willUpdate` + controllers' `hostUpdate` + reflection) for SSR. **Async render (#469):** `render()` may be async; when it returns a promise, `update()` routes to `_commitAsync` (stale-while-revalidate, a monotonic `__renderToken` race guard, rejection to the `renderError()` boundary) and `_performRender` defers the post-commit half (`hostUpdated`, `firstUpdated` / `updated`, `updateComplete`) until the commit lands via `_postCommit`. `renderFallback()` is the optional client re-fetch loading UI (re-fetch only, never first paint); `renderError()` is the per-component error boundary | -| `render-server.js` | `renderToString`, `renderToStream` (async, with Suspense streaming), SSR slot substitution in `injectDSD`. The walker seeds the server attribute shim from the source attributes, threads the enclosing-instance ancestor chain into each instance (so the shim's `closest()` resolves a parent), calls `performServerUpdate` before `render()`, and appends reflected/added attributes (including host attributes set inside `render()`) to the opening tag | -| `render-client.js` | Client-side patcher + hydration; the only file that touches `document`. Also discovers and binds light-DOM slot parts | +| `component.js` | Barrel re-exporting `WebComponent` base class, reactive property helpers, and server element shim sub-modules (`component/lifecycle.js`, `component/reactive.js`, `component/server-element.js`). `WebComponent` base class: lifecycle, properties, reactive accessors, light-vs-shadow DOM, scheduling, slot host wiring. On the server the base is a DOM shim (attribute methods backed by a Map, no-op events, inert `attachInternals`, `closest()` over the SSR ancestor chain, and the host IDL reflections `dataset` / `className` / `hidden` / `id` / `title` / `slot` / `role` / `tabIndex` / `aria*`); `performServerUpdate` runs the pre-render lifecycle (`willUpdate` + controllers' `hostUpdate` + reflection) for SSR. **Async render (#469):** `render()` may be async; when it returns a promise, `update()` routes to `_commitAsync` (stale-while-revalidate, a monotonic `__renderToken` race guard, rejection to the `renderError()` boundary) and `_performRender` defers the post-commit half (`hostUpdated`, `firstUpdated` / `updated`, `updateComplete`) until the commit lands via `_postCommit`. `renderFallback()` is the optional client re-fetch loading UI (re-fetch only, never first paint); `renderError()` is the per-component error boundary | +| `render-server.js` | Barrel re-exporting server-side rendering, streaming, and DSD slot substitution sub-modules (`render-server/stream.js`, `render-server/template-renderer.js`, `render-server/dsd.js`). `renderToString`, `renderToStream` (async, with Suspense streaming), SSR slot substitution in `injectDSD`. The walker seeds the server attribute shim from the source attributes, threads the enclosing-instance ancestor chain into each instance (so the shim's `closest()` resolves a parent), calls `performServerUpdate` before `render()`, and appends reflected/added attributes (including host attributes set inside `render()`) to the opening tag | +| `render-client.js` | Barrel re-exporting client-side patcher, hydration, template compilation, and part binding sub-modules (`render-client/template-compiler.js`, `render-client/parts.js`, `render-client/reconciler.js`). The only subsystem that touches `document`. Also discovers and binds light-DOM slot parts | | `slot.js` | Light-DOM `` runtime, full native parity (#1021): `HTMLSlotElement` polyfills (`assignedNodes` / `assignedElements` / `assignedSlot` / `assign`), the ordered `authored` record + derived `assignedByName` (`repartition`), the single writer `applySlotAssignments`, per-instance method interception + the `RENDERING` window (`withRendererWrites`) + two read-only sensors for liveness, the parent-keyed prune rule, the unmatched-name park, first-wins resolution, fallback swap, and the `projectAuthored` router seam | | `directives.js` | the lit-html-parity directive set (`unsafeHTML`, `live`, `keyed`, `guard`, `templateContent`, `ref` / `createRef`, `cache`, `until`, `asyncAppend` / `asyncReplace`, `watch`, plus each `is*` guard). `repeat` lives in `repeat.js`. All are re-exported from `index.js` / `index-browser.js` so the bare specifier and the `/directives` subpath (which collapses onto the dist browser bundle) expose the full set | | `repeat.js` | `repeat(items, keyFn, templateFn)` for keyed list reconciliation | @@ -110,11 +110,42 @@ return instead of a silent wire loss. All are pure declaration files (erased at runtime, zero build cost). A page imports them with `import type { Metadata, PageProps } from '@webjsdev/core'`. The `Metadata` and `PageProps` / `LayoutProps` shapes MUST stay in lockstep with what -`packages/server/src/ssr.js` actually reads / constructs, never Next.js's +`packages/server/src/ssr/head.js` actually reads / constructs, never Next.js's superset. `routes.d.ts`'s `WebjsRoutes` / `RouteParamMap` are EMPTY by default (so `Route = string`); `webjs types` generates `.webjs/routes.d.ts` to augment them per app. +## Module size and barrel splits + +Several modules here are barrels over a sibling directory (`component.js`, +`render-client.js`, `render-server.js`, `router-client.js`, `slot.js`). The rule +that produced them, and that any future split must follow, is in +`../../.agents/skills/webjs/references/module-structure.md`. Target 800 lines, +around 1000 at the most, barrels exempt, no CI guard. + +Three things bite specifically in this package: + +1. **The barrel keeps the original path.** `package.json` `exports` maps + `./client-router` at `./src/router-client.js`, the 26 hand-written `.d.ts` + overlays are matched to their runtime sibling BY PATH by two guard tests, and + 32 test files import `src/router-client.js` relatively. Renaming a barrel to + match its subpath breaks all three for no behaviour gain. +2. **Symbol identity.** `slot.js` creates `SLOT_STATE` and `SLOT_OWNER` with + `Symbol()`. Substituting `Symbol.for()` yields a registry symbol no host + carries, so every lookup returns `undefined`, forwarded-slot stamping stops + happening, and nothing outside the browser suite notices. +3. **Mutable state goes with its writers.** An ESM import binding cannot be + assigned, so `inBrowser` sits with `installSlotPolyfills`, the `N_*` natives + sit with `captureNatives`, and the client router's `restoreGeneration`, + `currentNavigationToken` and `prefetchViewObserver` each expose a + one-statement accessor for the navigator to call. Keep importing a binding + that is also READ: dropping it leaves a free variable that throws only when + its line runs. + +Rebuild `dist` before e2e or Bun (they resolve the built bundle, so a src-only +edit is invisible and a counterfactual passes vacuously), and run +`npm run test:browser` for any renderer, router, component, or slot change. + ## Package-specific invariants 1. **No build step in your edit-and-refresh loop.** `.js` only, diff --git a/packages/core/src/component.js b/packages/core/src/component.js index 865a180f3..01b6f5778 100644 --- a/packages/core/src/component.js +++ b/packages/core/src/component.js @@ -1,1896 +1,2 @@ -import { render as clientRender } from './render-client.js'; -import { readAttributeValue } from './attribute-reader.js'; -import { setActiveActionSignal } from './action-abort-client.js'; -import { carriesFunction } from './form-action.js'; -import { isCSS, adoptStyles } from './css.js'; -import { register, tagOf } from './registry.js'; -import { parse as deserializeProp } from './serialize.js'; -import { Signal } from './signal.js'; -import { resolveAttributeProperty } from './attribute-reader.js'; -import { - captureAuthoredChildren, - adoptSSRAssignments, - ensureSlotState, - hasSlotState, - hasFrameworkRenderedSubtree, - installSlotInterception, - installSlotSensors, - teardownSlotSensors, - reconnectSweep, -} from './slot.js'; - -const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undefined'; - -/** - * @typedef {Object} ReactiveController - * A controller is a reusable piece of lifecycle logic that plugs into a - * WebComponent host. Controllers let you extract cross-cutting concerns - * (timers, intersection observers, media queries, form validation, fetch - * caching) out of the component class and share them across unrelated - * components. - * - * **When to use:** any time two or more components need the same - * `connectedCallback` / `disconnectedCallback` / pre-render / post-render - * behaviour. Instead of a mixin or base-class hierarchy, attach a controller. - * - * **Why it exists:** mirrors Lit's `ReactiveController` protocol so - * ecosystem controllers are interoperable. - * - * @property {() => void} [hostConnected] - * Called when the host element is inserted into the DOM - * (`connectedCallback`). Use for subscriptions, observers, timers. - * @property {() => void} [hostDisconnected] - * Called when the host element is removed from the DOM - * (`disconnectedCallback`). Use for cleanup: unsubscribe, disconnect - * observers, clear timers. - * @property {() => void} [hostUpdate] - * Called just before the host renders (inside `_performRender`, after - * `willUpdate` but before `render()`). Use for reading layout or - * preparing data that the render depends on. - * @property {() => void} [hostUpdated] - * Called after the host has rendered and the DOM is up to date. Use for - * post-render side effects that depend on the new DOM (measuring, - * focusing, scrolling). - */ - -/** - * @typedef {Object} PropertyDeclaration - * Declares how a single reactive property behaves. Used inside - * `static properties = { propName: { …declaration } }`. - * - * @property {Function} [type] - * Constructor used for attribute → property coercion. - * Supported built-ins: `String`, `Number`, `Boolean`, `Object`, `Array`. - * Default: `String`. - * - * @property {boolean} [reflect] - * When `true`, writing to the property also sets the corresponding - * HTML attribute on the element (kebab-cased). Useful when you want - * CSS attribute selectors like `my-el[mode="dark"]` to work. - * - * @property {boolean} [state] - * When `true`, the property is *internal-only*: it is NOT exposed as an - * HTML attribute (excluded from `observedAttributes`) and never reflects. - * It still triggers a re-render when changed via the generated setter. - * Use for private reactive state that shouldn't leak into the DOM. - * - * @property {(newValue: unknown, oldValue: unknown) => boolean} [hasChanged] - * Custom dirty-check function. Called by the generated setter before - * scheduling an update. Return `true` to trigger a re-render, `false` - * to skip. Default: strict inequality `(a, b) => a !== b`. - * - * Note: this fires on the FIRST assignment too, with `oldValue` set - * to `undefined`. Numeric comparators that subtract against undefined - * produce `NaN`, which evaluates to `false`, silently rejecting the - * constructor's initial assignment. Treat `oldValue === undefined` as - * "always changed" in custom comparators so the initial value lands. - * - * @property {{ fromAttribute: (value: string|null, type?: Function) => unknown, toAttribute: (value: unknown, type?: Function) => string|null }} [converter] - * Custom serialization/deserialization pair for the HTML attribute. - * `fromAttribute` is called by BOTH attribute readers through the shared - * `readAttributeValue` in `attribute-reader.js`: the client - * `attributeChangedCallback` and the SSR - * `applyAttrsToInstance` in `render-server.js` (#1340). So it runs - * server-side too, and must not touch browser globals. - * `toAttribute` is called when reflecting back to the attribute. - * If omitted, the built-in type-based coercion is used. - */ - -/** - * Default change detection: strict inequality. - * @param {unknown} a - * @param {unknown} b - * @returns {boolean} - */ -function defaultHasChanged(a, b) { - return a !== b; -} - - -/** - * `String(v)` that cannot itself throw. - * - * A rejection reason is whatever the author rejected with, and not every value - * converts to a primitive: `Object.create(null)` has no `toString`, and a - * revoked Proxy throws on any operation. Coercing one in an error-reporting - * path turned a reportable failure into a second, unreported one. - * - * @param {unknown} v - * @returns {string} - */ -function safeString(v) { - try { - return String(v); - } catch { - return Object.prototype.toString.call(v); - } -} - -/** - * Warn that a function value was dropped rather than reflected (#1169). - * - * A silently missing attribute is its own confusion, so say what went and why. - * The message deliberately does NOT include the value: printing the source is - * the leak this guard exists to prevent, and a server log is not always a - * private place. - * - * UNCONDITIONAL, matching the `.prop=${fn}` unserializable-value drop in - * `render-server.js`, which is the sibling path this guard mirrors. Two - * reasons not to gate it on a dev flag. It fires only on a genuine mistake (a - * function is never a meaningful attribute value), so there is no volume to - * suppress, and reflection runs per assignment rather than per frame. - * - * More decisively, a `NODE_ENV` gate does not survive the dist build. - * `scripts/build-framework-dist.js` runs esbuild with `platform: 'browser'` - * and `minify: true`, which substitutes `process.env.NODE_ENV` with - * `"production"` and folds the check to a constant. The SSR half of the - * warning would then be unreachable in every published build, which is how - * every installed app runs, and SSR is the half that matters most, since that - * is where the leaked source reached visitors. - * - * @param {{ constructor: unknown, tagName?: string }} host - * @param {string} propName - * @param {string} attrName - */ -function warnFunctionReflection(host, propName, attrName) { - if (typeof console === 'undefined' || !console.warn) return; - const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown'; - console.warn( - `[webjs] reflect:true property "${propName}" on <${tag}> holds a function ` - + `(or an array carrying one), which has no HTML attribute representation. ` - + `Removing "${attrName}" instead of stringifying it (a stringified function ` - + `writes its source into the page, so a server action's body would ship to ` - + `the browser). Pass a string, or drop reflect on this property.` - ); -} - -/** - * Warn that a value with no JSON representation was dropped rather than - * reflected (#1253). - * - * `JSON.stringify` throws for three reasons an app hits in practice: a cycle, - * a `BigInt`, and an author `toJSON()` that throws. All three mean the same - * thing here, that there is no string to put in the attribute, so all three - * get the same answer as a function does. - * - * The caught message IS included, unlike `warnFunctionReflection`, which - * withholds its value on purpose. A function's string form is its source, - * which is the leak that guard exists to prevent; `JSON.stringify`'s own - * message names the property path rather than a value, and the sibling - * `.prop=${val}` SSR drop in `render-server.js` already ends the same way. - * - * UNCONDITIONAL, for the reason recorded on `warnFunctionReflection` above: a - * `NODE_ENV` gate is folded to a constant by the dist build, which would make - * the SSR half unreachable in every published build, and the SSR half is where - * the component silently vanishes. - * - * @param {{ constructor: unknown, tagName?: string }} host - * @param {string} propName - * @param {string} attrName - * @param {string} [detail] the message `JSON.stringify` threw - */ -function warnUnserializableReflection(host, propName, attrName, detail) { - if (typeof console === 'undefined' || !console.warn) return; - const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown'; - console.warn( - `[webjs] reflect:true property "${propName}" on <${tag}> holds a value ` - + `JSON.stringify cannot serialize (a cycle, a BigInt, or a throwing ` - + `toJSON), so it has no HTML attribute representation. Removing ` - + `"${attrName}" instead. Detail: ${detail}` - ); -} - -/** - * A minimal base for HTML Custom Elements that mirrors Lit's ergonomics - * while staying JSDoc-only and no-build. - * - * Subclasses declare: - * - `static properties`: attribute/property declarations with type info, - * reflection, custom converters, and internal-state mode - * - `static styles`: CSSResult or array thereof (only meaningful with - * `static shadow = true`; light-DOM components inherit global CSS) - * - `static shadow`: set `true` to opt in to shadow DOM (default: `false` - * → light DOM, so Tailwind / global CSS apply directly) - * - `render()`: returns a TemplateResult - * - * The tag name is not a static field: pass it to `.register('tag-name')` - * at the bottom of the file. Tag must contain a hyphen (HTML spec). - * - * Lifecycle (lit-aligned, called in order during each update cycle): - * 1. `shouldUpdate(changedProperties)`. Skip update if false. - * 2. `willUpdate(changedProperties)`. Safe to set properties; folds into this cycle. - * 3. controllers' `hostUpdate()` - * 4. `update(changedProperties)`. Default impl calls `render()` + commits. - * 5. controllers' `hostUpdated()` - * 6. `firstUpdated(changedProperties)`: once, on the first render only - * 7. `updated(changedProperties)`: every render commit - * 8. `updateComplete` promise resolves - * - * `changedProperties` is a `Map` where each entry maps - * a property name to its previous value. - * - * MAINTAINER NOTE. Adding a new overridable lifecycle hook here means - * the display-only component elision analyser must learn about it too, - * or it will wrongly elide a component that now does client work. Add - * the hook name to `CLIENT_LIFECYCLE_HOOKS` in - * `packages/server/src/component-elision.js`. The guard test at - * `packages/server/test/elision/lifecycle-coverage.test.js` introspects - * this prototype and fails until you do. - * - * Usage: - * ```js - * import { signal } from '@webjsdev/core'; - * - * const count = signal(0); - * - * class MyCounter extends WebComponent { - * render() { - * return html``; - * } - * } - * MyCounter.register('my-counter'); - * ``` - */ - -/** - * Inert `ElementInternals`-shaped object returned by the server shim's - * `attachInternals()`. Modeled on `@lit-labs/ssr-dom-shim`'s - * `ElementInternalsShim` (lit repo, `packages/labs/ssr-dom-shim/src/lib/ - * element-internals.ts`): form-association, validity, and custom-state - * calls are no-ops at SSR (no form, no constraint validation, no `:state()` - * matching server-side), so a component that calls `this.attachInternals()` - * in its constructor renders instead of crashing. The browser runs the real - * `attachInternals()` on hydration. - * - * Deliberate deviation from lit: lit's `checkValidity` / `reportValidity` - * THROW on the server. WebJs returns `true` instead, to keep SSR - * progressive-enhancement-safe (a stray validity call in a constructor must - * not 500 the page); the browser does the real validation. - * @returns {any} - */ -function makeServerInternals() { - return { - states: new Set(), - shadowRoot: null, - form: null, - labels: [], - role: '', - willValidate: true, - validity: /** @type {any} */ ({}), - validationMessage: '', - setFormValue() {}, - setValidity() {}, - checkValidity() { return true; }, - reportValidity() { return true; }, - }; -} - -/** - * Server-side stand-in for `HTMLElement`. The SSR pipeline constructs - * component instances in Node, where `HTMLElement` does not exist, so the - * base class is this shim. It is modeled on `@lit-labs/ssr-dom-shim`'s - * `ElementShim` (lit repo, `packages/labs/ssr-dom-shim/src/index.ts`): the - * attribute methods (`getAttribute` / `setAttribute` / `hasAttribute` / - * `removeAttribute` / `toggleAttribute`, the `attributes` getter) are backed - * by a Map, so lit muscle-memory patterns that read attributes in `render()` - * or set them while deriving state work server-side; the SSR walker seeds - * the Map from the element's source attributes and reads it back to surface - * reflected/added attributes in the output. Event methods are no-ops (no - * server event loop), and `attachInternals()` returns the inert object - * above. The genuinely browser-only surface (`querySelector`, layout reads, - * `attachShadow`, `focus`) is deliberately absent and still throws at SSR, - * which the `no-browser-globals-in-render` rule and the SSR crash hint flag. - * - * Deliberate deviation from lit: this shim lowercases attribute names so - * `getAttribute('Foo')` after `setAttribute('foo', x)` resolves, matching how - * a real browser treats HTML attribute names as case-insensitive. lit's shim - * keys the Map by the raw name (a known fidelity gap in lit-labs). - */ -class ServerElement { - constructor() { - /** - * Backing store for the attribute methods. Keys are lowercased - * attribute names (HTML attributes are case-insensitive). Seeded by the - * SSR walker from the element's source attributes. - * @type {Map} - */ - this.__ssrAttrs = new Map(); - /** @type {any} */ - this.__internals = null; - } - - /** Mirrors `Element.attributes`: an array of `{ name, value }`. */ - get attributes() { - return [...this.__ssrAttrs].map(([name, value]) => ({ name, value })); - } - - /** @param {string} name */ - getAttribute(name) { - const v = this.__ssrAttrs.get(String(name).toLowerCase()); - return v === undefined ? null : v; - } - - /** @param {string} name @param {unknown} value */ - setAttribute(name, value) { - // Emulate the browser casting all values to string (lit does the same). - this.__ssrAttrs.set(String(name).toLowerCase(), String(value)); - } - - /** @param {string} name */ - removeAttribute(name) { - this.__ssrAttrs.delete(String(name).toLowerCase()); - } - - /** @param {string} name */ - hasAttribute(name) { - return this.__ssrAttrs.has(String(name).toLowerCase()); - } - - /** @param {string} name @param {boolean} [force] */ - toggleAttribute(name, force) { - // Steps mirror https://dom.spec.whatwg.org/#dom-element-toggleattribute - const key = String(name).toLowerCase(); - const present = this.__ssrAttrs.has(key); - const next = force === undefined ? !present : force; - if (next) { - this.__ssrAttrs.set(key, ''); - return true; - } - this.__ssrAttrs.delete(key); - return false; - } - - /** @returns {string[]} */ - getAttributeNames() { - return [...this.__ssrAttrs.keys()]; - } - - /** - * Minimal `Element.closest()` for SSR. The SSR walker threads the chain - * of enclosing custom-element instances into each instance - * (`__ssrAncestors`); this walks self-then-ancestors and returns the - * nearest whose tag matches. Only bare tag-name selectors are supported - * server-side (`closest('ui-tabs')`), which is what compound components - * need to read parent state for a correct first paint; anything more - * specific (class, attribute, or descendant selectors) returns null, - * matching the pre-shim behaviour. The browser runs the real `closest()` - * on hydration. - * @param {string} selector - * @returns {any} - */ - closest(selector) { - const sel = String(selector).trim().toLowerCase(); - // Tag-name selectors only at SSR; bail (null) on anything else. - if (!/^[a-z][a-z0-9-]*$/.test(sel)) return null; - if (this.__ssrTag === sel) return this; - const chain = this.__ssrAncestors; - if (!Array.isArray(chain)) return null; - for (let i = chain.length - 1; i >= 0; i--) { - if (chain[i] && chain[i].__ssrTag === sel) return chain[i]; - } - return null; - } - - /** - * `HTMLElement.dataset`: a live view over the element's `data-*` - * attributes, backed by the SSR attribute Map. Reading / writing - * `el.dataset.fooBar` maps to the `data-foo-bar` attribute (camelCase - * to kebab-case), so a `render()` that sets `this.dataset.state = 'on'` - * surfaces `data-state="on"` in the SSR'd host tag instead of crashing - * on an undefined `dataset`. - * @returns {Record} - */ - get dataset() { - if (this.__dataset) return this.__dataset; - const el = this; - const toAttr = (p) => 'data-' + String(p).replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); - this.__dataset = new Proxy(/** @type {Record} */ ({}), { - get(_t, prop) { - if (typeof prop !== 'string') return undefined; - const v = el.getAttribute(toAttr(prop)); - return v === null ? undefined : v; - }, - set(_t, prop, value) { - if (typeof prop === 'string') el.setAttribute(toAttr(prop), value); - return true; - }, - has(_t, prop) { - return typeof prop === 'string' && el.hasAttribute(toAttr(prop)); - }, - deleteProperty(_t, prop) { - if (typeof prop === 'string') el.removeAttribute(toAttr(prop)); - return true; - }, - ownKeys() { - return el.getAttributeNames() - .filter((n) => n.startsWith('data-')) - .map((n) => n.slice(5).replace(/-([a-z])/g, (_m, c) => c.toUpperCase())); - }, - getOwnPropertyDescriptor(_t, prop) { - if (typeof prop === 'string' && el.hasAttribute(toAttr(prop))) { - return { enumerable: true, configurable: true, value: el.getAttribute(toAttr(prop)) }; - } - return undefined; - }, - }); - return this.__dataset; - } - - // IDL properties that reflect to a content attribute. A render() that - // mutates these on the host (a light-DOM compound-component pattern, e.g. - // `this.className = ...`, `this.hidden = !active`) then surfaces the - // matching attribute in the SSR'd host tag, matching the browser. - get className() { return this.getAttribute('class') ?? ''; } - set className(v) { this.setAttribute('class', v); } - get hidden() { return this.hasAttribute('hidden'); } - set hidden(v) { this.toggleAttribute('hidden', !!v); } - get id() { return this.getAttribute('id') ?? ''; } - set id(v) { this.setAttribute('id', v); } - get title() { return this.getAttribute('title') ?? ''; } - set title(v) { this.setAttribute('title', v); } - get slot() { return this.getAttribute('slot') ?? ''; } - set slot(v) { this.setAttribute('slot', v); } - get role() { return this.getAttribute('role'); } - set role(v) { v == null ? this.removeAttribute('role') : this.setAttribute('role', v); } - get tabIndex() { const v = this.getAttribute('tabindex'); return v === null ? -1 : (Number.parseInt(v, 10) || 0); } - set tabIndex(v) { this.setAttribute('tabindex', String(v)); } - - // No server event loop: listeners never fire at SSR. The no-op keeps a - // constructor that wires delegated listeners (a common lit pattern) from - // crashing; the browser re-runs the constructor on hydration where the - // real HTMLElement methods apply. - addEventListener() {} - removeEventListener() {} - dispatchEvent() { return true; } - - /** @returns {any} */ - attachInternals() { - // Match the browser (and lit's shim): a second attach is an error. - if (this.__internals !== null) { - throw new Error( - "Failed to execute 'attachInternals' on 'HTMLElement': " + - 'ElementInternals for the specified element was already attached.', - ); - } - this.__internals = makeServerInternals(); - return this.__internals; - } -} - -// ARIAMixin IDL reflections (`el.ariaPressed = 'true'` writes aria-pressed). -// A render() that sets ARIA state via the IDL properties then surfaces the -// matching aria-* attribute in the SSR'd host tag, matching the browser. The -// IDL name maps to the content attribute by lowercasing the part after -// `aria` and prefixing `aria-` (ariaPressed -> aria-pressed). -const ARIA_IDL_PROPS = [ - 'ariaAtomic', 'ariaAutoComplete', 'ariaBusy', 'ariaChecked', 'ariaColCount', - 'ariaColIndex', 'ariaColSpan', 'ariaCurrent', 'ariaDescription', 'ariaDisabled', - 'ariaExpanded', 'ariaHasPopup', 'ariaHidden', 'ariaInvalid', 'ariaKeyShortcuts', - 'ariaLabel', 'ariaLevel', 'ariaLive', 'ariaModal', 'ariaMultiLine', - 'ariaMultiSelectable', 'ariaOrientation', 'ariaPlaceholder', 'ariaPosInSet', - 'ariaPressed', 'ariaReadOnly', 'ariaRequired', 'ariaRoleDescription', - 'ariaRowCount', 'ariaRowIndex', 'ariaRowSpan', 'ariaSelected', 'ariaSetSize', - 'ariaSort', 'ariaValueMax', 'ariaValueMin', 'ariaValueNow', 'ariaValueText', -]; -for (const idl of ARIA_IDL_PROPS) { - const attr = 'aria-' + idl.slice(4).toLowerCase(); - Object.defineProperty(ServerElement.prototype, idl, { - configurable: true, - get() { return this.getAttribute(attr); }, - set(v) { v == null ? this.removeAttribute(attr) : this.setAttribute(attr, String(v)); }, - }); -} - -// Base class choice: real HTMLElement on the browser, the shim on the server. -const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement); - -/** - * Marker stamped on the anonymous subclass the `WebComponent({...})` factory - * produces. It lets `_assertFactoryProperties` tell the framework's own - * factory-generated `static properties` (allowed) apart from a `static - * properties` a user wrote by hand in a class body (no longer allowed). - */ -const FACTORY_PROPS = Symbol('webjs.factoryProps'); - -// Per-class memo so the constructor-time enforcement walk runs once per class. -const _propsChecked = new WeakSet(); - -class WebComponentBase extends Base { - /** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */ - static shadow = false; - - /** - * Hydration strategy for this component. - * - * **AI hint:** Set `static hydrate = 'visible'` to defer client-side - * hydration until the element scrolls into (or near) the viewport. The - * server-rendered Declarative Shadow DOM content stays visible the whole - * time: users see the SSR HTML immediately while JavaScript activation - * is deferred. This is useful for below-the-fold components that don't - * need interactivity right away. - * - * - `undefined` (default): hydrate immediately on `connectedCallback`. - * - `'visible'`: hydrate when the element enters the viewport - * (with a 200 px root margin). - * - * @type {'visible' | undefined} - */ - static hydrate = undefined; - - /** - * Attribute/property declarations. - * - * Each key is a property name; the value is a {@link PropertyDeclaration}. - * - * Properties declared here get auto-generated accessors (getter/setter) - * that trigger re-renders on change, coerce attribute values by type, - * and optionally reflect back to attributes. - * - * Properties with `state: true` are excluded from `observedAttributes` - * and never reflect: they behave like private reactive state. - * - * @type {Record} - */ - static properties = {}; - - /** - * Styles to adopt into the shadow root. - * @type {import('./css.js').CSSResult | import('./css.js').CSSResult[] | null} - */ - static styles = null; - - /** - * Register this class as a custom element under `tag`. - * - * class Counter extends WebComponent { … } - * Counter.register('my-counter'); - * - * Delegates to `customElements.define` (or the server-side shim) via the - * internal registry. The module URL for `` - * hints is derived server-side by scanning the app tree: no need to - * pass `import.meta.url`. - * - * @param {string} tag Must contain a hyphen (HTML spec). - */ - static register(tag) { - register(tag, this); - } - - /** - * Returns the list of attribute names the browser should observe. - * Properties with `state: true` are excluded: they are internal-only - * and do not correspond to any HTML attribute. - * - * @returns {string[]} - */ - static get observedAttributes() { - const props = this.properties || {}; - return Object.keys(props) - .filter((k) => !(typeof props[k] === 'object' && props[k].state)) - .map((k) => (typeof props[k] === 'object' && props[k].attribute) || hyphenate(k)); - } - - constructor() { - super(); - this._renderRoot = null; - this._scheduled = false; - this._connected = false; - - /** - * Set of attached reactive controllers. - * @type {Set} - */ - this.__controllers = new Set(); - - /** - * Whether the component has completed its first render. - * Used to gate the one-time `firstUpdated()` call. - * @type {boolean} - */ - this.__firstRendered = false; - - /** - * Map of changed properties accumulated since the last render. Keys are - * property names; values are the previous value before the change. Passed - * to `shouldUpdate`, `willUpdate`, - * `update`, `firstUpdated`, and `updated`. Cleared at the start of each - * render cycle so accumulations during hooks queue for the next cycle. - * @type {Map} - */ - this._changedProperties = new Map(); - - /** - * Resolver for the currently-pending updateComplete promise. `null` when - * no update is pending. - * @type {((value: boolean) => void) | null} - * @private - */ - this._updateResolve = null; - - /** - * Promise that resolves after the next render commit. Resolves to `true` - * when there are no further pending updates, `false` otherwise. - * @type {Promise} - * @private - */ - this._updatePromise = Promise.resolve(true); - - /** - * Set while the component is inside the update phase (between - * `shouldUpdate` and `updated`). Property assignments during this window - * fold into the CURRENT `changedProperties` Map without scheduling a - * new microtask render. Assignments during `updated()` (after the flag - * clears) queue a fresh cycle. - * @type {boolean} - * @private - */ - this._isUpdating = false; - - // Enforce the declare-free factory DX: a hand-written `static properties` - // in a class body is a hard error (use `extends WebComponent({ … })`). - this._assertFactoryProperties(); - - // Install reactive property accessors for `static properties` declarations. - this._initializeProperties(); - } - - /** - * Throw if a class in this instance's constructor chain declares its own - * `static properties`. Reactive properties must be declared via the - * `extends WebComponent({ … })` factory, which stamps {@link FACTORY_PROPS} - * on the subclass it generates; a `static properties` written by hand in a - * class body carries no such marker and is rejected here (issue #598). - * - * The walk stops at {@link WebComponentBase} (whose `static properties = {}` - * default is internal) and is memoized per class so it runs once. - * @private - */ - _assertFactoryProperties() { - const Ctor = /** @type {any} */ (this.constructor); - if (_propsChecked.has(Ctor)) return; - let C = Ctor; - while (C && C !== WebComponentBase) { - if (Object.hasOwn(C, 'properties') && !Object.hasOwn(C, FACTORY_PROPS)) { - const name = C.name || 'a component'; - throw new Error( - `${name}: \`static properties\` is no longer supported. Declare reactive ` + - `properties via the factory instead: \`class ${name} extends WebComponent({ ` + - `count: Number })\`. Use the \`prop()\` helper for options ` + - `(\`prop(Number, { reflect: true })\`) and set defaults in the ` + - `constructor after \`super()\`. See https://webjs.dev/docs/components.`, - ); - } - C = Object.getPrototypeOf(C); - } - _propsChecked.add(Ctor); - } - - /** - * For every key in `static properties`, create a getter/setter pair on - * the instance that coerces values, runs `hasChanged`, schedules updates, - * and optionally reflects to the HTML attribute. - * - * This is called once from the constructor. The backing store is a plain - * object (`this.__propValues`) so accessors don't collide with the - * prototype. - * @private - */ - _initializeProperties() { - const Ctor = /** @type {any} */ (this.constructor); - const props = Ctor.properties; - if (!props || typeof props !== 'object') return; - - /** @type {Record} */ - this.__propValues = {}; - - for (const [propName, decl] of Object.entries(props)) { - const d = typeof decl === 'object' ? decl : { type: decl }; - // Capture any value set before the accessor was installed (e.g. via - // attribute or property assignment before `super()` returns). - const initial = /** @type {any} */ (this)[propName]; - - Object.defineProperty(this, propName, { - configurable: true, - enumerable: true, - get: () => this.__propValues[propName], - set: (newVal) => { - const oldVal = this.__propValues[propName]; - const changed = (d.hasChanged || defaultHasChanged)(newVal, oldVal); - if (!changed) return; - this.__propValues[propName] = newVal; - - // Reflect to attribute if requested (and not internal state). - if (d.reflect && !d.state && this._connected) { - this._reflectAttribute(propName, newVal, d); - } - - // requestUpdate records the (name, oldValue) entry AND schedules - // a render. When called during the update phase (willUpdate / - // hostUpdate / update / hostUpdated), the scheduler short-circuits - // and the entry folds into the current cycle's changedProperties. - this.requestUpdate(propName, oldVal); - }, - }); - - if (initial !== undefined) { - this.__propValues[propName] = initial; - } else if (d.default !== undefined) { - // Declarative `default` option (lit-parity). A function default is - // CALLED per instance, so an object / array default is a fresh value - // per element. Written straight to the backing store; an applied - // attribute (attributeChangedCallback runs later) overrides it. - this.__propValues[propName] = - typeof d.default === 'function' ? d.default() : d.default; - } - } - } - - /** - * Write a property value back to its corresponding HTML attribute. - * Uses a custom `converter.toAttribute` if provided, otherwise the - * built-in type-based serialization. - * - * @param {string} propName - * @param {unknown} value - * @param {PropertyDeclaration} decl - * @private - */ - _reflectAttribute(propName, value, decl) { - // A custom `attribute` option wins over the kebab-cased property name. - const attrName = decl.attribute || hyphenate(propName); - // Guard against re-entrant loops: attributeChangedCallback fires when - // we call setAttribute, which would call the setter again. - if (this.__reflectingAttribute) return; - this.__reflectingAttribute = true; - try { - if (decl.converter && decl.converter.toAttribute) { - const serialized = decl.converter.toAttribute(value, decl.type); - if (serialized == null) this.removeAttribute(attrName); - else this.setAttribute(attrName, serialized); - } else if (typeof value === 'function') { - // #1169: the value IS a function, and no branch below has a - // meaningful serialization for one. `String(fn)` is the function's - // SOURCE, so a reflected `'use server'` action would ship its whole - // body, closure secrets included, to every visitor, and - // `JSON.stringify(fn)` is `undefined`, which `setAttribute` writes as - // the literal string "undefined". Treat it like `null` and remove the - // attribute, matching what the `.prop=${fn}` SSR binding already does - // for an unserializable value. - // - // Placed AFTER the converter branch on purpose: a custom - // `toAttribute` is author-controlled, so its author has taken - // responsibility for serializing whatever they are handed. - this.removeAttribute(attrName); - warnFunctionReflection(this, propName, attrName); - } else if (decl.type === Boolean) { - if (value) this.setAttribute(attrName, ''); - else this.removeAttribute(attrName); - } else if (value == null) { - this.removeAttribute(attrName); - } else if (decl.type === Object || decl.type === Array) { - // One rule about an unserializable reflected value, in two halves. - // - // A JSON-typed prop CARRYING a function is safe and stays whole: - // `JSON.stringify` drops a function to `null` in an array and omits - // the key in an object, so `[1, 2, fn]` serializes to `[1,2,null]` - // with no source and no data loss. Refusing here would discard the - // `1` and the `2` on a path that never leaked. - // - // A value `JSON.stringify` cannot serialize AT ALL is the other half - // (#1253): a cycle, a `BigInt`, or an author `toJSON()` that throws. - // The throw used to escape reflection entirely, so a property - // assignment threw from the setter, a client upgrade threw out of - // `connectedCallback` before the first render, and an SSR render was - // swallowed by per-component isolation, which surfaces an error box in - // dev and emits the component EMPTY at a 200 in PRODUCTION, where the - // cause is only in the server log. There is no string to put in the - // attribute, so the - // attribute goes, exactly as it does for a function. The `catch` IS - // the detection: `JSON.stringify` already walks the value, so its own - // failure covers all three causes, where a cycle-only pre-walk would - // miss the other two and pay a full traversal per reflection. - let serialized; - let serializable = true; - try { - serialized = JSON.stringify(value); - } catch (e) { - serializable = false; - this.removeAttribute(attrName); - warnUnserializableReflection(this, propName, attrName, e && e.message); - } - // Outside the `try` on purpose, so a genuine `setAttribute` failure - // (an invalid attribute name) still surfaces instead of being folded - // into the unserializable path. - if (serializable) this.setAttribute(attrName, serialized); - } else if (carriesFunction(value)) { - // The string fall-through is the one place a CARRIED function still - // leaks. `String([fn])` is `Array.prototype.join`, which runs - // `String()` on every element, so `[serverAction]` writes the same - // source `serverAction` does. Hence the recursive predicate the - // form-action guard already uses, rather than a bare `typeof`, but - // scoped to this branch rather than applied above, where JSON - // handles the same shape losslessly. - this.removeAttribute(attrName); - warnFunctionReflection(this, propName, attrName); - } else { - this.setAttribute(attrName, String(value)); - } - } finally { - this.__reflectingAttribute = false; - } - } - - connectedCallback() { - if (!isBrowser) return; - - // Apply any `data-webjs-prop-*` attributes emitted by SSR. The server - // emits these for `.prop=${val}` bindings in parent templates so - // rich-typed values (Array, Object, Date, Map, Set, BigInt, cycles) - // round-trip through the rendered HTML. Once applied, the attributes - // are stripped so the settled DOM matches what the user would expect - // from the JS source: no framework artifacts left on the element. - // One-time per element. Subsequent reconnections do nothing. - if (!this.__webjsPropsHydrated) { - this.__webjsPropsHydrated = true; - this._hydratePropAttrs(); - } - - const Ctor = /** @type any */ (this.constructor); - - // Selective hydration: defer activation until the element scrolls into - // (or near) the viewport. The DSD content from SSR stays visible the - // whole time: the user sees the server-rendered HTML. - if ( - Ctor.hydrate === 'visible' && - typeof IntersectionObserver !== 'undefined' && - !this.__hydrationActivated - ) { - this.__hydrationActivated = false; - this.__hydrationObserver = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - this.__hydrationObserver.unobserve(this); - this.__hydrationObserver.disconnect(); - this.__hydrationObserver = null; - this.__hydrationActivated = true; - this._activate(); - return; - } - } - }, - { rootMargin: '200px' } - ); - this.__hydrationObserver.observe(this); - return; - } - - this._activate(); - } - - /** - * Internal activation method that performs the actual connectedCallback - * work: setting up the render root, adopting styles, notifying - * controllers, and performing the first render. - * - * Called directly from `connectedCallback()` for normal components, or - * deferred via IntersectionObserver when `static hydrate = 'visible'`. - * - * @private - */ - /** - * Read `data-webjs-prop-*` attributes (emitted by SSR for `.prop=${val}` - * bindings in parent templates), decode each via the wire serializer, - * assign the decoded value to the corresponding camelCase property on - * this instance, and remove the attribute from the DOM. After this - * runs, inspecting the element shows the same attributes the developer - * would expect from the JS source. - * - * @private - */ - _hydratePropAttrs() { - /** @type {string[]} */ - const names = []; - const attrs = this.attributes; - for (let i = 0; i < attrs.length; i++) { - const n = attrs[i].name; - if (n.startsWith('data-webjs-prop-')) names.push(n); - } - for (const fullName of names) { - const raw = this.getAttribute(fullName); - this.removeAttribute(fullName); - if (raw == null) continue; - const propName = camelCase(fullName.slice('data-webjs-prop-'.length)); - try { - /** @type any */ (this)[propName] = deserializeProp(raw); - } catch (err) { - console.warn( - `[webjs] failed to decode ${fullName} on <${this.tagName.toLowerCase()}>: ${err && err.message}` - ); - } - } - } - - _activate() { - this._connected = true; - // Reflect declared reflect:true properties from their current value now - // that the element is connected. Constructor / willUpdate defaults were - // set while disconnected (the setter skips reflection then), so this is - // what makes a freshly-created client element carry the same reflected - // attributes the SSR walker emitted. Same-value reflects are no-ops, so a - // hydrated element (whose attribute already arrived from SSR) is unchanged. - this._reflectDeclaredAttributes(); - const Ctor = /** @type any */ (this.constructor); - // Mark LIGHT-DOM component hosts so the framework default host rule - // (`@layer webjs-host { :where([data-wj-host]) { display: block } }`) - // applies. SSR already stamps this on server-rendered light hosts - // (idempotent here); this also covers a client-only light component (never - // SSR'd) so it does not collapse. Shadow hosts are NOT marked: a document - // rule would override the shadow author's own `:host { display: … }`, so - // shadow components control their host display via `:host` in `static styles`. - if (Ctor.shadow !== true && !this.hasAttribute('data-wj-host')) { - this.setAttribute('data-wj-host', ''); - } - if (Ctor.shadow === true) { - const hadSSRShadow = !!this.shadowRoot; - if (!this.shadowRoot) { - /** @type any */ (this).attachShadow({ mode: 'open' }); - } - this._renderRoot = this.shadowRoot; - const styles = Ctor.styles; - const list = Array.isArray(styles) ? styles : isCSS(styles) ? [styles] : []; - if (list.length) { - // If the shadow root came from Declarative Shadow DOM (SSR), it - // contains an inline ')) { - state = 'text'; - rawTail = ''; - currentTag = ''; - } - break; - case 'attr-name': - if (c === '=') { state = 'after-eq'; html += c; } - else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; } - else if (c === '>') { state = 'text'; attrName = ''; html += c; } - else { attrName += c; html += c; } - break; - case 'after-eq': - if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; html += c; } - else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; } - else if (c === '>') { state = 'text'; attrName = ''; html += c; } - else { state = 'attr-unquoted'; html += c; } - break; - case 'attr-unquoted': - if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; } - else if (c === '>') { state = 'text'; attrName = ''; html += c; } - else html += c; - break; - case 'attr-quoted': - html += c; - if (c === attrQuote) { state = 'in-tag'; attrName = ''; } - break; - case 'skip-attr': - // Consume mixed-attribute chars without appending to html. - // The attribute was replaced with a sentinel on the first hole. - if (c === attrQuote) { - // Closing quote: finalize the attr-mixed part. - if (mixedAttr) { - const idx0 = mixedAttr.firstPartIdx; - const group = []; - for (let k = idx0; k < parts.length; k++) { - if (parts[k].kind === 'noop' || parts[k].kind === 'attr-mixed') group.push(k); - } - // Build statics from the template strings array. - // For `attr="a ${x} b ${y} c"`, group=[idx0,idx1]. - // statics[0] = tail of strings[idx0] after the `="` - // statics[1] = strings[idx1] (between holes) - // statics[n] = prefix of strings[last+1] up to closing quote - const statics = []; - const s0 = strings[group[0]]; - const qp = s0.lastIndexOf(attrQuote); - statics.push(qp >= 0 ? s0.slice(qp + 1) : s0); - for (let k = 1; k < group.length; k++) { - statics.push(strings[group[k]]); - } - const sLast = strings[group[group.length - 1] + 1]; - const eq = sLast.indexOf(attrQuote); - statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast); - - parts[idx0] = { - kind: 'attr-mixed', - path: [], - name: mixedAttr.name, - statics, - group, - }; - // The mixed attribute is rebuilt from ALL its holes' values, but - // it is anchored at a single part (group[0]). The later holes stay - // `noop`, so a change confined to one of them would be skipped by - // updateInstance's per-hole dirty-check and the attribute would go - // stale. Point every non-anchor member back at the anchor so a - // change to any hole re-applies the whole attribute. - for (let m = 1; m < group.length; m++) { - parts[group[m]] = { kind: 'noop', path: [], mixedAnchor: idx0 }; - } - mixedAttr = null; - } - state = 'in-tag'; - attrName = ''; - } - break; - } - } - - if (i < strings.length - 1) { - const partIdx = parts.length; - if (state === 'comment') { - // Holes inside are dropped. Comments are inert and - // the compile cache is keyed on `strings`, so per-render values - // can't be baked in anyway. - commentDashes = 0; - parts.push({ kind: 'noop', path: [] }); - continue; - } - if (state === 'rawtext') { - // Inside ') || rawTail.endsWith('')) { + state = 'text'; + rawTail = ''; + currentTag = ''; + } + break; + case 'attr-name': + if (c === '=') { state = 'after-eq'; html += c; } + else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; } + else if (c === '>') { state = 'text'; attrName = ''; html += c; } + else { attrName += c; html += c; } + break; + case 'after-eq': + if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; html += c; } + else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; } + else if (c === '>') { state = 'text'; attrName = ''; html += c; } + else { state = 'attr-unquoted'; html += c; } + break; + case 'attr-unquoted': + if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; } + else if (c === '>') { state = 'text'; attrName = ''; html += c; } + else html += c; + break; + case 'attr-quoted': + html += c; + if (c === attrQuote) { state = 'in-tag'; attrName = ''; } + break; + case 'skip-attr': + // Consume mixed-attribute chars without appending to html. + // The attribute was replaced with a sentinel on the first hole. + if (c === attrQuote) { + // Closing quote: finalize the attr-mixed part. + if (mixedAttr) { + const idx0 = mixedAttr.firstPartIdx; + const group = []; + for (let k = idx0; k < parts.length; k++) { + if (parts[k].kind === 'noop' || parts[k].kind === 'attr-mixed') group.push(k); + } + // Build statics from the template strings array. + // For `attr="a ${x} b ${y} c"`, group=[idx0,idx1]. + // statics[0] = tail of strings[idx0] after the `="` + // statics[1] = strings[idx1] (between holes) + // statics[n] = prefix of strings[last+1] up to closing quote + const statics = []; + const s0 = strings[group[0]]; + const qp = s0.lastIndexOf(attrQuote); + statics.push(qp >= 0 ? s0.slice(qp + 1) : s0); + for (let k = 1; k < group.length; k++) { + statics.push(strings[group[k]]); + } + const sLast = strings[group[group.length - 1] + 1]; + const eq = sLast.indexOf(attrQuote); + statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast); + + parts[idx0] = { + kind: 'attr-mixed', + path: [], + name: mixedAttr.name, + statics, + group, + }; + // The mixed attribute is rebuilt from ALL its holes' values, but + // it is anchored at a single part (group[0]). The later holes stay + // `noop`, so a change confined to one of them would be skipped by + // updateInstance's per-hole dirty-check and the attribute would go + // stale. Point every non-anchor member back at the anchor so a + // change to any hole re-applies the whole attribute. + for (let m = 1; m < group.length; m++) { + parts[group[m]] = { kind: 'noop', path: [], mixedAnchor: idx0 }; + } + mixedAttr = null; + } + state = 'in-tag'; + attrName = ''; + } + break; + } + } + + if (i < strings.length - 1) { + const partIdx = parts.length; + if (state === 'comment') { + // Holes inside are dropped. Comments are inert and + // the compile cache is keyed on `strings`, so per-render values + // can't be baked in anyway. + commentDashes = 0; + parts.push({ kind: 'noop', path: [] }); + continue; + } + if (state === 'rawtext') { + // Inside / - let tagStart = -1; // index in `out` of the `<` opening the current tag - /** @type {string | null} */ - let pendingActionId = null; // identity of a bound form action, until the tag closes - /** @type {string | null} */ - let pendingSubmitterTag = null; // tag of a bound submitter, until it closes - // Shapes on the CURRENT start tag that a bound form may not carry (#1155). - // Collected as the tag is scanned and judged at its `>`, because the action - // hole may come after them. - let pendingActionCount = 0; - /** @type {string[]} */ - let pendingPropAttrs = []; - /** @type {string[]} */ - let pendingSubmitterProps = []; - let isCloseTag = false; - - // A bound `action=${fn}` is committed at its hole, but the edits it implies - // (forcing `method` / `enctype`, and the hidden identity field) are only - // possible once the whole start tag is known: an attribute the author wrote - // AFTER the action hole still counts, and the hidden field belongs INSIDE - // the form, after the `>`. So the hole records the identity and this runs at - // the `>`, rewriting the start tag that was just emitted. - const closeBoundFormTag = () => { - // Reset per tag whether or not this one was bound, so a later form is never - // judged on an earlier tag's shapes. - const propAttrs = pendingPropAttrs; - const submitterProps = pendingSubmitterProps; - const duplicateAction = pendingActionCount > 1; - const submitterTag = pendingSubmitterTag; - pendingPropAttrs = []; - pendingSubmitterProps = []; - pendingActionCount = 0; - pendingSubmitterTag = null; - if (pendingActionId != null) { - assertConvergentBoundForm({ duplicateAction, propAttrs }); - const bound = bindFormActionStartTag(out.slice(tagStart), pendingActionId); - out = out.slice(0, tagStart) + bound.tag + bound.hidden; - pendingActionId = null; - } - if (submitterTag != null) { - // #1307: a bound submitter carries its WHOLE submission, so `formmethod` - // and the enctype are injected onto the button here rather than inherited - // from a form this scan may not even be able to see. That is what removed - // the enclosing-form question, and with it the four-state scope tracking - // that could never answer it for a button inside a component. - out = out.slice(0, tagStart) - + bindSubmitterStartTag(out.slice(tagStart), submitterTag, { duplicateAction, propAttrs: submitterProps }); - } - }; - // #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped - // here but applied for real in the browser, where all three are reflected IDL - // attributes, so a bound form carrying one submits differently with JS than - // without it. Recorded and refused at the `>`, once the tag's action hole is - // known. - const notePropAttr = (name, tag) => { - const t = String(tag).toLowerCase(); - if (t === 'button' || t === 'input') { - // #1207: the submitter twin. `name` / `value` / `formAction` / `formMethod` - // / `formEnctype` all reflect on a submitter, so a `.prop` spelling is - // dropped here and written to the attribute in the browser. - if (isSubmitterReflectedProp(name)) pendingSubmitterProps.push(String(name)); - return; - } - if (t !== 'form') return; - let n = String(name).toLowerCase(); - if (n === 'encoding') n = 'enctype'; - if (n === 'method' || n === 'enctype') pendingPropAttrs.push(String(name)); - }; - const noteActionHole = (name, tag) => { - const t = String(tag).toLowerCase(); - const n = String(name).toLowerCase(); - if ((t === 'form' && n === 'action') || - ((t === 'button' || t === 'input') && n === 'formaction')) { - pendingActionCount += 1; - } - }; - - // Every `>` in a tag state funnels through here, so the bound-form bookkeeping - // stays in one place rather than at five call sites. - // - // `allowRawtext` is NOT a preference. Only two of those five call sites ever - // entered rawtext: the `tag-name` and `in-tag` exits. The three attribute - // exits (`attr-name`, `after-eq`, `attr-unquoted`) always forced `text`, so - // `` from escaped into - // raw script, which is an XSS mitigation this change has no business - // touching. Whether that escaping is the RIGHT behaviour is a separate - // question from #1207; this preserves it exactly. - const handleTagEnd = (allowRawtext) => { - closeBoundFormTag(); - isCloseTag = false; - state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text'; - if (state === 'rawtext') rawTail = ''; - }; - - for (let i = 0; i < strings.length; i++) { - const s = strings[i]; - for (let j = 0; j < s.length; j++) { - const c = s[j]; - switch (state) { - case 'text': - out += c; - if (c === '<') { state = 'tag-open'; tagStart = out.length - 1; isCloseTag = false; } - break; - case 'tag-open': - out += c; - if (c === '!') state = 'bang-1'; - else if (c === '/') { state = 'tag-name'; currentTag = ''; isCloseTag = true; } - else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); } - else state = 'text'; - break; - case 'bang-1': - out += c; - state = c === '-' ? 'bang-dash' : 'tag-name'; - break; - case 'bang-dash': - out += c; - if (c === '-') { state = 'comment'; commentDashes = 0; } - else state = 'tag-name'; - break; - case 'comment': - out += c; - if (c === '-') commentDashes += 1; - else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; } - else commentDashes = 0; - break; - case 'tag-name': - out += c; - if (c === '>') { - handleTagEnd(true); - } else if (/\s/.test(c)) state = 'in-tag'; - else currentTag += c.toLowerCase(); - break; - case 'in-tag': - out += c; - if (c === '>') { - handleTagEnd(true); - } else if (!/\s/.test(c) && c !== '/') { - state = 'attr-name'; - attrName = c; - attrStart = out.length - 1; - } - break; - case 'rawtext': - out += c; - rawTail = (rawTail + c.toLowerCase()).slice(-9); - if (rawTail.endsWith('') || rawTail.endsWith('')) { - state = 'text'; - rawTail = ''; - currentTag = ''; - } - break; - case 'attr-name': - if (c === '=') { state = 'after-eq'; out += c; } - else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; } - else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); } - else { attrName += c; out += c; } - break; - case 'after-eq': - if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; out += c; } - else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; } - else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); } - else { state = 'attr-unquoted'; out += c; } - break; - case 'attr-unquoted': - if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; } - else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); } - else out += c; - break; - case 'attr-quoted': - out += c; - if (c === attrQuote) { state = 'in-tag'; attrName = ''; } - break; - } - } - - if (i < values.length) { - let val = values[i]; - // Resolve promises anywhere in the value graph. - if (val && typeof /** @type any */ (val).then === 'function') { - val = await val; - } - if (state === 'comment') { - // Holes inside are emitted raw (no escaping; comments - // are inert and not rendered by browsers). - out += String(val ?? ''); - commentDashes = 0; - } else if (state === 'rawtext') { - // Inside ` is TEXT (it only steps back to the escaped state) and the - * element ends at the NEXT ``. The legacy comment-wrapped inline - * script that document.writes a script tag is the pattern that produces this. - * Stopping at the first `` there re-opened the original #1128 bug in - * the one element the scanner most explicitly claims to handle. - * - * @param {string} html - * @param {number} from index just past the opening tag's `>` - * @returns {number} - */ -function endOfScriptContent(html, from) { - const re = /|<\/script(?=[\s/>])|])/gi; - re.lastIndex = from; - let escaped = false; - let dbl = false; - let m; - while ((m = re.exec(html)) !== null) { - const t = m[0]; - if (t === '`, ``, and any dash - // run followed by `>` clear BOTH flags: entering fresh it cancels the - // escape before it starts, and inside an escaped or double-escaped body - // it is the exit a browser honours, after which the element ends at the - // next ``. - let q = m.index + 4; - while (html[q] === '-') q += 1; - if (html[q] === '>') { escaped = false; dbl = false; re.lastIndex = q + 1; } - else if (!escaped) escaped = true; - } - else if (t === '-->') { escaped = false; dbl = false; } - else if (t[1] === '/') { - if (dbl) dbl = false; - else return m.index; - } else if (escaped) dbl = true; - } - return -1; -} - -/** - * Byte ranges of `html` where a tag-shaped match is NOT an element (#1128). - * - * The element scanners below match tags with a flat regex over already- - * assembled markup, which has no notion of an HTML context. So a registered tag - * name written inside a comment used to be constructed and rendered as a real - * element, and the replacement consumed the rest of the comment INCLUDING its - * closing `-->`, leaving an unterminated comment that swallowed every following - * byte. Whether it happened depended on whether the name in the comment was a - * registered component, which is what made it look random. - * - * This is a single left-to-right pass rather than a search for `` and `` close - * immediately, `--!>` closes as well as `-->`, and an unterminated comment - * runs to EOF, exactly as a browser would treat the same bytes. - * - **Markup declarations and bogus comments** (``, ``), - * which end at the next `>`. - * - **Tags**, consumed with their quoted attribute values, so `<` and ``, - }); - continue; - } - // - // The authored inner HTML + slot partition were extracted BEFORE - // the render (the #1015 reorder, see above), so here: - // 1. Substitute each in the rendered output with a - // framework-marked element carrying projection or - // fallback content per first-wins rule. - // 2. Recursively run injectDSD on the substituted output so - // nested custom elements (inside projected children) get - // their own DSD pass. - const innerWithSlots = substituteSlotsInRender(rawInner, partitioned, tag); - const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance], dev); - edits.push({ - start: m.index, - end: closeEnd, - text: `${opening}${innerProcessed}`, - }); - } - } catch (e) { - const hint = browserMemberHint(e); - if (hint) { - console.error( - `[webjs] SSR failed for <${tag}>: ${hint} It was touched in the component's constructor or render(), which run during SSR. Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes / props).`, - e, - ); - } else { - console.error(`[webjs] SSR failed for <${tag}>:`, e); - } - // Per-component error isolation (#469). A render that throws (most - // commonly a rejected `await getData()` in an async render, but any - // render throw) is caught HERE, per component: the loop continues so - // siblings render normally, and this element renders a component-scoped - // error state instead of bubbling to the route error.js or leaving its - // raw, unprocessed children in the output. renderError() customizes the - // error UI; the default surfaces the message in dev and renders an empty - // (silent, isolated) element in prod so no internal detail leaks. - const err = e instanceof Error ? e : new Error(String(e)); - let errorInner = ''; - try { - let errTpl; - if (instance && typeof instance.renderError === 'function') { - errTpl = instance.renderError(err); - } - if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, dev); - errorInner = await render(errTpl, ctx); - if (errorInner.trim()) { - errorInner = await injectDSD(errorInner, ctx, instance ? [...ancestors, instance] : ancestors, dev); - } - } catch (renderErrorThrew) { - console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew); - errorInner = ''; - } - // Replace the element (opening tag through its matching close) with the - // error state plus a hydration marker, so the client error boundary - // (component.js renderError) can take over on hydration. - let closeEnd = m.index + match.length; - if (!selfClose) { - const innerStart = m.index + match.length; - const closeIdx = findClosingTagInString(html, innerStart, tag, inert); - if (closeIdx !== -1) { - const closeRe = new RegExp(``, 'i'); - const cm = closeRe.exec(html.slice(closeIdx)); - closeEnd = closeIdx + (cm ? cm[0].length : ``.length); - } else { - closeEnd = html.length; - } - } - // A shadow component renders into a shadow root on the client, so its - // SSR error state must ride a DSD template too (matching the success - // path), not land in light DOM. Otherwise the client renders the error - // into the shadow root while the light error box lingers underneath. - const isShadowErr = /** @type any */ (Cls).shadow === true; - // Mark the LIGHT host here too, so a component whose SSR render() throws - // paints its error state as display:block (not the inline default), - // matching the success path. When an `async render()` rejects, it throws - // before the success-path withHostMarker (above) ran, so `opening` is still - // unmarked; when a later template render throws, the success marker already - // ran and this call is a no-op (withHostMarker is idempotent). Shadow hosts - // stay unmarked (their :host must win). - if (!isShadowErr) opening = withHostMarker(opening); - let text; - if (isShadowErr) { - const rawStyles = /** @type any */ (Cls).styles; - const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : []; - const styleStr = stylesToString(styleList); - text = `${opening}`; - } else { - text = `${opening}${errorInner}`; - } - edits.push({ start: m.index, end: closeEnd, text }); - } - } - if (!edits.length) return html; - - // Drop edits whose range lives inside an earlier edit's range. This - // happens when an outer custom element with in its render takes - // an edit that spans its opening + closing tags (covering inner custom - // elements among authored children); the inner matches were enumerated - // independently against the original html, but those inner elements - // are processed by the recursive injectDSD call on innerWithSlots. - // Keeping both edits would double-process them and corrupt the output. - // A consequence: a nested instance's render() runs once per chain depth - // (the discarded top-level pass sees an empty ancestor chain, so its - // closest() reads null; the kept recursive pass has the real chain). The - // kept pass is the only output, and closest() is a read, so render() must - // stay pure at SSR (the standard SSR contract), not branch on side effects. - edits.sort((a, b) => a.start - b.start); - /** @type {{start:number, end:number, text:string}[]} */ - const filtered = []; - let consumedTo = -1; - for (const e of edits) { - if (e.start >= consumedTo) { - filtered.push(e); - consumedTo = e.end; - } - } - // Apply edits from last to first so indices stay stable. - let out = html; - for (let i = filtered.length - 1; i >= 0; i--) { - const { start, end, text } = filtered[i]; - out = out.slice(0, start) + text + out.slice(end); - } - return out; -} - -// --------------------------------------------------------------------------- -// Slot SSR helpers -// --------------------------------------------------------------------------- - -const VOID_ELEMENTS = new Set([ - 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', - 'link', 'meta', 'param', 'source', 'track', 'wbr', -]); - -/** @param {string} tag @returns {boolean} */ -function isVoidElement(tag) { - return VOID_ELEMENTS.has(tag.toLowerCase()); -} - -/** - * Resolve `` boundaries in an HTML string (#471). For each - * top-level boundary (nested ones are handled by the recursive injectDSD that - * processes a boundary's streamed children): - * - * - Streaming (a SuspenseCtx is present): emit the boundary as - * `FALLBACK` and push the raw inner - * children to `ctx.pending` (wrapped in `unsafeHTML` so the streaming pass - * renders them as HTML, not escaped text, then runs injectDSD over them). - * `streamSuspenseBoundaries` later streams the resolved children as a - * `