@webjsdev/mcp 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,325 +0,0 @@
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`), the middleware `ctx` shape, and `actionSignal()` cancellation
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` (where a bound form lives on a page, `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://webjs.dev/docs.
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 every BOUNDARY (the RPC endpoint, a `route()` REST endpoint, and a form submission), receiving the action's FIRST argument. That argument reads the same way on every boundary: on the form boundary it is the submitted fields as a plain object (`raw.title`) that also answers the `FormData` readers (`raw.get('title')`), so one validator serves RPC, REST and a form. On the form boundary a `{ success: false, fieldErrors }` return becomes a 422 RE-RENDER of the page with the validator's result on `actionData`, rather than a JSON 422. 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 interface CreatePostInput { title: string; body: string }
109
-
110
- // `unknown` is CORRECT here and nowhere else in this file: the validator IS
111
- // the narrowing site for an untrusted wire payload. Never `any`, which would
112
- // un-type the returned `data` and with it the action's own input.
113
- export const validate = (input: unknown) => {
114
- const raw = (input ?? {}) as Record<string, unknown>;
115
- const fieldErrors: Record<string, string> = {};
116
- const title = String(raw.title ?? '').trim();
117
- if (!title) fieldErrors.title = 'Title is required';
118
- if (String(raw.body ?? '').length < 10) fieldErrors.body = 'Too short';
119
- if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
120
- // `satisfies` ties the validator's output to the action's input, so the two
121
- // cannot drift apart.
122
- return { success: true, data: { title, body: String(raw.body) } satisfies CreatePostInput };
123
- };
124
- export async function createPost(input: CreatePostInput) { /* runs only when valid */ }
125
- ```
126
-
127
- 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.
128
-
129
- ## Binding an action to a form
130
-
131
- A `<form action=${importedAction}>` is the one way a form submits to a server action, and it is the whole wiring:
132
-
133
- ```ts
134
- import { createPost } from '#modules/posts/actions/create-post.server.ts';
135
- html`<form action=${createPost}><input name="title"></form>`;
136
- ```
137
-
138
- 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, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, encoded per the declared `enctype` (#1307: multipart stays `FormData`, urlencoded, which is the HTML default, is sent as `URLSearchParams`), so the two paths are identical by construction.
139
-
140
- **A form-bound action receives a `FormData` whose fields are also properties** (#1664), so it reads like the object the same function gets over RPC or through `route()`: every field is a property holding its last value (`input.title`), and destructuring, spreading and a schema library's `parse(input)` all work. It is still a real `FormData`, so `input.get('title')`, `instanceof FormData`, the writers (which keep the properties in step) and `fetch(url, { body: input })` all behave as usual. A `FormData` passed as the first argument of an RPC call gets the same treatment. Three rules to know:
141
-
142
- - A field submitted more than once keeps only its LAST value on the property. Name a checkbox group `tags[]` to receive `input.tags` as an array, or read `input.getAll('tags')`; `get`, `getAll` and `has` accept the bracket-less name, so both spellings agree. A name submitted both plain and with `[]` is one array, and dev warns, since that is an authoring mistake. In dev the server warns once when a plain name arrives more than once. Iteration (`entries`, `keys`, `Object.fromEntries(input)`) reports names as submitted, brackets included, the way a `FormData` does.
143
- - A file field is a real `File` (`input.avatar.name`, `await input.avatar.text()`).
144
- - Iteration (`entries`, `keys`, `Object.fromEntries(input)`) reports names as submitted, brackets included.
145
-
146
- Values are still the strings and files the browser submitted, so `validate` remains the typing seam: its transform-return (`'7'` to `7`, a trimmed title) becomes the action's typed input.
147
-
148
- A failing form action does not need to echo the submission back. On the 422 re-render `actionData.values` already carries every submitted text field (any `values` the action returns are layered on top), and with JavaScript on the client router restores what was typed into every control the re-render did not explicitly set (#1581). Return `values` only to normalize or blank a field; see `routing-and-pages.md` for the page side.
149
-
150
- Everything the action declares applies here too, or an action would be protected over RPC and open over a form:
151
-
152
- - `validate` runs on the submitted fields, in the object shape above.
153
- - the `middleware` chain runs, with the page's route context (`params`, `searchParams`, `url`) added to `ctx`.
154
- - `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data.
155
- - `invalidates` and `tags` receive the SAME first argument the action does, so `invalidates: (input) => ['post:' + input.id]` works on a form submission exactly as it does over RPC. When a `validate` is declared, its transform result is what the config functions see.
156
- - `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`.
157
- - A form whose buttons run DIFFERENT actions binds each on its submitter, `<button formaction=${publishDraft}>`. **The submitter is self-sufficient** (#1307): the renderer gives it `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the identity riding the button's own `name`/`value` pair, and a submitter's `formmethod` overrides the form's `method` per HTML. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and the enclosing form does not need binding for the button's sake. Bind the form when the FORM itself should run an action on a plain submit. In dev the client logs one `console.error` at submit time for a submission holding an identity it cannot deliver, and in production both server-visible fingerprints reach `onError` with a code (`WEBJS_FORM_SUBMITTED_AS_GET` for an identity in the query string, `WEBJS_FORM_ACTION_MISSING` for a body carrying no identity). See `muscle-memory-gotchas.md` for the shape.
158
-
159
- The response drives the page: a success is a `303` PRG (to `result.redirect` when it is a same-site local path, else the page's own url), a failure re-renders the SAME page with `status` (default `422`) and the result on `actionData`, a submission carrying no identity is a `405`, and one whose hash no longer resolves is a `422` with a resubmit message (a form held open across a deploy). The submission is Origin-verified like an RPC call, so no token field is needed.
160
-
161
- A streamed return (#489) is refused from a form-bound action: the RPC stub decodes frames, but a submission is answered with a redirect or a page, and with JS off there is no consumer at all. Stream from a programmatic call instead.
162
-
163
- ## HTTP-verb config exports & decision guide
164
-
165
- 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)`).
166
-
167
- ### HTTP Verbs Decision Guide
168
-
169
- | Action Kind | Target Verb | Example Declaration | HTTP Semantics & Features |
170
- |---|---|---|---|
171
- | **Form-Bound Action** (`<form action=${fn}>`) | **POST** (default) | *(no export or `export const method = 'POST'`) | Standard HTML form submission. Enforces `POST` + `multipart/form-data` or `urlencoded`. **Never export `method = 'GET'`** (triggers 405 refusal & `webjs check` violation). |
172
- | **RPC Read Action (Query)** | **GET** | `export const method = 'GET'` | Read-only RPC calls (`await getTodos()`). Args ride URL query params (with POST fallback over 4KB). CSRF-exempt, supports ETags, 304 revalidation, and `export const cache`. |
173
- | **RPC Write Action (Mutation)** | **POST** / **PUT** / **PATCH** / **DELETE** | Default or `export const method = 'DELETE'` | Data-modifying RPC calls (`await deleteUser(4)`). Carries CSRF protection, serialized payload body, and evicts cached query tags via `export const invalidates`. |
174
-
175
- ### Choosing the right HTTP verb
176
-
177
- 1. **Form-Bound Actions (`<form action=${fn}>` / `<button formaction=${fn}>`):**
178
- - **MUST be POST.** Leave unannotated (default) or export `export const method = 'POST'`.
179
- - **NEVER export `export const method = 'GET'` for form actions.** The HTML renderer automatically emits `method="post"` and `formenctype` for form actions. Binding a `method = 'GET'` action to a form returns a `405 Method Not Allowed` at runtime and triggers a `webjs check` error (`form-action-not-a-get-action`).
180
- - **NEVER add `method="get"` to a bound `<form action=${fn}>`.** WebJs manages form submission semantics automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
181
-
182
- 2. **Programmatic / RPC Read Actions (Queries):**
183
- - **ALWAYS export `export const method = 'GET'` for read-only queries.**
184
- - When an action only fetches data (`await getUser(id)`), exporting `method = 'GET'` instructs the client RPC stub to issue an HTTP GET request with arguments encoded in query parameters.
185
- - Enables browser/CDN caching, weak ETags (returning 304 Not Modified on cache hit), and HTTP `Cache-Control` header generation when paired with `export const cache = ...`.
186
-
187
- 3. **Programmatic / RPC Write Actions (Mutations):**
188
- - **Use POST, PUT, PATCH, or DELETE for writes.**
189
- - Use default `POST` or explicitly export `PUT`/`PATCH`/`DELETE` for RESTful RPC calls (`await removeUser(id)`).
190
- - Pair mutating actions with `export const invalidates = (args...) => ['tag']` to evict cached reads matching those tags upon completion.
191
-
192
- ```ts
193
- // modules/users/queries/get-user.server.ts: a cached, tagged GET read
194
- 'use server';
195
- export const method = 'GET'; // absent = POST
196
- export const cache = 60; // seconds, or { maxAge, swr, public }
197
- export const tags = (id: number) => ['user:' + id];
198
- export async function getUser(id: number) { return db.query.users.findFirst({ where: { id } }); }
199
- ```
200
-
201
- ```ts
202
- // modules/users/actions/update-user.server.ts: a mutation evicting matching tags
203
- 'use server';
204
- export const method = 'PATCH'; // explicit verb
205
- export const invalidates = (id: number) => ['user:' + id];
206
- export const middleware = [requireAuth]; // async (ctx, next) => result; read ctx via actionContext()
207
- export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
208
- ```
209
-
210
- - A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries a weak `ETag` (304 on `If-None-Match`); with a `cache` export it also carries `Cache-Control` + `X-Webjs-Tags` (without one it is `no-store`, so caching is opted into by `cache`, not by the verb). A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and once it completes without throwing evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate` (a returned `{ success: false }` envelope still evicts, since the action ran). A method mismatch is a `405` + `Allow`.
211
- - The `cache` object maps onto the `Cache-Control` header. The number shorthand `cache = 60` means `{ maxAge: 60 }`. `maxAge` is the freshness window in seconds (`max-age=<n>`), `swr` is a stale-while-revalidate grace window in seconds (`stale-while-revalidate=<n>`, an expired response is still served instantly while the browser revalidates in the background, usually a bodyless 304 thanks to the ETag), and `public` flips the scope from the default `private`. So `{ maxAge: 60, swr: 300 }` emits `private, max-age=60, stale-while-revalidate=300`.
212
- - **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.
213
- - Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`. Each middleware is `async (ctx, next) => result` where `ctx` is `{ request, args, signal, context }`. It writes to the shared bag `ctx.context.<key>` (for example `ctx.context.user = user`), which is exactly what `actionContext().user` reads back in the action. A direct server-to-server call skips the RPC boundary (so its middleware does NOT run), so the action must guard rather than assume a middleware-set value is present.
214
-
215
- ### Cancellation with `actionSignal()`
216
-
217
- Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Only an action called in the synchronous part of a component's own `render()` is tied to that render. One called from `connectedCallback`, an event handler, `firstUpdated()` / `updated()`, or a `Task` is never cancelled by a re-render, including a child element's `connectedCallback` that runs while its parent's template is being committed, so a child can start its first fetch there without the parent's next render cancelling it. Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
218
-
219
- ```ts
220
- 'use server';
221
- import { actionSignal } from '@webjsdev/server';
222
- export async function search(q: string) {
223
- const signal = actionSignal();
224
- const res = await fetch(`https://api/x?q=${q}`, { signal }); // aborts the fetch on disconnect
225
- if (signal.aborted) return { success: false, error: 'Request cancelled.', status: 499 };
226
- return { success: true, data: await res.json() };
227
- }
228
- ```
229
-
230
- A guard placed BEFORE any await can never fire (nothing has yielded yet). Outside an action the signal never aborts, so a server-to-server call stays safe.
231
-
232
- ## The `ActionResult<T>` envelope
233
-
234
- Every action returns this additive envelope.
235
-
236
- ```ts
237
- type ActionResult<T> =
238
- | { success: true; data?: T; redirect?: string } // redirect MUST be a same-site local path
239
- | { success: false; error?: string; fieldErrors?: Record<string, string>;
240
- values?: Record<string, string>; status?: number };
241
- ```
242
-
243
- **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`.
244
-
245
- **`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.
246
-
247
- ## Exposing an action over REST: the `route()` adapter
248
-
249
- 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).
250
-
251
- ```ts
252
- // app/api/posts/route.ts
253
- import { route } from '@webjsdev/server';
254
- import * as postActions from '#modules/posts/actions/create-post.server.ts';
255
- export const POST = route(postActions); // module namespace: applies the action's OWN validate + middleware
256
- ```
257
-
258
- 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.
259
-
260
- 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`).
261
-
262
- ## Keeping server-only types off the client
263
-
264
- 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.
265
-
266
- ```ts
267
- // SAFE: type-only, erased before it reaches the browser.
268
- import type { Post } from '#db/schema.server.ts';
269
- // UNSAFE: a value import survives stripping and pins the server schema. Throws at load.
270
- import { posts } from '#db/schema.server.ts';
271
- ```
272
-
273
- 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://webjs.dev/docs.
274
-
275
- ## SSR action seeding, and how to tell it is working
276
-
277
- When a shipping component's `async render()` awaits an action during SSR, WebJs serializes that result into the page and the generated RPC stub reads it on its FIRST client call. So `const u = await getUser(this.id)` runs once, on the server, and hydration reuses the result with no network round-trip.
278
-
279
- **You write nothing for this.** It is automatic, on by default, and there is no API to call. The only thing you can do is break it, so the section below is about noticing when you have.
280
-
281
- ### The correctness boundary
282
-
283
- A seed hit returns the value the SSR render that produced this page computed for exactly this action, function, and argument list, so a hit cannot show the user something different from the HTML they are already looking at. A page navigation evicts whatever the outgoing page left unconsumed, both the block still in the DOM and anything already ingested from it, so a departed render's value is never served. On an HTML-cached page (`export const revalidate`) the seed rides inside the cached bytes, so it is exactly as fresh as the HTML it came with. A miss simply re-fetches.
284
-
285
- There is one shape where a hit can differ from the paint, and WebJs warns about it in dev: **an action that returns a DIFFERENT result for the SAME arguments twice in one render.** The seed carries the last result while the first component painted the first one. So keep an action deterministic for a given argument list. A counter, a `Math.random()`, a `new Date()` in the return value, or a read of mutable module state all break that rule, and dev prints:
286
-
287
- ```
288
- [webjs] SSR action seeding: "getUser" returned two DIFFERENT results for the SAME arguments during one render. ...
289
- ```
290
-
291
- The fix is to make the action deterministic, or to move the varying part into an argument so the two calls get different keys.
292
-
293
- ### Reading the dev diagnostics
294
-
295
- A miss is invisible from the outside: the page still renders correctly, it just pays a round-trip per async component on every first load. Two channels make it visible in dev, and neither exists in production.
296
-
297
- **Server side, per request.** The `X-Webjs-Seed` response header, also folded into the dev access-log line as a `seed` field:
298
-
299
- | Value | What it means |
300
- |---|---|
301
- | `off` | Seeding is switched off (`"webjs": { "seed": false }` or `WEBJS_SEED=0`). Not a defect. |
302
- | `html-cache` | The #241 HTML response cache answered. The seeds rode inside the cached bytes. |
303
- | `collected=3, emitted=3` | Healthy. Three action results were captured and all three reached the page. |
304
- | `collected=3, emitted=0` | The serializer threw and dropped the whole block. Something in a returned value is not serializer-safe. |
305
- | `collected=3, emitted=0, streamed` | The page streams, so nothing could be emitted (see below). |
306
-
307
- Check it with `curl -sSI localhost:3000/` or in the network tab.
308
-
309
- **Browser side, per page view.** One `console.warn` at the first idle after hydration, and only when a call missed AND the client can be certain why. It stays silent otherwise, including on a page that emitted no seeds at all: every action call routes through the seed lookup, including ones that were never SSR-invoked and never could have been seeded (a mutation, a `Task` autorun, a `connectedCallback` read), so a miss there is not evidence of a defect. That case is the server header's job, where `collected=0` is unambiguous. The line names one of these:
310
-
311
- - *"This page streams"*, so no seeds could be emitted. Expected, not a bug (see below).
312
- - *"The page's seeds could not be serialized."* Something an action returned is not serializer-safe, so the whole block was dropped. The response header shows `collected` above `emitted` for the same reason.
313
- - *"The page seeded these actions under DIFFERENT arguments."* The key is `hash(action file) / function name / serialized arguments`, so the client asked with an argument the SSR render never used. Common cause: the component computes its argument from browser-only state (a `localStorage` read, a `connectedCallback` assignment), which the server render could not have known. A miss on an action the page never seeded at all is NOT reported, because a mutation or a client-only read routes through the same lookup and could never have been seeded.
314
-
315
- A miss AFTER hydration is correct and is not reported: the seed is consume-once, so a deliberate refetch or an argument change is supposed to go to the network.
316
-
317
- `seedStats()` from `@webjsdev/core` returns `{ ingested, replaced, hits, misses, keyMisses, pending }` (`keyMisses` being the provable subset of `misses`, a call for an action the page seeded under other arguments) if you want to assert this in a browser test or read it from the console. A non-zero `pending` at rest usually means the seeding component ELIDED, so its module never shipped and nothing on the client was ever going to consume the seed. `pending` covers the page you are on: a page navigation evicts whatever the outgoing page left unconsumed, both the block still sitting in the DOM and anything already ingested from it, since those values belong to a render no longer on screen.
318
-
319
- ### The streamed-page exception
320
-
321
- A page carrying a `Suspense` or `<webjs-suspense>` boundary emits NO seed block at all, not just none for the streamed region: a streamed render's deferred boundaries resolve after the first flush, so their results cannot ride the block. Every action call on that page goes to the network on hydration. That is a real trade, so make it deliberately: reach for a streaming boundary when a slow region would otherwise block the first byte, and leave a fast page buffered so it seeds.
322
-
323
- ### Switching it off
324
-
325
- `"webjs": { "seed": false }` in `package.json`, or `WEBJS_SEED=0`. The client then re-fetches on hydration exactly as it did before the feature, and stale-while-revalidate hides the flicker. Turn it off only to isolate a problem; there is no reason to ship with it off.
@@ -1,229 +0,0 @@
1
- # Module structure: file size, design principles, and splitting a large module
2
-
3
- Read this before splitting a large source file, and before arguing about how
4
- big a module is allowed to be.
5
-
6
- Two things live here. The first is what "well structured" means in this repo,
7
- which is mostly judgment rather than a number. The second is the mechanical
8
- procedure for barrelling a large module into a directory, which is NOT judgment:
9
- it has a small number of failure modes that are silent, and every one of them
10
- has bitten this codebase already.
11
-
12
- ---
13
-
14
- ## Design principles: judgment, not a checker
15
-
16
- SOLID, DRY, and KISS apply here the way they apply anywhere. They are prose
17
- guidance, followed by judgment, and deliberately NOT enforced by `webjs check`.
18
- That split is the same one the rest of the project uses: `webjs check` carries
19
- correctness rules only (code that is wrong to ship), and anything a sensible
20
- project could reasonably do differently stays a convention.
21
-
22
- What they mean in practice, in a buildless framework whose source IS what runs:
23
-
24
- - **Single responsibility** is about what a module OWNS, not how long it is. A
25
- module owns one concern when you can state that concern in a sentence without
26
- the word "and". `router-client/prefetch.js` owns speculative fetching. It is
27
- 584 lines, and it is one responsibility.
28
- - **DRY applies to knowledge, not to text.** Two identical lines that would
29
- change for different reasons are not duplication. A constant that appears in
30
- three places IS, which is why this repo has drift guards that read one copy
31
- and assert it against another (see the guard section below).
32
- - **KISS beats cleverness in a framework more than in an app.** The source is
33
- the documentation surface for the AI agents that use WebJs, and it ships
34
- unbundled to be read. An indirection that saves five lines and costs a reader
35
- a jump is a bad trade here.
36
- - **Dependency direction matters more than dependency inversion.** Modules layer
37
- downward: constants at the bottom, then pure helpers, then orchestration.
38
- Nothing imports upward. This is not architectural taste, it is what keeps ESM
39
- cycles out (see below). Two subsystems are genuinely mutually recursive and
40
- cannot layer, the client router (a navigation fetches, the fetch swaps, the
41
- swap upgrades, an upgraded element navigates) and light-DOM slots (projecting
42
- installs the interceptors, an intercepted mutation re-projects). Those two are
43
- named in `test/architecture/import-cycles.test.mjs`, which fails on any THIRD
44
- cycle, so the rule holds everywhere it can.
45
-
46
- ---
47
-
48
- ## File size: target 800, ceiling around 1000
49
-
50
- **A source module targets 800 lines and should stay around 1000 at the most.**
51
- The ceiling is approximate on purpose. A module at 1040 is fine; one at 1900
52
- needs either a split or a named exemption (below), and the question to ask at
53
- that size is which responsibility it has picked up rather than how many lines
54
- it has. A barrel is exempt entirely, because its length is a function of how
55
- many names it re-exports.
56
-
57
- **Where this number comes from.** It was set by measuring the frameworks this
58
- project takes its cues from, not by picking a round figure:
59
-
60
- | Module | Lines |
61
- |---|---|
62
- | `lit/packages/lit-html/src/lit-html.ts` | 2303 |
63
- | `lit/packages/reactive-element/src/reactive-element.ts` | 1754 |
64
- | `vite/packages/vite/src/node/optimizer/index.ts` | 1487 |
65
- | `vite/packages/vite/src/node/server/index.ts` | 1447 |
66
-
67
- Every one of them draws its seams by responsibility and lets the orchestration
68
- entry stay large. None of them enforces a line count.
69
-
70
- **A much smaller cap is a bad trade, and this repo has the measurement.**
71
- Splitting ten modules into about ninety produced three bindings that needed
72
- accessors because ESM forbids assigning an imported binding, one dropped import
73
- that threw only inside a deferred callback, one symbol identity swap that
74
- silently broke slot forwarding, and three drift guards that broke or would have
75
- passed vacuously. Four of those six were invisible to `npm test`. Every module
76
- boundary is a place where those failures can happen, so boundaries are worth
77
- adding for cohesion and worth nothing when added to hit a number.
78
-
79
- There is also a runtime cost. In dev the browser fetches core source files
80
- individually rather than the bundle, so N modules is N requests at one more
81
- level of import-graph depth.
82
-
83
- **The ceiling is measured with a RAW line count** (the number `wc -l` prints,
84
- the number you see when you open the file), because that is how #1365 specified
85
- it and because a metric a reader cannot reproduce by looking at the file invites
86
- argument about the metric instead of the module. The tension with dense
87
- documentation is real: this repo's comment style can put a well-factored module
88
- at twice its code size, and a raw ceiling must never become a reason to delete
89
- explanation. The answer to that tension is the exemption list, not a different
90
- metric.
91
-
92
- **There is deliberately NO CI guard for this.** A line-count gate is a proxy
93
- metric that fights cohesion: it reds forever on a generated data table like
94
- `html-entities.js`, and it has to carry an exemption list that rots. So the
95
- ceiling is a REVIEW-TIME check, not a test. Measure it when you split something:
96
-
97
- ```sh
98
- find <the tree you produced> -name '*.js' -exec wc -l {} + | awk '$1 > 1000 && $2 != "total"'
99
- ```
100
-
101
- **A module that genuinely cannot or should not go under the ceiling gets a
102
- NAMED exemption**, argued in the PR that produces it, with its measured size and
103
- its reason. The three exemptions the #1365 split carries show what a valid
104
- reason looks like:
105
-
106
- - **lit parity** (`component/lifecycle.js`): the file tracks lit's
107
- `reactive-element.ts`, which lit keeps WHOLE at 1754 lines, and the project's
108
- standing decision is to keep lit-derived code close to lit rather than
109
- restructure it.
110
- - **mutual recursion** (`render-client/parts.js`): the apply and instance group
111
- calls back into itself, so a real split creates the import cycle the D4 rule
112
- forbids, and the escape (a runtime dispatch registry) is a worse trade.
113
- - **a single closure over shared request state** (`dev/handler.js`): splitting
114
- means threading that state through a context object, a high-risk rewrite of
115
- every app's boot path for zero behaviour gain.
116
-
117
- Note what a valid reason is NOT: "it is mostly comments." If a module is over
118
- the ceiling only because it is well documented, that is a signal the ceiling is
119
- being measured too literally, not grounds for an exemption. Say why the CODE
120
- cannot be split, or split it.
121
-
122
- ---
123
-
124
- ## Splitting a module into a barrel plus a directory
125
-
126
- The naming rule, settled once for the whole framework: **the original file keeps
127
- its path and becomes the barrel, and its parts land in a sibling directory named
128
- after it.** So `packages/core/src/slot.js` stays, and its parts go in
129
- `packages/core/src/slot/`. Do NOT rename the barrel to match a public export
130
- subpath. The `package.json` `exports` map, the hand-written `.d.ts` overlays and
131
- their two guard tests, the docs pages that print importmap examples, and every
132
- relative test import all key off the current path.
133
-
134
- ### The rule that matters most
135
-
136
- **A split is a MOVE, not a rewrite.** Retyping a function while relocating it is
137
- how a refactor with a green export surface ships behaviour changes. Move the
138
- lines verbatim. The only edits a move should produce are the `export ` keyword
139
- where a declaration now crosses a module boundary, and the generated import
140
- lines.
141
-
142
- ### Where mutable module state goes
143
-
144
- A module-scope `let` goes in the module that WRITES it, not the module that
145
- looks like its topical home. ESM import bindings are read-only, so a module
146
- cannot assign a binding it imported.
147
-
148
- When two modules genuinely write the same binding, the owner exposes a
149
- one-statement accessor and the other module calls it:
150
-
151
- ```js
152
- // scroll.js owns the counter.
153
- export let restoreGeneration = 0;
154
- export function bumpRestoreGeneration() { restoreGeneration += 1; }
155
-
156
- // navigator.js reads the live binding and calls the accessor to write.
157
- import { restoreGeneration, bumpRestoreGeneration } from './scroll.js';
158
- ```
159
-
160
- Keep importing the binding itself wherever it is READ. Dropping it from the
161
- import list while a read site survives leaves a free variable, which throws only
162
- when that line executes. In the client router that meant a Back-button scroll
163
- restore silently landing at offset 0, with every node test still green.
164
-
165
- ### Cycles and TDZ
166
-
167
- Layer the modules and never import upward. Node tolerates an import cycle, but
168
- reading a `const` or `class` binding during the cycle's evaluation phase throws
169
- a TDZ `ReferenceError` at module load, and in a minified browser bundle that is
170
- a blank page rather than a test failure.
171
-
172
- Where a back edge is unavoidable, resolve it by calling a function at call time
173
- rather than reading a binding at module scope.
174
-
175
- ### Symbol identity
176
-
177
- `Symbol('x')` mints a unique value. `Symbol.for('x')` looks one up in the global
178
- registry by string. They are never interchangeable, and substituting one for the
179
- other produces a value that no existing object carries, so every lookup quietly
180
- returns `undefined`. Import the symbol from the module that created it.
181
-
182
- ### Drift guards read source files by path
183
-
184
- This repo has guard tests that `readFileSync` a source file and grep it for a
185
- constant, to pin two copies of a value against each other. Barrelling a file
186
- breaks every one of them, and breaks them in two different ways:
187
-
188
- - an `assert.match` fails on its own precondition, which is loud and fine;
189
- - an `assert.doesNotMatch` starts passing **vacuously**, which is silent and is
190
- the reason this is written down.
191
-
192
- After any split, grep the test tree for reads of the file you just barrelled and
193
- point each guard at the barrel PLUS every module beneath it.
194
-
195
- ### Verification, in order
196
-
197
- ```sh
198
- # 1. Export surface is identical, in BOTH directions. `added` must be empty too:
199
- # a behaviour-preserving split adds no public surface.
200
- node --input-type=module -e "
201
- const before = await import('/tmp/before.js');
202
- const after = await import('./packages/core/src/<file>.js');
203
- const A = Object.keys(before).sort(), B = Object.keys(after).sort();
204
- console.log(JSON.stringify({
205
- missing: A.filter((k) => !B.includes(k)),
206
- added: B.filter((k) => !A.includes(k)),
207
- }));
208
- "
209
-
210
- # 2. Every code line survived. Normalize away comments and the `export ` prefix,
211
- # then diff. Anything left is a line the split CHANGED, and each one needs a
212
- # reason in the commit message.
213
-
214
- # 3. The module loads at all (catches a TDZ throw introduced by a cycle).
215
- node --input-type=module -e "await import('./packages/core/index-browser.js')"
216
- node --input-type=module -e "await import('./packages/core/index.js')"
217
-
218
- # 4. Rebuild dist BEFORE any e2e or Bun run, which resolve the built bundle and
219
- # would otherwise test the pre-split code and pass vacuously.
220
- node scripts/build-framework-dist.js
221
-
222
- # 5. The browser suite is MANDATORY for a renderer, router, component, or slot
223
- # split. Those defects are post-hydration: the export surface is unchanged,
224
- # the SSR bytes are unchanged, and node tests stay green.
225
- npm test && npm run test:browser
226
- ```
227
-
228
- Step 5 is not optional and not a formality. Of the two silent defects this
229
- codebase has hit from splitting, both were caught by the browser suite alone.