@webjsdev/mcp 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ Use this skill for end-to-end WebJs app work. It helps you choose the right laye
9
9
 
10
10
  ## Full Documentation
11
11
 
12
- This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). The complete hosted docs live at https://docs.webjs.dev.
12
+ This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). The complete hosted docs live at https://webjs.dev/docs.
13
13
 
14
14
  ## What WebJs Is
15
15
 
@@ -22,7 +22,7 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou
22
22
  - **`*.server.ts`** is the one server boundary. With `'use server'` its exports are RPC-callable from the client (the import is rewritten to a stub); without it the file is a server-only utility whose browser import throws at load. This, not a component annotation, is how a dependency (the DB driver, secrets, `node:*`) is kept off the client.
23
23
  - **`route.ts`** is a server-only HTTP handler (named `GET`/`POST` exports), the one routing file that is NOT isomorphic.
24
24
 
25
- **Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and `<form>` server actions submit. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.
25
+ **Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and a `<form action=${importedAction}>` submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.
26
26
 
27
27
  ## When To Use This Skill
28
28
 
@@ -45,7 +45,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
45
45
  | Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
46
46
  | Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
47
47
  | The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
48
- | TypeScript at runtime, erasable syntax, full-stack types | `references/typescript.md` |
48
+ | TypeScript at runtime, erasable syntax, full-stack types, the derive-the-type rule (never `unknown` / `any`) | `references/typescript.md` |
49
49
  | Unit, browser, e2e tests, the `handle()` harness, Bun parity | `references/testing.md` |
50
50
  | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` |
51
51
  | Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` |
@@ -54,7 +54,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
54
54
 
55
55
  Common bundles:
56
56
 
57
- - **Form or CRUD feature** then routing-and-pages, data-and-actions, testing; add auth if user-specific
57
+ - **Form or CRUD feature** then data-and-actions, routing-and-pages, testing; add auth if user-specific
58
58
  - **Interactive widget** then components, styling; add client-router-and-streaming only if it streams
59
59
  - **Protected area** then auth-and-sessions, routing-and-pages, testing
60
60
  - **Instant-feeling mutation** then data-and-actions, optimistic-ui
@@ -68,7 +68,8 @@ Common bundles:
68
68
  5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser.
69
69
  6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it.
70
70
  7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`).
71
- 8. **Test the narrowest meaningful layer**, and render the app in a real browser for any UI change (static checks do not catch a collapsed layout).
71
+ 8. **Type every boundary from its source, never `unknown` or `any`.** The row type comes from the schema (`typeof todos.$inferSelect`), the action's input from a named `interface` and its result from `ActionResult<T>`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`. `unknown` belongs on a payload nothing has vouched for yet that the next line narrows, and on a parameter of your own helper that forwards into an `html` template hole. Everywhere else, including a layout's `children`, it is a missing type. See `references/typescript.md`.
72
+ 9. **Test the narrowest meaningful layer**, and render the app in a real browser for any UI change (static checks do not catch a collapsed layout).
72
73
 
73
74
  ## Project Layout
74
75
 
@@ -104,6 +105,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection
104
105
  9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s).
105
106
  10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators.
106
107
  11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor).
108
+ 12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}>` inside a bound form. Quoted bindings, non-submit controls, `<input type="submit">` (the identity needs its `value`, which is also its label, so use a `<button>`), submitter `name` / `value` / `form` / static `formaction` attributes, a `.prop` spelling of any of those, `action=${fn}` off a `<form>`, a bound form with `method="get"`, `formmethod="get"` or an unparseable `formenctype` on ANY submitter in a bound form, and a non-action function all throw. A page has no `action` export, so a bare `<form method="post">` is a 405.
107
109
 
108
110
  ## Export Map
109
111
 
@@ -112,17 +114,20 @@ Find the right export fast. Load the linked reference for full examples.
112
114
  ### `@webjsdev/core` (browser + isomorphic)
113
115
 
114
116
  - `html` / `css` tagged templates. `WebComponent({ ... })` base-class factory; `prop(type?, opts?)` declares one reactive property. `register(tag, C)` / `Class.register('tag')`.
115
- - `signal` / `computed` reactive state; `render(v, el)` client render.
117
+ - `signal` / `computed` reactive state, `effect(fn)` client-only reaction (returns a disposer), `batch(fn)` coalesced writes; `render(v, el)` client render.
116
118
  - `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary.
117
119
  - `Suspense({fallback, children})` page-level streaming; `<webjs-suspense>` component-level streaming.
118
120
  - `optimistic()` optimistic UI; `navigate(url)` / `revalidate(url?)` client-router control; `connectWS` / `richFetch`.
121
+ - `asset(path)` content-hashes a `public/` url so a deploy cannot serve stale bytes (`href=${asset('/public/app.css')}`), served `immutable` for a year. Page / layout / metadata route only, inside the render function. See `references/built-ins.md`.
119
122
  - Types: `Metadata`, `PageProps<R>`, `LayoutProps<R>`, `RouteHandlerContext<R>`, `WebjsConfig`.
120
123
  - `@webjsdev/core/server`: `renderToString` / `renderToStream` (Node side).
121
- - `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`. `Task` lives at `@webjsdev/core/task`, context at `/context`.
124
+ - `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`, `asyncAppend` / `asyncReplace`, `templateContent`. `Task` / `TaskStatus` live at `@webjsdev/core/task`, context (`createContext` / `ContextProvider` / `ContextConsumer`) at `/context`. See `references/components.md` for the directive table + Task + context.
122
125
 
123
126
  ### `@webjsdev/server` (server side)
124
127
 
125
128
  - `createRequestHandler`, `cors()`, `route(action, opts?)` REST adapter, `sitemap()` / `sitemapIndex()`, `actionContext()`, `actionSignal()`, `requestId()`, `cache()` / `revalidateTag`.
129
+ - Route-handler toolkit: `json(v)` rich responder, `readBody(req)`, `clientIp(req)`, no-arg `headers()` / `cookies()` / `cspNonce()` (client counterpart `richFetch` is in `@webjsdev/core`). See `references/routing-and-pages.md`.
130
+ - Auth + sessions: `createAuth` (+ `Credentials` / `Google` / `GitHub`), `auth()` / `auth(req)`, `session()` + `cookieSession` / `storeSession`, `getSession(req)` (`.get` / `.set` / `.flash` / `.destroy`). File storage: `getFileStore` / `diskStore` / `signedUrl`. See `references/auth-and-sessions.md` + `references/built-ins.md`.
126
131
  - Data layer is Drizzle in `db/*.server.ts`. Auth, sessions, caching, rate limit, file storage are built in and pluggable (`references/built-ins.md`).
127
132
 
128
133
  ### File conventions
@@ -185,24 +190,32 @@ class Counter extends WebComponent({ count: prop(Number) }) {
185
190
  Counter.register('my-counter');
186
191
  ```
187
192
 
188
- ### The no-JS write path (a page action)
193
+ ### The no-JS write path (a form-bound action)
189
194
 
190
195
  ```ts
191
- // app/contact/page.ts
192
- export const action = async ({ formData }) => {
196
+ // modules/contact/actions/send-message.server.ts
197
+ 'use server';
198
+ export async function sendMessage(formData: FormData) {
193
199
  const email = String(formData.get('email') || '');
194
200
  if (!email) return { success: false, fieldErrors: { email: 'required' } };
195
201
  return { success: true, redirect: '/thanks' };
196
- };
197
- export default function Contact({ actionData }) { /* render form + actionData errors */ }
202
+ }
203
+
204
+ // app/contact/page.ts
205
+ import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
206
+ export default function Contact({ actionData }) {
207
+ return html`<form action=${sendMessage}><input name="email"></form>`;
208
+ }
198
209
  ```
199
210
 
200
- Success is a 303 (PRG); failure re-renders the page at 422 with the result on `actionData`. With JS the client router applies the response in place.
211
+ Binding the action is the whole wiring: the renderer omits `action` (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` identity field. A form-bound action always receives the `FormData`.
212
+
213
+ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `actionData`. With JS the client router applies the response in place. A submission that binds nothing is a 405, and the submission is Origin-verified like an RPC call.
201
214
 
202
215
  ## Security And Session Defaults
203
216
 
204
217
  - Never ship demo secrets. Require session and provider secrets from the environment and fail fast if missing.
205
- - Action RPC CSRF is an Origin / `Sec-Fetch-Site` check, not a token cookie. A safe GET action is CSRF-exempt. A `route.ts` REST endpoint is NOT covered: authenticate every mutating endpoint, validate, rate-limit.
218
+ - CSRF is an Origin / `Sec-Fetch-Site` check on both the action RPC and the form-submit path, not a token cookie. A safe GET action is CSRF-exempt. A `route.ts` REST endpoint is NOT covered: authenticate every mutating endpoint, validate, rate-limit.
206
219
  - Prod action errors are sanitized to a generic message plus a digest. Put a user-facing message on the `ActionResult` `{ success: false, error }` envelope, never on a raw throw.
207
220
  - Use `forbidden()` for an authenticated user lacking permission, `unauthorized()` for an unauthenticated request. Inside a `'use server'` RPC action, return an `ActionResult` for an auth failure instead of throwing.
208
221
  - For CORS use `cors()` from `@webjsdev/server`; `credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.
@@ -210,8 +223,8 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
210
223
  ## Testing Defaults
211
224
 
212
225
  - Prefer server/handler tests first: drive the app with `handle()` from `@webjsdev/server/testing` and assert on the `Response`.
213
- - Add a browser test (`webjs test --browser`) for anything touching hydration, the client router, slots, or custom-element upgrade. A unit test is necessary but NOT sufficient for a browser-facing change.
214
- - Render the app and LOOK for any UI change: `webjs check` and `webjs typecheck` pass even when a layout collapses. Static tools give no signal for a visual defect.
226
+ - Add a browser test (`npm run test:browser`) for anything touching hydration, the client router, slots, or custom-element upgrade. A unit test is necessary but NOT sufficient for a browser-facing change.
227
+ - Render the app and LOOK for any UI change: `npm run check` and `npm run typecheck` pass even when a layout collapses. Static tools give no signal for a visual defect.
215
228
  - WebJs runs on Node 24+ AND Bun. Prove a runtime-sensitive change (serializer, listener, streams, `node:crypto`, the TS stripper) on both.
216
229
 
217
230
  ## Common Mistakes To Avoid
@@ -221,6 +234,10 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
221
234
  - Using a `static properties` block or a class-field initializer for reactive props instead of the `WebComponent({ ... })` factory.
222
235
  - Quoting an event / property / boolean hole (`@click="${fn}"`).
223
236
  - Writing `fetch()` to call your own server instead of importing the action.
237
+ - Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
238
+ - Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
239
+ - Writing `formmethod="get"` or `formenctype="text/plain"` on any button inside a bound form. Neither can carry the action's body, so both are refused even when the button binds nothing.
240
+ - Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
224
241
  - Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
225
242
  - A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
226
243
  - A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
@@ -2,10 +2,10 @@
2
2
 
3
3
  ## What This Covers
4
4
 
5
- - Sessions: cookie by default, Redis-backed when configured, the `SESSION_SECRET` requirement
6
- - Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action
7
- - Login and logout flows (`signIn` / `signOut` / `handlers`)
8
- - Protecting a route: gate at the top of a page or action, redirect when unauthenticated
5
+ - Sessions: the `session()` middleware + storage factories (`cookieSession` / `storeSession`), the `getSession(req)` method API (`.get` / `.set` / `.flash` / `.destroy`), the `SESSION_SECRET` requirement
6
+ - Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action, scrypt password hashing
7
+ - Login and logout flows: mounting `handlers` at `app/api/auth/[...path]`, the no-JS credentials form (`/api/auth/signin/credentials` + `redirectTo` + `?error`), `signIn` / `signOut`
8
+ - Protecting a route: a page-top `auth()` gate OR a per-segment `middleware.ts` calling `auth(req)`
9
9
  - `forbidden()` (403) vs `unauthorized()` (401) and their nearest-wins boundary files
10
10
  - Returning an `ActionResult` for an auth failure inside a `'use server'` action (do NOT throw there)
11
11
  - The Origin / `Sec-Fetch-Site` CSRF model (not a token cookie)
@@ -21,23 +21,30 @@ scaling, the full caching surface).
21
21
 
22
22
  ## Sessions
23
23
 
24
- Enable sessions in middleware, read and write them in a page or action.
24
+ Enable sessions with `session()` MIDDLEWARE, then read and write them with `getSession(req)` in any route or middleware the session wraps.
25
25
 
26
26
  ```ts
27
- // middleware.ts: enable on all routes
28
- import { session } from '@webjsdev/server';
29
- export default session(); // auto: REDIS_URL present -> server-side, else -> cookie
27
+ // middleware.ts: enable on all routes. Storage is pluggable.
28
+ import { session, cookieSession, storeSession } from '@webjsdev/server';
29
+ export default session({ secret: process.env.SESSION_SECRET, storage: cookieSession() });
30
+ // cookieSession() -> whole session in a signed cookie (stateless, the default)
31
+ // storeSession() -> session in the active store (memoryStore in dev, Redis in prod), id in the cookie
32
+ ```
30
33
 
31
- // in a page or action
34
+ `getSession(req)` returns a small key/value `Session` with a METHOD API, not property assignment. Mutating it makes the middleware re-sign and set the cookie on the way out:
35
+
36
+ ```ts
32
37
  import { getSession } from '@webjsdev/server';
33
- const s = getSession(req);
34
- s.userId = user.id; // auto-saved after the response
38
+ export async function GET(req: Request) {
39
+ const s = getSession(req);
40
+ s.set('userId', user.id); // write
41
+ const id = s.get('userId'); // read
42
+ s.flash('notice', 'Saved'); // one-read-only value (cleared after the next read)
43
+ s.destroy(); // clear the whole session (logout)
44
+ }
35
45
  ```
36
46
 
37
- Cookie sessions (the default) are signed and encrypted with no server
38
- state. Store sessions (with Redis) keep the session id in the cookie and
39
- the data in Redis. Both require `SESSION_SECRET`, read from the
40
- environment (never a literal in source) so boot fails if it is missing.
47
+ Cookie sessions (the default) are signed with no server state; store sessions keep only the id in the cookie and the data in the store. `cookieSession` / `storeSession` are aliases for `cookieSessionStorage` / `storeSessionStorage`. Both strategies require `SESSION_SECRET`, read from the environment (never a literal in source) so boot fails if it is missing.
41
48
 
42
49
  ## Authentication (`createAuth`)
43
50
 
@@ -46,16 +53,17 @@ Configure providers once in a `.server.ts` file. `createAuth` returns
46
53
  `handlers` (the OAuth redirect endpoints).
47
54
 
48
55
  ```ts
49
- // lib/auth.server.ts
56
+ // modules/auth/auth.server.ts
50
57
  import { createAuth, Credentials, Google, GitHub } from '@webjsdev/server';
51
58
  import { db } from '#db/connection.server.ts';
59
+ import { compare } from './password.server.ts';
52
60
 
53
61
  export const { auth, signIn, signOut, handlers } = createAuth({
54
62
  providers: [
55
63
  Credentials({
56
64
  async authorize(credentials) {
57
65
  const user = await db.query.users.findFirst({ where: { email: credentials.email } });
58
- if (!user || !verifyPassword(credentials.password, user.passwordHash)) return null;
66
+ if (!user || !(await compare(credentials.password, user.passwordHash))) return null;
59
67
  return { id: user.id, name: user.name, email: user.email, role: user.role };
60
68
  },
61
69
  }),
@@ -63,9 +71,50 @@ export const { auth, signIn, signOut, handlers } = createAuth({
63
71
  GitHub(), // reads AUTH_GITHUB_ID, AUTH_GITHUB_SECRET
64
72
  ],
65
73
  secret: process.env.AUTH_SECRET, // required, 32+ random chars, from the env
74
+ pages: { error: '/login' }, // a failed sign-in 302s here with ?error=<code>
66
75
  });
67
76
  ```
68
77
 
78
+ **Password hashing is the app's job** (WebJs ships no `verifyPassword`). Use `scrypt` from `node:crypto` (built into Node AND Bun, no dependency) in a server-only utility, and call it from `authorize`:
79
+
80
+ ```ts
81
+ // modules/auth/password.server.ts (a server-only utility, never reaches the browser)
82
+ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
83
+ import { promisify } from 'node:util';
84
+ const scryptAsync = promisify(scrypt);
85
+ export async function hash(pw: string) {
86
+ const salt = randomBytes(16).toString('hex');
87
+ return salt + ':' + ((await scryptAsync(pw, salt, 64)) as Buffer).toString('hex');
88
+ }
89
+ export async function compare(pw: string, stored: string) {
90
+ const [salt, key] = stored.split(':');
91
+ return timingSafeEqual((await scryptAsync(pw, salt, 64)) as Buffer, Buffer.from(key, 'hex'));
92
+ }
93
+ ```
94
+
95
+ **Mount `handlers` at an `app/api/auth/[...path]/route.ts` catch-all** (at the app root, NOT under a feature folder): `createAuth` hardcodes `/api/auth/signin/*` and `/api/auth/callback/*` for its form posts and OAuth callback URIs.
96
+
97
+ ```ts
98
+ // app/api/auth/[...path]/route.ts
99
+ import { handlers } from '#modules/auth/auth.server.ts';
100
+ export const GET = handlers.GET;
101
+ export const POST = handlers.POST;
102
+ ```
103
+
104
+ **The no-JS sign-in / sign-out flow is plain forms** (progressive-enhancement-safe). Sign in by POSTing to `/api/auth/signin/credentials` with a hidden `redirectTo`, and read `?error` (mapped from `pages.error`) for feedback; sign out by POSTing to `/api/auth/signout`:
105
+
106
+ ```html
107
+ <form method="POST" action="/api/auth/signin/credentials">
108
+ <input type="hidden" name="redirectTo" value="/dashboard">
109
+ <input name="email" type="email" required><input name="password" type="password" required>
110
+ <button>Sign in</button>
111
+ </form>
112
+ <!-- log out -->
113
+ <form method="POST" action="/api/auth/signout"><button>Log out</button></form>
114
+ ```
115
+
116
+ For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a form-bound action can return directly (the framework honors a returned `Response` verbatim).
117
+
69
118
  Sessions are JWT by default (stateless, scales horizontally). OAuth
70
119
  providers handle the full redirect flow. Read the session anywhere on the
71
120
  server with `auth()`.
@@ -106,7 +155,7 @@ is produced, so a logged-out visitor never receives protected markup.
106
155
  ```ts
107
156
  // app/dashboard/page.ts
108
157
  import { html, redirect } from '@webjsdev/core';
109
- import { auth } from '#lib/auth.server.ts';
158
+ import { auth } from '#modules/auth/auth.server.ts';
110
159
 
111
160
  export default async function Dashboard() {
112
161
  const session = await auth();
@@ -119,6 +168,20 @@ Reading the session through `auth()` also auto-excludes the page from the
119
168
  server HTML response cache, so a per-user page is never cached and served
120
169
  to another visitor (see `built-ins.md`).
121
170
 
171
+ To gate a WHOLE subtree in one place, use a per-segment `middleware.ts` that reads `auth(req)` (the explicit-request form) and returns a `302` BEFORE the page renders. It runs for every request under its segment and needs only a cookie read (no DB query), so the gate is real the moment the app boots:
172
+
173
+ ```ts
174
+ // app/dashboard/middleware.ts (protects /dashboard/*)
175
+ import { auth } from '#modules/auth/auth.server.ts';
176
+ export default async function requireAuth(req: Request, next: () => Promise<Response>) {
177
+ const session = await auth(req);
178
+ if (!session?.user) return new Response(null, { status: 302, headers: { location: '/login' } });
179
+ return next();
180
+ }
181
+ ```
182
+
183
+ `auth(req)` takes the in-flight request explicitly (for a middleware / route); the ambient `auth()` (no argument) reads from context inside a page or action.
184
+
122
185
  ## `forbidden()` (403) vs `unauthorized()` (401)
123
186
 
124
187
  Two control-flow throws from `@webjsdev/core`, mirroring the `notFound()`
@@ -135,7 +198,7 @@ default page.
135
198
  ```ts
136
199
  // app/admin/page.ts
137
200
  import { html, forbidden, unauthorized } from '@webjsdev/core';
138
- import { auth } from '#lib/auth.server.ts';
201
+ import { auth } from '#modules/auth/auth.server.ts';
139
202
 
140
203
  export default async function Admin() {
141
204
  const session = await auth();
@@ -145,7 +208,7 @@ export default async function Admin() {
145
208
  }
146
209
  ```
147
210
 
148
- Both work from a page or layout render AND from a page `action` (the no-JS
211
+ Both work from a page or layout render AND from a form-bound action (the no-JS
149
212
  write path). The boundary files (`app/forbidden.ts`, `app/unauthorized.ts`,
150
213
  or nested variants) live alongside the routes they cover and each
151
214
  default-export a function returning the boundary markup.
@@ -165,8 +228,10 @@ an `ActionResult` failure envelope with a status the client can act on.
165
228
  ```ts
166
229
  // modules/posts/actions/delete-post.server.ts
167
230
  'use server';
168
- import { auth } from '#lib/auth.server.ts';
231
+ import { eq } from 'drizzle-orm';
232
+ import { auth } from '#modules/auth/auth.server.ts';
169
233
  import { db } from '#db/connection.server.ts';
234
+ import { posts } from '#db/schema.server.ts';
170
235
 
171
236
  export async function deletePost(input: { id: string }) {
172
237
  const session = await auth();
@@ -4,11 +4,11 @@ Env vars, caching, rate limiting, broadcast, file storage, and the `package.json
4
4
 
5
5
  ## What This Covers
6
6
 
7
- - **Environment variables** and the `WEBJS_PUBLIC_` browser-exposed prefix.
7
+ - **Environment variables**, the `WEBJS_PUBLIC_` browser-exposed prefix, and `env.ts` boot validation.
8
8
  - **Caching primitives.** `cache()` with tag invalidation, HTTP `Cache-Control`, the server HTML response cache (`export const revalidate`), content-hash asset URLs, conditional GET (ETag).
9
9
  - **Rate limiting** (`rateLimit()` middleware) and **broadcast** (`broadcast()` over WebSockets).
10
10
  - **File storage.** `FileStore` / `diskStore`, safe keys, signed URLs.
11
- - **The `"webjs"` config block.** Security headers, CSP, redirects, trailing-slash, basePath, allowed origins, client-router opt-out, ingress caps, dev/start task orchestration.
11
+ - **The `"webjs"` config block.** Security headers, CSP, redirects, trailing-slash, basePath, allowed origins, client-router opt-out, ingress caps, dev/start task orchestration, the doctor severity gate.
12
12
  - **Observability.** Access log, `requestId()`, the `onError` hook, `instrumentation.ts`, the build-info endpoint.
13
13
 
14
14
  Read this when wiring caching or rate limiting, storing uploads, hardening headers, or configuring redirects and observability. **Auth and sessions are a separate reference (`auth-and-sessions.md`).** Server actions, `revalidateTag` from a mutation, and the `ActionResult` envelope live in `data-and-actions.md`.
@@ -25,6 +25,18 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
25
25
 
26
26
  Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
27
27
 
28
+ **Validate required vars at boot with an app-root `env.ts`** (optional). It default-exports either a SCHEMA object (each var mapped to a type `string` / `number` / `boolean` / `url` / `enum`, or an options object with `optional` / `default` / `minLength` / `pattern` / `values`) OR a validator function `(env) => void` that throws. It runs at boot after `.env` loads, coerces values and writes defaults back to `process.env`, and fails fast naming EVERY bad var:
29
+
30
+ ```ts
31
+ // env.ts
32
+ export default {
33
+ DATABASE_URL: 'url',
34
+ SESSION_SECRET: { type: 'string', minLength: 16 },
35
+ PORT: { type: 'number', default: 8080 },
36
+ LOG_LEVEL: { type: 'enum', values: ['debug', 'info', 'warn'], default: 'info' },
37
+ };
38
+ ```
39
+
28
40
  ## Caching
29
41
 
30
42
  ### `cache()` for query and computation results
@@ -59,18 +71,33 @@ export const metadata = { cacheControl: 'public, max-age=60' };
59
71
 
60
72
  ### Server HTML response cache (`export const revalidate`)
61
73
 
62
- For a page that renders the **same HTML for every visitor**, opt into caching the SSR output (WebJs's no-build equivalent of ISR). Keyed by full URL only.
74
+ For a page that renders the **same HTML for every visitor**, opt into caching the SSR output (WebJs's no-build equivalent of ISR). Keyed by the request origin plus the full URL.
63
75
 
64
76
  ```ts
65
77
  // app/blog/page.ts
66
78
  export const revalidate = 60; // cache this page's HTML for 60s
67
79
  ```
68
80
 
69
- **Safety.** This asserts the page is identical for everyone for N seconds. Never set it on a page that reads `cookies()`, a session, or per-user data. The framework auto-marks a request dynamic and refuses to cache when the render reads per-user state through a framework helper (`cookies()`, `headers()`, `getSession()`, `auth()`), so an `auth()`-gated page fails safe. It also never caches a non-200, a streamed Suspense body, a `Set-Cookie` response, or a page under CSP. Evict on a write with `revalidatePath('/blog')`; `revalidateAll()` clears everything (single-instance / dev). This differs from the client-side `revalidate()` in `@webjsdev/core`, which evicts the browser snapshot cache.
81
+ **Safety.** This asserts the page is identical for everyone for N seconds. Never set it on a page that reads `cookies()`, a session, or per-user data. The framework auto-marks a request dynamic and refuses to cache when the render reads per-user state through a framework helper (`cookies()`, `headers()`, `getSession()`, `auth()`), so an `auth()`-gated page fails safe. It also never caches a non-200, a streamed Suspense body, a `Set-Cookie` response, or a page under CSP. Evict on a write with `revalidatePath('/blog')`; `revalidateAll()` clears everything (single-instance / dev). This differs from the client-side `revalidate()` in `@webjsdev/core`, which evicts the browser snapshot cache. Keys carry the request ORIGIN as well as the path (#1097), because `ctx.url` comes from forwarded headers a proxy passes through rather than strips, so a hostile `X-Forwarded-Host` would otherwise bake an attacker-chosen origin into a body every later visitor gets served. A single-host deploy is unaffected (one origin, one entry per URL). It does mean a bare path does not name one entry, so `revalidatePath` resolves the origin from the calling request, which is exact for a server action; pass an absolute url from a background job that serves no requests of its own, where a bare path warns and evicts nothing. Under `webjs.basePath` the absolute url is the public one and its mount prefix is stripped, so it evicts the entry the write stored.
70
82
 
71
83
  ### Content-hash asset URLs and conditional GET
72
84
 
73
- Both are automatic, prod-focused, and need no config. In production every served module and `public/` asset gets a per-file `?v=<hash>` and `Cache-Control: public, max-age=31536000, immutable`, so a returning client fetches a changed file only when its bytes change. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Private (`no-store` / `private`) and streamed responses are excluded from the ETag path (no cross-session 304). Dev is byte-faithful (no hashing).
85
+ Both are automatic, prod-focused, and need no config. In production every served MODULE gets a per-file `?v=<hash>`, and a `?v=`-carrying request is answered `Cache-Control: public, max-age=31536000, immutable`, so a returning client fetches a changed file only when its bytes change.
86
+
87
+ A `public/` asset you reference yourself is opt-in, via `asset()` (#1194):
88
+
89
+ ```ts
90
+ import { html, asset } from '@webjsdev/core';
91
+ html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
92
+ ```
93
+
94
+ That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, so this is a convention rather than a `webjs check` rule (`webjs doctor` does flag the plain form, see below). Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched.
95
+
96
+ Forgetting it is the one real cost of opt-in, so `webjs doctor` catches it: a page, layout, or error boundary writing a plain `<link rel="stylesheet" href="/public/app.css">` gets a WARN naming the `file:line` and the fix (#1095). It reads your source and rewrites nothing, and it stays quiet about the non-marks that are deliberate: a cross-origin sheet, a `rel="icon"`, a `rel="preload"`, and any `href=${expr}` hole. Same posture as Rails (a `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url from the build graph, surfaced through `links()`): take the fingerprint at the point the url is PRODUCED, never by rewriting a rendered document. A warning is easy to miss, so make it fatal in the app that cares: gate `UNMARKED_ASSET_LINKS` to `error` (see the doctor severity gate below) and one `npm run doctor` step in CI stops the un-versioned url reaching a deploy. The scaffold ships exactly that.
97
+
98
+ It is opt-in rather than automatic because only the author knows which urls are the REQUEST. Do NOT mark a `rel="preload"` hint whose asset is actually fetched by CSS `url()`: the preload cache is keyed on the full url, so a versioned hint can never satisfy the unversioned request the stylesheet makes, and the file is fetched twice. Mark the thing that fetches, not the hint. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing).
99
+
100
+ **A page's ETag is only useful if the page renders the same bytes twice.** The ETag is a hash of the response body, so any per-render-varying value anywhere in the document defeats it: a `Date.now()`, a `Math.random()`, an id from a module-scope counter (which never resets in a long-lived server), or a CSP nonce. The failure is silent and total. The page renders correctly, every content assertion still passes, the header is still present, and the only symptom is that no `If-None-Match` ever matches, so every revalidation ships the whole document instead of an empty 304. A page under CSP is excluded from the server HTML cache for exactly this reason (the nonce must differ per response). If a page opts into a public `Cache-Control`, guard it with a test that renders the page twice, through its layout, and asserts the two outputs are byte-identical.
74
101
 
75
102
  ## Rate limiting
76
103
 
@@ -82,7 +109,7 @@ import { rateLimit } from '@webjsdev/server';
82
109
  export default rateLimit({ window: '1m', max: 60 });
83
110
  ```
84
111
 
85
- Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the client IP), `message`, `store`, `trustProxy`. Over-limit responds `429` with `Retry-After` and `X-RateLimit-*` headers; an allowed response carries the remaining-quota headers too. For multi-instance scaling, set the global store to Redis once at startup.
112
+ Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the client IP), `message`, `store`, `trustProxy` (honour the forwarded-IP headers; inert while `WEBJS_NO_TRUST_PROXY=1` is set, which outranks it and keeps the limiter on the framework-stamped peer). Over-limit responds `429` with `Retry-After` and `X-RateLimit-*` headers; an allowed response carries the remaining-quota headers too. For multi-instance scaling, set the global store to Redis once at startup.
86
113
 
87
114
  ## Broadcast
88
115
 
@@ -96,7 +123,7 @@ export function WS(ws, req) {
96
123
  }
97
124
  ```
98
125
 
99
- `broadcast(path, data, opts?)` fans out to all clients on `path`; `opts.except` skips one socket (typically the sender). `clientCount(path)` returns the live count. Single-instance by default; wire Redis pub/sub yourself for multi-instance.
126
+ `broadcast(path, data, opts?)` fans out to all clients on `path`; `opts.except` skips one socket (typically the sender). Single-instance by default; wire Redis pub/sub yourself for multi-instance.
100
127
 
101
128
  ## File storage
102
129
 
@@ -112,13 +139,13 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
112
139
 
113
140
  **Never trust a user filename as a key.** `generateKey(file.name)` returns an opaque `<uuid>.<ext>` with a sanitized extension; a traversal attempt yields a bare safe key. Keys are containment-checked before any filesystem op.
114
141
 
115
- **Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed.
142
+ **Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed. Pass `base` to point the signed link at your own serve route instead of the default upload URL: `signedUrl(key, { secret, base: '/files/' + key, expiresIn: 3600 })`.
116
143
 
117
144
  **Serving-XSS warning.** The recorded content-type is attacker-controlled (the browser sent it at upload). A serving route MUST send `X-Content-Type-Options: nosniff` and SHOULD send `Content-Disposition: attachment` for user uploads. Only serve inline after validating bytes against a strict inert allowlist, never `text/html` / `image/svg+xml`. Add the uploads directory to `.gitignore`.
118
145
 
119
146
  ## The `"webjs"` config block (package.json)
120
147
 
121
- All keys are optional; a malformed entry is dropped at boot with a warning, never crashing the pipeline.
148
+ All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot with a warning, never crashing the pipeline. The one exception is `doctor.gate`, which is read by the `webjs doctor` CLI rather than the server and rejects a bad entry outright (see the doctor severity gate below): a gate whose typo was quietly ignored would leave CI un-gated while looking gated, which is the one thing that mechanism cannot afford.
122
149
 
123
150
  ### Security headers
124
151
 
@@ -136,6 +163,8 @@ On by default (`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`,
136
163
 
137
164
  Off by default. `{ "webjs": { "csp": true } }` enables a strict-dynamic + per-request nonce posture. An object form merges `directives` and supports `reportOnly`. Read the nonce with `cspNonce()` from `@webjsdev/core` to stamp your own inline `<script>`.
138
165
 
166
+ Enforcement is the HTTP `Content-Security-Policy` HEADER, never a `<meta http-equiv>` tag, so `frame-ancestors` / `report-uri` work. The emitted `<meta name="csp-nonce">` is only the client-side nonce CARRIER. Across a client-router soft navigation the ORIGINAL page-load nonce stays authoritative (the browser enforces the original document's CSP header, not the fetched response's fresh one), so the router preserves that meta and re-stamps every dynamically-inserted script / preload with the original nonce via `getCspNonce()`. The server still mints a fresh nonce per request, and CSP pages are excluded from the HTML cache so a nonce is never served stale. No client config is needed.
167
+
139
168
  ### Redirects, trailing-slash, basePath, allowed origins
140
169
 
141
170
  ```jsonc
@@ -181,6 +210,23 @@ An over-limit body responds `413` without buffering the whole payload.
181
210
 
182
211
  `before` runs to completion first (a non-zero exit aborts the boot). `parallel` (dev only) runs long-lived watchers alongside the server and tears them down on exit. `watch` (dev only) adds extra live-reload directories outside the app tree.
183
212
 
213
+ ### Doctor severity gate
214
+
215
+ `webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` depends on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries.
216
+
217
+ ```jsonc
218
+ { "webjs": {
219
+ "doctor": { "gate": {
220
+ "UNMARKED_ASSET_LINKS": "error", // fail the exit on this one
221
+ "ELISION_CARRIERS": "off" // silence it entirely, even under --strict
222
+ } }
223
+ } }
224
+ ```
225
+
226
+ Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports without failing, `off` silences the check, meaning its finding is not printed and it cannot fail the exit (it still appears on the checklist as `[off]` and in the summary's silenced count, so a silenced check is never invisible, and `--json` still carries the whole result). A code with no entry keeps its default (`error` for a hard toolchain failure, `warn` otherwise), so an app that declares nothing behaves exactly as before. Read the codes off `webjs doctor --json`, where every result carries its `code` and its effective `severity`.
227
+
228
+ Two guarantees worth knowing. A result that could not check (a network or toolchain outage) is capped at `warn` and can never be escalated, so a jspm or npm outage cannot red your CI. And a malformed gate exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. That covers an unknown code, a bad severity, a wrong shape (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`, since every one of those would otherwise leave the build un-gated while the `package.json` looks gated. Under `--json` the offenders come back as a `configErrors` array alongside an empty `results`, each entry a `{ kind }` of `malformed` / `unknown-key` / `unknown-code` / `bad-severity`. Wire it up with one workflow step, `npm run doctor`, and change what is fatal in `package.json` rather than in the workflow.
229
+
184
230
  ## Observability
185
231
 
186
232
  Wired at the single response funnel, covering pages, routes, actions, and assets uniformly.