diff --git a/.dockerignore b/.dockerignore index 98b20806..7371e3c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,18 @@ .github node_modules **/node_modules -**/.webjs +# `.webjs/` is ignored EXCEPT for `.webjs/vendor/`. The vendor +# subdirectory holds the committed importmap manifest (.webjs/vendor/ +# importmap.json) and optionally downloaded bundle bytes (from +# `webjs vendor pin --download`). Both must reach the production +# image so the server doesn't need api.jspm.io reachable at boot. +# DO NOT collapse to `**/.webjs`: parent exclusion blocks child +# negations and the vendor files would silently never reach the +# image. Mirrors the .gitignore pattern enforced by the +# `gitignore-vendor-not-ignored` lint rule. +**/.webjs/* +!**/.webjs/vendor/ +!**/.webjs/vendor/** **/dist **/build **/out diff --git a/.gitignore b/.gitignore index 608b6c19..e7f49847 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,15 @@ dist/ build/ out/ .cache/ -.webjs/ +# `.webjs/vendor/` is the EXCEPTION: holds the committed importmap +# manifest + optional downloaded bundles for `webjs vendor pin`. See +# packages/cli/templates/.gitignore for the full rationale. +# DO NOT collapse to `.webjs/`: parent exclusion blocks child +# negations and silently breaks `webjs vendor pin`. The +# `gitignore-vendor-not-ignored` lint rule guards this. +.webjs/* +!.webjs/vendor/ +!.webjs/vendor/** # generated Tailwind CSS - built from public/input.css via `npm run dev` / `npm run start` **/public/tailwind.css diff --git a/AGENTS.md b/AGENTS.md index 8e03070f..6c8a68f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -555,7 +555,7 @@ For partial-swap NOT tied to a folder layout, wrap in ``. 7. **Light-DOM components with custom CSS MUST prefix every class selector with their tag name.** Tailwind utilities are unique by construction, so prefer them. 8. **Non-root layouts and pages MUST NOT** write `` / `` / `` / ``. Only the root layout may. 9. **No backtick characters inside `html\`...\`` template bodies**, even inside CSS / HTML comments. A nested backtick closes the literal at JS-parse time and 500s in prod. -10. **TypeScript must be erasable.** Set `compilerOptions.erasableSyntaxOnly: true`. No `enum`, no `namespace` with values, no constructor parameter properties, no legacy decorators with `emitDecoratorMetadata`, no `import = require`. The framework strips types via Node 24+'s built-in `module.stripTypeScriptTypes` (position-preserving, no sourcemap). If you disable the flag and use non-erasable syntax, the dev server falls back to esbuild on those files (~3x wire bytes, inline sourcemap). The `erasable-typescript-only` check enforces the flag. See `agent-docs/typescript.md` for erasable equivalents. +10. **TypeScript must be erasable.** Set `compilerOptions.erasableSyntaxOnly: true`. No `enum`, no `namespace` with values, no constructor parameter properties, no legacy decorators with `emitDecoratorMetadata`, no `import = require`. The framework strips types via Node 24+'s built-in `module.stripTypeScriptTypes` (position-preserving, no sourcemap). If you disable the flag and use non-erasable syntax, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. webjs is buildless end-to-end and has no bundler fallback. Two lint rules enforce this: `erasable-typescript-only` (checks the tsconfig flag) and `no-non-erasable-typescript` (scans source for the four offending patterns even if the flag is off). See `agent-docs/typescript.md` for erasable equivalents. 11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation, and no colon attached to a code-shaped LHS.** Banned glyphs as pause punctuation: U+2014; a plain hyphen surrounded by spaces between word characters; a semicolon surrounded by spaces between word characters. Banned colon attachments (prefer verb-led rephrasings): `xyz()` followed by colon-then-prose; a custom-element tag like `` followed by colon-then-prose; `[expr]` subscript followed by colon-then-prose; markdown definition lists with `foo()` followed by colon-then-prose. Prefer a period, comma, colon on a plain-noun LHS only, parentheses, or a restructured sentence. Plain hyphens stay fine in natural roles (compound words, CLI flags, filenames, ranges). Semicolons stay fine inside code. Colons stay fine in TS / JSON / CSS syntax. Enforced for Claude Code via `.claude/hooks/block-prose-punctuation.sh` (PreToolUse on Write / Edit / MultiEdit / NotebookEdit / Bash). The hook scans only NEW content; you can still edit a line that already contains a banned glyph to remove it. diff --git a/README.md b/README.md index f9a3a03c..0fe1553f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ TypeScript with zero build step, real SSR with Declarative Shadow DOM. ## Why webjs - **AI-first.** Predictable file conventions, one function per file, an explicit `.server.ts` boundary, and an `AGENTS.md` contract. The whole design lets LLMs modify code without loading the entire codebase into context. -- **No build step you run.** `.ts` files served directly. Node 24+ is the minimum runtime, and the dev server strips types via Node's built-in `module.stripTypeScriptTypes` (position-preserving, no sourcemap, near-zero overhead). TypeScript must be erasable. Non-erasable constructs (enums, value-carrying namespaces, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`) trigger an esbuild fallback for those files (~3x wire bytes, inline sourcemap). Edit, refresh, done. +- **No build step you run.** `.ts` files served directly. Node 24+ is the minimum runtime, and the dev server strips types via Node's built-in `module.stripTypeScriptTypes` (position-preserving, no sourcemap, near-zero overhead). TypeScript must be erasable. Non-erasable constructs (enums, value-carrying namespaces, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`) fail at strip time with a 500 pointing at the `no-non-erasable-typescript` lint rule, since webjs is buildless end-to-end with no bundler fallback. Edit, refresh, done. - **Web components, light DOM by default.** Pages and components render as light DOM so global CSS and Tailwind utilities apply directly: no `::part`, no `:host`, no CSS-var plumbing. Shadow DOM is opt-in (`static shadow = true`) when you need scoped styles or third-party-embed isolation. `` projection (named slots, fallback content, `assignedNodes` / `slotchange`) works identically in both modes. Both modes SSR fully, no hydration runtime. - **Progressive enhancement, built in.** Pages *and* components are SSR'd to real HTML. Every web component's `render()` runs on the server, so its initial markup is in the response before any script loads. Content reads, links navigate, forms submit (server actions are plain HTML POSTs), and display-only custom elements look right, all without JavaScript. JS is opt-in *per interactive behavior*, not per component: a counter renders as "0" without JS, and only the +/- click handling needs scripts. The HTML is the floor, and the client router and `@click` / signal interactivity are layered on top. - **Tailwind CSS by default.** The scaffold ships with the Tailwind browser runtime + `@theme` design tokens. Prefer hand-written CSS? Opt out entirely, and the framework works just as well with vanilla CSS when you follow the wrapper-scoping convention (`.page-`, `.layout-`, component-tag scoped). Full recipe in the [Styling docs](./docs/app/docs/styling/page.ts). @@ -262,7 +262,7 @@ Pre-1.0. Current packages: `@webjsdev/core` 0.7.1, `@webjsdev/server` 0.7.2, `@w - **Core:** Signals (`signal`, `computed`, `effect`, `batch`, TC39 Stage 1 shape) as the default state primitive, with WebComponent's built-in SignalWatcher auto-tracking `.get()` reads inside `render()`. Reactive properties via `static properties` reserved for HTML attribute round-trip (`declare`-pattern enforced via the `reactive-props-use-declare` rule). Full lit-API parity: ReactiveController hooks (`hostConnected`, `hostDisconnected`, `hostUpdate`, `hostUpdated`) and lifecycle (`shouldUpdate`, `willUpdate`, `update`, `updated`, `firstUpdated`, `updateComplete`), 12 directives (`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `templateContent`, `ref` + `createRef`, `cache`, `until`, `asyncAppend`, `asyncReplace`, `watch`). SSR with DSD (opt-in) + light-DOM hydration (default), light-DOM `` projection (framework-driven, same API as shadow DOM), fine-grained client renderer, `Suspense()`, client router with `composedPath()` for shadow DOM, mixed-attribute interpolation, MutationObserver upgrade safety net. - **Data:** Server actions with webjs's built-in serializer (`Date`, `Map`, `Set`, `BigInt`, `TypedArray`, `Blob`, `File`, `FormData`, reference cycles all survive the wire). Two-marker server-file convention: `.server.{js,ts}` for path-level source-protection (browser imports get a throw-at-load stub), `'use server'` for RPC registration (file is also browser-callable). `expose()` for REST with optional `validate` hook. `json()` + `richFetch()` for content-negotiated APIs. `cache()` for server-side query caching with TTL + `invalidate()`. `WEBJS_PUBLIC_*` env vars injected into `window.process.env` at SSR (no build step, no transform). - **Server:** File router with `page.ts`, `layout.ts`, `route.ts`, `error.ts`, `loading.ts`, `not-found.ts`, `middleware.ts`, metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `opengraph-image`), per-segment middleware, `rateLimit()`, WebSockets (`WS` export + `connectWS()` + `broadcast()`), CSRF, gzip / brotli compression, HTTP/2, 103 Early Hints, modulepreload hints, health probes, graceful shutdown on `SIGTERM`, `Session` class with `SessionStorage` (cookie or store-backed), NextAuth-style `createAuth()` (Credentials, Google, GitHub), single pluggable cache store (in-memory by default, swap to Redis with one `setStore()` call shared by auth, sessions, caching, and rate limiting). -- **DX:** Node 24+ minimum runtime, with the dev server stripping TypeScript via Node's built-in `module.stripTypeScriptTypes` (zero build, position-preserving, no sourcemap). esbuild stays as a per-file fallback for non-erasable TS (enums, value-carrying namespaces, constructor parameter properties, legacy decorators) and for transitive `node_modules` vendor bundling. `webjs check` lint covers `use-server-needs-extension`, `no-server-env-in-components`, `reactive-props-use-declare`, `erasable-typescript-only`, `shell-in-non-root-layout`, `no-json-data-files`, and more (run `webjs check --rules` to enumerate). `AGENTS.md` contract + `CLAUDE.md` + per-tool agent configs (`.cursorrules`, `.windsurfrules`, `.github/copilot-instructions.md`, `.claude/settings.json` PreToolUse hook guarding edits on `main`). Live reload in dev (chokidar + SSE). `@webjsdev/ts-plugin` editor-only piece bundles `ts-lit-plugin` and layers webjs-aware intelligence on top: type-checked `` html`…` `` templates, custom-element go-to-definition, attribute auto-complete from `static properties`, silenced "Unknown tag" diagnostics for `Class.register('tag-name')` elements, all gated by the file's import graph. Not required for the framework to run. +- **DX:** Node 24+ minimum runtime, with the dev server stripping TypeScript via Node's built-in `module.stripTypeScriptTypes` (zero build, position-preserving, no sourcemap). Non-erasable TS (enums, value-carrying namespaces, constructor parameter properties, legacy decorators) fails with a 500 pointing at the `no-non-erasable-typescript` lint rule. webjs is buildless end-to-end and has no bundler fallback. Vendor (`node_modules`) packages resolve through importmap to jspm.io URLs at runtime; the webjs server doesn't bundle them. `webjs vendor pin` writes resolved URLs to `.webjs/vendor/importmap.json` for deterministic deploys; `webjs vendor pin --download` additionally vendors bundle bytes for offline-capable production. `webjs check` lint covers `use-server-needs-extension`, `no-server-env-in-components`, `reactive-props-use-declare`, `erasable-typescript-only`, `no-non-erasable-typescript`, `shell-in-non-root-layout`, `no-json-data-files`, and more (run `webjs check --rules` to enumerate). `AGENTS.md` contract + `CLAUDE.md` + per-tool agent configs (`.cursorrules`, `.windsurfrules`, `.github/copilot-instructions.md`, `.claude/settings.json` PreToolUse hook guarding edits on `main`). Live reload in dev (chokidar + SSE). `@webjsdev/ts-plugin` editor-only piece bundles `ts-lit-plugin` and layers webjs-aware intelligence on top: type-checked `` html`…` `` templates, custom-element go-to-definition, attribute auto-complete from `static properties`, silenced "Unknown tag" diagnostics for `Class.register('tag-name')` elements, all gated by the file's import graph. Not required for the framework to run. - **Release:** Per-package per-version changelog under `changelog//.md`, auto-generated on the same commit that bumps a `package.json` `version` field (universal pre-commit hook). The `.github/workflows/release.yml` workflow watches for new changelog files on `main` and dual-publishes to npm (`npm publish --workspace=@webjsdev/`) and GitHub Releases (`gh release create @`), both idempotent so re-runs pick up where they left off. Free for public repos via `NPM_TOKEN` + the auto-provisioned `GITHUB_TOKEN`. ## License diff --git a/agent-docs/advanced.md b/agent-docs/advanced.md index 2553b158..3ffcb265 100644 --- a/agent-docs/advanced.md +++ b/agent-docs/advanced.md @@ -40,10 +40,16 @@ Five stacked zero-build optimizations: `IntersectionObserver` (200px root margin). The SSR-rendered DSD content is visible immediately. `static hydrate = 'visible'` further defers `connectedCallback` activation. Ideal for below-the-fold widgets. -5. **Auto-vendor bundling (Vite-style optimizeDeps).** At startup the server - scans client-reachable source for bare npm import specifiers. Each - package is bundled into a single ESM file via esbuild and served at - `/__webjs/vendor/.js`. The import map is populated automatically. +5. **Auto-vendor via jspm.io (Rails 7 + importmap-rails posture).** At + startup the server scans client-reachable source for bare npm import + specifiers. Each `pkg@version` is resolved through `api.jspm.io/generate` + to a CDN URL (`https://ga.jspm.io/npm:@/...`) and added + to the import map; the browser fetches each package directly from + the CDN. `webjs vendor pin` commits the resolved URLs + SHA-384 + integrity hashes to `.webjs/vendor/importmap.json` for reproducible + deploys; `webjs vendor pin --download` also caches the bundle bytes + locally under `.webjs/vendor/@.js` for air-gapped / + strict-CSP deployments. No bundler runs at any point. ## No-build production model @@ -53,8 +59,15 @@ production. The Rails 7+ / Hotwire pattern: - **Importmap-driven**: bare-specifier imports (`from "react"`) are resolved via ` - + - - + - `; + * + * and the same source file is safe to import from the browser (where + * `cspNonce()` evaluates to '' and the attribute becomes empty, + * which the browser ignores). Layouts and pages MUST load on the + * browser so that side-effect component imports register custom + * elements; that constraint is what forces this isomorphic shape. + */ + +/** @type {(() => string) | null} */ +let _provider = null; + +/** + * Internal: server-only wiring. `@webjsdev/server`'s context module + * calls this once at load time to install the actual nonce reader. + * Browser builds never call it, so cspNonce stays at its default ''. + * + * @param {() => string} fn + */ +export function setCspNonceProvider(fn) { + _provider = fn; +} + +/** + * The runtime function. Returns the nonce from the current request, + * or '' if no provider is set (browser) or no nonce is in scope + * (no CSP, request without nonce, etc.). + * + * @returns {string} + */ +export function cspNonce() { + if (!_provider) return ''; + try { + return _provider() || ''; + } catch { + return ''; + } +} diff --git a/packages/core/src/render-server.js b/packages/core/src/render-server.js index 64ccb91c..b6525fe3 100644 --- a/packages/core/src/render-server.js +++ b/packages/core/src/render-server.js @@ -6,6 +6,7 @@ import { isRepeat } from './repeat.js'; import { isSuspense } from './suspense.js'; import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js'; import { stringify, parse } from './serialize.js'; +import { cspNonce } from './csp-nonce.js'; /** * Render a TemplateResult (or any renderable value) to an HTML string. @@ -1100,6 +1101,14 @@ async function streamTemplate(tr, ctx, controller) { * @param {ReadableStreamDefaultController} controller */ async function streamSuspenseBoundaries(ctx, controller) { + // Resolve the per-request nonce once per call. The provider in + // @webjsdev/server sources it from AsyncLocalStorage; outside a + // request scope (or in the browser) the helper returns '' and we + // emit the script unnonced, which is fine on documents not under + // strict CSP and matches the no-nonce case for the rest of the + // SSR pipeline. + const nonce = cspNonce(); + const nonceAttr = nonce ? ` nonce="${escapeAttr(nonce)}"` : ''; while (ctx.pending.length) { const batch = ctx.pending.splice(0); await Promise.all( @@ -1110,7 +1119,7 @@ async function streamSuspenseBoundaries(ctx, controller) { const full = await injectDSD(html, ctx); controller.enqueue( `` + - `'; + _addNewHead(newHead); + const added = document.head.querySelector('script[src="/added.js"]'); + assert.ok(added, 'script should be added'); + // Browser's CSP cache holds the FIRST page-load nonce, so the new + // script must carry that one (not the per-request nonce that came + // with the fetched head fragment). + assert.equal(added.getAttribute('nonce'), 'original-page-nonce', + 'dynamic script nonce must match the page-load meta tag, not the source-page nonce'); +}); + +test('addNewHeadElements: head diff ignores per-request nonce differences (no spurious re-add)', () => { + // Same script src, same content, but differs only in nonce attribute. + // Without nonce-aware diff, the current page's script would not match + // the new page's, and the new page's would be appended every nav. + document.head.innerHTML = + ''; + const newHead = document.createElement('head'); + newHead.innerHTML = + ''; + const before = document.head.querySelectorAll('script[src="/x.js"]').length; + _addNewHead(newHead); + const after = document.head.querySelectorAll('script[src="/x.js"]').length; + assert.equal(after, before, + 'nonce-only difference must not trigger re-add (would duplicate the script every nav)'); +}); + /* ==================================================================== * mergeHead: full-merge head (used on full body swap) * ==================================================================== */ @@ -445,6 +491,51 @@ test('mergeHead: re-creates script elements so they execute', () => { assert.equal(added.getAttribute('type'), 'module'); }); +test('mergeHead: applies meta csp-nonce to created scripts (replaces source nonce)', () => { + // Same Turbo pattern as addNewHeadElements but exercised through + // the full-merge code path. Meta is in the current head BEFORE + // mergeHead runs; the new head is what we navigate to. + document.head.innerHTML = ''; + const newHead = document.createElement('head'); + newHead.innerHTML = + '' + + ''; + _merge(newHead); + const added = document.head.querySelector('script[src="/m.js"]'); + assert.ok(added, 'script added'); + assert.equal(added.getAttribute('nonce'), 'page-nonce', + 'mergeHead must apply the meta nonce, not the source-page nonce'); +}); + +test('addNewHeadElements + mergeHead: nonce-only diff on tags does not duplicate preloads', () => { + // Browsers gate cross-origin modulepreload by script-src nonce, so + // preload links also carry per-request nonces after the recent CSP + // fix. Without nonce-aware diff, every nav would re-append the + // same preload because the nonce differs. + document.head.innerHTML = + ''; + const newHead = document.createElement('head'); + newHead.innerHTML = + ''; + _addNewHead(newHead); + const links = document.head.querySelectorAll('link[rel="modulepreload"][href="https://cdn.example/x.js"]'); + assert.equal(links.length, 1, 'no duplicate preload after nonce-only diff'); +}); + +test('reactivateScripts: applies meta csp-nonce to re-emitted body scripts', () => { + // After a full body swap, reactivateScripts walks body scripts and + // re-creates them so the browser executes them. Each created + // script must carry the meta nonce, not whatever was in the new + // page's source. + document.head.innerHTML = ''; + document.body.innerHTML = ''; + _reactivateScripts(document.body); + const s = document.body.querySelector('script'); + assert.ok(s, 'script reactivated'); + assert.equal(s.getAttribute('nonce'), 'body-nonce', + 'reactivated body scripts must carry the meta nonce, not the source nonce'); +}); + /* ==================================================================== * isNonHtmlPath * ==================================================================== */ @@ -482,7 +573,7 @@ test('isNonHtmlPath: does NOT skip normal page paths', () => { * navigate: Content-Type guard + fallback paths * ==================================================================== */ -function installNavigationMocks({ contentType, body = '', ok = true, captureHeaders = false }) { +function installNavigationMocks({ contentType, body = '', ok = true, captureHeaders = false, responseHeaders = {} }) { const originalFetch = globalThis.fetch; const originalLocation = globalThis.location; const originalHistory = globalThis.history; @@ -492,14 +583,31 @@ function installNavigationMocks({ contentType, body = '', ok = true, captureHead /** @type {{ url: string | null, headers: Record | null }} */ const captured = { url: null, headers: null }; + // Caller may pass `body` and `responseHeaders` as a function so the + // mock returns a different shape per call (used to chain navigations + // against different mismatched importmaps). + const bodyFn = typeof body === 'function' ? body : () => body; + const headersFn = typeof responseHeaders === 'function' ? responseHeaders : () => responseHeaders; + globalThis.fetch = async (url, init) => { captured.url = String(url); captured.headers = init && init.headers ? { ...init.headers } : null; + // Normalize all response-header keys to lowercase so headers.get + // (which itself lowercases its argument per Fetch spec) finds + // them regardless of how the test author cased the input. Without + // this, a test passing { 'X-Webjs-Build': 'foo' } would silently + // see headers.get('x-webjs-build') return null. + const raw = { 'content-type': contentType, ...headersFn() }; + /** @type {Record} */ + const respHeaders = {}; + for (const [k, v] of Object.entries(raw)) { + if (v != null) respHeaders[String(k).toLowerCase()] = String(v); + } return { ok, status: ok ? 200 : 500, - headers: { get: (k) => (k.toLowerCase() === 'content-type' ? contentType : null) }, - text: async () => body, + headers: { get: (k) => respHeaders[String(k).toLowerCase()] ?? null }, + text: async () => bodyFn(), }; }; @@ -598,6 +706,274 @@ test('navigate: cross-origin URL delegates to location.href (no fetch)', async ( } finally { restore(); } }); +test('navigate: importmap mismatch triggers full-page reload (no partial swap)', async () => { + // After a deploy that bumped a vendor pin, current-tab nav must + // fall back to a full page load. The new page expects the new + // module URLs (and new SRI hashes); partial swap leaves the old + // importmap in place and silently breaks module resolution. + // Mirrors Turbo's tracked_element_mismatch reload behavior. + document.head.innerHTML = ''; + document.body.innerHTML = '

current

'; + const newBody = + '' + + '' + + '

after deploy

'; + const { redirect, restore } = installNavigationMocks({ contentType: 'text/html', body: newBody }); + try { + await navigate('http://localhost/posts/123'); + // Hard reload should fire; partial swap must NOT run. + assert.equal(redirect.href, 'http://localhost/posts/123', + 'mismatched importmap must trigger full reload to the target URL'); + // The current document.body must NOT have been swapped. + assert.equal(document.body.querySelector('p')?.textContent, 'current', + 'partial swap must have been aborted'); + } finally { restore(); } +}); + +test('navigate: identical importmap proceeds with partial swap (no reload)', async () => { + const map = '{"imports":{"dayjs":"https://ga.jspm.io/npm:dayjs@1.11.13/index.js"}}'; + document.head.innerHTML = ``; + document.body.innerHTML = '

current

'; + const newBody = + `` + + `

new

`; + const { redirect, restore } = installNavigationMocks({ contentType: 'text/html', body: newBody }); + try { + await navigate('http://localhost/about'); + // No hard reload: redirect.assigns should not include the target. + assert.ok(!redirect.assigns.includes('http://localhost/about'), + 'identical importmap must NOT trigger reload; expected partial swap'); + } finally { restore(); } +}); + +test('navigate: response-header lookup is case-insensitive (mock contract)', async () => { + // The Fetch spec says Headers.get() is case-insensitive. Our mock + // normalizes to lowercase so a test passing `X-Webjs-Build` in any + // casing reaches the production code that calls + // `resp.headers.get('x-webjs-build')`. + document.head.innerHTML = ''; + document.body.innerHTML = '

current

'; + sessionStorage.removeItem('webjs:importmap-reload'); + const { redirect, restore } = installNavigationMocks({ + contentType: 'text/html', + body: '

x

', + responseHeaders: { 'X-Webjs-Build': 'B' }, // intentionally mixed case + }); + try { + await navigate('http://localhost/case'); + assert.equal(redirect.href, 'http://localhost/case', + 'mixed-case X-Webjs-Build must still be found by lowercase lookup'); + } finally { + restore(); + sessionStorage.removeItem('webjs:importmap-reload'); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + } +}); + +test('navigate: importmap drift detected via X-Webjs-Build header on partial response', async () => { + // Partial-response navs (the X-Webjs-Have optimization) carry only + // the inner body, no head. Without the X-Webjs-Build header the + // client has nothing to compare against and would silently apply + // a stale importmap. With the header, the server-side hash is + // sufficient to detect drift even when the body has no importmap. + document.head.innerHTML = ''; + document.body.innerHTML = '

current

'; + // Simulate a partial response: just the inner body fragment, no + // , no importmap tag. + const partialBody = '

after deploy

'; + sessionStorage.removeItem('webjs:importmap-reload'); + const { redirect, restore } = installNavigationMocks({ + contentType: 'text/html', + body: partialBody, + responseHeaders: { 'x-webjs-build': 'NEWHASH' }, + }); + try { + await navigate('http://localhost/posts/123'); + assert.equal(redirect.href, 'http://localhost/posts/123', + 'partial response with different X-Webjs-Build must trigger reload'); + // The current document.body must NOT have been swapped. + assert.equal(document.body.querySelector('p')?.textContent, 'current', + 'partial swap must have been aborted'); + } finally { + restore(); + sessionStorage.removeItem('webjs:importmap-reload'); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + } +}); + +test('navigate: matching X-Webjs-Build proceeds with partial swap (no reload)', async () => { + document.head.innerHTML = ''; + document.body.innerHTML = '

current

'; + sessionStorage.removeItem('webjs:importmap-reload'); + const { redirect, restore } = installNavigationMocks({ + contentType: 'text/html', + body: '

after nav

', + responseHeaders: { 'x-webjs-build': 'SAMEHASH' }, + }); + try { + await navigate('http://localhost/about'); + assert.ok(!redirect.assigns.includes('http://localhost/about'), + 'matching X-Webjs-Build must NOT trigger reload'); + } finally { + restore(); + sessionStorage.removeItem('webjs:importmap-reload'); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + } +}); + +test('navigate: two consecutive importmap mismatches → second falls through (infinite-reload guard)', async () => { + // The reload-guard sessionStorage flag prevents an infinite reload + // loop if the importmap genuinely changes on every nav (live pin + // editing in dev, etc). + document.head.innerHTML = ''; + document.body.innerHTML = '

current

'; + sessionStorage.removeItem('webjs:importmap-reload'); + let buildVersion = 1; + const { redirect, restore } = installNavigationMocks({ + contentType: 'text/html', + body: '

partial

', + // Each call returns a different x-webjs-build, simulating churn. + responseHeaders: () => ({ 'x-webjs-build': `HASH${buildVersion++}` }), + }); + try { + await navigate('http://localhost/first'); + assert.equal(redirect.href, 'http://localhost/first', + 'first mismatch must reload'); + assert.equal(sessionStorage.getItem('webjs:importmap-reload'), '1', + 'reload flag must be set after first reload'); + // Second consecutive mismatch (same tab, no clean swap in between): + // guard must fall through to the partial swap. + redirect.href = null; + await navigate('http://localhost/second'); + assert.equal(redirect.href, null, + 'second consecutive mismatch must NOT reload (infinite-loop guard)'); + assert.equal(sessionStorage.getItem('webjs:importmap-reload'), null, + 'flag is cleared by the guard after the second mismatch'); + } finally { + restore(); + sessionStorage.removeItem('webjs:importmap-reload'); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + } +}); + +test('popstate cache restore clears the importmap-reload flag', async () => { + // The bug: the reload-flag clear was nested inside + // `if (href && !frameId && !revalidating)` so cache restores + // (revalidating=true, href=null) never cleared the flag. After + // "reload due to deploy → Back to a cached page", the flag would + // stay set, suppressing the next legitimate reload. Fix moves the + // clear to ANY clean swap including revalidation. This test: + // pre-set the flag, popstate to a cached URL, verify cleared. + const origLoc = globalThis.location; + const origFetch = globalThis.fetch; + const prevPageUrl = _currentPageUrl(); + sessionStorage.setItem('webjs:importmap-reload', '1'); + _snapshotCache.set('/cached-here', { + html: 'cached', + scrollX: 0, + scrollY: 0, + }); + globalThis.location = /** @type any */ ({ + href: 'http://localhost/cached-here', + pathname: '/cached-here', + origin: 'http://localhost', + search: '', + hash: '', + }); + _setCurrentPageUrl('http://localhost/elsewhere'); + globalThis.fetch = async () => new Response('', { + status: 200, headers: { 'content-type': 'text/html' }, + }); + const origScrollTo = globalThis.window?.scrollTo; + if (globalThis.window) globalThis.window.scrollTo = () => {}; + document.head.innerHTML = ''; + document.body.innerHTML = 'before-pop'; + try { + // Synchronous assertion: _onPopState calls performNavigation + // which runs synchronously until its first await. For a cache- + // hit popstate, the entire body up to and including the + // cache-restore applySwap and the (un-awaited) background + // revalidation kickoff runs sync. So immediately after + // _onPopState returns, the cache-restore applySwap has run + // BUT the background revalidation's own applySwap (which would + // also clear the flag via the no-mismatch path) has not. This + // isolates the test to the cache-restore clear specifically. + _onPopState({}); + assert.equal(sessionStorage.getItem('webjs:importmap-reload'), null, + 'cache restore (revalidating=true) MUST clear the reload flag SYNCHRONOUSLY'); + // Let the background revalidation finish (avoid unhandled rejection). + await new Promise((r) => setTimeout(r, 5)); + } finally { + _snapshotCache.delete('/cached-here'); + _setCurrentPageUrl(prevPageUrl); + globalThis.location = origLoc; + globalThis.fetch = origFetch; + if (globalThis.window) globalThis.window.scrollTo = origScrollTo; + sessionStorage.removeItem('webjs:importmap-reload'); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + } +}); + +test('navigate: clean swap clears reload flag so a later mismatch reloads again', async () => { + // After "reload due to mismatch → clean nav → later mismatch", the + // later mismatch must trigger its own fresh reload. Regression for + // the bug where the flag stayed set across the clean nav and + // suppressed the legitimate later reload. + sessionStorage.removeItem('webjs:importmap-reload'); + document.head.innerHTML = ''; + document.body.innerHTML = '

current

'; + + // Step 1: mismatch → reload, flag set. + let mocks = installNavigationMocks({ + contentType: 'text/html', + body: '

partial

', + responseHeaders: { 'x-webjs-build': 'HASH2' }, + }); + try { + await navigate('http://localhost/step1'); + assert.equal(mocks.redirect.href, 'http://localhost/step1'); + assert.equal(sessionStorage.getItem('webjs:importmap-reload'), '1'); + } finally { mocks.restore(); } + + // Step 2: clean swap (matching build → no reload). Flag should be cleared. + document.head.innerHTML = ''; + mocks = installNavigationMocks({ + contentType: 'text/html', + body: '

clean

', + responseHeaders: { 'x-webjs-build': 'HASH2' }, + }); + try { + await navigate('http://localhost/step2'); + assert.ok(!mocks.redirect.assigns.includes('http://localhost/step2'), + 'matching build must NOT reload'); + assert.equal(sessionStorage.getItem('webjs:importmap-reload'), null, + 'clean swap MUST clear the reload flag (the bug fixed in this commit)'); + } finally { mocks.restore(); } + + // Step 3: another mismatch (e.g. a second deploy) → fresh reload. + mocks = installNavigationMocks({ + contentType: 'text/html', + body: '

partial2

', + responseHeaders: { 'x-webjs-build': 'HASH3' }, + }); + try { + await navigate('http://localhost/step3'); + assert.equal(mocks.redirect.href, 'http://localhost/step3', + 'a later mismatch after a clean nav must reload again'); + } finally { + mocks.restore(); + sessionStorage.removeItem('webjs:importmap-reload'); + // Reset document state so later tests don't inherit our importmap. + document.head.innerHTML = ''; + document.body.innerHTML = ''; + } +}); + test('navigate: fetch rejection falls back to full page navigation', async () => { const originalFetch = globalThis.fetch; const originalLocation = globalThis.location; @@ -1350,7 +1726,7 @@ function captureWarn(fn) { return calls; } -test('addNewHeadElements: warns when incoming importmap differs from current', () => { +test('addNewHeadElements: skips incoming importmap (importmap-mismatch reload handled by applySwap)', () => { document.head.innerHTML = ''; const newHead = new globalThis.DOMParser().parseFromString( '', @@ -1358,32 +1734,13 @@ test('addNewHeadElements: warns when incoming importmap differs from current', ( ).head; const warnings = captureWarn(() => _addNewHead(newHead)); - assert.equal(warnings.length, 1, 'one warning emitted'); - assert.match(warnings[0], /importmap/, 'warning mentions importmap'); -}); - -test('addNewHeadElements: silent when incoming importmap matches current', () => { - const map = '{"imports":{"a":"/a.js"}}'; - document.head.innerHTML = ``; - const newHead = new globalThis.DOMParser().parseFromString( - ``, - 'text/html' - ).head; - - const warnings = captureWarn(() => _addNewHead(newHead)); - assert.equal(warnings.length, 0, 'no warning when importmaps are identical'); -}); - -test('addNewHeadElements: silent when current page has no importmap', () => { - document.head.innerHTML = ''; - const newHead = new globalThis.DOMParser().parseFromString( - '', - 'text/html' - ).head; - - const warnings = captureWarn(() => _addNewHead(newHead)); - assert.equal(warnings.length, 0, - "no current importmap to conflict with: silent (the new map still won't be injected, but that's separate)"); + // No console.warn now. Mismatch triggers a full-page reload at + // applySwap's entry; if execution reaches here, the maps are + // identical or there's no current map yet. + assert.equal(warnings.length, 0, 'addNewHeadElements no longer warns'); + // Importmap not added to current head (immutable; current wins). + const maps = document.head.querySelectorAll('script[type="importmap"]'); + assert.equal(maps.length, 1, 'only the original importmap remains in head'); }); /* ==================================================================== diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index a7fc6035..a596b74e 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -30,7 +30,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | File | What it owns | |---|---| -| `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package, with an esbuild fallback for non-erasable syntax), **server-file guardrail**, live reload via SSE | +| `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE | | `router.js` | Scans `app/` once, builds the route table, matches pages + APIs (`buildRouteTable`, `matchPage`, `matchApi`) | | `ssr.js` | SSR pipeline: nested layouts, metadata → ``, Suspense streaming, error boundaries | | `actions.js` | `.server.js` / `.server.ts` scanner. Generates RPC stubs for browser-bound imports; exposes RPC endpoints; honours `expose()` | @@ -46,8 +46,8 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `context.js` | AsyncLocalStorage per-request context (`getRequest`, `withRequest`, `headers`, `cookies`) | | `serializer.js` | Default serializer + `setSerializer` / `getSerializer` for the RPC wire format | | `json.js` | `json()` + `readBody()` content-negotiation helpers | -| `check.js` | Convention validator backing `webjs check`. New rule: `no-json-data-files` | -| `vendor.js` | Auto-bundle bare-specifier npm deps for the browser | +| `check.js` | Convention validator backing `webjs check`. Rules include `no-json-data-files`, `no-non-erasable-typescript` | +| `vendor.js` | Resolve bare-specifier npm deps via jspm.io. Reads `.webjs/vendor/importmap.json` if present (committed pin file), else calls `api.jspm.io/generate` at boot. `--download` mode also serves cached bundle files from `.webjs/vendor/` | | `module-graph.js` | Dependency graph for transitive preload hints | | `importmap.js` | Browser import-map builder | | `component-scanner.js` | Maps every webjs component class to its browser-visible URL | diff --git a/packages/server/README.md b/packages/server/README.md index f85838b8..6d2d2766 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -18,8 +18,13 @@ to scaffold and run an app, which pulls this package in as a dependency. - **WebSockets**: export `WS` from `route.ts` and it becomes a WebSocket endpoint on the same path. - **Live reload** for dev. -- **Bare-specifier auto-bundling** for npm packages via import maps, backed - by esbuild (Vite-style `optimizeDeps`). +- **Bare-specifier resolution** for npm packages via import maps, + resolved through jspm.io at runtime (Rails 7 + importmap-rails + posture). Browser fetches bundles directly from `ga.jspm.io` CDN; + webjs's server does not bundle vendor packages. Run `webjs vendor + pin` to commit resolved URLs to `.webjs/vendor/importmap.json` + (deterministic deploys), or `--download` to additionally vendor + bundle bytes for offline-capable production. ## Install diff --git a/packages/server/index.js b/packages/server/index.js index b0662935..2bb47c76 100644 --- a/packages/server/index.js +++ b/packages/server/index.js @@ -11,10 +11,23 @@ export { invokeAction, } from './src/actions.js'; export { buildImportMap, importMapTag, setVendorEntries } from './src/importmap.js'; -export { scanBareImports, extractPackageName, bundlePackage, vendorImportMapEntries, clearVendorCache, serveVendorBundle } from './src/vendor.js'; +export { + scanBareImports, + extractPackageName, + vendorImportMapEntries, + resolveVendorImports, + clearVendorCache, + getPackageVersion, + jspmGenerate, + pinAll, + unpinPackage, + listPinned, + readPinFile, + serveDownloadedBundle, +} from './src/vendor.js'; export { buildModuleGraph, transitiveDeps } from './src/module-graph.js'; export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js'; -export { headers, cookies, getRequest, withRequest } from './src/context.js'; +export { headers, cookies, getRequest, withRequest, cspNonce } from './src/context.js'; export { defaultLogger } from './src/logger.js'; export { rateLimit, parseWindow } from './src/rate-limit.js'; export { memoryStore, redisStore, getStore, setStore } from './src/cache.js'; diff --git a/packages/server/package.json b/packages/server/package.json index 643a353c..e0a91abc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -16,7 +16,6 @@ "dependencies": { "@webjsdev/core": "^0.7.1", "chokidar": "^3.6.0", - "esbuild": "^0.28.0", "ws": "^8.20.0" }, "publishConfig": { diff --git a/packages/server/src/cache.js b/packages/server/src/cache.js index 20d34e72..efc30716 100644 --- a/packages/server/src/cache.js +++ b/packages/server/src/cache.js @@ -47,6 +47,17 @@ export function memoryStore(opts = {}) { return entry.expiresAt !== null && Date.now() > entry.expiresAt; } + // Only a finite, positive ttlMs sets an expiration. NaN, Infinity, + // 0, negative, or non-number all fall back to "no TTL" (null). + // Without this, NaN slips past the truthiness check and entries + // silently live forever, which masks bugs in caller code that + // computes ttl from arithmetic. + function expiresAtFrom(ttlMs) { + return typeof ttlMs === 'number' && Number.isFinite(ttlMs) && ttlMs > 0 + ? Date.now() + ttlMs + : null; + } + return { async get(key) { const entry = map.get(key); @@ -61,7 +72,7 @@ export function memoryStore(opts = {}) { map.delete(key); // remove old position map.set(key, { value, - expiresAt: ttlMs ? Date.now() + ttlMs : null, + expiresAt: expiresAtFrom(ttlMs), }); evict(); }, @@ -73,12 +84,18 @@ export function memoryStore(opts = {}) { if (!entry || isExpired(entry)) { map.set(key, { value: '1', - expiresAt: ttlMs ? Date.now() + ttlMs : null, + expiresAt: expiresAtFrom(ttlMs), }); return 1; } const next = parseInt(entry.value, 10) + 1; + // Mutate value + re-insert so the bumped key counts as recent + // for LRU eviction. Without the re-insert, a hot rate-limit + // bucket stays at its original position and gets evicted ahead + // of less-active keys. entry.value = String(next); + map.delete(key); + map.set(key, entry); return next; }, }; diff --git a/packages/server/src/check.js b/packages/server/src/check.js index a0816a62..9a6b8b04 100644 --- a/packages/server/src/check.js +++ b/packages/server/src/check.js @@ -92,13 +92,23 @@ export const RULES = [ { name: 'erasable-typescript-only', description: - 'Apps must opt into TypeScript\'s `erasableSyntaxOnly: true` so the compiler rejects non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators with emitDecoratorMetadata, import = require) at edit time. webjs strips types via Node\'s built-in `module.stripTypeScriptTypes`, which only supports erasable TypeScript and produces byte-exact position preservation (no sourcemap overhead). Files using non-erasable syntax fall back to esbuild + inline sourcemap, which is supported as a safety net for third-party deps but should not be the path your own code takes. The rule checks the project\'s tsconfig.json and warns when `erasableSyntaxOnly` is missing or set to false. Set `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json to comply.', + 'Apps must opt into TypeScript\'s `erasableSyntaxOnly: true` so the compiler rejects non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators with emitDecoratorMetadata, import = require) at edit time. webjs strips types via Node\'s built-in `module.stripTypeScriptTypes`, which only supports erasable TypeScript and produces byte-exact position preservation (no sourcemap overhead). Files using non-erasable syntax fail at strip time and the dev server returns a 500 pointing at the no-non-erasable-typescript rule; webjs is buildless end-to-end and has no bundler fallback. The rule checks the project\'s tsconfig.json and warns when `erasableSyntaxOnly` is missing or set to false. Set `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json to comply.', }, { name: 'use-server-needs-extension', description: 'Files that declare the `\'use server\'` directive at the top must also have the `.server.{js,ts,mts,mjs}` extension. The two markers are complementary, not interchangeable: `.server.ts` is the path-level boundary that triggers source protection by the file router; `\'use server\'` is the semantic opt-in that registers exports as RPC-callable from client code. A `\'use server\'` directive without the extension is silently ignored: the file is served to the browser as plain source, exports are NOT registered as RPC, and code the developer expects to run on the server actually runs in the browser. Rename the file to add the `.server.` infix.', }, + { + name: 'no-non-erasable-typescript', + description: + 'Scans .ts / .mts source for the four non-erasable TypeScript constructs (enum declarations, namespace blocks with value statements, constructor parameter properties, and `import = require`) that the framework\'s type-stripper rejects at request time. Companion to `erasable-typescript-only`: that rule checks the tsconfig flag, this rule checks the actual source. Both run by default so the flag check catches violations early in the editor while the source scan catches violations even if the tsconfig flag is missing or the rule is bypassed. Skips node_modules, dist, build, .git, .next, and _private folders.', + }, + { + name: 'gitignore-vendor-not-ignored', + description: + 'Verifies the `.gitignore` exception for `.webjs/vendor/` is structurally correct via `git check-ignore`. The intended pattern is `.webjs/*` (NOT `.webjs/`) plus `!.webjs/vendor/` plus `!.webjs/vendor/**`. The common-looking pattern `.webjs/` excludes the directory itself, after which git cannot re-include children (gitignore semantics: a parent exclusion blocks child negations). Without this rule, an AI agent or human editor would silently break `webjs vendor pin` by simplifying the pattern; the failure is invisible until production. Rule fires when the working directory is a git repo and a `.gitignore` exists; skipped when neither is true.', + }, ]; /** Set of all known rule names for fast lookup. */ @@ -737,8 +747,8 @@ export async function checkConventions(appDir, opts) { violations.push({ rule: 'no-json-data-files', file: s.rel, - message: `${s.why}. webjs apps must persist data with Prisma + SQLite (already wired up: see prisma/schema.prisma and lib/prisma.ts), not JSON files.`, - fix: `Define a Prisma model in prisma/schema.prisma for this data, run \`webjs db migrate \` to create the migration, then read/write via \`import { prisma } from 'lib/prisma.ts'\`. Delete ${s.rel} once the data has moved.`, + message: `${s.why}. webjs apps must persist data with Prisma + SQLite (already wired up: see prisma/schema.prisma and lib/prisma.server.ts), not JSON files.`, + fix: `Define a Prisma model in prisma/schema.prisma for this data, run \`webjs db migrate \` to create the migration, then read/write via \`import { prisma } from 'lib/prisma.server.ts'\`. Delete ${s.rel} once the data has moved.`, }); } } @@ -781,15 +791,16 @@ export async function checkConventions(appDir, opts) { } // --- Rule: erasable-typescript-only --- - // The dev server's primary type-stripper is Node's built-in + // The dev server's type-stripper is Node's built-in // module.stripTypeScriptTypes, which rejects non-erasable TS (enum, // namespace with values, constructor parameter properties, legacy - // decorators, `import = require`). The fallback path is esbuild + - // inline sourcemap, which is a real ~3x wire-byte hit on every .ts - // request that takes it. Enforce TS-side rejection of those patterns - // via `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json so - // violations surface as red squiggles in the editor before they ever - // hit the dev server. + // decorators, `import = require`). There is no fallback: non-erasable + // syntax is rejected at request time with a 500. Enforce TS-side + // rejection of those patterns via `compilerOptions.erasableSyntaxOnly: + // true` in tsconfig.json so violations surface as red squiggles in + // the editor before they ever hit the dev server. The companion + // no-non-erasable-typescript rule (below) catches violations even if + // the tsconfig flag is unset. if (isRuleEnabled('erasable-typescript-only', overrides)) { let tsconfigContent = null; try { @@ -816,8 +827,8 @@ export async function checkConventions(appDir, opts) { file: 'tsconfig.json', message: flag === false - ? '`compilerOptions.erasableSyntaxOnly` is `false`. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators) falls back to esbuild + inline sourcemap on every request, costing ~3x wire bytes and losing byte-exact stack-trace positions.' - : '`compilerOptions.erasableSyntaxOnly` is not set. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Setting this flag makes the TypeScript compiler flag non-erasable syntax as a red squiggle in the editor instead of letting it silently slip through to a slower runtime fallback.', + ? '`compilerOptions.erasableSyntaxOnly` is `false`. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators) fails at strip time and the dev server returns a 500. webjs is buildless end-to-end and has no bundler fallback; turn the flag on so the TypeScript compiler catches non-erasable constructs as red squiggles at edit time.' + : '`compilerOptions.erasableSyntaxOnly` is not set. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Setting this flag makes the TypeScript compiler flag non-erasable syntax as a red squiggle in the editor instead of letting it silently slip through to a 500 at runtime.', fix: 'Set `"erasableSyntaxOnly": true` under `compilerOptions` in tsconfig.json. Replace any existing `enum` declarations with `const X = { ... } as const` plus a `type X = typeof X[keyof typeof X]` union. Replace constructor parameter properties with explicit field declarations + assignments.', }); @@ -825,6 +836,93 @@ export async function checkConventions(appDir, opts) { } } + // --- Rule: no-non-erasable-typescript --- + // Scans .ts source for the four non-erasable TypeScript constructs + // that the runtime stripper rejects. Complement to + // erasable-typescript-only: the flag check catches the case where + // the user opts into the tsconfig flag; this scan catches the + // case where the flag is missing OR the user has bypassed it and + // written offending syntax anyway. Both rules ship enabled by + // default so violators get the strongest signal possible. + if (isRuleEnabled('no-non-erasable-typescript', overrides)) { + /** @type {Array<{ name: string, regex: RegExp, fix: string }>} */ + const NON_ERASABLE_PATTERNS = [ + { + name: 'enum', + // Matches `enum X {`, `export enum X {`, `const enum X {`, + // `declare enum X {`. Requires uppercase first letter on the + // identifier to avoid matching variables literally named "enum" + // in user code (rare but possible). + regex: /^[ \t]*(?:export[ \t]+)?(?:declare[ \t]+)?(?:const[ \t]+)?enum[ \t]+[A-Z]\w*[ \t]*\{/m, + fix: 'Replace `enum Foo { A, B }` with `const Foo = { A: "A", B: "B" } as const; type Foo = typeof Foo[keyof typeof Foo];`.', + }, + { + name: 'namespace with values', + // Matches `namespace Foo { ... ... }` at top + // level. Type-only namespaces (which ARE erasable) won't contain + // `let|const|var|function|class` as statements, so this catches + // only the value-carrying form. False positives possible for + // type-only namespaces that contain those words in type aliases; + // accept this as a soft warning. + regex: /^[ \t]*(?:export[ \t]+)?namespace[ \t]+\w+[ \t]*\{[\s\S]*?\b(?:let|const|var|function|class)\b/m, + fix: 'Replace `namespace Foo { export const x = 1 }` with `export const Foo = { x: 1 } as const;` or split the contents into separate modules.', + }, + { + name: 'constructor parameter property', + // Matches `constructor(public x: T)`, `constructor(private foo, ...)`, + // `constructor(readonly bar)`. Looks for one of the four access + // modifiers immediately followed by an identifier inside the + // constructor's parameter list. + regex: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/, + fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`. The reactive-props-use-declare rule has the framework-specific shape: `declare x: number;` (no value) plus the assignment in the constructor body.', + }, + { + name: 'import = require', + // TypeScript-style CommonJS import. Catches `import foo = + // require("bar")` and `export import foo = require("bar")`. + regex: /^[ \t]*(?:export[ \t]+)?import[ \t]+\w+[ \t]*=[ \t]*require[ \t]*\(/m, + fix: 'Replace `import foo = require("bar")` with `import * as foo from "bar"` or `import { something } from "bar"`.', + }, + ]; + + // Walk every .ts / .mts file under appDir, skipping node_modules, + // build outputs, version control, and the framework's own private + // folders. Match the conventional excludes that fs-walk.js's caller + // contract expects. + for await (const abs of walk(appDir, (p) => /\.m?ts$/.test(p))) { + // Skip anything inside node_modules or common build / cache dirs. + const relPath = relative(appDir, abs); + if ( + relPath.includes('node_modules' + sep) || + relPath.startsWith('dist' + sep) || + relPath.startsWith('build' + sep) || + relPath.startsWith('.next' + sep) || + relPath.startsWith('.git' + sep) || + relPath.split(sep).some((s) => s.startsWith('_')) + ) { + continue; + } + let content; + try { + content = await readFile(abs, 'utf8'); + } catch { + continue; + } + for (const { name, regex, fix } of NON_ERASABLE_PATTERNS) { + const m = content.match(regex); + if (m && typeof m.index === 'number') { + const line = content.slice(0, m.index).split('\n').length; + violations.push({ + rule: 'no-non-erasable-typescript', + file: relPath, + message: `Non-erasable TypeScript construct (${name}) detected at line ${line}. The framework's type-stripper rejects this at request time with a 500.`, + fix, + }); + } + } + } + } + // --- Rule: use-server-needs-extension --- // Catch files that declare `'use server'` at the top but lack the // `.server.{js,ts}` extension. Under the two-marker convention the @@ -874,5 +972,72 @@ export async function checkConventions(appDir, opts) { } } + // --- Rule: gitignore-vendor-not-ignored --- + // The .gitignore pattern for .webjs/vendor/ is subtle: `.webjs/` + // alone excludes the directory entirely and git can't re-include + // children of an excluded parent. The correct pattern is `.webjs/*` + // plus `!.webjs/vendor/` plus `!.webjs/vendor/**`. AI agents + // and human reviewers frequently "simplify" this back to `.webjs/`, + // silently breaking `webjs vendor pin`. + // + // This rule verifies the actual gitignore behavior by spawning + // `git check-ignore` against a representative pin-file path. If + // git reports the file as ignored, the pattern is broken. + // + // Skipped when the directory isn't a git repo or has no .gitignore + // (the user hasn't opted into version control yet). + if (isRuleEnabled('gitignore-vendor-not-ignored', overrides)) { + const hasGit = await pathExists(join(appDir, '.git')); + const hasGitignore = await pathExists(join(appDir, '.gitignore')); + if (hasGit && hasGitignore) { + const { spawnSync } = await import('node:child_process'); + // 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 ignored, 1 when not ignored. + 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', + }); + if (result.status === 0) { + violations.push({ + rule: 'gitignore-vendor-not-ignored', + file: '.gitignore', + message: + `${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ and they MUST be committed for production deploys to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line in .gitignore that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second possible cause is a broader rule (e.g. \`*.js\` at root) that hides 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' + + 'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).', + }); + } + } + } + } + return violations; } + +/** + * Async fs.exists shim. Returns true if the path exists at all (file + * or directory), false on ENOENT or any other stat failure. + * + * @param {string} p absolute path + * @returns {Promise} + */ +async function pathExists(p) { + try { + await stat(p); + return true; + } catch { + return false; + } +} diff --git a/packages/server/src/context.js b/packages/server/src/context.js index d58bacc1..f9e391f0 100644 --- a/packages/server/src/context.js +++ b/packages/server/src/context.js @@ -1,5 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { parseCookies } from './csrf.js'; +import { setCspNonceProvider, cspNonce } from '@webjsdev/core'; /** * Per-request context backed by AsyncLocalStorage. Lets server-side code @@ -31,6 +32,41 @@ export function getRequest() { return als.getStore()?.req ?? null; } +/** + * Server-only implementation of the CSP nonce reader: pulls the + * current request from AsyncLocalStorage, parses the + * `script-src 'nonce-...'` value from its CSP header, returns '' + * when none in scope. + * + * The public `cspNonce()` function lives in `@webjsdev/core` so user + * layouts / pages can import it without dragging server-only deps + * (node:async_hooks etc.) into browser-loaded modules. The actual + * implementation is wired here, server-side only, via + * `setCspNonceProvider`. On the browser there is no provider, so + * `cspNonce()` returns '' (empty `nonce=""` attribute, browser + * ignores it). + */ +// The regex captures the first `nonce-...` token anywhere in the CSP +// header. Webjs uses a single per-request nonce shared across all +// directives that emit it (the standard CSP3 single-nonce model), +// so reading the first match is correct. If a future caller emits +// styled inline content under a separate style nonce, this reader +// would need to become directive-scoped. Kept identical to the +// matching helper in ssr.js so both paths interpret the header the +// same way. +setCspNonceProvider(() => { + const req = als.getStore()?.req; + if (!req) return ''; + const csp = req.headers.get('content-security-policy') || ''; + const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp); + return match ? match[1] : ''; +}); + +// Re-export for backwards-compat: callers that imported cspNonce from +// @webjsdev/server still work. New code should import from +// @webjsdev/core for browser-isomorphism. +export { cspNonce }; + /** * Read-only headers for the in-flight request. Throws outside a request * (e.g. at module top-level). diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index c254e88d..7dd0f1a7 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -19,7 +19,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; // equivalent built-in, we will need to install `amaro` directly (or // an equivalent: Sucrase preserves lines but not columns; SWC's // strip-only also works). The fast-path `stripTs` helper would -// change one import line; the fallback path (esbuild) stays. +// change one import line. // // Suppress the one-shot ExperimentalWarning that Node prints the // first time `stripTypeScriptTypes` is called. The API is committed @@ -57,7 +57,7 @@ import { import { defaultLogger } from './logger.js'; import { withRequest } from './context.js'; import { attachWebSocket } from './websocket.js'; -import { scanBareImports, vendorImportMapEntries, serveVendorBundle, clearVendorCache } from './vendor.js'; +import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache } from './vendor.js'; import { buildModuleGraph, transitiveDeps } from './module-graph.js'; import { primeComponentRegistry, findOrphanComponents } from './component-scanner.js'; @@ -92,17 +92,16 @@ const MIME = { * Capped at 500 entries to prevent unbounded memory growth in * long-running production servers. * - * Primary stripper: `module.stripTypeScriptTypes` (Node 24+ built-in). + * Stripper: `module.stripTypeScriptTypes` (Node 24+ built-in). * Position-preserving whitespace replacement. No sourcemap is * emitted because every (line, column) maps to itself in the source. * - * Fallback stripper: `esbuild.transform`. Triggered only when the - * primary path throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` (the file - * uses `enum`, `namespace`, parameter properties, or legacy - * decorators). Emits an inline sourcemap so DevTools can still - * resolve source positions for the regenerated JS. Mostly fires for - * third-party `.ts` files; user code is enforced erasable by - * `webjs check`. + * Only erasable TypeScript is supported. Non-erasable syntax (`enum`, + * `namespace` with values, parameter properties, legacy decorators + * with `emitDecoratorMetadata`, `import = require`) throws at strip + * time. The `erasable-typescript-only` and `no-non-erasable-typescript` + * lint rules catch these at edit time. webjs is buildless end-to-end: + * there is no bundler fallback. * * @type {Map} */ @@ -130,7 +129,8 @@ export async function createRequestHandler(opts) { // Scan for bare npm imports and register vendor import map entries. const bareImports = await scanBareImports(appDir); - setVendorEntries(vendorImportMapEntries(bareImports)); + const initialVendor = await resolveVendorImports(bareImports, appDir); + setVendorEntries(initialVendor.imports, initialVendor.integrity); // Build module dependency graph for transitive preload hints. const moduleGraph = await buildModuleGraph(appDir); @@ -164,14 +164,39 @@ export async function createRequestHandler(opts) { moduleGraph, }; + // Rebuilds are serialized so a slow rebuild #1 (e.g. waiting on a + // jspm.io fetch) cannot overwrite a fresher rebuild #2's + // setVendorEntries / route table when it finally finishes. Without + // this, two file edits inside one chokidar debounce window could + // produce a permanently-stale importmap until the next rebuild. + // Each rebuild also gets a monotonic token; setVendorEntries is only + // applied if its token still matches the latest scheduled rebuild. + let rebuildInFlight = Promise.resolve(); + let latestRebuildToken = 0; + async function rebuild() { + const token = ++latestRebuildToken; + rebuildInFlight = rebuildInFlight.then(() => doRebuild(token)).catch((e) => { + logger.error?.(`[webjs] rebuild failed:`, e); + }); + return rebuildInFlight; + } + + async function doRebuild(token) { state.routeTable = await buildRouteTable(appDir); state.actionIndex = await buildActionIndex(appDir, dev); state.middleware = await loadMiddleware(appDir, dev, logger); // Re-scan bare imports and module graph on rebuild clearVendorCache(); state.bareImports = await scanBareImports(appDir); - setVendorEntries(vendorImportMapEntries(state.bareImports)); + const v = await resolveVendorImports(state.bareImports, appDir); + // Defensive: if a newer rebuild has been queued while we were + // awaiting resolveVendorImports, drop our result. The newer one + // will overwrite anyway, but checking the token here avoids a + // brief window of stale entries. + if (token === latestRebuildToken) { + setVendorEntries(v.imports, v.integrity); + } state.moduleGraph = await buildModuleGraph(appDir); // Re-scan components in case a new file was added or a tag renamed. await primeComponentRegistry(appDir); @@ -408,12 +433,31 @@ async function handleCore(req, ctx) { return fileResponse(abs, { dev, immutable: false }); } - // Vendor bundles: /__webjs/vendor/.js: generic auto-bundler - // (Vite-style optimizeDeps) for any bare npm import that webjs can't - // serve directly as ESM. + // Vendor URL handler for `webjs vendor pin --download` mode only. + // In default pin mode (or no-pin mode) the importmap routes bare + // imports straight to ga.jspm.io URLs and the browser bypasses this + // server entirely. When the user ran `webjs vendor pin --download`, + // the importmap has local `/__webjs/vendor/.js` URLs and this + // handler serves the committed bundle files from `.webjs/vendor/`. if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) { - const pkgName = decodeURIComponent(path.slice('/__webjs/vendor/'.length, -'.js'.length)); - return serveVendorBundle(pkgName, appDir, dev); + // Vendor bundles are read-only static content. Allow GET/HEAD for + // the normal fetch, OPTIONS for any cross-origin preflight (we + // return 204 with the same Allow header rather than 405, which + // some intermediaries treat as a hard failure even for a CORS + // probe), and 405 everything else. + if (method === 'OPTIONS') { + return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } }); + } + if (method !== 'GET' && method !== 'HEAD') { + return new Response(null, { status: 405, headers: { allow: 'GET, HEAD, OPTIONS' } }); + } + const filename = path.slice('/__webjs/vendor/'.length); + const resp = await serveDownloadedBundle(filename, appDir, dev); + if (method === 'HEAD') { + // HEAD must return same headers as GET with no body. + return new Response(null, { status: resp.status, headers: resp.headers }); + } + return resp; } // Internal server-action RPC endpoint @@ -450,6 +494,19 @@ async function handleCore(req, ctx) { if (path.startsWith('/public/') || path === '/favicon.ico') { const p = path === '/favicon.ico' ? '/public/favicon.ico' : path; const abs = join(appDir, p); + // Containment check. `join` normalises `..` segments, so a path + // like `/public/%2E%2E/secret/x.svg` decodes (after URL parsing, + // which doesn't touch `%2E`) to `/public/../secret/x.svg` and + // `join(appDir, ...)` resolves it to `appDir/secret/x.svg`. The + // resulting `abs` could be inside `appDir` but OUTSIDE `appDir/ + // public/`, exposing files the user reasonably thought were + // private under their non-public directories. Reject anything + // that doesn't stay under `appDir/public/` (and the favicon + // exception, which is already validated above). + const publicRoot = join(appDir, 'public') + sep; + if (!abs.startsWith(publicRoot)) { + return new Response(null, { status: 404 }); + } if (await exists(abs)) return fileResponse(abs, { dev, immutable: false }); } @@ -499,7 +556,7 @@ async function handleCore(req, ctx) { headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' }, }); } - // TypeScript source: esbuild-strip types, cache by mtime. + // TypeScript source: strip types via Node 24+'s built-in, cache by mtime. if (/\.m?ts$/.test(abs)) { return tsResponse(abs, dev); } @@ -817,38 +874,26 @@ async function exists(p) { } /** - * Strip TypeScript types from `source`, using Node's built-in - * `module.stripTypeScriptTypes` first (whitespace replacement, - * position-preserving, no sourcemap needed) and falling back to - * esbuild for files using non-erasable syntax (`enum`, `namespace`, - * parameter properties, legacy decorators). + * Strip TypeScript types from `source` via Node's built-in + * `module.stripTypeScriptTypes`. Position-preserving whitespace + * replacement: no sourcemap is needed because every (line, column) + * maps to itself in the source. * - * The framework's own code and the user's app code are kept on - * erasable TS by the `erasable-typescript-only` convention check. - * The fallback exists for third-party `.ts` files that the runtime - * occasionally needs to serve. + * Only erasable TypeScript is supported. Non-erasable syntax + * (`enum`, `namespace` with values, parameter properties, legacy + * decorators with `emitDecoratorMetadata`, `import = require`) + * throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` from Node and the + * dev server returns the error to the caller. The + * `erasable-typescript-only` and `no-non-erasable-typescript` lint + * rules catch these at edit time. There is no bundler fallback; + * webjs is buildless end-to-end. * * @param {string} source - * @param {string} abs + * @param {string} _abs (unused; preserved for symmetry with prior signature) * @returns {Promise} */ -async function stripTs(source, abs) { - try { - return stripTypeScriptTypes(source); - } catch (err) { - if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') { - const { transform: esbuild } = await loadEsbuild(); - const r = await esbuild(source, { - loader: 'ts', - format: 'esm', - target: 'es2022', - sourcemap: 'inline', - sourcefile: abs, - }); - return r.code; - } - throw err; - } +async function stripTs(source, _abs) { + return stripTypeScriptTypes(source); } /** @@ -871,7 +916,43 @@ async function tsResponse(abs, dev) { }); } const source = await readFile(abs, 'utf8'); - const code = await stripTs(source, abs); + let code; + try { + code = await stripTs(source, abs); + } catch (err) { + // Node's stripTypeScriptTypes throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX + // for enum, namespace with values, parameter properties, legacy + // decorators with emitDecoratorMetadata, and import = require. + // Return a clean 500 with the file path and a pointer at the + // erasable-typescript-only lint rule rather than letting the + // error bubble up unstyled. + if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') { + // Log full detail server-side regardless of mode so operators + // see what went wrong in their logs. + // eslint-disable-next-line no-console + console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`); + const msg = dev + // Dev: include the file path and Node's error message so the + // developer's browser tooling can point them at the offending + // construct. Replace `*` + `/` with `*\\/` so a path or + // message containing the comment-close sequence cannot + // terminate the wrapper comment early. + ? `[webjs] non-erasable TypeScript in ${abs}: ${err.message}\n\n` + + `webjs is buildless: only erasable TS syntax is supported. ` + + `Replace enum / namespace / parameter-property / legacy-decorator / ` + + `import = require constructs with their erasable equivalents. ` + + `Run \`webjs check\` for guidance (no-non-erasable-typescript rule).` + // Prod: terse, no path leak, no Node-message leak (Node's + // message can include source snippets). Operators get the + // detail in server logs above. + : `[webjs] server error transforming a .ts response. Check server logs.`; + return new Response(`/* ${msg.replace(/\*\//g, '*\\/')} */`, { + status: 500, + headers: { 'content-type': 'application/javascript; charset=utf-8' }, + }); + } + throw err; + } // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order). if (TS_CACHE.size >= TS_CACHE_MAX) { const oldest = TS_CACHE.keys().next().value; @@ -932,20 +1013,6 @@ function locatePackageDir(appDir, pkgName) { return null; } -/** - * Load esbuild. Resolved as a real dependency of `@webjsdev/server`, - * so the bare specifier always resolves regardless of where the cli is - * installed (global, local, workspace-linked). - * - * @returns {Promise} - */ -let _esbuild = null; -async function loadEsbuild() { - if (_esbuild) return _esbuild; - _esbuild = await import('esbuild'); - return _esbuild; -} - const RELOAD_CLIENT_JS = `// webjs dev reload client const es = new EventSource('/__webjs/events'); es.addEventListener('reload', () => location.reload()); diff --git a/packages/server/src/importmap.js b/packages/server/src/importmap.js index 28061533..d2930a11 100644 --- a/packages/server/src/importmap.js +++ b/packages/server/src/importmap.js @@ -1,40 +1,160 @@ +import { createHash } from 'node:crypto'; +import { jsonForScriptTag } from './script-tag-json.js'; + +// Local attribute escaper. Matches ssr.js's escapeAttr (the source +// of truth for HTML attribute escaping in this package). Kept inline +// to avoid a cross-file dependency for one small helper. +function escapeAttr(s) { + return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/} */ let _extraEntries = {}; +/** + * SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite). + * Populated only when a pin file with `integrity` is present; + * live-API mode skips it. + * @type {Record} + */ +let _vendorIntegrity = {}; + /** * Merge additional vendor entries into the import map. * Called by the server after scanning for bare imports. * @param {Record} entries + * @param {Record} [integrity] SRI hashes keyed by URL */ -export function setVendorEntries(entries) { +export function setVendorEntries(entries, integrity) { _extraEntries = entries; + _vendorIntegrity = integrity || {}; + // Bust the importmap-hash cache. Next call to importMapHash() + // recomputes against the new entries. + _importMapHash = ''; +} + +/** + * Stable SHA-256 of the current importmap JSON, used as the + * `data-webjs-build` attribute on ``; +/** + * Serialise the import map to an HTML script tag string. + * + * When `nonce` is provided (extracted from the incoming + * Content-Security-Policy header by ssr.js), it's emitted as + * `nonce="..."` on the script tag. Strict-CSP apps using + * `script-src 'nonce-...'` require this; without it the browser + * blocks the importmap and every bare-specifier import fails. + * + * Defense-in-depth: JSON content is run through `jsonForScriptTag` + * so a string value containing `` (e.g. a maliciously + * crafted vendor URL that somehow slipped past the jspm.io filter) + * cannot close the importmap tag early and inject script content. + * + * @param {{ nonce?: string }} [opts] + */ +export function importMapTag(opts = {}) { + // Full attribute escape, not just `"` to `"`. The nonce arrives + // from the request's CSP header (parsed by ssr.js), which we treat + // as untrusted input even though CSP spec restricts nonce charset to + // base64-ish. A misconfigured upstream emitting `nonce-` should + // not get its `<` rendered raw into our HTML. + const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : ''; + // Stamp the build hash so the client router can detect post-deploy + // importmap changes on intra-shell partial-response navigations. + // See importMapHash() above for the rationale. + const b = ` data-webjs-build="${importMapHash()}"`; + return ``; } diff --git a/packages/server/src/module-graph.js b/packages/server/src/module-graph.js index 70c1a326..a25e551e 100644 --- a/packages/server/src/module-graph.js +++ b/packages/server/src/module-graph.js @@ -12,6 +12,7 @@ */ import { readFile, readdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import { join, resolve, dirname, extname } from 'node:path'; /** @type {RegExp} match static `import … from '…'` and `import '…'` */ @@ -133,9 +134,25 @@ function resolveImport(spec, fromFile, appDir) { } else { target = resolve(base, spec); } - // Exact match check: we can't use async `stat` in a sync resolver, so we - // store the resolved path optimistically. The graph is advisory (for preload - // hints), not load-bearing, so a wrong entry is harmless: the browser will - // just get a redundant preload that 404s and is ignored. + // Sync exact-then-fallback resolution. The graph is advisory (it + // drives preload hints, not module loading), so a wrong entry is + // harmless: the browser just gets a redundant preload that 404s. + // But emitting a working modulepreload when the user wrote + // `import x from './foo'` (no extension) is much better than + // leaving the resolver waterfall to discover it lazily, so probe + // existsSync for the common fallbacks the JSDoc above promises. + if (existsSync(target)) return target; + if (!extname(target)) { + for (const ext of ['.ts', '.js', '.mts', '.mjs']) { + if (existsSync(target + ext)) return target + ext; + } + for (const ext of ['.ts', '.js']) { + const indexed = join(target, 'index' + ext); + if (existsSync(indexed)) return indexed; + } + } + // Optimistic fallback: return the original resolution so the graph + // still has an entry, even though the path may 404 on the browser. + // Matches prior behavior. return target; } diff --git a/packages/server/src/script-tag-json.js b/packages/server/src/script-tag-json.js new file mode 100644 index 00000000..08ef653b --- /dev/null +++ b/packages/server/src/script-tag-json.js @@ -0,0 +1,63 @@ +/** + * JSON serialization safe for interpolation inside an HTML ``; } @@ -646,12 +658,12 @@ function wrapHead(opts) { // the request's CSP header by the caller. const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : ''; - const imports = opts.moduleUrls.map((u) => `import ${JSON.stringify(u)};`).join('\n'); + const imports = opts.moduleUrls.map((u) => `import ${jsonForScriptTag(u)};`).join('\n'); const lazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length ? opts.lazyComponents : null; const lazyBoot = lazyEntries - ? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${JSON.stringify(lazyEntries)});` + ? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${jsonForScriptTag(lazyEntries)});` : ''; const boot = (imports || lazyBoot) ? `` : ''; const reload = opts.dev ? `` : ''; @@ -910,11 +922,33 @@ function wrapHead(opts) { // module, then any custom `metadata.preload` entries (fonts, images, etc.) // (linkTags array was declared earlier so the metadata block above can // push icons / canonical / hreflang / archives / etc. into it.) + // + // Cross-origin URLs (vendor packages served from jspm.io etc.) MUST + // carry `crossorigin="anonymous"` on the preload link. Without it + // the browser either ignores the preload entirely or double-fetches + // (once for the preload as a non-CORS request, once for the actual + // module as a CORS request, defeating the optimization). Same-origin + // URLs get no attribute; adding `crossorigin=""` there would also + // double-fetch in some browsers because the preload becomes CORS + // but the import doesn't. + // CSP nonce on the preload link: under strict CSP (script-src + // 'nonce-...') the browser also gates modulepreload by the same + // policy. Without the attribute the preload is blocked and the + // import either falls back to a cold fetch or fails. Rails (via + // importmap-rails) applies nonce on every modulepreload tag for + // the same reason. + const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : ''; for (const url of opts.moduleUrls) { - linkTags.push(``); + linkTags.push( + ``, + ); } for (const url of opts.preloads || []) { - linkTags.push(``); + linkTags.push( + ``, + ); } if (Array.isArray(m.preload)) { for (const p of m.preload) { @@ -1010,10 +1044,11 @@ function wrapHead(opts) { +${opts.nonce ? `` : ''} ${metaTags.join('\n')} ${escapeHtml(title)} ${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })} -${importMapTag()} +${importMapTag({ nonce: opts.nonce })} ${linkTags.join('\n')} ${boot} ${reload} @@ -1126,12 +1161,15 @@ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appD * @param {URL | undefined} url * @param {Record} [metadata] */ -function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata) { +function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata, nonce) { const encoder = new TextEncoder(); const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' }); // Default: no caching. Pages are dynamic by default: the developer // opts in to caching explicitly via metadata.cacheControl. headers.set('cache-control', metadata?.cacheControl || 'no-store'); + // See htmlResponse: build hash on every response for the client + // router's importmap-mismatch detection on partial swaps. + headers.set('x-webjs-build', importMapHash()); if (req && !readToken(req)) { const secure = url ? url.protocol === 'https:' : false; headers.append('set-cookie', cookieHeader(newToken(), { secure })); @@ -1169,9 +1207,16 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, // Emit just the