@webjsdev/mcp 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -99
- package/package.json +5 -10
- package/src/brief.js +52 -0
- package/src/catalog.js +82 -0
- package/src/mcp-init.js +139 -0
- package/src/mcp-source.js +94 -8
- package/src/mcp.js +32 -144
- package/resources/AGENTS.md +0 -704
- package/resources/SKILL.md +0 -287
- package/resources/corpus.json +0 -6
- package/resources/references/auth-and-sessions.md +0 -300
- package/resources/references/built-ins.md +0 -346
- package/resources/references/client-router-and-streaming.md +0 -317
- package/resources/references/components.md +0 -505
- package/resources/references/data-and-actions.md +0 -325
- package/resources/references/module-structure.md +0 -229
- package/resources/references/muscle-memory-gotchas.md +0 -362
- package/resources/references/optimistic-ui.md +0 -179
- package/resources/references/routing-and-pages.md +0 -323
- package/resources/references/runtime.md +0 -113
- package/resources/references/service-worker.md +0 -80
- package/resources/references/styling.md +0 -312
- package/resources/references/testing.md +0 -223
- package/resources/references/typescript.md +0 -231
- package/resources/references/ui-kit.md +0 -161
- package/src/mcp-docs.js +0 -643
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
# Routing and Pages
|
|
2
|
-
|
|
3
|
-
## What This Covers
|
|
4
|
-
|
|
5
|
-
- Pages, layouts, and where the HTML shell comes from
|
|
6
|
-
- Dynamic (`[param]`), catch-all (`[...rest]`), and optional catch-all (`[[...rest]]`) segments, route groups, private folders
|
|
7
|
-
- `route.ts` HTTP handlers and `middleware.ts`, the route-handler toolkit (`json` / `readBody` / `clientIp` / the no-arg accessors), and calling one from the client with `richFetch`
|
|
8
|
-
- `metadata` and `generateMetadata` (folded in here), including image metadata routes that return a `Response`
|
|
9
|
-
- Control-flow throws: `notFound()`, `redirect()`, `forbidden()`, `unauthorized()`
|
|
10
|
-
- The no-JS write path: a `<form>` bound to a `'use server'` action
|
|
11
|
-
- Boundaries: `error.ts`, `loading.ts`, `not-found.ts`, `forbidden.ts`, `unauthorized.ts`, and the two root-only ones
|
|
12
|
-
|
|
13
|
-
Read this when a task touches the route contract, a URL, a `<head>` tag, a redirect, a 404, or a form POST that a page owns. Sibling refs: `components.md` (anything interactive), `data-and-actions.md` (server actions, queries, validation, the `ActionResult` envelope), `auth-and-sessions.md` (`forbidden()` / `unauthorized()` flows).
|
|
14
|
-
|
|
15
|
-
## The Execution Model (read this first)
|
|
16
|
-
|
|
17
|
-
Pages and layouts run **only on the server** to produce HTML. They do NOT hydrate, so their own markup cannot be interactive (an `@click` in a page template is dropped at SSR, a signal read in a page body never re-renders). They still LOAD in the browser so imported components register. Put every interactive behaviour in a component.
|
|
18
|
-
|
|
19
|
-
`route.ts` is the one routing file that is NOT isomorphic: a server-only HTTP handler, never shipped to the client.
|
|
20
|
-
|
|
21
|
-
### How much of the page belongs in the component
|
|
22
|
-
|
|
23
|
-
"Put every interactive behaviour in a component" is not "put the section containing it in a component". Because a page never hydrates, the markup it renders costs the browser nothing, so a page that keeps its static content and delegates only the interactive fragment is both the cheapest and the conventional shape:
|
|
24
|
-
|
|
25
|
-
```ts
|
|
26
|
-
// app/products/[id]/page.ts
|
|
27
|
-
export default async function Product({ params }: PageProps<'/products/[id]'>) {
|
|
28
|
-
const product = await getProduct(params.id);
|
|
29
|
-
return html`
|
|
30
|
-
<article>
|
|
31
|
-
<h1>${product.name}</h1>
|
|
32
|
-
<p>${product.description}</p>
|
|
33
|
-
<spec-table .rows=${product.specs}></spec-table>
|
|
34
|
-
|
|
35
|
-
<add-to-cart product-id=${product.id}></add-to-cart>
|
|
36
|
-
|
|
37
|
-
<review-list .reviews=${product.reviews}></review-list>
|
|
38
|
-
</article>
|
|
39
|
-
`;
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
Only `<add-to-cart>` ships. `<spec-table>` and `<review-list>` are display-only, so the framework elides them and the browser fetches neither. Wrapping the whole article in a `<product-page>` component to "own the page" would ship all three, because a component rendered by a component that ships can no longer be elided. The sizing rule and a before/after are in `components.md` under "Sizing an island".
|
|
44
|
-
|
|
45
|
-
## Pages (`app/**/page.ts`)
|
|
46
|
-
|
|
47
|
-
The default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. It returns a `TemplateResult`; it never calls `render()` itself.
|
|
48
|
-
|
|
49
|
-
```ts
|
|
50
|
-
// app/about/page.ts
|
|
51
|
-
import { html } from '@webjsdev/core';
|
|
52
|
-
export default function About() {
|
|
53
|
-
return html`<h1>About</h1>`;
|
|
54
|
-
}
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
`params` and `searchParams` are awaitable AND synchronously readable (`params.id` and `await params` both work, Next.js 15/16 parity). Throw `notFound()` or `redirect(url)` to short-circuit. Reach data through a `.server.ts` query; never import the DB driver into a page.
|
|
58
|
-
|
|
59
|
-
Optional named exports: `metadata` / `generateMetadata` (below) and `export const revalidate` (seconds, opts into the HTML response cache, only for a page identical for every visitor). There is no `action` export; the write path is a form bound to a `'use server'` action (below).
|
|
60
|
-
|
|
61
|
-
## Layouts (`app/**/layout.ts`)
|
|
62
|
-
|
|
63
|
-
The default export receives `{ children, params, searchParams, url }` and must embed `children`. Layouts nest by folder; `metadata` merges with the deepest winning.
|
|
64
|
-
|
|
65
|
-
```ts
|
|
66
|
-
// app/layout.ts (root)
|
|
67
|
-
import { html } from '@webjsdev/core';
|
|
68
|
-
import type { LayoutProps } from '@webjsdev/core';
|
|
69
|
-
export default function RootLayout({ children }: LayoutProps) {
|
|
70
|
-
return html`<html lang="en"><head></head><body>${children}</body></html>`;
|
|
71
|
-
}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Only the **root layout** (`app/layout.ts` exactly) MAY write `<!doctype>` / `<html>` / `<head>` / `<body>`; the framework splices in the importmap, modulepreload, title, and meta. Non-root layouts and pages MUST NOT write the shell (the `shell-in-non-root-layout` rule). If the root layout omits the shell, the framework auto-emits `<!doctype><html lang="en"><head></head><body>`.
|
|
75
|
-
|
|
76
|
-
## Dynamic and catch-all routes
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
// app/users/[id]/page.ts
|
|
80
|
-
import { html } from '@webjsdev/core';
|
|
81
|
-
import { getUser } from '#modules/users/queries/get-user.server.ts';
|
|
82
|
-
export default async function User({ params }: { params: { id: string } }) {
|
|
83
|
-
const user = await getUser(params.id); // via a server query, never the DB directly
|
|
84
|
-
return html`<h1>${user.name}</h1>`;
|
|
85
|
-
}
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
- `[param]/page.ts` dynamic segment, read via `params.param`.
|
|
89
|
-
- `[...rest]/page.ts` catch-all, `[[...rest]]/page.ts` optional catch-all.
|
|
90
|
-
- Overlapping routes resolve by positional specificity, for pages and `route.ts` handlers alike: segment by segment, a static segment beats `[param]`, which beats a catch-all, so `api/auth/callback/github/route.ts` answers before `api/auth/[...path]/route.ts` whatever the directory order.
|
|
91
|
-
- `(group)/...` route group: the folder is NOT in the URL but still scopes layout / error.
|
|
92
|
-
- `_private/...` private folder: ignored by the router.
|
|
93
|
-
|
|
94
|
-
## Route handlers (`app/**/route.ts`)
|
|
95
|
-
|
|
96
|
-
Named async exports per HTTP method, each `(Request, { params }) => Response | value` (a non-Response value auto-JSONs). A folder cannot have both `page.ts` and `route.ts`.
|
|
97
|
-
|
|
98
|
-
```ts
|
|
99
|
-
// app/api/health/route.ts
|
|
100
|
-
export async function GET() {
|
|
101
|
-
return { ok: true };
|
|
102
|
-
}
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
**NEVER throw `redirect()` / `notFound()` / `forbidden()` inside a `route.ts` handler** (an uncaught throw is a generic 500). Return a real response instead: `return Response.redirect(url, 303)` for a redirect, `return new Response('Not Found', { status: 404 })` for a 404. A `route.ts` is also NOT covered by the action CSRF check, so authenticate every mutating endpoint, validate, and rate-limit. Export `WS(ws, req, { params })` from the same file for a WebSocket endpoint.
|
|
106
|
-
|
|
107
|
-
**The route-handler toolkit** (from `@webjsdev/server`). Two accessors take the request explicitly: `readBody(req)` decodes a rich request body (the inverse of `json()`, round-tripping `Date` / `Map` / `Set` / `BigInt` / `Blob`), and `clientIp(req)` reads the caller IP. To respond with rich types, `json(value)` serializes with the same wire (so a `Date` survives), versus a plain object return that auto-JSONs. The context accessors take NO argument because they read the in-flight request from context: `headers()`, `cookies()`, `requestId()`, `cspNonce()`.
|
|
108
|
-
|
|
109
|
-
```ts
|
|
110
|
-
import { json, readBody, clientIp } from '@webjsdev/server';
|
|
111
|
-
export async function POST(req: Request) {
|
|
112
|
-
const body = await readBody(req); // rich types decoded
|
|
113
|
-
return json({ at: new Date(), ip: clientIp(req) }); // Date survives the wire
|
|
114
|
-
}
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
**Calling your own `route.ts` from the client:** use `richFetch<T>(url, opts?)` from `@webjsdev/core`, a drop-in `fetch` that sends `Accept: application/vnd.webjs+json`, encodes a plain-object `body` with the rich wire, and decodes the `json()` response (so `data.at` is a real `Date`). This is the ONE legitimate hand-fetch (importing a `'use server'` action is the way to call the server otherwise, never a hand-written `fetch` to an action).
|
|
118
|
-
|
|
119
|
-
## Middleware (`middleware.ts`)
|
|
120
|
-
|
|
121
|
-
Optional root-level plus per-segment. The default export is `async (req, next) => Response`. Return a Response to short-circuit, or call `next()` and post-process. Per-segment middleware applies to its subtree, outermost to innermost.
|
|
122
|
-
|
|
123
|
-
The root file sits beside `app/`, not inside it, and may be `middleware.ts` / `.js` / `.mts` / `.mjs` (`.ts` wins if more than one exists). A per-segment `app/<segment>/middleware.*` takes any of the same extensions.
|
|
124
|
-
|
|
125
|
-
## Metadata and `generateMetadata`
|
|
126
|
-
|
|
127
|
-
A page exports `metadata` (static) or `generateMetadata(ctx)` (request-scoped, takes precedence). Values flow into `<head>` at SSR and merge across nested layouts (deeper wins). Type both with `Metadata`; `MetadataContext` types the argument. The surface is Next.js-compatible.
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
import type { Metadata, MetadataContext } from '@webjsdev/core';
|
|
131
|
-
|
|
132
|
-
export const metadata: Metadata = { title: 'Home', description: 'Welcome' };
|
|
133
|
-
|
|
134
|
-
export async function generateMetadata(ctx: MetadataContext): Promise<Metadata> {
|
|
135
|
-
return { title: `Post: ${ctx.params.slug}`, metadataBase: new URL(ctx.url).origin };
|
|
136
|
-
}
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Common fields: `title` (string or `{ template, default, absolute }`), `description`, `keywords`, `metadataBase` (resolves relative URLs in `openGraph` / `twitter` / `alternates` / `icons`), `openGraph`, `twitter`, `robots`, `alternates.canonical`, `icons`, `manifest`, and `jsonLd` (schema.org structured data, single object or array, HTML-safe-escaped automatically). `viewport`, `themeColor`, and `colorScheme` may also be set via a split `export const viewport = { ... }`. `cacheControl` is emitted as a response HEADER (not a `<meta>`); pages default to `no-store`, and any other value enables conditional GET (a weak `ETag` + `304`), `private` included. See https://webjs.dev/docs for the full field list.
|
|
140
|
-
|
|
141
|
-
### Search and share defaults (SEO)
|
|
142
|
-
|
|
143
|
-
Set `SITE_URL` (the app's public origin, e.g. `https://shop.example.com`) in production. It is the default `metadataBase`, and with it every page gets, unless it sets its own:
|
|
144
|
-
|
|
145
|
-
- `<link rel="canonical">` = site URL + the page's pathname (query dropped). Opt one page out with `alternates: { canonical: null }`. Without a site URL no canonical is emitted, because the request host can be set by a client.
|
|
146
|
-
- `og:title` / `og:description` from `title` / `description`, `og:type` `website`, `og:url` = the canonical.
|
|
147
|
-
- `og:image` from the nearest `opengraph-image` route above the page (and `twitter:image` from `twitter-image`), when the page declares no image.
|
|
148
|
-
- `twitter:card`: `summary_large_image` when there is an image, else `summary`.
|
|
149
|
-
|
|
150
|
-
`openGraph` and `twitter` take Next's shapes: `images` is a string, `URL`, `{ url, width, height, alt, type }` or an array of those (emits `og:image` + `og:image:width` / `height` / `alt`); camelCase keys become snake_case (`siteName` -> `og:site_name`); `publishedTime` / `modifiedTime` / `authors` / `section` / `tags` emit `article:*`. `metadataBase` may be a string or a `URL`.
|
|
151
|
-
|
|
152
|
-
So a public page needs only a specific title and description:
|
|
153
|
-
|
|
154
|
-
```ts
|
|
155
|
-
export const metadata: Metadata = {
|
|
156
|
-
title: 'Opening hours',
|
|
157
|
-
description: 'When the bakery is open, including public holidays.',
|
|
158
|
-
};
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
A signed-in or private page says `robots: { index: false }` (put it on the private section's layout and every page below inherits it); `pages()` then leaves it out of the sitemap too.
|
|
162
|
-
|
|
163
|
-
Structured data goes in `jsonLd` (from `generateMetadata` when it depends on a record): one schema.org object or an array, escaped safely into `<script type="application/ld+json">`.
|
|
164
|
-
|
|
165
|
-
## Control-flow throws
|
|
166
|
-
|
|
167
|
-
From `@webjsdev/core`: throw to short-circuit a page / layout render or a form-bound action.
|
|
168
|
-
|
|
169
|
-
- `notFound()` renders the nearest `not-found.ts` (nearest wins from the throwing chain).
|
|
170
|
-
- `redirect(url[, status])`. The no-status default is convention-picked at the catch site: `302` for a GET page render, `307` (method-preserving) for a form-bound action. Override with `redirect(url, 308)` or `redirect(url, { status })`.
|
|
171
|
-
- `forbidden()` renders the nearest `forbidden.ts` (authenticated user lacking permission); `unauthorized()` renders the nearest `unauthorized.ts` (request not authenticated).
|
|
172
|
-
|
|
173
|
-
None of these belong in a `route.ts` (return a `Response` there). Inside a `'use server'` RPC action, return an `ActionResult` for an auth failure rather than throwing (`data-and-actions.md`).
|
|
174
|
-
|
|
175
|
-
## The no-JS write path (a form-bound action)
|
|
176
|
-
|
|
177
|
-
A page imports a `'use server'` action and binds it into the form: `<form action=${sendMessage}>`. There is no page `action` export; the binding IS the wiring. The renderer omits the `action` attribute (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity. A non-GET/HEAD submission carrying that identity runs the action, wrapped in the segment middleware of the URL it was submitted to AND the action's own declared `middleware`. It works with JS off; with JS on the client router posts the same body to the same url and applies the response in place.
|
|
178
|
-
|
|
179
|
-
**Do not treat the page's segment middleware as the action's authorization gate.** An identity names the action, not the page that rendered it, so the same action is reachable by submitting to any page path (and, being a `'use server'` export, at its RPC endpoint too, where no segment middleware runs at all). That is the same reachability every server action has always had, and the removed page `action` export was the one shape where the URL really did scope the handler. Authorize the action itself: an `export const middleware = [requireAdmin]` on the action, or a check in its body, both of which run on either transport.
|
|
180
|
-
|
|
181
|
-
A form-bound action receives the submitted fields as ONE object, the same shape the action gets over RPC: each field is a property (`input.email`), and the `FormData` readers work on it too (`input.get('email')`, `input.getAll('tags')`). A name ending in `[]` arrives as an array, a file field is a real `File`, and a plain name submitted twice keeps its last value on the property. `validate` is the typing seam: its transform-return becomes the action's typed input. See `data-and-actions.md` for the full shape.
|
|
182
|
-
|
|
183
|
-
```ts
|
|
184
|
-
// modules/contact/actions/send-message.server.ts
|
|
185
|
-
'use server';
|
|
186
|
-
export async function sendMessage(input: { email?: string; body?: string }) {
|
|
187
|
-
const email = String(input.email || '').trim();
|
|
188
|
-
const body = String(input.body || '').trim();
|
|
189
|
-
const values = { email, body };
|
|
190
|
-
const fieldErrors: Record<string, string> = {};
|
|
191
|
-
if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
|
|
192
|
-
if (body.length < 10) fieldErrors.body = 'Message is too short';
|
|
193
|
-
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
|
|
194
|
-
await deliver({ email, body });
|
|
195
|
-
return { success: true, redirect: '/contact/thanks' };
|
|
196
|
-
}
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
```ts
|
|
200
|
-
// app/contact/page.ts
|
|
201
|
-
import { html } from '@webjsdev/core';
|
|
202
|
-
import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
|
|
203
|
-
|
|
204
|
-
export default function Contact({ actionData }: {
|
|
205
|
-
actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
|
|
206
|
-
}) {
|
|
207
|
-
const errors = actionData?.fieldErrors || {};
|
|
208
|
-
const values = actionData?.values || {};
|
|
209
|
-
return html`
|
|
210
|
-
<form action=${sendMessage} class="flex flex-col gap-3">
|
|
211
|
-
<input name="email" type="email" value=${values.email || ''} required>
|
|
212
|
-
${errors.email ? html`<p class="text-sm text-red-600">${errors.email}</p>` : ''}
|
|
213
|
-
<textarea name="body" required>${values.body || ''}</textarea>
|
|
214
|
-
${errors.body ? html`<p class="text-sm text-red-600">${errors.body}</p>` : ''}
|
|
215
|
-
<button type="submit">Send</button>
|
|
216
|
-
</form>
|
|
217
|
-
`;
|
|
218
|
-
}
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a bound `<form>` over `fetch` in a `@click` for any write a form can express.
|
|
222
|
-
|
|
223
|
-
**Typed values survive a failed submission without extra code (#1581).** On a failure re-render `actionData.values` carries EVERY submitted text field, with any `values` the action returned layered on top (the action's own win, so it can normalize or blank a field). So `value=${values.x}` refills a field the action never echoed, with JavaScript off. With JavaScript on, the client router goes further: it snapshots the submitted form and, when the non-2xx re-render is applied in place, puts back what was typed in every text input, textarea, select, radio and checkbox whose server-rendered default did not change. A control the server rendered differently on purpose (refilled, normalized, reset) keeps the server's value. Passwords, files and hidden inputs are never restored. Opt a form out with `data-preserve-values="false"`. Refilling from `actionData.values` is still the right habit, since it is what the no-JS path shows; a checkbox group or multi-select repeats its name, and `values` keeps only the last one, so read those from the submitted `FormData` in the action.
|
|
224
|
-
|
|
225
|
-
Three responses that are not the happy path:
|
|
226
|
-
|
|
227
|
-
- **A submission carrying no identity is a `405` + `Allow: GET, HEAD`.** A bare `<form method="post">` binds nothing, and the page path exists but only renders, so the method is what is wrong rather than the url.
|
|
228
|
-
- **An identity whose hash no longer resolves re-renders at `422`** with a resubmit message and the submitted values on `actionData`. That is a form held open across a deploy; a 404 would discard what was typed and a silent no-op would show success for a write that never happened.
|
|
229
|
-
- **A bound action declaring `export const method = 'GET'` is a `405`.** A GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. `webjs check`'s `form-action-not-a-get-action` catches it at edit time.
|
|
230
|
-
|
|
231
|
-
The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.
|
|
232
|
-
|
|
233
|
-
A submitter's own `formmethod` / `formenctype` / `formtarget` overrides the form's on PRESENCE, not on the value being non-empty, and the client router resolves them the same way (#1322). So `<button type="submit" formmethod="">` really does submit as a GET, because a present-but-empty enumerated attribute falls to its own invalid-value default rather than inheriting the form's `method="post"`.
|
|
234
|
-
|
|
235
|
-
Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
|
|
236
|
-
|
|
237
|
-
## Error, loading, and 404 boundaries
|
|
238
|
-
|
|
239
|
-
- `error.ts` default-exports `({ error, ...ctx }) => TemplateResult`; catches sibling-page and deeper render errors, innermost wins (prod sends only `error.message`).
|
|
240
|
-
- `loading.ts` wraps the sibling page in `Suspense` with an immediately-flushed fallback.
|
|
241
|
-
- `not-found.ts` / `forbidden.ts` / `unauthorized.ts` render the nearest matching boundary for the thrown control-flow signal, and receive the same ctx a page does (`params`, `searchParams`, `url`).
|
|
242
|
-
- **Every one of those boundaries renders INSIDE the layouts at and above its own segment** (#1298), so it carries the keyed `wj:children` pairs and a client-router navigation into a failing page stays a SOFT navigation with the surrounding chrome and its hydrated state intact. A layout deeper than the boundary is not rendered (it never rendered on the way in), and its module is not in the boundary's boot script; the boundary's own module IS. Since a boundary sits inside its own segment's layout, it cannot catch that layout, matching Next's `layout -> error -> page` hierarchy. What happens next differs by path, and the difference is worth knowing:
|
|
243
|
-
|
|
244
|
-
- On the **500 path**, a throwing layout is handled by the next `error.{js,ts}` OUT: the walk tries each boundary in the chain, innermost first, and a layout that throws fails every attempt whose wrapped set contains it, so control ends at `global-error` (or the default 500 page) when they are exhausted.
|
|
245
|
-
- On the **404 / 403 / 401 paths** there is NO outward walk. Each renders the one nearest boundary, so a throwing layout degrades that response to a chrome-less standalone render of the boundary with no boot script, keeping its status. A control-flow throw from a wrapped layout there (an auth-gate layout calling `redirect('/login')`) is discarded rather than honoured, deliberately: the status is already decided and the boundary page is the answer to that request.
|
|
246
|
-
|
|
247
|
-
A genuine layout crash is reported to `onError` (and to the dev overlay on the 404 / 403 / 401 paths, where nothing else claimed the frame) rather than being swallowed. Repeats of the SAME cause within one request collapse to a single report, because one shared layout can fail every boundary attempt (and, when the layout is what threw, arrives again as the error that produced the 500). The key is the STAGE plus the error's name, message and construction site: the stack below that site records how the throw was reached and differs on every re-render, so it cannot be part of the key, and the stage is what keeps `global-error`'s own crash from being swallowed by a boundary that failed through the same helper. Two DIFFERENT failures are both reported, and anything whose key cannot be derived safely (a non-Error throw) is always reported rather than risking a drop. A control-flow sentinel never is, since it is routing rather than a crash.
|
|
248
|
-
|
|
249
|
-
The BOUNDARY FILE's own crash (it throws, or fails to import at all) is reported the same way, and its response body follows the framework's standard rule for a thrown error: shown in dev, withheld in prod, where the page carries only its status. A thrown message is not author-controlled and may name a driver, a path or a connection string, so it does not reach the client; sanitizing the response never means losing the failure.
|
|
250
|
-
|
|
251
|
-
Two further consequences: a layout that fetches runs its fetch again on a boundary response, and a `<webjs-suspense>` inside a wrapped layout shows its fallback, because a boundary response is buffered so its status is final before the first byte. A 404 for a URL that matched NO route has no chain to wrap in and stays a bare document.
|
|
252
|
-
- Root-only (in `app/` exactly): `global-error.ts` is the app-wide catch-all after nested `error` boundaries are exhausted and renders its OWN `<!doctype><html><body>` (returned verbatim, so keep it static HTML with no components or hydration). That verbatim document is exactly why it is the one boundary left UNWRAPPED: a second shell would nest inside the root layout's, wrapping it would re-run the code that just threw, and with no boot script it could not soft-swap anyway. `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
|
|
253
|
-
|
|
254
|
-
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 (a nested one answers under its segment: `app/blog/opengraph-image.ts` is `/blog/opengraph-image`) and default-export a possibly-async function that receives `{ request, url, siteUrl, pages }`. `siteUrl` is `SITE_URL`, else the request origin. `sitemap` may return an ARRAY of entries (urls may be paths, resolved against `siteUrl`) and `robots` an OBJECT (Next's `MetadataRoute.Robots` shape); the server serializes them. `sitemap()` / `sitemapIndex()` / `robots()` from `@webjsdev/server` are the same serializers, for a route that builds its own Response.
|
|
255
|
-
|
|
256
|
-
`pages()` lists every public page as sitemap entries: each static page (route groups dropped), each dynamic page that exports `generateSitemapParams()`, and none whose static metadata chain says `robots: { index: false }`. So the sitemap follows the app as pages are added:
|
|
257
|
-
|
|
258
|
-
```ts
|
|
259
|
-
// app/sitemap.ts
|
|
260
|
-
import type { MetadataRouteContext } from '@webjsdev/server';
|
|
261
|
-
export default async function sitemap({ pages }: MetadataRouteContext) {
|
|
262
|
-
return pages();
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// app/products/[id]/page.ts: list the products this route serves
|
|
266
|
-
export async function generateSitemapParams() {
|
|
267
|
-
return (await listProducts()).map((p) => ({ params: { id: p.id }, lastModified: p.updatedAt }));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// app/robots.ts
|
|
271
|
-
import type { MetadataRouteContext } from '@webjsdev/server';
|
|
272
|
-
export default function robots({ siteUrl }: MetadataRouteContext) {
|
|
273
|
-
return { rules: { userAgent: '*', allow: '/', disallow: ['/account', '/api/'] }, sitemap: `${siteUrl}/sitemap.xml` };
|
|
274
|
-
}
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
`generateSitemapParams()` returns params objects (`[{ id: '1' }]`) or entries carrying them (`[{ params: { id: '1' }, lastModified }]`).
|
|
278
|
-
|
|
279
|
-
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).
|
|
280
|
-
|
|
281
|
-
### App icon and manifest (replace the placeholder)
|
|
282
|
-
|
|
283
|
-
**Every app needs its OWN icon.** `webjs create` ships `app/icon.svg` as a neutral PLACEHOLDER (a grey tile with a dashed frame, marked `data-webjs-placeholder="icon"`) and `app/manifest.webmanifest` with the app's name. Replacing the placeholder is part of building the app, not polish: until you do, the tab, the bookmark and the home-screen icon look like every other unfinished app, and `webjs doctor` warns (`APP_ICON`). Draw a simple symbol for what the app IS (a grid for a tic-tac-toe game, a cup for a cafe, a check for a task list), in the app's own colours, on a 32x32 or 24x24 `viewBox`: a filled rounded tile in the primary colour with one bold shape in its foreground colour reads at 16px. Avoid thin strokes (under 2px at 32px), text longer than one letter, and detail that blurs at tab size. Then set `name`, `short_name`, `theme_color` and `background_color` in `app/manifest.webmanifest` to match.
|
|
284
|
-
|
|
285
|
-
The icon conventions, all at the app ROOT and all auto-linked into `<head>` when the app declares no `metadata.icons`:
|
|
286
|
-
|
|
287
|
-
| File | Served at | Linked as |
|
|
288
|
-
|---|---|---|
|
|
289
|
-
| `app/icon.svg` / `icon.png` / `icon.ico` | `/icon.svg` ... | `<link rel="icon">` with `type`, `sizes="any"` (SVG) or the PNG's real pixel size |
|
|
290
|
-
| `app/apple-icon.png` (180x180) | `/apple-icon.png` | `<link rel="apple-touch-icon" sizes="180x180">` (iOS needs PNG, not SVG) |
|
|
291
|
-
| `app/icon.ts` / `apple-icon.ts` | `/icon`, `/apple-icon` | bare link (the route picks its content type per request) |
|
|
292
|
-
| `app/manifest.webmanifest` / `manifest.json` / `manifest.ts` | `/manifest.webmanifest`, `/manifest.json` | `<link rel="manifest">` (`metadata.manifest` wins; `manifest: null` opts out) |
|
|
293
|
-
| `app/favicon.ico` | `/favicon.ico` | not linked; browsers request it themselves |
|
|
294
|
-
|
|
295
|
-
`/favicon.ico` always answers: `public/favicon.ico`, else `app/favicon.ico`, else the app's icon (raster preferred over SVG, then the `icon.ts` route), so a crawler or feed reader that reads no markup gets the same icon. A static icon file wins the link over an icon route when both exist (the route still serves at its URL). Raster icons are linked before SVG, because Google's favicon crawler takes the first usable icon and wants a square raster. Use `icon.ts` only when the mark must be computed per request (per theme, per tenant); a route can render a PNG for `apple-icon.ts` the same way.
|
|
296
|
-
|
|
297
|
-
Declaring `metadata.icons` **suppresses** all of the above rather than merging with them, which is what Next does with its static icon files. So name icons explicitly only when they live elsewhere (a CDN, `public/`):
|
|
298
|
-
|
|
299
|
-
```ts
|
|
300
|
-
// app/layout.ts -> these win; app/icon.* and app/apple-icon.* are no longer linked
|
|
301
|
-
export const metadata = {
|
|
302
|
-
icons: {
|
|
303
|
-
icon: [
|
|
304
|
-
{ url: '/public/favicon-192.png', type: 'image/png', sizes: '192x192' },
|
|
305
|
-
{ url: '/public/favicon.svg', type: 'image/svg+xml', sizes: 'any' },
|
|
306
|
-
],
|
|
307
|
-
apple: { url: '/public/apple-touch-icon.png', sizes: '180x180' },
|
|
308
|
-
},
|
|
309
|
-
};
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
Never write a favicon 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.
|
|
313
|
-
|
|
314
|
-
`opengraph-image` and `twitter-image` are LINKED too, Next's behaviour: a page that declares no `openGraph.images` gets `og:image` pointing at the nearest `opengraph-image` route above it (absolute against the site URL), and likewise `twitter:image`. A page that declares its own image keeps it.
|
|
315
|
-
|
|
316
|
-
```ts
|
|
317
|
-
// app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
|
|
318
|
-
export default function OgImage() {
|
|
319
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">...</svg>`;
|
|
320
|
-
return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } });
|
|
321
|
-
}
|
|
322
|
-
// every page without its own image now carries og:image = <site>/opengraph-image
|
|
323
|
-
```
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
# Runtime: Node and Bun
|
|
2
|
-
|
|
3
|
-
## What This Covers
|
|
4
|
-
|
|
5
|
-
- Running a WebJs app on **Node 24+** or **Bun**, and why the app source you write is identical on either.
|
|
6
|
-
- The three things that actually differ under the hood (the listener shell, the TypeScript stripper, a handful of built-ins), and the one feature gap (103 Early Hints on Bun).
|
|
7
|
-
- Scaffolding a Bun-flavored app and the `bun --bun run dev` / `start` commands.
|
|
8
|
-
- Where Deno fits (planned, not yet supported).
|
|
9
|
-
|
|
10
|
-
Read this when you are choosing a runtime, deploying, debugging a runtime-specific difference, or a scaffold emitted `bun.lock` and you want to know what changed. For the TypeScript stripping mechanics see `typescript.md`. For SQLite, caching, and other built-ins see `built-ins.md`. For the cross-runtime test matrix see `testing.md`.
|
|
11
|
-
|
|
12
|
-
## The app source is identical
|
|
13
|
-
|
|
14
|
-
There is nothing runtime-specific in the app you write. The same `app/`, `modules/`, `components/`, and `db/` files run byte-for-byte on Node and Bun, and the bytes the browser fetches are identical either way. WebJs picks the runtime shell at boot through a runtime-neutral seam inside its server (`node:http` on Node, `Bun.serve` on Bun), so nothing in your code branches on the runtime.
|
|
15
|
-
|
|
16
|
-
Pick a runtime from the deploy target, not the code. Default to Node unless you specifically want Bun's faster listener or a Bun-native deploy image. You do not import a runtime adapter, set a flag in your pages, or handle Node and Bun differently anywhere in application code. The one place the runtime is chosen is the scaffold (`--runtime`) plus the run command (`bun --bun` versus `npm`), covered below.
|
|
17
|
-
|
|
18
|
-
### Where the difference actually lives
|
|
19
|
-
|
|
20
|
-
Three seams pick a runtime-specific implementation, all inside the framework, none in your app:
|
|
21
|
-
|
|
22
|
-
- **The listener.** `startServer` selects the `node:http` request shell on Node and a native `Bun.serve` shell on Bun. Both parse the request, run middleware, dispatch to your routes, and stream the response through the same downstream pipeline, so an SSR page, a server action RPC, and a route handler behave identically.
|
|
23
|
-
- **The type stripper.** WebJs serves `.ts` / `.mts` as ES modules by erasing the types in place with no bundler. Those two are the whole set: there is no JSX anywhere in the framework, so a `.tsx` file is not served. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`).
|
|
24
|
-
- **A few built-ins.** SQLite, hot reload, and WebSockets each bind to the runtime's native primitive (see the table).
|
|
25
|
-
|
|
26
|
-
## Node vs Bun at a glance
|
|
27
|
-
|
|
28
|
-
| Area | Node 24+ | Bun |
|
|
29
|
-
|---|---|---|
|
|
30
|
-
| Install | `npm install` | `bun install` |
|
|
31
|
-
| Run | `npm run dev` / `npm run start` | `bun run dev` / `bun run start` |
|
|
32
|
-
| Listener | `node:http` shell | native `Bun.serve` (faster on the listening path only, not end-to-end, because SSR render dominates a real page) |
|
|
33
|
-
| TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
|
|
34
|
-
| SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
|
|
35
|
-
| Hot reload | restart on change (the `webjs dev` supervisor, #1521) | in place in one long-lived process (`bun --hot` resets the module registry, #1575); only an `instrumentation.*` / `env.*` edit restarts it |
|
|
36
|
-
| WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
|
|
37
|
-
| 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
|
|
38
|
-
| Response compression (prod) | brotli / gzip / deflate over `node:zlib`: a buffered body (an SSR page, JSON, a module) is compressed synchronously and sent with its `Content-Length`, a streamed body through a streaming compressor, a buffered body under 1 KB is sent as is, and a buffered body under 32 KB is gzip-encoded even when brotli is accepted (gzip 6 is 2 to 3x cheaper than brotli 4 at that size for a few percent more bytes, #1646) | same (both shells share the seam, the 1 KB floor and the 32 KB brotli floor) |
|
|
39
|
-
| Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
|
|
40
|
-
| Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
|
|
41
|
-
|
|
42
|
-
**`webjs dev` lets an idle host sleep (#1507).** The live-reload stream sends nothing between edits (no keepalive) and is held open only while a tab showing the app is visible, so a sandbox or preview host that suspends on network quiet can suspend with a backgrounded dev tab open. Showing the tab reconnects, and an edit made meanwhile reloads the page on return. A host that counts an OPEN request as activity (a sandbox that suspends on idle) also needs `"webjs": { "dev": { "reloadIdle": 20 } }` (or `WEBJS_DEV_RELOAD_IDLE=20`): after that many seconds with no edit and no interaction the stream closes, and the next interaction, a tab showing, or an embed-bridge host command (`{ source: 'webjs-embed-host', type: 'resume' }`) reopens it. Off by default. Every reconnect also compares the server's state with the state the page on screen was rendered at, so an edit whose reload signal was lost while the stream was being replaced (a host that closes a held stream when it wakes) still reloads the page.
|
|
43
|
-
|
|
44
|
-
**The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying.
|
|
45
|
-
|
|
46
|
-
Bun keeps ONE dev server process for the whole session (#1575), so it gets the refresh. `bun --hot` re-runs the CLI on a change; the first run's server owns the process (its listener, live-reload stream, watchers and analysis caches) and a re-run only tells it the module registry was reset, so nothing is started twice and memory stays flat over hundreds of edits (it used to grow about 25 MB an edit until `bun --hot` stopped reloading). The app's modules load through a `Bun.plugin` that reads them fresh, and its `#` imports resolve through the app's own `imports` map, because Bun keeps the old source of a file that was replaced (an atomic save) and a stale directory listing for a new file next to a `*.server.*` module. So `bun --hot` no longer sees app edits itself, and the dev server asks it for a registry reset after an edit to a module some other module imports, a new module, or a `*.server.*` module, all without a process restart. A page, layout or route handler nothing imports needs no reset: the dev re-import is keyed by the file's content, so an unchanged file reuses its loaded module (no new module instance per request) and an edited one is a new import. `instrumentation.*` and `env.*` run once per process, so an edit to one restarts the server on both runtimes. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, `actions`, or `db` (a schema edit has to reach a fresh process, #1664; only JS and TS source under `db/` counts, so a database file and its sidecars never restart it), or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
|
|
47
|
-
|
|
48
|
-
After a reset, app code that imports `@webjsdev/server` gets a fresh copy of the package while the first run's server keeps handling requests. Everything the two copies must agree on (the request `auth()`, `cookies()` and `headers()` read, the action signal, the seed collector and action identity, the default cache store, sessions, broadcast clients) lives in process-wide state keyed by `Symbol.for`, so a signed-in page and the `'use server'` queries it calls still see the request after any number of edits (#1590). Before that fix, `auth()` without an explicit request returned `null` in app code after the first edit.
|
|
49
|
-
|
|
50
|
-
**`webjs dev` and `webjs start` serve the directory they are started in, and refuse anywhere else (#1526).** Run them in the app directory, the one holding `app/`. In a workspace (`apps/web` under a root `package.json` with `workspaces`) that is the member, even when the CLI is hoisted to the root `node_modules`: the hoisted bin and the Bun `--hot` child both keep the directory they were started in. Started where there is no `app/` (the workspace root, or a subdirectory such as `app/` itself), both exit 1 before any `before` step runs, naming the app to start (`cd apps/web && webjs dev`), instead of booting a server that answers 404 for every route.
|
|
51
|
-
|
|
52
|
-
**`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an `instrumentation.*` / `env.*` edit (#1575), and otherwise does only the crash recovery. An agent writing files the way an AI editor does (bursts, partial writes, syntax errors then fixes, renames, deletes, atomic writes) is exercised by `scripts/dev-reload-stress.mjs` (`node scripts/dev-reload-stress.mjs <appDir> <url>` against any running app). Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
|
|
53
|
-
|
|
54
|
-
The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.
|
|
55
|
-
|
|
56
|
-
Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client, only `http` and `https` are accepted as a scheme, and a malformed host is ignored rather than failing the request. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site.
|
|
57
|
-
|
|
58
|
-
`WEBJS_NO_TRUST_PROXY=1` is the global switch for "nothing trusted sits in front of this container, stop believing these headers". It is read in ONE place and is ABSOLUTE across every forwarded-header read: the URL rewrite, the HSTS scheme check, the CSRF host resolution, and `clientIp` (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`) alike. So `rateLimit({ trustProxy: true })` is inert while the flag is set, falling back to the framework-stamped peer address (its default with no option) and warning once per process. The two CAN be set in contradiction, and the direction rule is what resolves it: the flag only ever SUBTRACTS trust and the per-call option can only add it, so the flag wins. Setting both is a misconfiguration that buckets every visitor behind the proxy onto one key, which is what the warning is for. It used to miss the CSRF host, which read `X-Forwarded-Host` regardless; that gap closed in #1104. Setting it on a genuinely proxied deploy is a misconfiguration, and it now shows up as one, since the legacy no-`Sec-Fetch-Site` CSRF fallback compares `Origin` against the raw `Host` and rejects a legitimate cross-host request. The primary `Sec-Fetch-Site` path, which nearly every real browser request takes, never consults the host at all and is unaffected either way.
|
|
59
|
-
|
|
60
|
-
One limit worth knowing: the rewrite belongs to `startServer`. An app embedded through `createRequestHandler` gets the `Request` its host adapter built, so that adapter owns the correction, the same boundary that already applies to the trusted client IP.
|
|
61
|
-
|
|
62
|
-
**Anything SHARED that you derive from `ctx.url` must be keyed by the origin.** Neither Cloudflare nor Railway strips a client-supplied `X-Forwarded-Host`, they forward it, so a hostile value reaches the container through the proxy rather than around it. That is fine for a per-request response (the attacker only poisons their own), and a real problem the moment a response is shared. WebJs handles the cache it owns: the server HTML response cache (`export const revalidate`) folds `url.origin` into every cache key (#1097), so a poisoned body is unreachable from any origin the attacker does not already control. Apply the same rule to anything you cache yourself off `ctx.url`, and note the rule reaches caches WebJs does not own either. A page served `Cache-Control: public` is stored by a CDN under the CDN's OWN key, which does not include `X-Forwarded-Host`, so keying the server cache cannot protect that copy. Derive nothing origin-dependent into a page you make publicly cacheable, or pin the origin from config rather than from `ctx.url`.
|
|
63
|
-
|
|
64
|
-
## Scaffolding a Bun app
|
|
65
|
-
|
|
66
|
-
`webjs create <name>` defaults to Node. Add `--runtime bun` for a Bun-flavored app (or run `bun create webjs <name>`, which auto-detects Bun from the invoking package manager):
|
|
67
|
-
|
|
68
|
-
```sh
|
|
69
|
-
webjs create my-app --runtime bun
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
`--runtime` is orthogonal to `--template`, so it re-flavors either full-stack or api. A Bun scaffold emits a `bun.lock`, a pure `oven/bun:1-slim` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
|
|
73
|
-
|
|
74
|
-
## Running on Bun
|
|
75
|
-
|
|
76
|
-
A Bun app installs with `bun install` like Node, then its `dev` / `start` / `db` scripts force `bun --bun` so the server itself runs on Bun:
|
|
77
|
-
|
|
78
|
-
```sh
|
|
79
|
-
bun install
|
|
80
|
-
bun run dev # or: bun run start
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
`bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun, selecting the native `Bun.serve` listener and `amaro` type stripping. The app's dependencies resolve from `node_modules` exactly as on Node. The `start.before` migrate step (`webjs db migrate`) runs under Bun too. Commit the `bun.lock` for reproducible, offline installs. The scaffold's Bun Dockerfile runs `bun install --production` and serves via `CMD ["bun", "--bun", "run", "start"]`.
|
|
84
|
-
|
|
85
|
-
**What the dev watcher ignores.** On both runtimes a change to a file nothing serves never reloads the page: `*.log`, `coverage/`, `.cache/`, `test-results/`, `playwright-report/`, and anything the app's `.gitignore` ignores (except `.env*`). So `npm run dev > dev.log` in the app folder is safe.
|
|
86
|
-
|
|
87
|
-
## Deploying either runtime
|
|
88
|
-
|
|
89
|
-
Production runs `npm run start` (Node) or `bun run start` (Bun), which serves the source directly with no build step. Both speak plain HTTP/1.1, so put a reverse proxy or platform edge in front for TLS and HTTP/2 (production perf leans on HTTP/2 multiplexing plus `modulepreload` hints, not a bundle). A `start.before` migrate runs first on both runtimes.
|
|
90
|
-
|
|
91
|
-
The scaffold ships a matching Dockerfile per runtime: a Node image for the default, a pure `oven/bun:1-slim` image for `--runtime bun`. Both install production dependencies only and leave the package manager's cache out of the image. What the boot runs is a production dependency for that reason: `drizzle-kit` (the `start.before` migrate) and, on a UI app, `@tailwindcss/cli` + `tailwindcss` (the CSS compile). Keep a tool the boot runs in `dependencies`; one moved to `devDependencies` is missing from the image and the boot fails. Commit the lockfile the runtime uses (`package-lock.json` for Node, `bun.lock` for Bun) so the deploy install is reproducible and offline.
|
|
92
|
-
|
|
93
|
-
## SQLite busy_timeout
|
|
94
|
-
|
|
95
|
-
Both `node:sqlite` and `bun:sqlite` default `busy_timeout` to 0, so a contended write throws `database is locked` immediately. The generated connection sets `PRAGMA busy_timeout = 5000` plus `PRAGMA journal_mode = WAL` on the raw client before Drizzle wraps it, on both runtime branches, so you get a sane 5-second wait instead of an instant failure. This is already wired in the scaffold's `db/connection.server.ts`.
|
|
96
|
-
|
|
97
|
-
## Postgres pool (`--db postgres`)
|
|
98
|
-
|
|
99
|
-
A `--db postgres` app opens one `pg` Pool in `db/connection.server.ts`. It holds no session state between queries (no `SET`, `LISTEN`, or advisory lock kept across them), so the same code works behind a transaction pooler. Idle connections close after 10 seconds, a connection attempt gives up after 5 seconds (a database that is down fails the request instead of hanging it), `DATABASE_POOL_MAX` caps the pool (default 10), and an idle connection the server drops (a restart or failover) is logged rather than crashing the process. Keep it that way: do not add `SET` statements or session-scoped features to app code.
|
|
100
|
-
|
|
101
|
-
## Knowing when an edit has settled
|
|
102
|
-
|
|
103
|
-
After an edit, the dev server rebuilds and re-analyses the app, and on Node it may restart first. The reload frame a tab receives goes out BEFORE anything is re-imported, so a broken module only shows on the next request. Do not `sleep` and grep a page for "Server error". Run `npx webjs status [path...]` (#1664): it waits until the change has been rebuilt and the analysis has run again (retrying through a Node restart), requests each path (the app root when none is given) plus every page with a recorded render error, then prints every error, render, TypeScript strip, rebuild, route handler, server action, middleware, with its file, line and code frame. A render error stays listed across an unrelated rebuild until that url answers below 500 again (fixed, or deleted and now a 404), and the other kinds clear on each rebuild. A page re-requested only because of an old error fails the run on a server error, not on a 404. It exits 0 when settled and clean, 1 on any problem, 2 when no dev server answered; `--json` gives one document an agent can branch on. The same data is the dev-only `GET /__webjs/dev/status?wait=<seconds>` (`{ status, settled, ready, boot, seq, rebuilds, errors }`), which answers 404 under `npm run start`.
|
|
104
|
-
|
|
105
|
-
## Verifying a runtime-sensitive change
|
|
106
|
-
|
|
107
|
-
Most app code needs no runtime-specific testing, because it does not touch a runtime seam. If you DO change something runtime-sensitive (the serializer, a stream, `node:crypto`, low-level request handling, anything that behaves differently under `Bun.serve` versus `node:http`), prove it on both runtimes. The Node suite is the source of truth, and an additive Bun matrix re-runs the runtime-sensitive tests under Bun. See `testing.md` for the cross-runtime matrix and the `test/bun/**` assertions.
|
|
108
|
-
|
|
109
|
-
For an ordinary feature (a page, an action, a component) a single-runtime test is enough, since the source is identical on either runtime.
|
|
110
|
-
|
|
111
|
-
## Future runtimes
|
|
112
|
-
|
|
113
|
-
The listener seam is runtime-neutral, so a `Deno.serve` shell (or an embedded adapter) slots in at the same point when added. Edge runtimes with no filesystem are a separate, later target. Until then, treat Deno as planned, not supported, and build on Node or Bun.
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# Service Worker (offline, opt-in)
|
|
2
|
-
|
|
3
|
-
## What This Covers
|
|
4
|
-
|
|
5
|
-
- The opt-in progressive-enhancement service worker that UI scaffolds ship dormant (`public/sw.js` plus `public/offline.html`).
|
|
6
|
-
- Why it is progressive-enhancement-safe: the worker registers only from JS, so the JavaScript-disabled baseline is unchanged.
|
|
7
|
-
- How to enable it (the registration snippet in the root layout), what it caches, and the offline fallback.
|
|
8
|
-
- Cache versioning tied to the deploy, updating the worker, and removing it.
|
|
9
|
-
|
|
10
|
-
Read this when you want an offline experience or an asset cache in a WebJs app, or you see `public/sw.js` in a scaffold and want to know what it does. For CSP nonces see `built-ins.md`. For the root layout see `routing-and-pages.md`. For the content-hash `?v=` asset URLs it relies on see `built-ins.md`.
|
|
11
|
-
|
|
12
|
-
## What ships and why it is safe
|
|
13
|
-
|
|
14
|
-
WebJs's UI scaffold (full-stack, not the api template) ships a hand-authored service worker at `public/sw.js` and an offline fallback at `public/offline.html`. Both ship **dormant**: they do nothing until the app registers the worker, and the worker only ever registers from JavaScript. So with JS off no worker exists, and pages, links, and forms behave exactly as before. It is opt-in and adds an offline experience plus an asset cache without changing the no-JS baseline.
|
|
15
|
-
|
|
16
|
-
This is a thin, hand-readable worker built directly on the native Service Worker and Cache Storage APIs. There is no Workbox, no precache framework, and no bundler step, matching WebJs's no-build, close-to-web-standards posture. The file is yours to edit, not a framework internal.
|
|
17
|
-
|
|
18
|
-
## Enabling it
|
|
19
|
-
|
|
20
|
-
Add this inline script to the root layout's `<head>` (`app/layout.ts`). It registers after load and only when JS is present, so it stays progressive-enhancement-safe:
|
|
21
|
-
|
|
22
|
-
```html
|
|
23
|
-
<script>
|
|
24
|
-
if ('serviceWorker' in navigator) {
|
|
25
|
-
addEventListener('load', () => {
|
|
26
|
-
// Tie the worker version to the deploy: read the importmap build id and
|
|
27
|
-
// register /sw.js?v=<build>, so a new deploy registers a "new" worker.
|
|
28
|
-
const tag = document.querySelector('script[type="importmap"]');
|
|
29
|
-
const build = (tag && tag.dataset.webjsBuild) || '';
|
|
30
|
-
navigator.serviceWorker.register('/sw.js' + (build ? '?v=' + build : ''));
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
</script>
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
With WebJs's CSP enabled (`webjs.csp` in package.json), stamp the nonce on the script. Read it in the layout with `import { cspNonce } from '@webjsdev/core'` and emit `<script nonce="${cspNonce()}">...`.
|
|
37
|
-
|
|
38
|
-
## Scope and caching strategy
|
|
39
|
-
|
|
40
|
-
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.
|
|
41
|
-
|
|
42
|
-
- **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.
|
|
43
|
-
- **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.
|
|
44
|
-
|
|
45
|
-
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()`.
|
|
46
|
-
|
|
47
|
-
**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.
|
|
48
|
-
|
|
49
|
-
## The offline fallback page
|
|
50
|
-
|
|
51
|
-
`public/offline.html` is a plain, self-contained HTML page the worker serves only when a navigation fails and no cached copy of that URL exists. Treat it as the app's offline chrome and edit it to match the app's branding. It ships as a minimal placeholder in the scaffold. Because it renders with no network and no module system, keep it static: no component tags, no importmap dependency, inline any styles it needs.
|
|
52
|
-
|
|
53
|
-
## Versioning ties to the deploy
|
|
54
|
-
|
|
55
|
-
The cache name is `webjs-<build>`, where `<build>` is the `?v=` query the registration passes (the importmap build id from `data-webjs-build`). When a deploy changes the build id:
|
|
56
|
-
|
|
57
|
-
1. the page registers `/sw.js?v=<new-build>`, a different worker URL, so the browser fetches and installs the new worker;
|
|
58
|
-
2. the new worker's `activate` deletes every cache whose name is not the current `webjs-<new-build>`, evicting the prior deploy's cache.
|
|
59
|
-
|
|
60
|
-
So a deploy refreshes the offline cache automatically, with no manual cache busting. Without a `?v=` (a dev registration, say), the cache name is `webjs-dev`.
|
|
61
|
-
|
|
62
|
-
## Updating the worker
|
|
63
|
-
|
|
64
|
-
The browser re-checks `/sw.js` on navigation and replaces the worker when its bytes change. Because the registration URL carries the build id, a deploy always changes that URL and triggers the update. The worker calls `skipWaiting()` plus `clients.claim()`, so a new version takes control promptly. To change the caching strategy, edit `public/sw.js`.
|
|
65
|
-
|
|
66
|
-
## Removing it
|
|
67
|
-
|
|
68
|
-
Delete the registration snippet (and optionally `public/sw.js` and `public/offline.html`). To also un-register an already-installed worker on clients, ship this for one release:
|
|
69
|
-
|
|
70
|
-
```js
|
|
71
|
-
navigator.serviceWorker.getRegistrations().then(rs => rs.forEach(r => r.unregister()));
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Or rely on the worker's own update lifecycle to phase it out.
|
|
75
|
-
|
|
76
|
-
## Verifying it
|
|
77
|
-
|
|
78
|
-
After adding the registration snippet, load the app, then open the browser devtools Application panel and confirm a worker is registered and activated for the origin. To exercise the offline path, visit a page (so it caches), then toggle offline in devtools and reload: a previously visited page should serve from cache, and an unvisited URL should render `public/offline.html`. Confirm the JS-off baseline is unchanged by disabling JavaScript and checking that no worker registers and navigation still works as a plain server-rendered app.
|
|
79
|
-
|
|
80
|
-
Do not register the worker until the offline experience is something you actually want. Once registered, a worker changes caching for returning visitors: navigations stay network-first (an online visitor gets fresh server HTML), assets are served stale-while-revalidate, and the cached page shell is served only when the visitor is offline, until a new deploy build id evicts the cache.
|