diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index a79f216a0..a912c6560 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -116,6 +116,7 @@ Find the right export fast. Load the linked reference for full examples. - `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary. - `Suspense({fallback, children})` page-level streaming; `` component-level streaming. - `optimistic()` optimistic UI; `navigate(url)` / `revalidate(url?)` client-router control; `connectWS` / `richFetch`. +- `asset(path)` content-hashes a `public/` url so a deploy cannot serve stale bytes (`href=${asset('/public/app.css')}`), served `immutable` for a year. Page / layout / metadata route only, inside the render function. See `references/built-ins.md`. - Types: `Metadata`, `PageProps`, `LayoutProps`, `RouteHandlerContext`, `WebjsConfig`. - `@webjsdev/core/server`: `renderToString` / `renderToStream` (Node side). - `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`, `asyncAppend` / `asyncReplace`, `templateContent`. `Task` / `TaskStatus` live at `@webjsdev/core/task`, context (`createContext` / `ContextProvider` / `ContextConsumer`) at `/context`. See `references/components.md` for the directive table + Task + context. diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index e554513b8..275d11ee4 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -82,7 +82,18 @@ export const revalidate = 60; // cache this page's HTML for 60s ### Content-hash asset URLs and conditional GET -Both are automatic, prod-focused, and need no config. In production every served module and `public/` asset gets a per-file `?v=` and `Cache-Control: public, max-age=31536000, immutable`, so a returning client fetches a changed file only when its bytes change. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing). +Both are automatic, prod-focused, and need no config. In production every served MODULE gets a per-file `?v=`, and a `?v=`-carrying request is answered `Cache-Control: public, max-age=31536000, immutable`, so a returning client fetches a changed file only when its bytes change. + +A `public/` asset you reference yourself is opt-in, via `asset()` (#1194): + +```ts +import { html, asset } from '@webjsdev/core'; +html`` +``` + +That emits `/public/app.css?v=` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, which is why this is a convention rather than a check rule. Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched. + +It is opt-in rather than automatic because only the author knows which urls are the REQUEST. Do NOT mark a `rel="preload"` hint whose asset is actually fetched by CSS `url()`: the preload cache is keyed on the full url, so a versioned hint can never satisfy the unversioned request the stylesheet makes, and the file is fetched twice. Mark the thing that fetches, not the hint. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing). **A page's ETag is only useful if the page renders the same bytes twice.** The ETag is a hash of the response body, so any per-render-varying value anywhere in the document defeats it: a `Date.now()`, a `Math.random()`, an id from a module-scope counter (which never resets in a long-lived server), or a CSP nonce. The failure is silent and total. The page renders correctly, every content assertion still passes, the header is still present, and the only symptom is that no `If-None-Match` ever matches, so every revalidation ships the whole document instead of an empty 304. A page under CSP is excluded from the server HTML cache for exactly this reason (the nonce must differ per response). If a page opts into a public `Cache-Control`, guard it with a test that renders the page twice, through its layout, and asserts the two outputs are byte-identical. diff --git a/.agents/skills/webjs/references/service-worker.md b/.agents/skills/webjs/references/service-worker.md index 11bebb7a8..598a1c24d 100644 --- a/.agents/skills/webjs/references/service-worker.md +++ b/.agents/skills/webjs/references/service-worker.md @@ -40,7 +40,9 @@ With WebJs's CSP enabled (`webjs.csp` in package.json), stamp the nonce on the s Registered from `/sw.js`, the worker's scope is the site root (`/`), so it sees every navigation and same-origin asset request. Your worker file lives at `public/sw.js`, and although most `public/*` assets serve at `/public/`, the framework serves this one (and `public/offline.html`) at the SITE ROOT with a `Service-Worker-Allowed: /` header, so `register('/sw.js')` resolves to a 200 and the worker controls the whole origin. - **Navigations are network-first.** The worker tries the network first, so the user sees fresh server-rendered HTML, and it caches each successful page (the SSR shell). When the network fails, it serves the cached page if you have visited it, otherwise `/offline.html`. Network-first means the cache never makes a page go stale; it is purely an offline safety net. -- **Static assets are stale-while-revalidate.** Same-origin modules (the per-file ESM the no-build runtime serves), the framework runtime under `/__webjs/core/`, vendor bundles under `/__webjs/vendor/`, and `public/` assets are served from cache when present and refreshed in the background. In production these URLs carry a `?v=` content fingerprint, so a changed file gets a new URL and the cache can never serve stale bytes. +- **Static assets are stale-while-revalidate.** Same-origin modules (the per-file ESM the no-build runtime serves), the framework runtime under `/__webjs/core/`, vendor bundles under `/__webjs/vendor/`, and `public/` assets are served from cache when present and refreshed in the background. In production the framework-emitted URLs (modules, the core runtime, vendor bundles) carry a `?v=` content fingerprint automatically, so a changed file gets a new URL and the cache cannot serve stale bytes for those. + + A `public/` asset is the exception, and it matters most here. Its URL is fingerprinted only when you mark it with `asset()` (`href=${asset('/public/app.css')}`, see `built-ins.md`). An un-marked `public/` URL is stable across deploys, so the worker keeps serving the CACHED copy and revalidates in the background, which means the first load after a deploy renders the OLD bytes. Do not rely on the worker's cache version to save you: it is derived from the deploy build id, which is a deploy fingerprint rather than a per-file content hash, so a deploy that only changes a `public/` file need not change it. Mark any `public/` asset whose bytes change with `asset()`. **Never cached:** non-GET requests (writes), cross-origin requests, the action RPC endpoint (`/__webjs/action/`), and the dev-only `/__webjs/events` (SSE) and `/__webjs/reload.js`. Keeping writes and RPC off the cache means the worker can never serve a stale mutation result or replay a POST, so correctness is unaffected whether the worker is active or not. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f0ffbde5..25e2b2244 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,16 @@ jobs: # silently vanish on the other. Asserts both directions. - name: SSR comment and raw-text handling on Bun run: bun test/bun/comment-not-an-element.mjs + # asset() url resolution on Bun (#1194): the helper reads the file + # synchronously, hashes it with node:crypto, and compares node:path + # containment to decide whether a url may be fingerprinted at all. A + # divergence would either hand the two runtimes different urls for one + # file (thrashing every client's immutable cache across a mixed fleet) or + # weaken the gate that keeps a private file from being hashed and + # published. Asserts the hash, the fragment split, verdict-independence, + # and the traversal refusals. + - name: asset() url resolution on Bun + run: bun test/bun/asset-url.mjs # Dev hot reload on Bun (#514): start `webjs dev` under Bun, edit a # re-imported route module, and assert the response updates with NO manual # restart. The CLI re-execs under `bun --hot` on Bun (vs `node --watch` on diff --git a/AGENTS.md b/AGENTS.md index faaf9649e..ba72e77e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,7 +199,7 @@ import { html, css, WebComponent, prop, render } from '@webjsdev/core'; import { renderToString } from '@webjsdev/core/server'; ``` -The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server-only modules (`render-server.js`, `setCspNonceProvider`); `renderToString` / `renderToStream` live at `@webjsdev/core/server` for Node-side consumers. +The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server-only modules (`render-server.js`, `setCspNonceProvider`, `setAssetUrlProvider`); `renderToString` / `renderToStream` live at `@webjsdev/core/server` for Node-side consumers. | Export | Purpose | |---|---| @@ -213,6 +213,7 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server | `forbidden()` / `unauthorized()` | Throw to return 403 / 401 (#848, Next 15/16 parity). Renders the nearest `forbidden.{js,ts}` / `unauthorized.{js,ts}` boundary (innermost wins), else a default page, from a page/layout render OR a page `action` (the no-JS write path). `forbidden()` for an authenticated user lacking permission, `unauthorized()` for a request that is not authenticated. Same control-flow-throw model as `notFound()`: NOT for a `route.ts` handler, and inside a `'use server'` RPC action return an `ActionResult` for an auth failure instead (a raw throw there is a generic 500, like `notFound()`/`redirect()`). | | `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. | | `` | Component-level streaming boundary element (#471): wraps one or more components, flushes `.fallback` on the first byte, streams the resolved content in (concurrently across boundaries, progressively on soft nav). The renderer-recognized opt-in for SLOW async-render data. | +| `asset(path)` | Content-hash a `public/` asset url so a deploy cannot serve stale bytes: `href=${asset('/public/app.css')}` emits `?v=` and is served `immutable` for a year (#1194). Prod-only, `public/` paths only. Use it in a PAGE, LAYOUT, or metadata route, not inside a component that ships: hydration re-renders on the client where there is no resolver, so the hashed url is swapped for the bare path and the asset is fetched twice. Call it inside the render function, since a module-scope call is a side effect that ships the module. Mark only files that change with a DEPLOY (the hash is memoized for the process lifetime). Opt-in on purpose: do NOT mark a `rel=preload` hint whose asset is fetched by CSS `url()`, or the preload can never match. | | `connectWS(url, handlers)` / `richFetch` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. | | `navigate(url, opts?)` / `revalidate(url?)` | Programmatic client-router nav; evict the BROWSER snapshot cache. | | `optimistic(signal, value, action)` / `optimistic(host, { source, update })` | Imperative: set `signal` immediately, run `action`, roll back on error. Declarative (preferred): queue optimistic updates with auto-release via `.add(payload, promise?)`. See `references/client-router-and-streaming.md`. | diff --git a/examples/blog/app/layout.ts b/examples/blog/app/layout.ts index 81135cd6d..cab72c7a8 100644 --- a/examples/blog/app/layout.ts +++ b/examples/blog/app/layout.ts @@ -1,4 +1,4 @@ -import { html, cspNonce, type Metadata, type LayoutProps } from '@webjsdev/core'; +import { html, cspNonce, asset, type Metadata, type LayoutProps } from '@webjsdev/core'; import '#components/theme-toggle.ts'; const navLink = (href: string, label: string) => html` @@ -135,7 +135,7 @@ export default function RootLayout({ children }: LayoutProps) { } }); - +