@webjsdev/mcp 0.1.7 → 0.1.9

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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -1
  3. package/resources/AGENTS.md +86 -72
  4. package/resources/SKILL.md +226 -0
  5. package/resources/references/auth-and-sessions.md +220 -0
  6. package/resources/references/built-ins.md +200 -0
  7. package/resources/references/client-router-and-streaming.md +204 -0
  8. package/resources/references/components.md +167 -0
  9. package/resources/references/data-and-actions.md +187 -0
  10. package/resources/references/muscle-memory-gotchas.md +170 -0
  11. package/resources/references/optimistic-ui.md +128 -0
  12. package/resources/references/routing-and-pages.md +158 -0
  13. package/resources/references/runtime.md +80 -0
  14. package/resources/references/service-worker.md +78 -0
  15. package/resources/references/styling.md +123 -0
  16. package/resources/references/testing.md +125 -0
  17. package/resources/references/typescript.md +148 -0
  18. package/src/mcp-docs.js +27 -14
  19. package/src/mcp-source.js +1 -1
  20. package/src/mcp.js +9 -47
  21. package/src/routes-report.js +90 -0
  22. package/resources/agent-docs/advanced.md +0 -1334
  23. package/resources/agent-docs/built-ins.md +0 -389
  24. package/resources/agent-docs/components-shadowing.md +0 -87
  25. package/resources/agent-docs/components.md +0 -734
  26. package/resources/agent-docs/configuration.md +0 -284
  27. package/resources/agent-docs/framework-dev.md +0 -73
  28. package/resources/agent-docs/lit-muscle-memory-gotchas.md +0 -470
  29. package/resources/agent-docs/metadata.md +0 -334
  30. package/resources/agent-docs/orm.md +0 -198
  31. package/resources/agent-docs/recipes.md +0 -664
  32. package/resources/agent-docs/runtime.md +0 -65
  33. package/resources/agent-docs/service-worker.md +0 -104
  34. package/resources/agent-docs/styling.md +0 -279
  35. package/resources/agent-docs/testing.md +0 -479
  36. package/resources/agent-docs/types-and-mutations.md +0 -171
  37. package/resources/agent-docs/typescript.md +0 -402
@@ -1,664 +0,0 @@
1
- # Recipes
2
-
3
- Copy-paste patterns for the most common webjs tasks. Each recipe is the
4
- canonical shape, follow it rather than inventing a variant. The full API
5
- reference lives in the root `AGENTS.md`.
6
-
7
- ## Schema-first: from scaffold to product (do this FIRST)
8
-
9
- A freshly scaffolded app ships an EXAMPLE `User` model, an example
10
- `app/page.ts`, and an example component. They are starting-point references,
11
- not the product. The first thing to do for a real app is replace the example
12
- schema with the real domain models, then build features on top. This is the
13
- transition agents most often get wrong, so it is the first recipe.
14
-
15
- > **Two non-negotiables.** NEVER leave the example `User` model in
16
- > `db/schema.server.ts` if the app does not actually have users (delete or
17
- > replace it). NEVER persist app data in JSON files (`data/todos.json`,
18
- > `db.json`), in a module-scope array or `Map`, or in `localStorage`. Those
19
- > reset on every reload and cannot scale. Every piece of stored data is a
20
- > Drizzle table.
21
-
22
- 1. **Edit `db/schema.server.ts`** to the real domain. Replace the example
23
- `User` model with the models the app needs (the helpers come from
24
- `db/columns.server.ts`).
25
-
26
- ```ts
27
- // db/schema.server.ts
28
- import { table, pk, text, bool, createdAt, index } from './columns.server.ts';
29
- export const posts = table('posts', {
30
- id: pk(),
31
- title: text().notNull(),
32
- body: text().notNull(),
33
- published: bool().notNull().default(false),
34
- createdAt: createdAt(),
35
- }, (t) => [index(t.createdAt)]);
36
- ```
37
-
38
- 2. **Generate + migrate.** Drizzle has no codegen; `generate` turns the schema
39
- into SQL, `migrate` applies it:
40
-
41
- ```sh
42
- npm run db:generate # schema -> SQL migration (drizzle-kit)
43
- npm run db:migrate # apply it to the dev SQLite database
44
- ```
45
-
46
- 3. **Generate one query and one action per operation**, one exported function
47
- per file, named after the file, under the feature module. Reads go in
48
- `queries/`, mutations in `actions/`. Both are `.server.ts` with
49
- `'use server'`, so their browser imports become typed RPC stubs.
50
-
51
- ```ts
52
- // modules/posts/queries/list-posts.server.ts
53
- 'use server';
54
- import { db } from '../../../db/connection.server.ts';
55
- export async function listPosts() {
56
- return db.query.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' } });
57
- }
58
- ```
59
-
60
- ```ts
61
- // modules/posts/actions/create-post.server.ts
62
- 'use server';
63
- import { db } from '../../../db/connection.server.ts';
64
- import { posts } from '../../../db/schema.server.ts';
65
- export async function createPost(input: { title: string; body: string }) {
66
- const title = String(input?.title || '').trim();
67
- if (!title) return { success: false, error: 'title required', status: 400 };
68
- const [post] = await db.insert(posts).values({ title, body: String(input?.body || '') }).returning();
69
- return { success: true, data: post };
70
- }
71
- ```
72
-
73
- 4. **Wire it into a page** by calling the query (the page runs on the server,
74
- so it imports the `.server` query directly and awaits it). Never import
75
- the DB driver into a page; reach the database through the query.
76
-
77
- ```ts
78
- // app/posts/page.ts
79
- import { html } from '@webjsdev/core';
80
- import { listPosts } from '../../modules/posts/queries/list-posts.server.ts';
81
- export default async function Posts() {
82
- const posts = await listPosts();
83
- return html`<ul>${posts.map((p) => html`<li>${p.title}</li>`)}</ul>`;
84
- }
85
- ```
86
-
87
- For the write path, pair `create-post.server.ts` with a `<form>` plus a page
88
- `action` or a `route.ts` POST handler (see the form-mutation recipe below), so
89
- it works without JavaScript and the client router upgrades it automatically.
90
-
91
- ## Add a page
92
-
93
- ```ts
94
- // app/about/page.ts
95
- import { html } from '@webjsdev/core';
96
- export default function About() {
97
- return html`<h1>About</h1>`;
98
- }
99
- ```
100
-
101
- ## Add a dynamic route
102
-
103
- ```ts
104
- // app/users/[id]/page.ts
105
- import { html } from '@webjsdev/core';
106
- export default async function User({ params }: { params: { id: string } }) {
107
- const user = await fetchUser(params.id); // via a server action, never import the DB directly
108
- return html`<h1>${user.name}</h1>`;
109
- }
110
- ```
111
-
112
- ## Add a server action (RPC from a client component)
113
-
114
- ```ts
115
- // modules/users/actions/update-profile.server.ts
116
- 'use server';
117
- import { eq } from 'drizzle-orm';
118
- import { db } from '../../../db/connection.server.ts';
119
- import { users } from '../../../db/schema.server.ts';
120
- export async function updateProfile(input: { name: string }) {
121
- const name = String(input?.name || '').trim();
122
- if (!name) return { success: false, error: 'name required', status: 400 };
123
- const [row] = await db.update(users).set({ name }).where(eq(users.id, me.id)).returning();
124
- return { success: true, data: row };
125
- }
126
- ```
127
-
128
- Call it from a client component via a normal import. The dev server
129
- rewrites the import to a typed RPC stub.
130
-
131
- ## Validate a server action's input (#245)
132
-
133
- Declare a `validate` config export beside the action (#488). It runs
134
- SERVER-SIDE before the action body on the RPC boundary (the path a client
135
- component import takes), receiving the action's FIRST argument. On failure
136
- it returns a structured `ActionResult`
137
- (`{ success: false, fieldErrors, status: 422 }`) the client reads as
138
- `result.fieldErrors`. The framework ships no validation library; the
139
- validator is a plain function (or a three-line zod adapter).
140
-
141
- ```ts
142
- // modules/posts/actions/create-post.server.ts
143
- 'use server';
144
- import { db } from '../../../db/connection.server.ts';
145
- import { posts } from '../../../db/schema.server.ts';
146
-
147
- // the validator: receives the action's FIRST argument
148
- export const validate = (input: any) => {
149
- const fieldErrors: Record<string, string> = {};
150
- const title = String(input?.title || '').trim();
151
- if (!title) fieldErrors.title = 'Title is required';
152
- if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short';
153
- if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
154
- return { success: true, data: { title, body: String(input.body) } }; // coerced input
155
- };
156
-
157
- // the action body: runs ONLY when validation passes
158
- export async function createPost(input: { title: string; body: string }) {
159
- const [row] = await db.insert(posts).values(input).returning();
160
- return { success: true, data: row };
161
- }
162
- ```
163
-
164
- Reading the structured failure in a client component is just a property
165
- read on the returned object (an invalid call resolves with the failure
166
- envelope, it does NOT throw):
167
-
168
- ```ts
169
- // components/post-form.ts (browser)
170
- import { createPost } from '../modules/posts/actions/create-post.server.ts';
171
-
172
- const result = await createPost({ title: this.title, body: this.body });
173
- if (!result.success) {
174
- this.errors = result.fieldErrors ?? {}; // { title: 'Title is required', ... }
175
- return;
176
- }
177
- // result.data is the created row
178
- ```
179
-
180
- **Zod adapter (keeps the framework zod-free):** wrap `safeParse` so its
181
- result becomes the contract envelope.
182
-
183
- ```ts
184
- import { z } from 'zod';
185
- const Schema = z.object({ title: z.string().min(1), body: z.string().min(10) });
186
-
187
- export const validate = (i: any) => {
188
- const r = Schema.safeParse(i);
189
- return r.success
190
- ? { success: true, data: r.data }
191
- : { success: false, fieldErrors: r.error.flatten().fieldErrors };
192
- };
193
- export async function createPost(input) { /* ... */ }
194
- ```
195
-
196
- To ALSO expose the action over REST with the SAME validator, put it behind
197
- a `route.ts` and pass the validator as the `route()` adapter's `{ validate }`
198
- option: `export const POST = route(createPost, { validate })`. A
199
- `{ success: false, fieldErrors }` return becomes a 422 JSON response there; a
200
- validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a
201
- non-envelope return transforms the input.
202
-
203
- ## HTTP verbs, caching, streaming, and cancellation (#488, #489, #490, #492)
204
-
205
- A `'use server'` action is a POST by default. Reserved sibling exports, read
206
- statically (the same way a page reads `export const revalidate`), change its
207
- HTTP semantics WITHOUT changing the call site (you still write `await
208
- getUser(7)`). One callable function per configured file (a second callable is a
209
- `webjs check` error).
210
-
211
- ```ts
212
- // modules/users/queries/get-user.server.ts: a cached, tagged GET read
213
- 'use server';
214
- export const method = 'GET'; // absent = POST
215
- export const cache = 60; // seconds, or { maxAge, swr, public }
216
- export const tags = (id: number) => ['user:' + id];
217
- export async function getUser(id: number) { return db.query.users.findFirst({ where: { id } }); }
218
- ```
219
-
220
- ```ts
221
- // a mutation evicts the tags it touches
222
- 'use server';
223
- export const invalidates = (id: number) => ['user:' + id];
224
- export const middleware = [requireAuth]; // #490: async (ctx, next) => result; actionContext()
225
- export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
226
- ```
227
-
228
- - A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt,
229
- and carries `Cache-Control` + a weak `ETag` (304 on `If-None-Match`) +
230
- `X-Webjs-Tags`. A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body
231
- (DELETE rides the URL), is CSRF-protected, and on success evicts its
232
- `invalidates` tags and reports them via `X-Webjs-Invalidate`. A method mismatch
233
- is a `405` + `Allow`.
234
- - **SAFETY:** `cache` with `public: true` SHARES one response across all users,
235
- keyed only by URL + args. Use it ONLY for data identical for every visitor (the
236
- same rule as a page's `export const revalidate`).
237
- - **Streaming (#489):** return a `ReadableStream` / async iterable / async
238
- generator (any verb) and the call site does `for await (const c of await
239
- streamTokens(8))`. Each chunk is rich-serialized as yielded, back-pressured,
240
- and the source is cancelled on disconnect. A streamed result is never cached /
241
- ETagged / seeded.
242
- - **Cancellation (#492):** read the request `AbortSignal` via `actionSignal()`
243
- (from `@webjsdev/server`) to stop work on disconnect; a superseded client
244
- `async render()` auto-aborts the previous in-flight fetch.
245
- - **SSR seeding (#472):** an action result invoked during a non-streamed SSR
246
- render is serialized into the page; the stub reads that seed on its first client
247
- call, so a shipping component does not re-fetch on hydration (consume-once,
248
- fail-open; opt out with `"webjs": { "seed": false }`).
249
-
250
- ## Add a component
251
-
252
- ```ts
253
- // components/hello-world.ts
254
- import { WebComponent, html } from '@webjsdev/core';
255
- export class HelloWorld extends WebComponent {
256
- render() { return html`<p>Hello!</p>`; }
257
- }
258
- HelloWorld.register('hello-world');
259
- ```
260
-
261
- ## Fetch data in a component (async render, #469)
262
-
263
- A leaf component fetches its own server data into the first paint, co-located, with no prop-drilling. Make `render()` async and call a `'use server'` action directly (real fn at SSR, RPC stub on the client). SSR awaits it, so the data is in the first paint.
264
-
265
- ```ts
266
- // (a) blocking async render: real data in the first paint, the common case
267
- class UserProfile extends WebComponent({ uid: String }) {
268
- async render() {
269
- const u = await getUser(this.uid);
270
- return html`<h3>${u.name}</h3>`;
271
- }
272
- }
273
- UserProfile.register('user-profile');
274
-
275
- // (b) stream a SLOW region (fallback on the first byte, content streams in)
276
- html`
277
- <webjs-suspense .fallback=${html`<p>Loading…</p>`}>
278
- <user-profile uid="42"></user-profile>
279
- </webjs-suspense>
280
- `;
281
-
282
- // (c) renderFallback() = the CLIENT re-fetch loading state (never the first paint)
283
- class UserActivity extends WebComponent({ uid: String }) {
284
- renderFallback() { return html`<div class="skeleton h-24"></div>`; }
285
- async render() {
286
- const items = await getActivity(this.uid);
287
- return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
288
- }
289
- }
290
-
291
- // (d) errors are isolated by default: NO renderError() needed (add it only to customize)
292
- class Report extends WebComponent {
293
- async render() { return html`<pre>${await getReport()}</pre>`; }
294
- }
295
- ```
296
-
297
- The client re-fetch default is stale-while-revalidate (the current content stays until the new render resolves). Use `<webjs-suspense>` only for genuinely slow data; keep `Task` / signals for client-only data that should NOT be in the first paint. Full decision guide in `agent-docs/components.md` and the `data-fetching` doc page.
298
-
299
- ## Form mutation with server-side validation (no JS required)
300
-
301
- This is webjs's progressive-enhancement write-path. A `<form method="POST">`
302
- posts to a page `action` that validates on the server, re-renders the page
303
- with field errors on failure (preserving the user's input), and redirects
304
- on success. It works with JS disabled, and the client router upgrades it to
305
- an in-place swap when JS is on, same UI either way. No form library.
306
-
307
- A `page.{js,ts}` may export an `action` next to its default render
308
- function. A non-GET/HEAD submission to the page's own URL runs the action,
309
- wrapped in the page's segment middleware.
310
-
311
- ```ts
312
- // app/contact/page.ts
313
- import { html } from '@webjsdev/core';
314
- import { sendMessage } from '../../modules/contact/actions/send-message.server.ts';
315
-
316
- // Runs only on the server. Receives the already-parsed `formData` plus the
317
- // raw `request`, `params`, `searchParams`, and `url`.
318
- export async function action({ formData }: { formData: FormData }) {
319
- const email = String(formData.get('email') || '').trim();
320
- const body = String(formData.get('body') || '').trim();
321
- const values = { email, body };
322
- const fieldErrors: Record<string, string> = {};
323
- if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
324
- if (body.length < 10) fieldErrors.body = 'Message is too short';
325
- if (Object.keys(fieldErrors).length) {
326
- return { success: false, fieldErrors, values, status: 422 };
327
- }
328
- await sendMessage({ email, body });
329
- return { success: true, redirect: '/contact/thanks' };
330
- }
331
-
332
- export default function Contact({ actionData }: {
333
- actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
334
- }) {
335
- const errors = actionData?.fieldErrors || {};
336
- const values = actionData?.values || {};
337
- return html`
338
- <form method="POST" class="flex flex-col gap-3">
339
- <input name="email" type="email" value=${values.email || ''} required>
340
- ${errors.email ? html`<p class="text-sm text-red-600">${errors.email}</p>` : ''}
341
- <textarea name="body" required>${values.body || ''}</textarea>
342
- ${errors.body ? html`<p class="text-sm text-red-600">${errors.body}</p>` : ''}
343
- <button type="submit">Send</button>
344
- </form>
345
- `;
346
- }
347
- ```
348
-
349
- ### How the result is interpreted (server side)
350
-
351
- | Action outcome | HTTP response |
352
- |---|---|
353
- | success result (see the failure rule below) | `303 See Other` to a same-site `redirect` if present, else the page's own path (Post/Redirect/Get) |
354
- | thrown `redirect('/x')` | `307` (method-preserving, the action default since it is a POST), or the explicit status if `redirect('/x', 308)` / `redirect('/x', { status })` was used. A redirect thrown during a plain GET page render instead defaults to `302`. |
355
- | thrown `notFound()` | `404` rendered via `not-found.{js,ts}` |
356
- | failure result (`success: false`, or `fieldErrors`, or an `error`) | re-SSR the SAME page with `status` (default `422`) and the result on `ctx.actionData` |
357
-
358
- **Failure detection is robust.** A result is treated as a FAILURE (re-render)
359
- when ANY of these hold, so an error is never swallowed just because the author
360
- omitted a literal `success: false`:
361
-
362
- - `result.success === false`, OR
363
- - `result.fieldErrors` is present, OR
364
- - `result.error` is present AND `result.success !== true`.
365
-
366
- Everything else is a success (explicit `success: true`, or a bare value /
367
- `undefined` / `null` with no error markers), which PRG-redirects.
368
-
369
- **`result.redirect` must be a same-site local path.** It is honored only when
370
- it begins with a single `/` (a relative path like `/login` or `/a?b=1#c`). A
371
- protocol-relative `//host` and any absolute `scheme://host` URL are rejected and
372
- the redirect falls back to the page's own path, because a user-controlled
373
- redirect target is an open-redirect vector. For a legitimate EXTERNAL redirect,
374
- throw `redirect(absoluteUrl)` (the nav sentinel, author-controlled) instead of
375
- returning it as `result.redirect`.
376
-
377
- ### The `ActionResult` shape
378
-
379
- The envelope is additive over the existing `{ success, data, error, status }`:
380
-
381
- ```ts
382
- type ActionResult<T> =
383
- | { success: true; data?: T; redirect?: string } // redirect MUST be a same-site local path
384
- | {
385
- success: false;
386
- error?: string;
387
- fieldErrors?: Record<string, string>; // per-field messages, keyed by input `name`
388
- values?: Record<string, string>; // the submitted values (text fields), to repopulate inputs
389
- status?: number; // defaults to 422 on the re-render
390
- };
391
- ```
392
-
393
- The page reads `ctx.actionData?.fieldErrors?.<name>` for the message and
394
- `ctx.actionData?.values?.<name>` to set a native `value=`. On a plain GET
395
- render `actionData` is `undefined`, so the page renders empty inputs and no
396
- error blocks. (`values` carries text fields as strings; for a file upload see
397
- the "Receive and persist an uploaded file" recipe below.)
398
-
399
- ### Why no `fetch` in a `@click` handler here
400
-
401
- Native `<input value=...>` repopulation plus the browser's Constraint
402
- Validation API (`required`, `type="email"`, `minlength`) cover the input
403
- side, and the server action result carries the field-level errors. Reaching
404
- for `fetch` + a JS submit handler would break the no-JS baseline. Use a
405
- `<form>` + a page `action` for any write-path that a form can express.
406
-
407
- See `agent-docs/advanced.md` for the client-router side (how the enhanced
408
- 303/422 swap works) and the rest of the form-submission behavior.
409
-
410
- ## Snappy UX: Form mutation with Optimistic UI updates
411
-
412
- When building interactive applications (like a Todo app, chat app, or likes toggle), using Optimistic UI creates a Snappy UX by immediately updating the UI state in the browser before the server confirms the operation. **Default to `optimistic()` from `@webjsdev/core` for every mutation where the client can predict the result.** Only skip it when the result is unpredictable (AI-generated content), the user must wait for side effects (payment, email), or the mutation is destructive with no undo.
413
-
414
- To support progressive enhancement, write a baseline `<form>` that works without JS, then enhance the component in the browser using JS.
415
-
416
- ### Optimistic UI Recipe
417
-
418
- 1. **The Server Action**:
419
- Create a standard server action that performs the database write and returns the new database entity.
420
-
421
- ```ts
422
- // modules/todos/actions/create-todo.server.ts
423
- 'use server';
424
- import { db } from '../../../db/connection.server.ts';
425
- import { todos } from '../../../db/schema.server.ts';
426
-
427
- export async function createTodo(input: { title: string }) {
428
- const title = String(input.title || '').trim();
429
- if (!title) return { success: false, error: 'Title is required', status: 400 };
430
-
431
- const [todo] = await db.insert(todos).values({ title, completed: false }).returning();
432
- return { success: true, data: todo };
433
- }
434
- ```
435
-
436
- 2. **The Component with `optimistic()` (preferred)**:
437
- Use the declarative `optimistic(host, { source, update })` API. It tracks a queue of pending updates, computes the combined value via a reducer, and auto-releases when the promise settles. No try-catch, no manual rollback, no temp-ID bookkeeping.
438
-
439
- ```ts
440
- // components/todo-list.ts
441
- import { html, WebComponent, prop, optimistic } from '@webjsdev/core';
442
- import { createTodo } from '../modules/todos/actions/create-todo.server.ts';
443
- import { Todo } from '../modules/todos/types.ts';
444
-
445
- export class TodoList extends WebComponent({
446
- todos: prop<Todo[]>(Array),
447
- }) {
448
- private optimisticTodos = optimistic(this, {
449
- source: () => this.todos,
450
- update: (state, title: string) => [
451
- ...state,
452
- { id: crypto.randomUUID() as any, title, completed: false, pending: true },
453
- ],
454
- });
455
-
456
- async handleSubmit(e: SubmitEvent) {
457
- e.preventDefault();
458
- const form = e.target as HTMLFormElement;
459
- const title = String(new FormData(form).get('title') || '').trim();
460
- if (!title) return;
461
-
462
- form.reset();
463
-
464
- const promise = createTodo({ title });
465
- this.optimisticTodos.add(title, promise);
466
-
467
- const result = await promise;
468
- if (result.success && result.data) {
469
- this.todos = [
470
- ...this.todos.filter(t => t.pending),
471
- result.data,
472
- ...this.todos.filter(t => !t.pending),
473
- ];
474
- }
475
- }
476
-
477
- render() {
478
- return html`
479
- <form @submit=${this.handleSubmit}>
480
- <input type="text" name="title" placeholder="New todo..." required />
481
- <button type="submit">Add</button>
482
- </form>
483
-
484
- <ul>
485
- ${this.optimisticTodos.value.map(
486
- (todo) => html`
487
- <li class=${todo.pending ? 'opacity-50 pointer-events-none' : ''}>
488
- ${todo.title}
489
- </li>
490
- `
491
- )}
492
- </ul>
493
- `;
494
- }
495
- }
496
- TodoList.register('todo-list');
497
- ```
498
-
499
- For simple boolean flips, the imperative `optimistic(signal, value, action)` is cleaner:
500
-
501
- ```ts
502
- import { signal, optimistic } from '@webjsdev/core';
503
- import { likePost } from '#actions/like-post.server.ts';
504
-
505
- const liked = signal(false);
506
- // in an @click handler:
507
- await optimistic(liked, true, () => likePost(postId));
508
- ```
509
-
510
- 3. **Manual optimistic (when you cannot use `optimistic()`)**:
511
- If you need fine-grained control (custom loading states, partial rollbacks, multi-step reconciliation), write the manual pattern:
512
-
513
- - Declare a reactive property to hold the list.
514
- - Generate a temporary ID, construct the optimistic item, update state immediately.
515
- - Run the server action.
516
- - Reconcile: replace the optimistic item on success, revert on failure.
517
-
518
- ```ts
519
- async handleSubmit(e: SubmitEvent) {
520
- e.preventDefault();
521
- const form = e.target as HTMLFormElement;
522
- const title = String(new FormData(form).get('title') || '').trim();
523
- if (!title) return;
524
- form.reset();
525
-
526
- const tempId = `temp-${crypto.randomUUID()}`;
527
- const previousTodos = this.todos;
528
- this.todos = [
529
- { id: tempId as any, title, completed: false, pending: true },
530
- ...this.todos,
531
- ];
532
-
533
- try {
534
- const result = await createTodo({ title });
535
- if (result.success && result.data) {
536
- this.todos = this.todos.map((t) => (t.id === tempId ? result.data! : t));
537
- } else {
538
- this.todos = previousTodos;
539
- }
540
- } catch {
541
- this.todos = previousTodos;
542
- }
543
- }
544
- ```
545
-
546
- ## Receive and persist an uploaded file
547
-
548
- A file upload is just a `<form enctype="multipart/form-data">` posting to a page
549
- `action`. With JS disabled it is a native round-trip; with JS the client router
550
- upgrades it in place. No upload library, no `fetch`. The bytes are STREAMED to
551
- storage via the file-storage primitive (`getFileStore()`), never buffered whole,
552
- and a `route.{js,ts}` serves them back through a signed URL.
553
-
554
- ```ts
555
- // app/avatar/page.ts
556
- import { html } from '@webjsdev/core';
557
- import { saveAvatar } from '../../modules/avatar/actions/save-avatar.server.ts';
558
-
559
- export async function action({ formData }: { formData: FormData }) {
560
- const file = formData.get('avatar'); // a web `File`
561
- if (!(file instanceof File) || file.size === 0) {
562
- return { success: false, fieldErrors: { avatar: 'Choose an image' }, status: 422 };
563
- }
564
- const result = await saveAvatar(file); // persists + returns the key
565
- if (!result.success) return result;
566
- return { success: true, redirect: '/avatar' };
567
- }
568
-
569
- export default function Avatar({ actionData }: {
570
- actionData?: { fieldErrors?: Record<string, string> };
571
- }) {
572
- const errors = actionData?.fieldErrors || {};
573
- return html`
574
- <form method="POST" enctype="multipart/form-data" class="flex flex-col gap-3">
575
- <input name="avatar" type="file" accept="image/*" required>
576
- ${errors.avatar ? html`<p class="text-sm text-red-600">${errors.avatar}</p>` : ''}
577
- <button type="submit">Upload</button>
578
- </form>
579
- `;
580
- }
581
- ```
582
-
583
- The action delegates to a `.server` action that streams the file to storage with
584
- a generated, traversal-safe key and persists that key on the DB row. Never use
585
- the user-supplied filename as a key; `generateKey` makes an opaque one.
586
-
587
- ```ts
588
- // modules/avatar/actions/save-avatar.server.ts
589
- 'use server';
590
- import { getFileStore, generateKey } from '@webjsdev/server';
591
- import { eq } from 'drizzle-orm';
592
- import { db } from '../../../db/connection.server.ts';
593
- import { users } from '../../../db/schema.server.ts';
594
-
595
- export async function saveAvatar(file: File) {
596
- const key = generateKey(file.name); // <uuid>.<ext>, safe
597
- const { size, contentType } = await getFileStore().put(key, file); // streams to disk
598
- if (size > 5 * 1024 * 1024) { // app-level policy check
599
- await getFileStore().delete(key);
600
- return { success: false, fieldErrors: { avatar: 'Max 5 MB' }, status: 422 };
601
- }
602
- await db.update(users).set({ avatarKey: key }).where(eq(users.id, 'me'));
603
- return { success: true, data: { key, contentType } };
604
- }
605
- ```
606
-
607
- Serve the stored file from a `route.{js,ts}`, streaming `get(key)` and (optionally)
608
- gating it behind a signed URL so the object is not world-readable by key alone.
609
-
610
- ```ts
611
- // app/files/[key]/route.ts
612
- import { getFileStore, verifySignedUrl } from '@webjsdev/server';
613
-
614
- export async function GET(request: Request, { params }: { params: { key: string } }) {
615
- const check = verifySignedUrl(new URL(request.url).searchParams, process.env.AUTH_SECRET!);
616
- if (!check.valid || check.key !== params.key) {
617
- return new Response('Forbidden', { status: 403 });
618
- }
619
- const handle = await getFileStore().get(params.key);
620
- if (!handle) return new Response('Not Found', { status: 404 });
621
- return new Response(handle.body, { // streams; never reads the file into memory
622
- headers: {
623
- 'content-type': handle.contentType,
624
- 'content-length': String(handle.size),
625
- // SECURITY (do NOT drop these for user-uploaded bytes). The stored
626
- // content-type came from the UPLOAD, which is client-controlled, so an
627
- // attacker can upload HTML/SVG tagged `text/html` under an innocent key.
628
- // `nosniff` stops the browser MIME-sniffing it into HTML, and
629
- // `attachment` forces a download instead of rendering it in your origin,
630
- // which is what turns an upload into stored XSS.
631
- 'x-content-type-options': 'nosniff',
632
- 'content-disposition': 'attachment',
633
- },
634
- });
635
- }
636
- ```
637
-
638
- Mint the signed URL where you render the link (a page or component):
639
-
640
- ```ts
641
- import { signedUrl } from '@webjsdev/server';
642
- const href = signedUrl(user.avatarKey, { secret: process.env.AUTH_SECRET!, expiresIn: 3600 });
643
- ```
644
-
645
- > **Serving user uploads safely (the canonical upload vulnerability).** The
646
- > content-type a store records is the one the BROWSER sent at upload time, so it
647
- > is attacker-controlled. Serving it inline lets an attacker run script in your
648
- > origin (stored XSS) via an HTML or `image/svg+xml` payload under an innocent
649
- > key. ALWAYS send `X-Content-Type-Options: nosniff`, and prefer
650
- > `Content-Disposition: attachment` for anything a user uploaded. Only serve a
651
- > user upload INLINE (no `attachment`) when you have validated the bytes
652
- > server-side and are emitting a content-type from a strict inert allowlist
653
- > (e.g. `image/png`, `image/jpeg`), never reflecting `text/html` or
654
- > `image/svg+xml`. Best of all, serve user uploads from a SEPARATE origin / cookieless
655
- > subdomain so even a sniffing bypass cannot reach your session.
656
-
657
- For a public asset you control, you may drop the signature and serve
658
- `getFileStore().get(key)` directly, but keep `nosniff` + `attachment` for
659
- anything a user supplied. To point storage at a custom directory or an
660
- S3-compatible backend, call `setFileStore(diskStore({ dir, baseUrl }))` (or a
661
- custom adapter) once at startup; the call sites above do not change. The default
662
- uploads directory is `<cwd>/.webjs/uploads`, which the app should `.gitignore`.
663
- See the "File storage" section in `agent-docs/built-ins.md` for the full
664
- interface and the traversal-safety + signed-URL guarantees.