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
23 changes: 22 additions & 1 deletion .agents/skills/webjs/references/routing-and-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,28 @@ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere

Metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `apple-icon.ts`, `opengraph-image.ts`, `twitter-image.ts`) live at app root or static segments and default-export a possibly-async function; `sitemap()` / `sitemapIndex()` from `@webjsdev/server` serialize spec-valid XML.

The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless). Then point `metadata` at the route via `openGraph.images` / `twitter.images` / `icons` (or drop a static file in `public/` instead).
The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless).

**`icon` and `apple-icon` are LINKED for you.** An app that declares no `metadata.icons` gets `<link rel="icon" href="/icon">` and `<link rel="apple-touch-icon" href="/apple-icon">` in the head automatically, for whichever of the two routes it defines (base-path prefixed, since that is where the route answers). No `type` or `sizes` is asserted, because the route picks its content type at request time and the browser sniffs the served one.

Declaring `metadata.icons` **suppresses** the routes rather than merging with them, which is what Next does with its static icon files. So an app that outgrows a placeholder `app/icon.ts` names its real icons and the route stops being linked without having to be deleted:

```ts
// app/layout.ts -> these win; /icon and /apple-icon are no longer linked
export const metadata = {
icons: {
icon: [
{ url: '/public/favicon-192.png', type: 'image/png', sizes: '192x192' },
{ url: '/public/favicon.svg', type: 'image/svg+xml', sizes: 'any' },
],
apple: { url: '/public/apple-touch-icon.png', sizes: '180x180' },
},
};
```

Declare a favicon through `metadata.icons` (or a metadata route), never as a hand-written `<link rel="icon">`: only the root layout may write a shell at all (invariant 8), so a hand-written tag is unavailable to every other layout. A `public/favicon.ico` needs no declaration either way, since the framework serves it at the origin root for crawlers that read no markup.

`opengraph-image` and `twitter-image` are NOT auto-linked (a preview image is a per-page editorial choice, not a site-wide default). Point `metadata` at those via `openGraph.images` / `twitter.images`.

```ts
// app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ Default export receives `{ children, params, searchParams, url }`, must embed `c

### Error / loading / metadata routes

`error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<script>` in it must carry the nonce via `cspNonce()` (an inline `<style>` needs one only if you tighten `style-src`, since the default allows inline style outright). `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML.
`error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<script>` in it must carry the nonce via `cspNonce()` (an inline `<style>` needs one only if you tighten `style-src`, since the default allows inline style outright). `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML. **`icon` / `apple-icon` are auto-LINKED** into the head (`<link rel="icon" href="/icon">`, `<link rel="apple-touch-icon" href="/apple-icon">`, base-path prefixed, no asserted `type` / `sizes` since the route picks its content type at request time), so writing the file is the whole wiring. A declared `metadata.icons` **suppresses** them rather than merging, matching Next's precedence for its static icon files, so an app that outgrows a placeholder route names its real icons instead of deleting it. `opengraph-image` / `twitter-image` are NOT auto-linked (a preview image is a per-page editorial choice): point `metadata.openGraph.images` / `metadata.twitter.images` at them. Declare a favicon through `metadata.icons` or a metadata route, never a hand-written `<link rel="icon">`, since only the root layout may write a shell at all (invariant 8). See `references/routing-and-pages.md`.

### Route handlers (`app/**/route.{js,ts}`)

Expand Down
10 changes: 9 additions & 1 deletion packages/server/src/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';

import { buildRouteTable, matchPage, matchApi } from './router.js';
import { generateRouteTypes } from './route-types.js';
import { ssrPage, ssrNotFound, setClientRouterEnabled } from './ssr.js';
import { ssrPage, ssrNotFound, setClientRouterEnabled, setMetadataIconRoutes } from './ssr.js';
import { runFormAction, reportFormSubmittedAsGet } from './form-dispatch.js';
import { handleApi } from './api.js';
import {
Expand Down Expand Up @@ -742,6 +742,12 @@ export async function createRequestHandler(opts) {
// like env.js / readiness.js), not a router stem. Resolve it once and stash it
// on the route table so ssrOpts + the browser-servable gate reach it uniformly.
routeTable.instrumentationClient = await findInstrumentationClient(appDir);
// Auto-linked favicons: tell the head builder which icon metadata routes
// exist, so `app/icon.*` is linked when the app declares no metadata.icons.
// Bound here rather than threaded through ssrOpts, matching
// setClientRouterEnabled; re-bound in doRebuild so adding or deleting the
// file takes effect without a restart.
setMetadataIconRoutes(routeTable.metadataRoutes);

// Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
// dev so an editor's tsserver always has up-to-date route types without the
Expand Down Expand Up @@ -1207,6 +1213,8 @@ export async function createRequestHandler(opts) {
// it so routing reflects added/removed route files immediately.
state.routeTable = await buildRouteTable(appDir);
state.routeTable.instrumentationClient = await findInstrumentationClient(appDir);
// Adding or deleting app/icon.* changes whether the head auto-links it.
setMetadataIconRoutes(state.routeTable.metadataRoutes);
// Refresh the generated route types (#258) so adding/removing a route file
// updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
// best-effort (see emitRouteTypes).
Expand Down
67 changes: 63 additions & 4 deletions packages/server/src/ssr.js
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,54 @@ let _clientRouterEnabled = true;
export function setClientRouterEnabled(enabled) { _clientRouterEnabled = enabled !== false; }
function clientRouterEnabled() { return _clientRouterEnabled; }

// Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
// Set at boot and on each route rebuild from the route table, the same shape
// as setClientRouterEnabled above, so no opt has to thread through every
// render path. Empty by default, which keeps an app that declares its icons
// (or has neither route) byte-identical.
/** @type {{ icon: boolean, apple: boolean }} */
let _metadataIconRoutes = { icon: false, apple: false };

/**
* Record the icon metadata routes the app defines.
*
* @param {Iterable<{ stem: string }> | null | undefined} metadataRoutes
* The route table's `metadataRoutes`, or nullish to clear.
*/
export function setMetadataIconRoutes(metadataRoutes) {
const stems = new Set();
for (const r of metadataRoutes || []) if (r && r.stem) stems.add(r.stem);
_metadataIconRoutes = { icon: stems.has('icon'), apple: stems.has('apple-icon') };
}

/**
* The implicit `metadata.icons` an app's icon routes stand for, or null when
* it has none. Base-path prefixed, because that is where the routes are
* SERVED: the listener strips the base path before matching, so under
* `webjs.basePath` the route answers at `<basePath>/icon`. A user-authored
* `icons` URL is deliberately left alone (it may be cross-origin, and the
* author writes the path they mean), so only these framework-emitted ones
* are prefixed.
*
* No `type` or `sizes` is emitted. A metadata route picks its own content
* type at request time, which is the reason to use one, so declaring a type
* here could contradict the bytes; and `sizes` is unknowable without reading
* the response. Both are optional in HTML, and a browser sniffs the served
* content type.
*
* @returns {{ icon?: string, apple?: string } | null}
*/
function autoMetadataRouteIcons() {
const { icon, apple } = _metadataIconRoutes;
if (!icon && !apple) return null;
const bp = basePath();
/** @type {{ icon?: string, apple?: string }} */
const out = {};
if (icon) out.icon = withBasePath('/icon', bp);
if (apple) out.apple = withBasePath('/apple-icon', bp);
return out;
}

function wrapHead(opts) {
// CSP nonce: if provided, all inline <script> tags get nonce="…" so they
// pass strict Content-Security-Policy headers. The nonce is extracted from
Expand Down Expand Up @@ -1837,10 +1885,21 @@ function wrapHead(opts) {
// - apple → <link rel="apple-touch-icon">
// - shortcut→ <link rel="shortcut icon">
// - other → <link rel="…" href="…"> using the entry's `rel` field
if (m.icons) {
const buckets = typeof m.icons === 'string' || Array.isArray(m.icons)
? { icon: m.icons }
: m.icons;
//
// With no `icons` declared, an `app/icon.*` / `app/apple-icon.*` metadata
// ROUTE is linked automatically (Next parity). Those routes served their
// bytes and nothing referenced them before, so writing the file that every
// other framework treats as "this is my favicon" produced a blank tab and no
// diagnostic. A declared `icons` SUPPRESSES the routes rather than merging
// with them, which is also what Next does: it merges static icon files only
// when the resolved metadata has no `icons` of its own. Suppressing matters
// here because the file is frequently a placeholder an app has outgrown, and
// an author who names their icons has said which ones they want.
const declaredOrRouteIcons = m.icons || autoMetadataRouteIcons();
if (declaredOrRouteIcons) {
const buckets = typeof declaredOrRouteIcons === 'string' || Array.isArray(declaredOrRouteIcons)
? { icon: declaredOrRouteIcons }
: declaredOrRouteIcons;
/** @param {string} rel @param {unknown} entry */
const pushIcon = (rel, entry) => {
if (!entry) return;
Expand Down
151 changes: 151 additions & 0 deletions test/bun/metadata-icon-routes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Cross-runtime proof that an `app/icon.*` metadata ROUTE is auto-linked into
* the SSR'd head, on both Node and Bun. Run from the repo root:
*
* node test/bun/metadata-icon-routes.mjs
* bun test/bun/metadata-icon-routes.mjs
*
* This is runtime-sensitive on two counts, which is why it is here and not
* only in test/ssr. It rides the SSR head-emission path, and it is wired
* through module state that `createRequestHandler` binds at boot from the
* route table, so a unit test of the head builder alone would pass while the
* real handler emitted nothing. The route files are `.ts`, so resolving them
* also crosses each runtime's TypeScript stripper (Node 24+'s built-in one,
* amaro on Bun).
*
* Asserts, on whichever runtime executes it: the route is linked and serves,
* a declared `metadata.icons` suppresses it (the Next precedence rule), an app
* with neither emits no icon link, and the auto-emitted href carries the app's
* `basePath` because that is where the route actually answers.
*/
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { createRequestHandler } from '../../packages/server/src/dev.js';

const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;

function makeApp(files) {
const appDir = mkdtempSync(join(tmpdir(), 'webjs-icon-routes-'));
for (const [rel, body] of Object.entries(files)) {
const abs = join(appDir, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, body);
}
return appDir;
}

const PAGE = `export default function Home() { return 'home'; }\n`;

// A genuinely TypeScript metadata route, so each runtime's stripper has to
// handle it before the router can see the file at all.
const ICON_ROUTE = [
'export default function Icon(): Response {',
" return new Response('<svg xmlns=\"http://www.w3.org/2000/svg\"/>', {",
" headers: { 'content-type': 'image/svg+xml' },",
' });',
'}',
'',
].join('\n');

const APPLE_ICON_ROUTE = ICON_ROUTE.replace('function Icon', 'function AppleIcon');

/** Every icon href the rendered head declares. */
async function headIconHrefs(handle, url = 'http://localhost/') {
const res = await handle(new Request(url));
assert.equal(res.status, 200, `page did not render on ${runtime}`);
const head = (await res.text()).split('</head>')[0];
return [...head.matchAll(/<link rel="[^"]*icon[^"]*"[^>]*href="([^"]+)"/g)].map((m) => m[1]);
}

// app/icon.ts with no declared icons: linked, and the link resolves.
{
const appDir = makeApp({ 'app/page.js': PAGE, 'app/icon.ts': ICON_ROUTE });
const h = await createRequestHandler({ appDir, dev: false });
await h.warmup?.();

const hrefs = await headIconHrefs(h.handle);
assert.deepEqual(hrefs, ['/icon'], `app/icon.ts was not auto-linked on ${runtime}`);

// The whole point of the link: the URL it names has to answer.
const icon = await h.handle(new Request('http://localhost/icon'));
assert.equal(icon.status, 200, `/icon did not serve on ${runtime}`);
assert.match(icon.headers.get('content-type') || '', /^image\//, `/icon served no image on ${runtime}`);
}

// app/apple-icon.ts maps to rel="apple-touch-icon", not a second rel="icon".
{
const appDir = makeApp({
'app/page.js': PAGE,
'app/icon.ts': ICON_ROUTE,
'app/apple-icon.ts': APPLE_ICON_ROUTE,
});
const h = await createRequestHandler({ appDir, dev: false });
await h.warmup?.();

const res = await h.handle(new Request('http://localhost/'));
const head = (await res.text()).split('</head>')[0];
assert.match(head, /<link rel="icon" href="\/icon">/, `icon route missing on ${runtime}`);
assert.match(
head,
/<link rel="apple-touch-icon" href="\/apple-icon">/,
`apple-icon route missing on ${runtime}`,
);
}

// A declared metadata.icons wins outright, the way Next merges its static icon
// files only when the resolved metadata declares no icons of its own.
{
const appDir = makeApp({
'app/page.js': PAGE,
'app/icon.ts': ICON_ROUTE,
'app/apple-icon.ts': APPLE_ICON_ROUTE,
'app/layout.js': [
"export const metadata = { icons: '/public/brand.svg' };",
'export default function Layout({ children }) { return children; }',
'',
].join('\n'),
});
const h = await createRequestHandler({ appDir, dev: false });
await h.warmup?.();

const hrefs = await headIconHrefs(h.handle);
assert.deepEqual(
hrefs,
['/public/brand.svg'],
`a declared metadata.icons did not suppress the icon routes on ${runtime}`,
);
}

// Neither route nor declaration: no icon link at all. The counterfactual that
// keeps every existing app byte-identical.
{
const appDir = makeApp({ 'app/page.js': PAGE });
const h = await createRequestHandler({ appDir, dev: false });
await h.warmup?.();

assert.deepEqual(await headIconHrefs(h.handle), [], `an icon link appeared from nowhere on ${runtime}`);
}

// Under webjs.basePath the route is SERVED at <basePath>/icon (the listener
// strips the prefix before matching), so the emitted href has to carry it or
// the link 404s on exactly the deployments that need it most.
{
const appDir = makeApp({
'app/page.js': PAGE,
'app/icon.ts': ICON_ROUTE,
'package.json': JSON.stringify({ name: 'basepath-icon-app', webjs: { basePath: '/app' } }, null, 2),
});
const h = await createRequestHandler({ appDir, dev: false });
await h.warmup?.();

const hrefs = await headIconHrefs(h.handle, 'http://localhost/app/');
assert.deepEqual(hrefs, ['/app/icon'], `the auto-linked icon href ignored basePath on ${runtime}`);

const icon = await h.handle(new Request('http://localhost/app/icon'));
assert.equal(icon.status, 200, `the base-path icon href did not serve on ${runtime}`);
}

console.log(`metadata icon routes: auto-link, precedence and basePath OK on ${runtime}`);
Loading
Loading