@webjsdev/mcp 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/resources/AGENTS.md +113 -42
- package/resources/SKILL.md +33 -16
- package/resources/references/auth-and-sessions.md +86 -21
- package/resources/references/built-ins.md +55 -9
- package/resources/references/client-router-and-streaming.md +59 -9
- package/resources/references/components.md +117 -8
- package/resources/references/data-and-actions.md +63 -12
- package/resources/references/muscle-memory-gotchas.md +104 -3
- package/resources/references/optimistic-ui.md +49 -10
- package/resources/references/routing-and-pages.md +62 -18
- package/resources/references/runtime.md +10 -1
- package/resources/references/service-worker.md +5 -3
- package/resources/references/styling.md +116 -2
- package/resources/references/testing.md +67 -9
- package/resources/references/typescript.md +75 -6
- package/resources/references/ui-kit.md +18 -10
package/resources/SKILL.md
CHANGED
|
@@ -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://
|
|
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
|
|
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
|
|
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
|
|
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. **
|
|
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`
|
|
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
|
|
193
|
+
### The no-JS write path (a form-bound action)
|
|
189
194
|
|
|
190
195
|
```ts
|
|
191
|
-
//
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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 (`
|
|
214
|
-
- Render the app and LOOK for any UI change: `
|
|
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:
|
|
6
|
-
- Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action
|
|
7
|
-
- Login and logout flows
|
|
8
|
-
- Protecting a route:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
s
|
|
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
|
|
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
|
-
//
|
|
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 || !
|
|
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 '#
|
|
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 '#
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
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
|
|
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.
|