@webjsdev/mcp 0.1.10 → 0.1.11
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/package.json +2 -2
- package/resources/AGENTS.md +113 -42
- package/resources/SKILL.md +33 -16
- package/resources/references/auth-and-sessions.md +86 -21
- package/resources/references/built-ins.md +55 -9
- package/resources/references/client-router-and-streaming.md +59 -9
- package/resources/references/components.md +117 -8
- package/resources/references/data-and-actions.md +63 -12
- package/resources/references/muscle-memory-gotchas.md +104 -3
- package/resources/references/optimistic-ui.md +49 -10
- package/resources/references/routing-and-pages.md +62 -18
- package/resources/references/runtime.md +10 -1
- package/resources/references/service-worker.md +5 -3
- package/resources/references/styling.md +116 -2
- package/resources/references/testing.md +67 -9
- package/resources/references/typescript.md +75 -6
- package/resources/references/ui-kit.md +18 -10
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
5
|
- The Next.js patterns that LOOK right in WebJs but break, because WebJs borrows Next's file-based routing shape but not its execution model (no RSC, no `'use client'` split): `redirect()` in a route handler, `fetch()` in a page, `<Link>`, `NEXT_PUBLIC_`, `await params`.
|
|
6
|
-
- The Lit patterns that break WebJs SSR or reactivity, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, interpolation into `<style
|
|
6
|
+
- The Lit patterns that break WebJs SSR or reactivity, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, interpolation into `<style>`, reading `assignedNodes()` in `firstUpdated` of a light-DOM component.
|
|
7
7
|
- The WebJs-shaped fix for each, with short code.
|
|
8
8
|
|
|
9
9
|
Read this when a pattern feels familiar from Next.js or Lit but you are not sure it transfers. For the component runtime see `components.md`; for the routing surface see `routing-and-pages.md`. The one difference underneath everything: pages and layouts render server-only and never hydrate, and the one client boundary is a `WebComponent` custom element.
|
|
@@ -24,10 +24,10 @@ In Next, `redirect()` works in Server Components, Actions, and Route Handlers al
|
|
|
24
24
|
// route.ts WRONG: redirect() is uncaught here.
|
|
25
25
|
export async function GET() { redirect('/login'); }
|
|
26
26
|
// route.ts RIGHT: return a real redirect Response.
|
|
27
|
-
export async function GET() { return Response.redirect(new URL('/login', req.url), 303); }
|
|
27
|
+
export async function GET(req: Request) { return Response.redirect(new URL('/login', req.url), 303); }
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Do NOT throw `redirect()` from a
|
|
30
|
+
Do NOT throw `redirect()` from a form-bound action to bounce a form POST either. The method-preserving 307 default re-POSTs the body and re-runs the mutation. Return an `ActionResult` with a `redirect` field instead (a 303 PRG), or throw only for a real external redirect.
|
|
31
31
|
|
|
32
32
|
### Reads are server actions, not `fetch()` in a Server Component
|
|
33
33
|
|
|
@@ -43,6 +43,103 @@ const users = await getUsers();
|
|
|
43
43
|
|
|
44
44
|
There is no React `cache()`, `use()`, or `unstable_cache`. Caching is the `cache()` query helper, `export const revalidate` on a page, or `export const cache` on a GET action.
|
|
45
45
|
|
|
46
|
+
### `<form action=${fn}>` binds, in exactly one shape
|
|
47
|
+
|
|
48
|
+
Next binds a Server Action with `<form action={createTodo}>`, and WebJs reads the same shape, so this muscle memory transfers. The mechanism underneath differs, and the difference is what the rest of this section is about: React serializes the binding (bound arguments included) into hidden fields, while WebJs emits ONE hidden field carrying the action's `<hash>/<fn>` identity and no arguments. A per-row action therefore takes its row id from a hidden input in the form, never from `action.bind(null, id)`.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
|
|
52
|
+
html`<form action=${submitFeedback}><input name="email"></form>`;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That is the whole 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 the identity field. Writing `method="get"` on a bound form throws, because a GET form sends no body and the action could never run.
|
|
56
|
+
|
|
57
|
+
**A form whose buttons run different actions binds each one on its submitter**, with the same unquoted spelling one level down:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
html`<form action=${saveDraft}>
|
|
61
|
+
<input name="title">
|
|
62
|
+
<button>Save</button>
|
|
63
|
+
<button formaction=${publishPost}>Publish</button>
|
|
64
|
+
</form>`;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed. The submitter must be a `<button>` and cannot carry its own `name`, `value`, or `form` attribute, because the identity already occupies that pair. `<input type="submit">` is refused for the binding: the identity has to occupy its `value`, which on that control is also the visible caption, so the button would render captioned with the action id and could never be labelled. A `<button>` has no such conflict, since its label is its children.
|
|
68
|
+
|
|
69
|
+
**Only the bare, unquoted `action=${fn}` on a `<form>` binds.** Every near-miss is a hard render error rather than a silently-inert form, and the reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike.
|
|
70
|
+
|
|
71
|
+
What escapes is the SOURCE the runtime reports, and how much that includes depends on the runtime. The body always goes: your query shapes, your table and column names, your internal paths, and any credential written inline.
|
|
72
|
+
|
|
73
|
+
Whether an OUTER value goes with it is not something to rely on either way. `Function.prototype.toString` returns source text, so on Node a module-scope `const` the body reads appears as its identifier. Bun transpiles the module before the engine sees it and can fold that literal into the body, so the same action reports the VALUE:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
// const VENDOR_API_KEY = 'sk_live_…'; then used as `Bearer ${VENDOR_API_KEY}`
|
|
77
|
+
node 26 Authorization: `Bearer ${VENDOR_API_KEY}` identifier only
|
|
78
|
+
bun 1.3 Authorization: "Bearer sk_live_…" the key itself
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Do not go looking for the rule that decides when it folds. Export status, read count, declaration position, and whether the module has an import have each been measured as the deciding factor and each produced a counterexample on the same bun version, so whatever the optimizer keys on is finer than any of them. The two rows above are one measurement on two specific versions, not a per-runtime guarantee: read them as proof that the boundary moves, never as a promise that Node keeps an outer binding private.
|
|
82
|
+
|
|
83
|
+
So treat everything reachable from the action as exposed. That is the assumption the refusal is built on, it is the only one that holds across runtimes, and it is the only one that stays true when the transpiler changes.
|
|
84
|
+
|
|
85
|
+
The refusal covers the shape, not one spelling of it. A quoted `action="${fn}"` and the mixed `action="/x/${fn}"` are refused, because quoting turns a binding hole back into a plain attribute; so is a function wrapped in an array (`action=${[fn]}`), since an array stringifies each element through `String()` and leaks identically. Unsupported `formaction=` shapes are refused, including non-submit controls, duplicate holes, and submitters carrying `name`, `value`, `form`, or static `formaction` attributes. Attribute names fold case, so `ACTION=${fn}` on a `<form>` BINDS like the lowercase spelling, while quoted or otherwise unsupported `formAction=${fn}` shapes are refused.
|
|
86
|
+
|
|
87
|
+
**Commenting the form out does not disable the hole.** A comment is HTML, the interpolation is JavaScript, and the renderer emits a comment's holes raw, so a hole inside `<!-- ... -->` never reaches the binding branch and is stringified instead: `<!-- <form action=${createTodo}> -->` ships the whole action body with no throw and no log. Commenting out a WORKING binding is therefore not a way to disable it, it is a way to turn it into a leak. Delete the form or move it out of the template. This is the one shape in this section that leaks silently, which is exactly why it is worth knowing.
|
|
88
|
+
|
|
89
|
+
It is not special to comments. `String(fn)` returns source text wherever it runs, so a bare function in a text child (`<div>${fn}</div>`) or any unclaimed attribute (`title=${fn}`) writes the same body out. Only the two form-action attribute names are claimed today; treat a function anywhere else in a template as a mistake that ships, and reach for `@event=${fn}` or a custom element's `.prop=${fn}`, neither of which stringifies.
|
|
90
|
+
|
|
91
|
+
The bound, refused, and allowed shapes in full. Every "no" row is a binding that stringifies nothing, so refusing it would break working code rather than close a leak:
|
|
92
|
+
|
|
93
|
+
| Written as | Refused? | Why |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `action=${fn}` unquoted, on a `<form>` | **no, it BINDS** | the one supported shape: the identity is resolved and emitted as a hidden field, nothing is stringified |
|
|
96
|
+
| `action=${fn}` on any other tag | yes | `action` submits nothing off a `<form>`, so it is an ordinary attribute and the function would be stringified |
|
|
97
|
+
| `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
|
|
98
|
+
| `formaction=${fn}` unquoted, on a submitter inside a bound form | **no, it BINDS** | the second supported shape (#1207). The identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, so no `formaction` url is emitted and the server takes the LAST `__webjs_action` entry |
|
|
99
|
+
| `formaction=${fn}` inside an UNBOUND `<form>` | yes | `method="post"` and the enctype are forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action cannot retrofit them |
|
|
100
|
+
| `formaction=${fn}` on a submitter carrying its own `name` or `value` | yes | the identity IS that name/value pair, so both halves are already spoken for. Bind one action on the form and dispatch on `name="intent"` if you need the button's own value |
|
|
101
|
+
| `formaction=${fn}` on a non-submit control, or `<input type="image">` | yes | `formaction` is inert on anything that does not submit, and an image submitter sends `name.x` / `name.y` coordinates instead of `name=value`, so the identity would never arrive |
|
|
102
|
+
| `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a form other than the bound one it sits in, so the boundness just checked was about the wrong element |
|
|
103
|
+
| `formmethod="get"` / `formenctype="text/plain"` on ANY submitter inside a bound form | yes | this is the Part B rule, and it applies whether or not that button binds an action of its own: a GET sends no body and `text/plain` is not parseable, so the submission works under JS (the router posts `FormData`) and 405s without it |
|
|
104
|
+
| `formmethod="dialog"` on a submitter that binds nothing | **no** | a native `<dialog>` dismissal, never a submission, so there is no body for the action to miss. It IS refused on a button that also binds an action, which is a straight contradiction |
|
|
105
|
+
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so its own `formmethod` is the author's business and Part B leaves it alone |
|
|
106
|
+
| `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it |
|
|
107
|
+
| `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes |
|
|
108
|
+
| a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position |
|
|
109
|
+
| a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page |
|
|
110
|
+
| `method=" post "` / `enctype=" multipart/form-data "` | yes | `method` and `enctype` are enumerated attributes matched against exact keywords with no whitespace stripping, so a padded value falls to the invalid-value default and the form submits as a GET with no body. Trimming it for you would emit the padded value anyway |
|
|
111
|
+
| `encoding="..."` as an ATTRIBUTE on a bound form | **no** | inert in HTML (`form.encoding` reads back `enctype`), so both renderers ignore it and still supply `enctype`. Only the `.encoding` PROPERTY aliases enctype, and that spelling IS refused, one row up |
|
|
112
|
+
| `.formAction=` on a button or input | yes | same reason, that is where `formAction` reflects |
|
|
113
|
+
| `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
|
|
114
|
+
| `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute, so a function is a legitimate value. One declared `reflect: true` reflects on a path outside these commit sites, which used to write `String(value)` and emit the source. It now removes the attribute and warns instead, for a bare function and for an array carrying one, unless the prop supplies its own `converter.toAttribute`, which runs first and stays the author's call |
|
|
115
|
+
| `?action=` | yes | a function never leaked through a boolean hole, but the binding is meaningless, so it is refused rather than emitting the bare `action=""` that ANY truthy value produces there. Two separate facts worth carrying: `action=""` is a conformance error (the spec wants a valid non-empty URL whenever the attribute is present), and deleting the attribute is still not the WebJs fix, since a page has no `action` export and an unbound `method="post"` form is a 405 (a bare GET form just re-renders). Bind it: `<form action=${fn}>` |
|
|
116
|
+
| `@action=` unquoted | **no** | an event listener, and a function is exactly what one takes |
|
|
117
|
+
| `@action="${fn}"` quoted | yes | quoting makes it an ordinary attribute again, so it leaks |
|
|
118
|
+
|
|
119
|
+
That last row is the one to remember: quoting a binding hole turns it back into a plain attribute, which is why invariant 4 requires `@`, `.` and `?` holes to be unquoted.
|
|
120
|
+
|
|
121
|
+
`.action=${fn}` on a native form is refused during SSR too, even though the property is dropped there and nothing could leak, so a page cannot render clean on the server and then throw on hydration.
|
|
122
|
+
|
|
123
|
+
**Inside a component you may never see the error.** Per-component SSR error isolation contains the throw, so development shows an error box in place of the component and production renders it empty with the page still returning 200. A form that has silently vanished in production is this bug wearing a disguise; the message is in the server log. Nothing leaks either way.
|
|
124
|
+
|
|
125
|
+
Two things that "renders it empty" understates, both worth knowing before you go looking:
|
|
126
|
+
|
|
127
|
+
- **Anything slotted into the failing component goes with it.** The isolation replaces the element from its opening tag through its matching close, so a shell or layout component whose template holds the bad form takes the page's whole authored body with it. Put `action=${fn}` in a shared header and every page renders a 200 with an empty body, not one missing header.
|
|
128
|
+
- **On a route with a `loading.{js,ts}`, there is no log line either.** That wraps the page in a `Suspense` boundary, so the page body renders AFTER the 200 and the shell have been flushed, and a boundary that throws there is currently swallowed with no server log, no `onError`, and no error boundary. The visitor gets chrome and an empty body; with JS off the skeleton simply stays. That silence is a known framework gap rather than intended behaviour, so do not read the missing log line as evidence the render succeeded.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
|
|
132
|
+
// RIGHT: bind the imported action. method and enctype are supplied.
|
|
133
|
+
html`<form action=${submitFeedback}><input name="email"></form>`;
|
|
134
|
+
// WRONG: a bare form binds nothing, so the submission is a 405. There is no
|
|
135
|
+
// page `action` export to catch it.
|
|
136
|
+
html`<form method="post"><input name="email"></form>`;
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A hole that resolves to `null` is NOT the same as omitting the attribute. `method=${null}` renders `method=""`, which cannot submit and is refused; `?method=${false}` emits nothing at all, so WebJs supplies `method="post"` and the form works. Both leave no attribute in the DOM, which is exactly why the check reads your template rather than the rendered element.
|
|
140
|
+
|
|
141
|
+
A string stays a string: `action="/search"` and `action=${'/search'}` are unchanged, which is what a search form (`<form method="get" action="/search">`) and a `route.ts` endpoint both want. Other attributes keep their existing stringify behaviour; only a FUNCTION under `action` / `formaction` is claimed.
|
|
142
|
+
|
|
46
143
|
### `params` and `searchParams` are awaitable AND synchronously readable
|
|
47
144
|
|
|
48
145
|
Next 15/16 made `params` / `searchParams` Promises. WebJs supports BOTH, so either muscle memory is correct.
|
|
@@ -149,6 +246,10 @@ The `@property()` decorator is banned by the erasable-TS invariant (decorators a
|
|
|
149
246
|
|
|
150
247
|
Lit defaults to shadow DOM, so `static styles = css` scopes automatically. WebJs defaults to light DOM. A `static styles` block without `static shadow = true` does nothing useful and any inline `<style>` with bare class names leaks globally. The webjs-shaped fix is Tailwind utilities, which apply directly in light DOM. Reach for `static shadow = true` plus `static styles` only when scoped CSS genuinely belongs in a shadow root, or prefix every selector with the tag name if authoring vanilla light-DOM CSS.
|
|
151
248
|
|
|
249
|
+
### Reading `assignedNodes()` in `firstUpdated` of a light-DOM component
|
|
250
|
+
|
|
251
|
+
In shadow DOM the browser projects slotted content natively before `firstUpdated`, so Lit muscle memory says `this.shadowRoot.querySelector('slot').assignedNodes()` is populated there. In light DOM the first projection lands one microtask AFTER the first render, so `firstUpdated` sees the `<slot>` element with an EMPTY `assignedNodes()`. The webjs-shaped fix: read assigned content from a `slotchange` listener (fires once projection lands, and on every later change), or wait a microtask. Every later read and every mutation-driven update behaves identically in both modes; only the first-render read differs.
|
|
252
|
+
|
|
152
253
|
### `:host { display: block }` on a light-DOM component
|
|
153
254
|
|
|
154
255
|
A custom element is `display: inline` by default, so a block container collapses. In Lit you fix this with `:host { display: block }`, which works because Lit is shadow-DOM-first. A light-DOM WebJs component has no shadow root, so there is no `:host` to write. There is nothing to do: the framework already defaults every light-DOM host to `display: block` via a low-priority `@layer webjs-host` rule, overridable by any Tailwind utility (`class="flex"` wins). A shadow-DOM component (`static shadow = true`) still sets `:host { display: block }` in `static styles` itself, exactly like Lit.
|
|
@@ -17,6 +17,8 @@ Read this when a mutation should feel instant, when the client can predict the r
|
|
|
17
17
|
|
|
18
18
|
`optimistic(host, { source, update })` returns an `OptimisticState<State, Action>` with a `.value` getter and an `.add(payload, promise?)` method. The `source` reads the authoritative state (usually a reactive prop). The `update` reducer transforms that state with each payload. Calling `.add()` pushes an update and schedules a re-render, so `.value` reflects the optimistic state on the next paint.
|
|
19
19
|
|
|
20
|
+
**The reducer must be pure.** `.value` re-folds the whole queue on every read, so `update` runs again on each render rather than once per `.add()`. Anything it MINTS is therefore minted per render. Mint a temp id in the handler and pass it in the payload. A `crypto.randomUUID()` inside the reducer gives the pending row a different id on every read, which breaks a keyed list and anything else treating that id as stable.
|
|
21
|
+
|
|
20
22
|
```ts
|
|
21
23
|
import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
|
|
22
24
|
import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
|
|
@@ -26,12 +28,18 @@ class TodoList extends WebComponent({
|
|
|
26
28
|
}) {
|
|
27
29
|
private optimisticTodos = optimistic(this, {
|
|
28
30
|
source: () => this.todos,
|
|
29
|
-
|
|
31
|
+
// KEEP THIS REDUCER PURE. `.value` re-folds every queued update on EVERY
|
|
32
|
+
// read, not once per `.add()`, so anything minted in here is minted again
|
|
33
|
+
// on each render. A `crypto.randomUUID()` here would hand the pending row
|
|
34
|
+
// a NEW id per render, and a keyed list (`repeat(todos, t => t.id, ...)`)
|
|
35
|
+
// would tear the row down and rebuild it every update, losing focus, any
|
|
36
|
+
// in-progress transition, and DOM state. Mint the temp id in the handler
|
|
37
|
+
// and carry it in the payload, so it is stable for the life of the row.
|
|
38
|
+
update: (state, add: { tempId: string; title: string }) => [
|
|
30
39
|
...state,
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
{ id: crypto.randomUUID() as any, title, completed: false, pending: true },
|
|
40
|
+
// `createdAt` is rebuilt per read too. That is tolerable only because
|
|
41
|
+
// nothing keys on it; put it in the payload as well if anything does.
|
|
42
|
+
{ id: add.tempId, title: add.title, completed: false, createdAt: new Date(), pending: true },
|
|
35
43
|
],
|
|
36
44
|
});
|
|
37
45
|
|
|
@@ -41,14 +49,22 @@ class TodoList extends WebComponent({
|
|
|
41
49
|
if (!title) return;
|
|
42
50
|
(e.target as HTMLFormElement).reset();
|
|
43
51
|
|
|
52
|
+
// Minted ONCE here, not in the reducer. `Todo['id']` is a string (a uuid
|
|
53
|
+
// primary key), so no cast is needed. Against an auto-increment integer
|
|
54
|
+
// id there is no honest client-side value, so model the temp row instead
|
|
55
|
+
// (an optional id, or a `tempId` the row keys on) rather than casting.
|
|
56
|
+
const tempId = crypto.randomUUID();
|
|
44
57
|
const promise = createTodo({ title });
|
|
45
|
-
this.optimisticTodos.add(title, promise);
|
|
58
|
+
this.optimisticTodos.add({ tempId, title }, promise);
|
|
46
59
|
|
|
47
60
|
const result = await promise;
|
|
48
61
|
if (result.success && result.data) {
|
|
49
|
-
// Reconcile: the optimistic
|
|
50
|
-
//
|
|
51
|
-
//
|
|
62
|
+
// Reconcile: the optimistic row is never written to `this.todos`. The
|
|
63
|
+
// overlay holds only the PAYLOAD, and `update` rebuilds the row from it
|
|
64
|
+
// on each `.value` read, so this prop holds only confirmed rows. Append
|
|
65
|
+
// the server's canonical row, matching the order the `update` reducer
|
|
66
|
+
// used. (The overlay entry auto-released when the promise settled, so
|
|
67
|
+
// `.value` does not double-count it on the next paint.)
|
|
52
68
|
this.todos = [...this.todos, result.data];
|
|
53
69
|
}
|
|
54
70
|
}
|
|
@@ -62,11 +78,34 @@ class TodoList extends WebComponent({
|
|
|
62
78
|
TodoList.register('todo-list');
|
|
63
79
|
```
|
|
64
80
|
|
|
65
|
-
**Auto-release is the whole point.** Pass the action's promise as the second argument to `.add(payload, promise)`, and the update auto-releases the moment that promise settles (resolve OR reject). It uses `.finally()`, with a `.then()` fallback for thenables that lack `.finally`. No try-catch, no manual rollback, no temp
|
|
81
|
+
**Auto-release is the whole point.** Pass the action's promise as the second argument to `.add(payload, promise)`, and the update auto-releases the moment that promise settles (resolve OR reject). It uses `.finally()`, with a `.then()` fallback for thenables that lack `.finally`. No try-catch, no manual rollback, no reconciling a temp id against the real one. The handler mints a temp id for the pending row, but nothing tracks it afterwards: the overlay drops whole when the promise settles, and the authoritative row arrives from `result.data`. On failure the optimistic entry simply drops when the promise rejects.
|
|
66
82
|
|
|
67
83
|
- Multiple `.add()` calls stack independently. Each carries its own release by ID, so overlapping in-flight mutations do not clobber one another.
|
|
68
84
|
- When `update` is omitted, the payload REPLACES the state directly (`Action = State`), matching the simple `useOptimistic(setState)` pattern.
|
|
69
85
|
|
|
86
|
+
### Author the optimistic mutation as a degrade-first form
|
|
87
|
+
|
|
88
|
+
Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler.
|
|
89
|
+
|
|
90
|
+
The SAME imported function is the form binding and the optimistic path's callee, which is what makes a degrade-first form cheap to write: there is no second wiring to keep in step.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
|
|
94
|
+
|
|
95
|
+
render() {
|
|
96
|
+
return html`
|
|
97
|
+
<form action=${createTodo} @submit=${this.handleSubmit}>
|
|
98
|
+
<input name="title" required>
|
|
99
|
+
<button>Add</button>
|
|
100
|
+
</form>
|
|
101
|
+
<ul>${this.optimisticTodos.value.map(t => html`<li class=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}</ul>`;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`method` and the enctype are supplied by the renderer, and the hidden identity field is re-inserted as the form's first child on every client render, so there is nothing to manage by hand.
|
|
106
|
+
|
|
107
|
+
When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons inside a bound form (#1207). Alternatively, a form that dispatches dynamically can bind ONE action and inspect a submit button's `name="intent"`.
|
|
108
|
+
|
|
70
109
|
## Seed the list from the server for SSR plus optimistic
|
|
71
110
|
|
|
72
111
|
For a page that server-renders a list AND lets the user add to it optimistically, let ONE component own both the list and the form, and seed it from the page through a `.prop` hole (a DOM property that round-trips through SSR on custom elements). The list is then fully server-rendered on first paint (readable with JS off) and re-renders optimistically on each add. A separate static list in the page would not update on an optimistic add.
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
- Pages, layouts, and where the HTML shell comes from
|
|
6
6
|
- Dynamic (`[param]`), catch-all (`[...rest]`), and optional catch-all (`[[...rest]]`) segments, route groups, private folders
|
|
7
|
-
- `route.ts` HTTP handlers and `middleware.ts`
|
|
8
|
-
- `metadata` and `generateMetadata` (folded in here)
|
|
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
9
|
- Control-flow throws: `notFound()`, `redirect()`, `forbidden()`, `unauthorized()`
|
|
10
|
-
- The no-JS
|
|
10
|
+
- The no-JS write path: a `<form>` bound to a `'use server'` action
|
|
11
11
|
- Boundaries: `error.ts`, `loading.ts`, `not-found.ts`, `forbidden.ts`, `unauthorized.ts`, and the two root-only ones
|
|
12
12
|
|
|
13
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).
|
|
@@ -32,7 +32,7 @@ export default function About() {
|
|
|
32
32
|
|
|
33
33
|
`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.
|
|
34
34
|
|
|
35
|
-
Optional named exports: `metadata` / `generateMetadata` (below)
|
|
35
|
+
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).
|
|
36
36
|
|
|
37
37
|
## Layouts (`app/**/layout.ts`)
|
|
38
38
|
|
|
@@ -41,7 +41,8 @@ The default export receives `{ children, params, searchParams, url }` and must e
|
|
|
41
41
|
```ts
|
|
42
42
|
// app/layout.ts (root)
|
|
43
43
|
import { html } from '@webjsdev/core';
|
|
44
|
-
|
|
44
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
45
|
+
export default function RootLayout({ children }: LayoutProps) {
|
|
45
46
|
return html`<html lang="en"><head></head><body>${children}</body></html>`;
|
|
46
47
|
}
|
|
47
48
|
```
|
|
@@ -78,10 +79,24 @@ export async function GET() {
|
|
|
78
79
|
|
|
79
80
|
**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.
|
|
80
81
|
|
|
82
|
+
**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()`.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { json, readBody, clientIp } from '@webjsdev/server';
|
|
86
|
+
export async function POST(req: Request) {
|
|
87
|
+
const body = await readBody(req); // rich types decoded
|
|
88
|
+
return json({ at: new Date(), ip: clientIp(req) }); // Date survives the wire
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**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).
|
|
93
|
+
|
|
81
94
|
## Middleware (`middleware.ts`)
|
|
82
95
|
|
|
83
96
|
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.
|
|
84
97
|
|
|
98
|
+
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.
|
|
99
|
+
|
|
85
100
|
## Metadata and `generateMetadata`
|
|
86
101
|
|
|
87
102
|
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.
|
|
@@ -96,28 +111,30 @@ export async function generateMetadata(ctx: MetadataContext): Promise<Metadata>
|
|
|
96
111
|
}
|
|
97
112
|
```
|
|
98
113
|
|
|
99
|
-
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
|
|
114
|
+
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.
|
|
100
115
|
|
|
101
116
|
## Control-flow throws
|
|
102
117
|
|
|
103
|
-
From `@webjsdev/core`: throw to short-circuit a page / layout render or a
|
|
118
|
+
From `@webjsdev/core`: throw to short-circuit a page / layout render or a form-bound action.
|
|
104
119
|
|
|
105
120
|
- `notFound()` renders the nearest `not-found.ts` (nearest wins from the throwing chain).
|
|
106
|
-
- `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
|
|
121
|
+
- `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 })`.
|
|
107
122
|
- `forbidden()` renders the nearest `forbidden.ts` (authenticated user lacking permission); `unauthorized()` renders the nearest `unauthorized.ts` (request not authenticated).
|
|
108
123
|
|
|
109
124
|
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`).
|
|
110
125
|
|
|
111
|
-
## The no-JS write path (a
|
|
126
|
+
## The no-JS write path (a form-bound action)
|
|
112
127
|
|
|
113
|
-
A `page.
|
|
128
|
+
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.
|
|
114
129
|
|
|
115
|
-
|
|
116
|
-
// app/contact/page.ts
|
|
117
|
-
import { html } from '@webjsdev/core';
|
|
118
|
-
import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
|
|
130
|
+
**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.
|
|
119
131
|
|
|
120
|
-
|
|
132
|
+
A form-bound action always receives the `FormData` as its first argument, which is where it differs from the same action called over RPC. `validate` is the typing seam: it receives that `FormData`, and its transform-return becomes the action's typed input.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// modules/contact/actions/send-message.server.ts
|
|
136
|
+
'use server';
|
|
137
|
+
export async function sendMessage(formData: FormData) {
|
|
121
138
|
const email = String(formData.get('email') || '').trim();
|
|
122
139
|
const body = String(formData.get('body') || '').trim();
|
|
123
140
|
const values = { email, body };
|
|
@@ -125,9 +142,15 @@ export async function action({ formData }: { formData: FormData }) {
|
|
|
125
142
|
if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
|
|
126
143
|
if (body.length < 10) fieldErrors.body = 'Message is too short';
|
|
127
144
|
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
|
|
128
|
-
await
|
|
145
|
+
await deliver({ email, body });
|
|
129
146
|
return { success: true, redirect: '/contact/thanks' };
|
|
130
147
|
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// app/contact/page.ts
|
|
152
|
+
import { html } from '@webjsdev/core';
|
|
153
|
+
import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
|
|
131
154
|
|
|
132
155
|
export default function Contact({ actionData }: {
|
|
133
156
|
actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
|
|
@@ -135,7 +158,7 @@ export default function Contact({ actionData }: {
|
|
|
135
158
|
const errors = actionData?.fieldErrors || {};
|
|
136
159
|
const values = actionData?.values || {};
|
|
137
160
|
return html`
|
|
138
|
-
<form
|
|
161
|
+
<form action=${sendMessage} class="flex flex-col gap-3">
|
|
139
162
|
<input name="email" type="email" value=${values.email || ''} required>
|
|
140
163
|
${errors.email ? html`<p class="text-sm text-red-600">${errors.email}</p>` : ''}
|
|
141
164
|
<textarea name="body" required>${values.body || ''}</textarea>
|
|
@@ -146,7 +169,17 @@ export default function Contact({ actionData }: {
|
|
|
146
169
|
}
|
|
147
170
|
```
|
|
148
171
|
|
|
149
|
-
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 `<form>`
|
|
172
|
+
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.
|
|
173
|
+
|
|
174
|
+
Three responses that are not the happy path:
|
|
175
|
+
|
|
176
|
+
- **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.
|
|
177
|
+
- **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.
|
|
178
|
+
- **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.
|
|
179
|
+
|
|
180
|
+
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.
|
|
181
|
+
|
|
182
|
+
Refusals worth knowing: `formaction=${fn}` is supported only on a `<button>` inside a bound form, 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.
|
|
150
183
|
|
|
151
184
|
## Error, loading, and 404 boundaries
|
|
152
185
|
|
|
@@ -156,3 +189,14 @@ How the result is read (server side): a success PRG-redirects with `303` (to a s
|
|
|
156
189
|
- 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). `global-not-found.ts` renders for an unmatched-anywhere URL when no `not-found` matches.
|
|
157
190
|
|
|
158
191
|
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.
|
|
192
|
+
|
|
193
|
+
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).
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
|
|
197
|
+
export default function OgImage() {
|
|
198
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">...</svg>`;
|
|
199
|
+
return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } });
|
|
200
|
+
}
|
|
201
|
+
// app/page.ts -> export const metadata = { openGraph: { images: ['/opengraph-image'] } };
|
|
202
|
+
```
|
|
@@ -35,9 +35,18 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
|
|
|
35
35
|
| Hot reload | `node --watch` | `bun --hot` |
|
|
36
36
|
| WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
|
|
37
37
|
| 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
|
|
38
|
+
| Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
|
|
38
39
|
|
|
39
40
|
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.
|
|
40
41
|
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
`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.
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
|
|
48
|
+
**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`.
|
|
49
|
+
|
|
41
50
|
## Scaffolding a Bun app
|
|
42
51
|
|
|
43
52
|
`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):
|
|
@@ -46,7 +55,7 @@ The 103 Early Hints gap costs only a small first-load latency edge where an edge
|
|
|
46
55
|
webjs create my-app --runtime bun
|
|
47
56
|
```
|
|
48
57
|
|
|
49
|
-
`--runtime` is orthogonal to `--template`, so it re-flavors
|
|
58
|
+
`--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` Dockerfile plus a bun-install CI, and bun-command agent docs. The test, db, and check tooling still runs on Node.
|
|
50
59
|
|
|
51
60
|
## Running on Bun
|
|
52
61
|
|
|
@@ -11,7 +11,7 @@ Read this when you want an offline experience or an asset cache in a WebJs app,
|
|
|
11
11
|
|
|
12
12
|
## What ships and why it is safe
|
|
13
13
|
|
|
14
|
-
WebJs's UI
|
|
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
15
|
|
|
16
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
17
|
|
|
@@ -40,7 +40,9 @@ With WebJs's CSP enabled (`webjs.csp` in package.json), stamp the nonce on the s
|
|
|
40
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
41
|
|
|
42
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
|
|
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()`.
|
|
44
46
|
|
|
45
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.
|
|
46
48
|
|
|
@@ -75,4 +77,4 @@ Or rely on the worker's own update lifecycle to phase it out.
|
|
|
75
77
|
|
|
76
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.
|
|
77
79
|
|
|
78
|
-
Do not register the worker until the offline experience is something you actually want,
|
|
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.
|