@webjsdev/cli 0.10.40 → 0.10.41
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/bin/webjs.js +4 -46
- package/lib/create.js +282 -479
- package/lib/doctor.js +1 -38
- package/package.json +5 -1
- package/templates/.agents/rules/workflow.md +61 -271
- package/templates/.agents/skills/webjs/SKILL.md +226 -0
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +220 -0
- package/templates/.agents/skills/webjs/references/built-ins.md +200 -0
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +204 -0
- package/templates/.agents/skills/webjs/references/components.md +167 -0
- package/templates/.agents/skills/webjs/references/data-and-actions.md +187 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +170 -0
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +128 -0
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +158 -0
- package/templates/.agents/skills/webjs/references/runtime.md +80 -0
- package/templates/.agents/skills/webjs/references/service-worker.md +78 -0
- package/templates/.agents/skills/webjs/references/styling.md +123 -0
- package/templates/.agents/skills/webjs/references/testing.md +125 -0
- package/templates/.agents/skills/webjs/references/typescript.md +148 -0
- package/templates/.claude/hooks/check-server-imports.mjs +1 -1
- package/templates/.claude/hooks/require-tests-with-src.sh +1 -1
- package/templates/.claude/settings.json +0 -14
- package/templates/.cursorrules +21 -189
- package/templates/.github/copilot-instructions.md +7 -185
- package/templates/.github/pull_request_template.md +1 -1
- package/templates/AGENTS.md +59 -1494
- package/templates/CLAUDE.md +0 -1
- package/templates/CONVENTIONS.md +32 -1383
- package/templates/GEMINI.md +11 -0
- package/templates/gallery/app/apple-icon.ts +0 -1
- package/templates/gallery/app/examples/todo/page.ts +0 -1
- package/templates/gallery/app/features/async-render/page.ts +0 -1
- package/templates/gallery/app/features/boundaries/page.ts +0 -1
- package/templates/gallery/app/features/broadcast/page.ts +0 -1
- package/templates/gallery/app/features/caching/page.ts +0 -1
- package/templates/gallery/app/features/client-router/page.ts +0 -1
- package/templates/gallery/app/features/client-router/second/page.ts +0 -1
- package/templates/gallery/app/features/components/page.ts +0 -1
- package/templates/gallery/app/features/directives/page.ts +0 -1
- package/templates/gallery/app/features/env/page.ts +0 -1
- package/templates/gallery/app/features/file-storage/page.ts +0 -1
- package/templates/gallery/app/features/forms/page.ts +0 -1
- package/templates/gallery/app/features/metadata/page.ts +0 -1
- package/templates/gallery/app/features/optimistic-ui/page.ts +0 -1
- package/templates/gallery/app/features/rate-limit/page.ts +0 -1
- package/templates/gallery/app/features/route-handler/page.ts +0 -1
- package/templates/gallery/app/features/routing/page.ts +0 -1
- package/templates/gallery/app/features/server-actions/page.ts +0 -1
- package/templates/gallery/app/features/service-worker/page.ts +0 -1
- package/templates/gallery/app/features/sessions/page.ts +0 -1
- package/templates/gallery/app/features/websockets/page.ts +0 -1
- package/templates/gallery/app/global-error.ts +0 -1
- package/templates/gallery/app/global-not-found.ts +0 -1
- package/templates/gallery/app/icon.ts +0 -1
- package/templates/gallery/app/manifest.ts +0 -1
- package/templates/gallery/app/opengraph-image.ts +0 -1
- package/templates/gallery/app/robots.ts +0 -1
- package/templates/gallery/app/sitemap.ts +0 -1
- package/templates/gallery/app/twitter-image.ts +0 -1
- package/templates/public/favicon.svg +5 -0
- package/templates/public/sw.js +1 -1
- package/templates/scripts/clear-gallery.mjs +95 -0
- package/lib/clear-placeholders.js +0 -98
- package/lib/design-bar.js +0 -67
- package/templates/.claude/hooks/design-review-before-stop.sh +0 -36
- package/templates/.claude/hooks/route-skills.sh +0 -35
- package/templates/.claude/skills/webjs-design-review/SKILL.md +0 -84
- package/templates/LAYOUT-REFERENCE.md +0 -96
- package/templates/lib/utils/ui.ts +0 -83
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Client Router and Streaming
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
- The automatic client router (SPA-style partial swaps), how it opts out, and programmatic `navigate()` / `revalidate()`.
|
|
6
|
+
- Link prefetch with device-adaptive defaults.
|
|
7
|
+
- `<webjs-frame>` partial-swap regions (WebJs's Turbo Frames).
|
|
8
|
+
- View Transitions opt-in.
|
|
9
|
+
- `<webjs-stream>` surgical element updates (WebJs's Turbo Streams) and streaming RPC results.
|
|
10
|
+
- `Suspense` page-level streaming and `<webjs-suspense>` component-level streaming.
|
|
11
|
+
- WebSockets (`connectWS`, the `WS` route export, `broadcast()`).
|
|
12
|
+
- The opt-in navigation-loading indicator.
|
|
13
|
+
|
|
14
|
+
Read this when a task touches client navigation, prefetch, partial-page swaps, streaming, or realtime. For the components that render inside these regions (async render, `renderFallback()`, signals) see `components.md`. For the server actions these features call see `data-and-actions.md`.
|
|
15
|
+
|
|
16
|
+
## The Client Router
|
|
17
|
+
|
|
18
|
+
The router auto-enables the moment `@webjsdev/core` loads in the browser, which is any page that ships a component. There is nothing to import or opt into. It intercepts same-origin `<a>` clicks (including inside shadow DOM), fetches the target HTML, and replaces only the inside of the deepest shared layout. Outer header, sidenav, and footer DOM is never re-rendered, so scroll positions, input values, and `<details>` state survive a navigation.
|
|
19
|
+
|
|
20
|
+
**Opting out.** App-wide with config, or per moment at runtime.
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
// package.json
|
|
24
|
+
{ "webjs": { "clientRouter": false } }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { disableClientRouter } from '@webjsdev/core';
|
|
29
|
+
disableClientRouter();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped.
|
|
33
|
+
|
|
34
|
+
**Programmatic navigation and cache eviction.**
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { navigate, revalidate } from '@webjsdev/core';
|
|
38
|
+
await navigate('/about'); // push history
|
|
39
|
+
await navigate('/login', { replace: true }); // replace history
|
|
40
|
+
revalidate('/products/123'); // evict one URL from the snapshot cache
|
|
41
|
+
revalidate(); // clear the entire snapshot cache
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward.
|
|
45
|
+
|
|
46
|
+
**Error recovery.** A 2xx/3xx swap applies in place, and an HTML error body of any status (a 422 re-rendered form, a 5xx error page) is ALSO applied in place with no reload. For a non-HTML error or a transport failure the router dispatches a cancelable `webjs:navigation-error` on `document` (detail `{ url, status, error }`). Call `preventDefault()` to own recovery, otherwise the router renders a minimal in-place alert into the layout slot.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
document.addEventListener('webjs:navigation-error', (e) => {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
showToast(`Could not load ${e.detail.url} (status ${e.detail.status})`);
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Form state.** A form submitting through the router gets `aria-busy="true"` for the in-flight duration, plus bubbling `webjs:submit-start` and `webjs:submit-end` (detail `{ form, url, ok }`) events. Style `form[aria-busy="true"]` in pure CSS or listen for the events.
|
|
56
|
+
|
|
57
|
+
## Link Prefetch
|
|
58
|
+
|
|
59
|
+
Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff.
|
|
60
|
+
|
|
61
|
+
Override per link with the `data-prefetch` attribute.
|
|
62
|
+
|
|
63
|
+
```html
|
|
64
|
+
<a href="/dashboard">adaptive default (intent on pointer, viewport on touch)</a>
|
|
65
|
+
<a href="/dashboard" data-prefetch="intent">hover / focus / touch</a>
|
|
66
|
+
<a href="/dashboard" data-prefetch="render">eager on insert</a>
|
|
67
|
+
<a href="/dashboard" data-prefetch="viewport">on scroll into view</a>
|
|
68
|
+
<a href="/dashboard" data-prefetch="none">never</a>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Next-style aliases work (`true` = `render`, `auto` = `viewport`, `false` = `none`). `viewport` uses an IntersectionObserver at threshold 0.5 with a ~250ms dwell, cancelled the instant a link scrolls back out, so a fast scroll spends no requests. Speculation is bounded by a concurrency cap, in-flight de-dupe, and an LRU + TTL cache, and is disabled entirely under `Save-Data`, `prefers-reduced-data`, or a 2g connection. The guiding rule is snappy but never at the cost of bloating the network tab, so when the two conflict the gate under-fetches.
|
|
72
|
+
|
|
73
|
+
A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<form>` submission (which the router never prefetches), never a GET link. A `webjs:prefetch` event fires on `document` when a fragment lands in the cache.
|
|
74
|
+
|
|
75
|
+
## `<webjs-frame>` Partial-Swap Regions
|
|
76
|
+
|
|
77
|
+
`<webjs-frame>` is WebJs's take on Turbo Frames, so most `<turbo-frame>` muscle memory transfers. It is a lazy, URL-addressable region that swaps on its own, driven by a link or form targeting its id, and it ships zero component JS. Use it for a region that loads or refreshes INDEPENDENTLY of a full-page navigation (a marketing widget, tabbed UI, a filtered results panel), which a page or layout cannot express.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
html`<webjs-frame id="activity">…contents…</webjs-frame>`
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
On click the router walks `closest('webjs-frame')` from the target. If a frame is found and the response carries a matching `<webjs-frame id>`, the swap is scoped to that frame's children, and the server returns ONLY that subtree.
|
|
84
|
+
|
|
85
|
+
**External targeting.** A trigger does not have to be nested inside the frame. An `<a>` or `<form>` carrying `data-webjs-frame="<id>"` drives that frame from anywhere (an explicit `data-webjs-frame` wins over the enclosing-frame default). `data-webjs-frame="_top"` is a reserved token forcing a full-page navigation that breaks out of the frame.
|
|
86
|
+
|
|
87
|
+
**Self-loading.** Give a frame a `src` and it self-fetches (through the same swap path).
|
|
88
|
+
|
|
89
|
+
```html
|
|
90
|
+
<webjs-frame id="rail" src="/widgets/rail"></webjs-frame> <!-- eager on connect -->
|
|
91
|
+
<webjs-frame id="comments" src="/posts/42/comments" loading="lazy"> <!-- fetch on viewport entry -->
|
|
92
|
+
<p>Loading comments...</p>
|
|
93
|
+
</webjs-frame>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A `src`-driven frame is JS-DEPENDENT (the browser does not natively fetch `<webjs-frame src>`), so use it for DEFERRED content where a JS-off placeholder is acceptable. For content that must exist without JS, render it server-side into the frame. A frame's route can itself use `<webjs-suspense>` to stream slow data behind a fallback. Frame events: `webjs:frame-busy` (both edges, `aria-busy` set for free) and a cancelable `webjs:frame-missing` when the response lacks the requested frame.
|
|
97
|
+
|
|
98
|
+
## View Transitions (opt-in)
|
|
99
|
+
|
|
100
|
+
The router can wrap a navigation's DOM mutation in the native View Transitions API so a swap cross-fades instead of snapping. It is OFF by default. Opt in with a meta in any page head (re-read per navigation), mirroring Turbo's convention.
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
<meta name="view-transition" content="same-origin">
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The accepted value is `same-origin`. When enabled it wraps all three swap paths (the layout-marker swap, the `<webjs-frame>` swap, and the full-body fallback). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`.
|
|
107
|
+
|
|
108
|
+
## `<webjs-stream>` Surgical Updates
|
|
109
|
+
|
|
110
|
+
`<webjs-stream>` is WebJs's take on Turbo Streams, and the action set mirrors `<turbo-stream>`. It is the only SINGLE-element update primitive (append one row, remove one item, bump a count, insert a toast), whereas a frame or layout swap redraws a whole region.
|
|
111
|
+
|
|
112
|
+
```html
|
|
113
|
+
<webjs-stream action="append" target="comments">
|
|
114
|
+
<template><li>Nice post!</li></template>
|
|
115
|
+
</webjs-stream>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Actions: `append` / `prepend` (child of the target id), `before` / `after` (sibling), `replace` (the target itself), `update` (the target's children), `remove` (no template). A `targets="<css-selector>"` applies to every match. There are two delivery paths sharing one applier. Over HTTP a form submission rides the router with `Accept: text/vnd.webjs-stream.html`, and the server returns a stream only when that Accept is present (JS off gets a normal render, so it stays progressive-enhancement-safe). Over a live channel, `renderStream(message)` applies a server-pushed payload.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// app/post/[id]/route.ts
|
|
122
|
+
import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
|
|
123
|
+
|
|
124
|
+
export async function POST(req: Request, { params }) {
|
|
125
|
+
const comment = await addComment(params.id, await req.formData());
|
|
126
|
+
const parts = stream.append('comments', `<li>${escapeHtml(comment.text)}</li>`);
|
|
127
|
+
broadcast(`post:${params.id}`, parts); // fan out to every viewer
|
|
128
|
+
if (acceptsStream(req)) return streamResponse(parts);
|
|
129
|
+
return Response.redirect(`/post/${params.id}`, 303); // no-JS fallback
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`stream.*` escapes the target id but NOT the content, so escape any user substring yourself, exactly like an `html` hole.
|
|
134
|
+
|
|
135
|
+
## Streaming (Suspense and RPC)
|
|
136
|
+
|
|
137
|
+
**Page-level streaming (`Suspense`).** Pass a promise as `children` to defer a slow region behind a fallback. TTFB is the time to render everything outside the boundary, and the resolved content streams in as a `<template>` when the promise lands.
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
import { html, Suspense } from '@webjsdev/core';
|
|
141
|
+
export default function Page() {
|
|
142
|
+
return html`<h1>Catalogue</h1>
|
|
143
|
+
${Suspense({ fallback: html`<p>Loading…</p>`, children: fetchExpensive() })}`;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Component-level streaming (`<webjs-suspense>`).** An `async render()` component BLOCKS the first byte by default (real data in the first paint). To STREAM a slow component behind a fallback instead, wrap it. Multiple boundaries fetch concurrently, and a throwing component is isolated to its own error state while siblings stream.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
html`<webjs-suspense .fallback=${html`<p>Loading section…</p>`}>
|
|
151
|
+
<user-profile uid="42"></user-profile>
|
|
152
|
+
</webjs-suspense>`
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Streaming RPC results.** A `'use server'` action that RETURNS a `ReadableStream`, async iterable, or async generator streams its chunks over the single RPC response. Detection is purely on the return value, so no config export is needed. This is the token-stream or progress case consumed imperatively after an interaction.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
'use server';
|
|
159
|
+
export async function* streamAnswer(prompt: string) {
|
|
160
|
+
for await (const token of llm.complete(prompt)) yield token;
|
|
161
|
+
}
|
|
162
|
+
// inside a component:
|
|
163
|
+
for await (const token of await streamAnswer(q)) this.text.set(this.text.get() + token);
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Back-pressure is respected, and the request `AbortSignal` cancels the source on a client disconnect or a superseded render. A mid-stream throw surfaces as an error from the iterable, so wrap the `for await` in `try/catch`. For a slow region you want behind a fallback on the FIRST paint, use `<webjs-suspense>` instead.
|
|
167
|
+
|
|
168
|
+
## WebSockets
|
|
169
|
+
|
|
170
|
+
**Server.** Export `WS` from a `route.{js,ts}` file. In dev the module re-imports per connection, so keep shared state on `globalThis`.
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
export function WS(ws, req, { params }) {
|
|
174
|
+
ws.on('message', (data) => ws.send('echo:' + data));
|
|
175
|
+
ws.on('close', () => { /* cleanup */ });
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected.
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
import { connectWS, renderStream } from '@webjsdev/core';
|
|
183
|
+
connectWS('/feed', { onMessage: (m) => renderStream(m) });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Broadcast.** `broadcast(path, data)` from `@webjsdev/server` fans a message to every connected client on that path (single-instance). For multi-instance, add Redis pub/sub yourself, there is no framework magic.
|
|
187
|
+
|
|
188
|
+
## Navigation-Loading Indicator (opt-in)
|
|
189
|
+
|
|
190
|
+
For a CSS-only progress affordance while a navigation is in flight, add `data-webjs-nav-progress` to `<html>` once in the root layout. The router then sets `data-navigating` on `<html>` during a nav (deferred 150ms, so quick navs never trigger it). Style off that attribute.
|
|
191
|
+
|
|
192
|
+
```html
|
|
193
|
+
<html data-webjs-nav-progress>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```css
|
|
197
|
+
html[data-navigating] { cursor: progress; }
|
|
198
|
+
html[data-navigating]::after {
|
|
199
|
+
content: ''; position: fixed; top: 0; left: 0; right: 0; height: 2px;
|
|
200
|
+
background: var(--accent); animation: progress 1s ease-in-out infinite;
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
It is opt-in because toggling an `<html>` attribute re-resolves `oklch()` / `color-mix()` tokens on WebKit (every iOS browser), which flashes the background for one frame on a token-driven theme. Enable it only when your theme does not lean on wide-gamut color tokens, otherwise use the JS path (listen for `webjs:navigate`, and `webjs:submit-start` for forms).
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Components
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
- Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
|
|
6
|
+
- Signals as the default state primitive for component-local and shared state
|
|
7
|
+
- The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
|
|
8
|
+
- Light DOM (default) versus shadow DOM, and the light-host `display: block` rule
|
|
9
|
+
- Slots with full shadow-DOM parity in both DOM modes
|
|
10
|
+
- `async render()`: SSR-blocking first paint, client stale-while-revalidate, `renderFallback()` / `renderError()`
|
|
11
|
+
- Display-only elision (when a component is stripped from the browser)
|
|
12
|
+
- Inherited members app code must NOT shadow (`title`, `remove`, `render`, ...)
|
|
13
|
+
|
|
14
|
+
Read this when you are authoring or reviewing a `WebComponent`. For styling a component (Tailwind, the tag-prefix rule, host sizing) see `styling.md`. For streaming a slow region or programmatic navigation see `client-router-and-streaming.md`. For Lit habits that break WebJs see `muscle-memory-gotchas.md`.
|
|
15
|
+
|
|
16
|
+
## Reactive properties: the base-class factory
|
|
17
|
+
|
|
18
|
+
Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`).
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { WebComponent, prop, html } from '@webjsdev/core';
|
|
22
|
+
|
|
23
|
+
class Dialog extends WebComponent({
|
|
24
|
+
open: prop(Boolean, { reflect: true }), // reflects to the `open` attribute
|
|
25
|
+
showClose: prop(Boolean, { attribute: 'show-close-button' }), // custom attribute name
|
|
26
|
+
variant: prop<'info' | 'danger'>(String, { reflect: true }), // narrowed union type
|
|
27
|
+
student: prop<Student>(Object), // narrowed object type
|
|
28
|
+
items: prop<Tag[]>(Array), // array-typed prop uses Array, not Object
|
|
29
|
+
internal: prop({ state: true }), // internal state, no attribute, no type
|
|
30
|
+
}) {
|
|
31
|
+
constructor() {
|
|
32
|
+
super();
|
|
33
|
+
this.open = false; // set defaults in the constructor, after super()
|
|
34
|
+
this.student = { name: '', email: '' };
|
|
35
|
+
this.items = [];
|
|
36
|
+
}
|
|
37
|
+
render() {
|
|
38
|
+
return html`<button ?disabled=${!this.open}>${this.variant}</button>`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
Dialog.register('ui-dialog');
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` to pass options or narrow the TS type.
|
|
45
|
+
|
|
46
|
+
| Option | Default | Meaning |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `type` | `String` | Constructor feeding the default attribute converter |
|
|
49
|
+
| `reflect` | `false` | Property changes write back to the HTML attribute |
|
|
50
|
+
| `state` | `false` | Internal-only. No attribute, not observed |
|
|
51
|
+
| `attribute` | derived from name | The HTML attribute name the property rides |
|
|
52
|
+
| `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
|
|
53
|
+
| `hasChanged` | strict `!==` | Custom change detection |
|
|
54
|
+
| `converter` | type-based | Custom attribute-to-property serialization |
|
|
55
|
+
|
|
56
|
+
For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type` flags the `Object` form). For anything the built-in converters cannot parse (Date, Map, Set) supply a `converter`.
|
|
57
|
+
|
|
58
|
+
**Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
|
|
59
|
+
|
|
60
|
+
## Signals are the default state primitive
|
|
61
|
+
|
|
62
|
+
Reserve the factory for values that ride an HTML attribute, reflect to one, or arrive via `.prop=${value}` SSR hydration. For everything else use signals.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { signal, computed } from '@webjsdev/core';
|
|
66
|
+
|
|
67
|
+
const cart = signal<Item[]>([]); // module-scope: shared across components, survives navigations
|
|
68
|
+
const count = computed(() => cart.get().length); // derived
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Read with `signal.get()` inside `render()`; the built-in `SignalWatcher` tracks the read and re-renders on change. An instance signal created in the constructor is component-local. For a fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
|
|
72
|
+
|
|
73
|
+
## Lifecycle (Lit-aligned) and what SSR runs
|
|
74
|
+
|
|
75
|
+
Each update cycle runs these in order; each receives a `changedProperties` Map.
|
|
76
|
+
|
|
77
|
+
| # | Hook | When |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| 1 | `shouldUpdate(changed)` | Return `false` to skip. Default `true`. |
|
|
80
|
+
| 2 | `willUpdate(changed)` | Pre-render. Assignments here fold into THIS cycle. |
|
|
81
|
+
| 3 | `update(changed)` | Default calls `render()` + commits. Override rarely. |
|
|
82
|
+
| 4 | `firstUpdated(changed)` | Once, on the first render only. |
|
|
83
|
+
| 5 | `updated(changed)` | Every commit. Ad-hoc post-render DOM work. |
|
|
84
|
+
| 6 | `updateComplete` resolves | `await el.updateComplete` to read post-render DOM in tests. |
|
|
85
|
+
|
|
86
|
+
**SSR runs only the value-deriving path**: the constructor, attribute application, `willUpdate` (and controllers' `hostUpdate`), `reflect: true` reflection, then `render()`. It does NOT invoke `connectedCallback`, `disconnectedCallback`, `firstUpdated`, `updated`, `update`'s DOM commit, `hostUpdated`, or `shouldUpdate`. So set first-paint defaults in the constructor, derive SSR-visible state in `willUpdate`, and keep browser-only work (DOM queries, layout, `localStorage`, viewport) in `connectedCallback` / `firstUpdated`. A browser global in the constructor or `render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
|
|
87
|
+
|
|
88
|
+
## Light DOM (default) vs shadow DOM
|
|
89
|
+
|
|
90
|
+
Light DOM is the default: global CSS and Tailwind utilities apply directly, no `:host` or CSS-var plumbing. Set `static shadow = true` only for `static styles = css\`...\`` scoped styles, third-party embed isolation, or the native `::slotted()` selector.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
class Panel extends WebComponent({ label: String }) {
|
|
94
|
+
static shadow = true;
|
|
95
|
+
static styles = css`:host { display: block } .body { padding: 16px }`;
|
|
96
|
+
render() { return html`<div class="body">${this.label}</div>`; }
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (`.my-card__body` or `my-card .body`). Prefer Tailwind, unique by construction. `static styles` on a light-DOM component is silently ignored.
|
|
101
|
+
- **Never interpolate into a component's `<style>` or `<script>` body** (`html\`<style>${x}</style>\``). The server emits it but the client drops the raw-text hole, so it paints then wipes to empty on hydrate (flagged by `no-interpolation-in-raw-text-element`). Use `static styles` or Tailwind.
|
|
102
|
+
- Light-DOM hosts are marked `display: block` via one low-priority `@layer webjs-host` rule (overridable by any Tailwind utility). Shadow hosts are NOT marked; set `:host { display: block }` in `static styles`. Size the HOST (put `w-full max-w-[...]` on the render root), not only an inner wrapper. See `styling.md`.
|
|
103
|
+
|
|
104
|
+
## Slots
|
|
105
|
+
|
|
106
|
+
The full `<slot>` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
class MyCard extends WebComponent {
|
|
110
|
+
render() {
|
|
111
|
+
return html`
|
|
112
|
+
<header><slot name="header"></slot></header>
|
|
113
|
+
<main><slot></slot></main>
|
|
114
|
+
<footer><slot name="footer">no actions</slot></footer>`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Named slots, the default slot (unnamed children, text, comments), fallback content (a slot's inner markup when nothing matches), first-wins resolution, and dynamic `name=${...}` all behave per spec. The DOM API mirrors shadow slots: `assignedNodes` / `assignedElements` (with `{ flatten: true }`), `element.assignedSlot`, and the `slotchange` event. Both modes are SSR'd (light DOM projects into `<slot data-webjs-light data-projection="actual">`, shadow DOM via Declarative Shadow DOM), so slotted content renders with no JS.
|
|
120
|
+
|
|
121
|
+
A compound child reads its parent at the first server paint via `closest('ui-tabs')` (only tag-name selectors resolve at SSR, and the compound parent must be light DOM). Genuine live-DOM reads (`querySelector`, `classList`, geometry) still throw at SSR, so keep them in `connectedCallback` / `firstUpdated`.
|
|
122
|
+
|
|
123
|
+
## Async render: first-paint server data
|
|
124
|
+
|
|
125
|
+
`render()` may be `async`, so a leaf component fetches its own server data into the first paint with no prop-drilling.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
class UserActivity extends WebComponent({ uid: String }) {
|
|
129
|
+
renderFallback() { return html`<div class="skeleton h-24"></div>`; } // optional, re-fetch only
|
|
130
|
+
async render() {
|
|
131
|
+
const items = await getActivity(this.uid); // 'use server' action: real fn at SSR, RPC stub on client
|
|
132
|
+
return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Three decoupled concerns, do not conflate them.
|
|
138
|
+
|
|
139
|
+
1. **SSR always blocks by default.** The server awaits `async render()`, so the resolved data is baked into the first paint. There is no first-paint fallback, ever (a progressive-enhancement upgrade over a client-fetched `Task`).
|
|
140
|
+
2. **The client re-fetch default is stale-while-revalidate.** When a prop or dependency change re-runs `async render()`, the previous content stays until the new render resolves. No blank, no flash, no user code.
|
|
141
|
+
3. **`renderFallback()` is the OPTIONAL re-fetch loading UI.** Shown ONLY during a client re-fetch, NEVER on first paint, and it does NOT create a server-streaming boundary.
|
|
142
|
+
|
|
143
|
+
Errors are isolated per component by default (no user code): a thrown `await` renders a component-scoped error state while siblings render, never bubbling to the route `error.ts`. Override `renderError(error)` only to customize it (dev shows the message, prod stays silent).
|
|
144
|
+
|
|
145
|
+
Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`Loading...\`}>` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself.
|
|
146
|
+
|
|
147
|
+
## Display-only elision
|
|
148
|
+
|
|
149
|
+
A component that does no client-side work renders the same SSR'd HTML with or without its JS, so WebJs strips its import from the served source (and any vendor reachable only through it). This is automatic and conservative. A component stays elidable while it has NONE of:
|
|
150
|
+
|
|
151
|
+
- an `@event` binding or native handler property (`.onclick`)
|
|
152
|
+
- a factory-declared reactive property that is not `{ state: true }`
|
|
153
|
+
- an overridden lifecycle hook (including `renderFallback` / `renderError`)
|
|
154
|
+
- an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
|
|
155
|
+
- code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed
|
|
156
|
+
- a rendered `<slot>`, or being rendered by a component that itself ships
|
|
157
|
+
|
|
158
|
+
A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis (a dynamically-built tag string, a `:defined` rule in an external stylesheet). `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.
|
|
159
|
+
|
|
160
|
+
## Members app code must not shadow
|
|
161
|
+
|
|
162
|
+
A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus the framework reactivity base. A reactive prop or method whose NAME collides either fails to compile (`TS2415` for a type-incompatible property, `TS2416` for a method signature) or silently hijacks the native member at runtime. The fix is always to rename.
|
|
163
|
+
|
|
164
|
+
- HTMLElement / Element: `title`, `id`, `slot`, `role`, `hidden`, `dir`, `lang`, `translate`, `draggable`, `tabIndex`, `className`, `dataset`, `remove`, `closest`, `matches`, `focus`, `blur`, `click`, `append` / `prepend`, `before` / `after`. Rename (`postTitle`, `removeItem`, `handleClick`).
|
|
165
|
+
- WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete`. Only override one deliberately, with its exact signature; never repurpose the name for app logic.
|
|
166
|
+
|
|
167
|
+
Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js`.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Data and Actions
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
- The `modules/<feature>/` architecture (thin `app/` adapters, `actions/` mutations, `queries/` reads, one function per file)
|
|
6
|
+
- `'use server'` RPC actions, the serializer-safe wire, and how a client import becomes a typed stub
|
|
7
|
+
- Input validation at the boundary via `export const validate`
|
|
8
|
+
- HTTP-verb config exports (`method`, `cache`, `tags`, `invalidates`, `middleware`)
|
|
9
|
+
- The `ActionResult<T>` envelope and its robust failure detection
|
|
10
|
+
- The `route()` REST adapter that exposes an action over HTTP
|
|
11
|
+
- Drizzle rc.3 reads (`db.query.*`) and mutations (`.returning()`)
|
|
12
|
+
- Keeping server-only types off the client (`import type` vs a value import)
|
|
13
|
+
|
|
14
|
+
Read this when a task touches a server mutation, a data read, input validation, a REST endpoint, or the shape a component consumes. Sibling refs: `routing-and-pages.md` (the page `action` write path, `route.ts` handlers), `auth-and-sessions.md` (protecting an action or endpoint), `optimistic-ui.md` (consuming `ActionResult` on the client), `typescript.md` (erasable syntax, full-stack types).
|
|
15
|
+
|
|
16
|
+
## The Architecture (read this first)
|
|
17
|
+
|
|
18
|
+
`app/` is routing ONLY: thin adapters that import from `modules/`. Feature logic lives under `modules/<feature>/`.
|
|
19
|
+
|
|
20
|
+
- `modules/<feature>/actions/*.server.ts` mutations (create, update, delete)
|
|
21
|
+
- `modules/<feature>/queries/*.server.ts` reads
|
|
22
|
+
- `modules/<feature>/components/*.ts` feature-owned components (shared UI goes in top-level `components/`)
|
|
23
|
+
- `modules/<feature>/utils/*.ts` pure helpers (no `'use server'`, no DB)
|
|
24
|
+
- `modules/<feature>/types.ts` browser-safe typedefs (no runtime server import)
|
|
25
|
+
|
|
26
|
+
**One exported function per action / query file, named after the file.** A configured `.server.ts` file with more than one callable function is a `webjs check` error. App-internal imports use the `#` root alias (`#modules/...`, `#db/...`), not deep `../../../` relatives.
|
|
27
|
+
|
|
28
|
+
## The `.server.ts` boundary
|
|
29
|
+
|
|
30
|
+
`.server.ts` is the one server boundary. It is BOTH source protection (the file router never serves the source) AND, with `'use server'`, an RPC mechanism.
|
|
31
|
+
|
|
32
|
+
| File | `'use server'`? | What it is |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `*.server.ts` | yes | Server action. Source-protected AND RPC-callable; the browser import becomes a stub POSTing to `/__webjs/action/<hash>/<fn>`. |
|
|
35
|
+
| `*.server.ts` | no | Server-only utility. Source-protected; the browser import is a throw-at-load stub. |
|
|
36
|
+
| plain `.ts` | yes | Lint violation (`use-server-needs-extension`). Rename to add `.server.`. |
|
|
37
|
+
| plain `.ts` | no | Browser-safe. |
|
|
38
|
+
|
|
39
|
+
**Importing the action IS the API.** The dev server rewrites a client import into a typed RPC stub, so you write `await createPost({ title })` and never hand-write `fetch()`. REST over HTTP is a `route.ts` that calls the action (below). Never import a no-`'use server'` utility directly into a shipping page / layout / component; its browser stub throws at load. Reach it through a `'use server'` action instead.
|
|
40
|
+
|
|
41
|
+
## A query and an action
|
|
42
|
+
|
|
43
|
+
Reads live in `queries/`, mutations in `actions/`. Both are `.server.ts` with `'use server'`, so their browser imports become typed RPC stubs. Args and returns round-trip through the serializer (it carries `Date` / `Map` / `Set` / `BigInt` / `Error` / typed arrays / `Blob` / `File` / `FormData` / cycles), so a query may return a `Date` and the client receives a real `Date`.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// modules/posts/queries/list-posts.server.ts
|
|
47
|
+
'use server';
|
|
48
|
+
import { db } from '#db/connection.server.ts';
|
|
49
|
+
export async function listPosts() {
|
|
50
|
+
return db.query.posts.findMany({
|
|
51
|
+
where: { published: true },
|
|
52
|
+
orderBy: { createdAt: 'desc' },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// modules/posts/actions/create-post.server.ts
|
|
59
|
+
'use server';
|
|
60
|
+
import { db } from '#db/connection.server.ts';
|
|
61
|
+
import { posts } from '#db/schema.server.ts';
|
|
62
|
+
export async function createPost(input: { title: string; body: string }) {
|
|
63
|
+
const title = String(input?.title || '').trim();
|
|
64
|
+
if (!title) return { success: false, error: 'title required', status: 400 };
|
|
65
|
+
const [row] = await db.insert(posts).values({ title, body: String(input?.body || '') }).returning();
|
|
66
|
+
return { success: true, data: row };
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A page runs on the server, so it imports the query directly and awaits it. A client component imports the action and calls it (rewritten to an RPC stub).
|
|
71
|
+
|
|
72
|
+
## The Drizzle query surface (rc.3)
|
|
73
|
+
|
|
74
|
+
**Reads go through the relational query API** (`db.query.<table>.findFirst` / `.findMany`), NOT `db.select().from()`. Filter with a plain object `where` (the RQBv2 shape), order with an `orderBy` object, and pull relations with `with`.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const post = await db.query.posts.findFirst({
|
|
78
|
+
where: { slug: input.slug },
|
|
79
|
+
with: { author: { columns: { name: true } } },
|
|
80
|
+
});
|
|
81
|
+
const rows = await db.query.posts.findMany({
|
|
82
|
+
where: { authorId: me.id },
|
|
83
|
+
orderBy: { createdAt: 'desc' },
|
|
84
|
+
columns: { id: true, slug: true, title: true },
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Two rc.3 removals trip older tutorials: `db.select({ ... })` with a projection object is a `TS2554` (call `select()` with NO argument for the full row, then narrow in JS), and `.returning({ ... })` with a field object is also `TS2554` (call `.returning()` bare).
|
|
89
|
+
|
|
90
|
+
**Mutations** use the query-builder with the imported SQL operators (`eq`, `and`, `inArray` from `drizzle-orm`) and read back with a no-arg `.returning()`:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { eq } from 'drizzle-orm';
|
|
94
|
+
const [row] = await db.insert(posts).values({ title, body, authorId: me.id }).returning();
|
|
95
|
+
const [updated] = await db.update(posts).set({ title }).where(eq(posts.id, id)).returning();
|
|
96
|
+
await db.delete(posts).where(eq(posts.id, id));
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
A `.returning()` row is the table's own columns only, never `with` relations. When the caller wants a joined shape, re-read with `db.query.*` or splice the already-known related value in by hand. Full surface at https://docs.webjs.dev.
|
|
100
|
+
|
|
101
|
+
## Input validation at the boundary
|
|
102
|
+
|
|
103
|
+
Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on the RPC boundary, receiving the action's FIRST argument. The framework only CALLS the validator (it ships no validation library) and reads its return: `{ success: true, data? }` runs the action (an optional `data` replaces the input), `{ success: false, fieldErrors }` returns a 422 WITHOUT running the body, and a THROW becomes a sanitized error.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// modules/posts/actions/create-post.server.ts
|
|
107
|
+
'use server';
|
|
108
|
+
export const validate = (input: any) => {
|
|
109
|
+
const fieldErrors: Record<string, string> = {};
|
|
110
|
+
const title = String(input?.title || '').trim();
|
|
111
|
+
if (!title) fieldErrors.title = 'Title is required';
|
|
112
|
+
if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short';
|
|
113
|
+
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
|
|
114
|
+
return { success: true, data: { title, body: String(input.body) } };
|
|
115
|
+
};
|
|
116
|
+
export async function createPost(input: { title: string; body: string }) { /* runs only when valid */ }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A client call resolves with the failure envelope (it does NOT throw), so the component reads `result.fieldErrors`. A zod adapter wraps `safeParse` so its result becomes the envelope; the framework stays zod-free.
|
|
120
|
+
|
|
121
|
+
## HTTP-verb config exports
|
|
122
|
+
|
|
123
|
+
A `'use server'` action is a POST by default. Reserved sibling exports, read statically (the same way a page reads `export const revalidate`), change its HTTP semantics WITHOUT changing the call site (you still write `await getUser(7)`).
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// modules/users/queries/get-user.server.ts: a cached, tagged GET read
|
|
127
|
+
'use server';
|
|
128
|
+
export const method = 'GET'; // absent = POST
|
|
129
|
+
export const cache = 60; // seconds, or { maxAge, swr, public }
|
|
130
|
+
export const tags = (id: number) => ['user:' + id];
|
|
131
|
+
export async function getUser(id: number) { return db.query.users.findFirst({ where: { id } }); }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// a mutation evicts the tags it touches
|
|
136
|
+
'use server';
|
|
137
|
+
export const invalidates = (id: number) => ['user:' + id];
|
|
138
|
+
export const middleware = [requireAuth]; // async (ctx, next) => result; read ctx via actionContext()
|
|
139
|
+
export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries `Cache-Control` + a weak `ETag` (304 on `If-None-Match`) + `X-Webjs-Tags`. A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on success evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate`. A method mismatch is a `405` + `Allow`.
|
|
143
|
+
- **SAFETY.** `cache` with `public: true` SHARES one response across ALL users, keyed only by URL + args. Use it ONLY for data identical for every visitor (the same rule as a page's `export const revalidate`), never for a session or per-user read.
|
|
144
|
+
- Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`.
|
|
145
|
+
|
|
146
|
+
## The `ActionResult<T>` envelope
|
|
147
|
+
|
|
148
|
+
Every action returns this additive envelope.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
type ActionResult<T> =
|
|
152
|
+
| { success: true; data?: T; redirect?: string } // redirect MUST be a same-site local path
|
|
153
|
+
| { success: false; error?: string; fieldErrors?: Record<string, string>;
|
|
154
|
+
values?: Record<string, string>; status?: number };
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Failure detection is robust.** A result is a FAILURE when `result.success === false`, OR `result.fieldErrors` is present, OR `result.error` is present and `result.success !== true`. Everything else is a success (an explicit `success: true`, or a bare value with no error markers). This means an error is never swallowed just because the author omitted a literal `success: false`.
|
|
158
|
+
|
|
159
|
+
**`result.redirect` must be a same-site local path** (a single leading `/`). A protocol-relative `//host` or an absolute `scheme://host` URL is rejected (open-redirect guard); for a real external redirect, throw `redirect(absoluteUrl)` instead. A user-facing error message belongs on the envelope (`{ success: false, error }`), never on a raw throw, because prod sanitizes a thrown action error to a generic message plus a digest.
|
|
160
|
+
|
|
161
|
+
## Exposing an action over REST: the `route()` adapter
|
|
162
|
+
|
|
163
|
+
A public REST endpoint is a `route.ts` that imports and calls the action, optionally through the `route()` adapter from `@webjsdev/server` (it merges query + route params + JSON body into one input object and JSON-responds).
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// app/api/posts/route.ts
|
|
167
|
+
import { route } from '@webjsdev/server';
|
|
168
|
+
import * as postActions from '#modules/posts/actions/create-post.server.ts';
|
|
169
|
+
export const POST = route(postActions); // module namespace: applies the action's OWN validate + middleware
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Passing the MODULE NAMESPACE lets the adapter read the action's declared `middleware` and `validate`, so a guard declared once next to the action protects the RPC and REST boundaries alike. Passing the imported FUNCTION (`route(createPost, { validate })`) cannot see sibling config exports, so it applies only what you pass. A `{ success: false, fieldErrors }` return becomes a 422 JSON response; a validator that THROWS becomes a 400.
|
|
173
|
+
|
|
174
|
+
A `route.ts` endpoint is NOT covered by the RPC CSRF check, so authenticate every mutating endpoint, use `validate`, and rate-limit (see `auth-and-sessions.md`).
|
|
175
|
+
|
|
176
|
+
## Keeping server-only types off the client
|
|
177
|
+
|
|
178
|
+
An interactive component needs the SHAPE of the data it renders, and those shapes derive from server-only modules. The one rule: **a type crossing to the browser is a TYPE, never a runtime value.** An `import type { ... }` is erased by the TypeScript stripper before the module reaches the browser. A plain `import { ... }` (a value import) survives stripping, pins the server module into the browser closure, and trips the `no-server-import-in-browser-module` check.
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// SAFE: type-only, erased before it reaches the browser.
|
|
182
|
+
import type { Post } from '#db/schema.server.ts';
|
|
183
|
+
// UNSAFE: a value import survives stripping and pins the server schema. Throws at load.
|
|
184
|
+
import { posts } from '#db/schema.server.ts';
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://docs.webjs.dev.
|