Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/webjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; `<webjs-suspense>` 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<R>`, `LayoutProps<R>`, `RouteHandlerContext<R>`, `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.
Expand Down
13 changes: 12 additions & 1 deletion .agents/skills/webjs/references/built-ins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<hash>` 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=<hash>`, 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`<link rel="stylesheet" href=${asset('/public/app.css')}>`
```

That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, 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.

Expand Down
4 changes: 3 additions & 1 deletion .agents/skills/webjs/references/service-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`, 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=<hash>` 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=<hash>` 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.

Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand All @@ -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. |
| `<webjs-suspense .fallback=${html\`…\`}>` | 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=<hash>` 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<T>` | 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`. |
Expand Down
4 changes: 2 additions & 2 deletions examples/blog/app/layout.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand Down Expand Up @@ -135,7 +135,7 @@ export default function RootLayout({ children }: LayoutProps) {
}
});
</script>
<link rel="stylesheet" href="/public/tailwind.css">
<link rel="stylesheet" href=${asset('/public/tailwind.css')}>
<style>
:root {
color-scheme: light dark;
Expand Down
14 changes: 13 additions & 1 deletion framework-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,14 @@ The purge is zone-wide rather than a path list: all four hostnames (`webjs.dev`,
cmp /tmp/o /tmp/e && echo fresh || echo stale
```

The permanent fix for the assets themselves is a content hash in the url, the way the framework already fingerprints module imports and its own emitted urls (`?v=`, #243). `tailwind.css` and the brand marks are referenced by hand-written urls in `website/app/layout.ts` and `website/lib/design/brand.ts`, which that machinery never sees, so they stay on stable urls and depend on this purge. Fingerprinting them would retire the dependency; the workflow stays worthwhile as the safety net for anything else on a stable url.
The assets that actually caused those incidents no longer depend on this purge. `tailwind.css`, the brand lockups and marks, and the highlight script are marked with `asset()` (#1194) in `website/app/layout.ts`, `website/lib/design/brand.ts`, and `website/app/brand/page.ts` (whose images AND download links are marked, so a designer cannot download the previous drawing while the thumbnail above it shows the new one), so each carries a `?v=<content-hash>` and is served `immutable` for a year. `examples/blog/app/layout.ts` marks its stylesheet for the same reason, since `example-blog.webjs.dev` sits in the same Cloudflare zone. New bytes mean a new url, which no cache can serve stale.

Some urls are deliberately NOT marked, and that is the point of an opt-in helper rather than an automatic rewrite:

- the three **font preloads**, because the real request comes from `@font-face url()` in the compiled stylesheet and CSS `url()` is not rewritten. The preload cache is keyed on the full url, so a versioned hint could never satisfy the unversioned request, and each font would be fetched twice.
- the **favicons**, whose hrefs are parsed literally by the SEO repo-health tests guarding the #1088 size bug, and where fingerprinting buys almost nothing.

The purge workflow therefore STAYS as the safety net for everything still on a stable url (an asset referenced from CSS, a `srcset` candidate, an un-marked path), and it remains the way to clear the edge after a deploy that changes one of those.

---

Expand Down Expand Up @@ -116,6 +123,11 @@ npm runs first; if it fails (auth, network, transient registry error), the GitHu

The workflow uses `NPM_TOKEN` (repo secret) and the auto-provisioned `GITHUB_TOKEN`. Free for public repos.

**When `server` or the scaffold consumes a NEW `@webjsdev/core` export, core MUST publish first.** `packages/server/src/dev.js` and `context.js` import core symbols statically (`setAssetUrlProvider`, `setCspNonceProvider`), and `webjs create` emits an app that imports them too. A server published against an older core dies at module load with `does not provide an export named ...`, and a cli published first makes every freshly scaffolded app 500 on every route. Two things force the right order:

1. **Give `changelog/core/<version>.md` the EARLIEST `date:` of the batch.** The publish loop sorts by that timestamp ASC, and the tie-break on equal timestamps is filename DESC, which would publish `server` BEFORE `core`. The loop is `set -e` sequential, so core-first is also the fail-safe order: if core's publish fails, nothing after it ships and no skew can reach the registry.
2. **Bump the declared range in the same release PR.** `packages/server/package.json` still declares `"@webjsdev/core": "^0.7.1"`, which every published core satisfies, so npm cannot catch the skew. Raising it to the version that actually carries the new export makes the resolver enforce the coupling permanently, independent of publish order. The scaffold cannot be range-protected (it installs `@latest`), so it relies on the ordering above.

**Update the global CLI after the publish lands.** The maintainer scaffolds and dogfoods with the globally installed `webjs` CLI, which lags a release until refreshed. So once `release.yml` has published (verify `npm view @webjsdev/cli version` matches the released version), refresh the global CLI on every manager: `npm update -g webjsdev`, `bun add -g webjsdev`, and `mise use -g npm:webjsdev@latest`. Run them AFTER the publish, never at merge time (they pull the LATEST PUBLISHED version). The `mise use` line is the one that actually moves a mise-shimmed `webjs` (a shim on PATH ahead of the npm/bun globals); verify with `mise which webjs`. This is reminded automatically by the `.claude/hooks/release-global-update.sh` PostToolUse hook, which fires when a `chore/release-*` PR merges (escape hatch `WEBJS_NO_RELEASE_GLOBAL_UPDATE=1`, regression test `test/hooks/release-global-update.test.mjs`).

---
Expand Down
13 changes: 10 additions & 3 deletions packages/cli/lib/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,7 @@ ${uiThemeRaw}
// or shed the whole gallery at once with `gallery:clear`.
await copyGallery(appDir);

await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce } from '@webjsdev/core';
await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce, asset } from '@webjsdev/core';
import '#components/theme-toggle.ts';

/**
Expand Down Expand Up @@ -1226,9 +1226,16 @@ export default function RootLayout({ children }: { children: unknown }) {
public/tailwind.css by css:build (run automatically by the dev and start
tasks; in dev it is also recompiled on request when a source changes, so
it never goes stale). A real stylesheet, so the app is fully styled with
JavaScript DISABLED (no in-browser compile). -->
JavaScript DISABLED (no in-browser compile).

<link rel="stylesheet" href="/public/tailwind.css">
asset() adds a content hash in production (/public/tailwind.css?v=...)
and the framework then serves it immutable for a year, so a deploy that
changes the CSS changes the url and no browser or CDN can serve the old
bytes. Mark the thing that FETCHES: do not wrap a <link rel="preload">
whose asset is really fetched by an @font-face url() in the CSS, or the
preload can never match the request and the file downloads twice. -->

<link rel="stylesheet" href=\${asset('/public/tailwind.css')}>
<style>
/* Design tokens: ONE definition per colour via light-dark(LIGHT, DARK), so
a palette change lands in a single place (DRY). The token NAMES are
Expand Down
Loading