@webjsdev/cli 0.10.40 → 0.10.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/webjs.js +4 -46
- package/lib/create.js +282 -479
- package/lib/doctor.js +1 -38
- package/package.json +5 -1
- package/templates/.agents/rules/workflow.md +61 -271
- package/templates/.agents/skills/webjs/SKILL.md +226 -0
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +220 -0
- package/templates/.agents/skills/webjs/references/built-ins.md +200 -0
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +204 -0
- package/templates/.agents/skills/webjs/references/components.md +167 -0
- package/templates/.agents/skills/webjs/references/data-and-actions.md +187 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +170 -0
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +128 -0
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +158 -0
- package/templates/.agents/skills/webjs/references/runtime.md +80 -0
- package/templates/.agents/skills/webjs/references/service-worker.md +78 -0
- package/templates/.agents/skills/webjs/references/styling.md +123 -0
- package/templates/.agents/skills/webjs/references/testing.md +125 -0
- package/templates/.agents/skills/webjs/references/typescript.md +148 -0
- package/templates/.claude/hooks/check-server-imports.mjs +1 -1
- package/templates/.claude/hooks/require-tests-with-src.sh +1 -1
- package/templates/.claude/settings.json +0 -14
- package/templates/.cursorrules +21 -189
- package/templates/.github/copilot-instructions.md +7 -185
- package/templates/.github/pull_request_template.md +1 -1
- package/templates/AGENTS.md +59 -1494
- package/templates/CLAUDE.md +0 -1
- package/templates/CONVENTIONS.md +32 -1383
- package/templates/GEMINI.md +11 -0
- package/templates/gallery/app/apple-icon.ts +0 -1
- package/templates/gallery/app/examples/todo/page.ts +0 -1
- package/templates/gallery/app/features/async-render/page.ts +0 -1
- package/templates/gallery/app/features/boundaries/page.ts +0 -1
- package/templates/gallery/app/features/broadcast/page.ts +0 -1
- package/templates/gallery/app/features/caching/page.ts +0 -1
- package/templates/gallery/app/features/client-router/page.ts +0 -1
- package/templates/gallery/app/features/client-router/second/page.ts +0 -1
- package/templates/gallery/app/features/components/page.ts +0 -1
- package/templates/gallery/app/features/directives/page.ts +0 -1
- package/templates/gallery/app/features/env/page.ts +0 -1
- package/templates/gallery/app/features/file-storage/page.ts +0 -1
- package/templates/gallery/app/features/forms/page.ts +0 -1
- package/templates/gallery/app/features/metadata/page.ts +0 -1
- package/templates/gallery/app/features/optimistic-ui/page.ts +0 -1
- package/templates/gallery/app/features/rate-limit/page.ts +0 -1
- package/templates/gallery/app/features/route-handler/page.ts +0 -1
- package/templates/gallery/app/features/routing/page.ts +0 -1
- package/templates/gallery/app/features/server-actions/page.ts +0 -1
- package/templates/gallery/app/features/service-worker/page.ts +0 -1
- package/templates/gallery/app/features/sessions/page.ts +0 -1
- package/templates/gallery/app/features/websockets/page.ts +0 -1
- package/templates/gallery/app/global-error.ts +0 -1
- package/templates/gallery/app/global-not-found.ts +0 -1
- package/templates/gallery/app/icon.ts +0 -1
- package/templates/gallery/app/manifest.ts +0 -1
- package/templates/gallery/app/opengraph-image.ts +0 -1
- package/templates/gallery/app/robots.ts +0 -1
- package/templates/gallery/app/sitemap.ts +0 -1
- package/templates/gallery/app/twitter-image.ts +0 -1
- package/templates/public/favicon.svg +5 -0
- package/templates/public/sw.js +1 -1
- package/templates/scripts/clear-gallery.mjs +95 -0
- package/lib/clear-placeholders.js +0 -98
- package/lib/design-bar.js +0 -67
- package/templates/.claude/hooks/design-review-before-stop.sh +0 -36
- package/templates/.claude/hooks/route-skills.sh +0 -35
- package/templates/.claude/skills/webjs-design-review/SKILL.md +0 -84
- package/templates/LAYOUT-REFERENCE.md +0 -96
- package/templates/lib/utils/ui.ts +0 -83
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webjs
|
|
3
|
+
description: Build and review WebJs applications. Use when working on WebJs app structure, pages, layouts, routes, server actions, components, signals, data and validation, auth, sessions, styling, the client router, streaming, or tests. WebJs is AI-first, web-components-first, and has no build step.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a WebJs App
|
|
7
|
+
|
|
8
|
+
Use this skill for end-to-end WebJs app work. It helps you choose the right layer first, reach for the right export, and avoid the WebJs-specific mistakes that Next.js or Lit muscle memory causes. WebJs is its own framework: the component API matches Lit and the routing feels like Next, but the execution model is neither.
|
|
9
|
+
|
|
10
|
+
## Full Documentation
|
|
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.
|
|
13
|
+
|
|
14
|
+
## What WebJs Is
|
|
15
|
+
|
|
16
|
+
WebJs is an AI-first, web-components-first framework with **no build step**: source files are served as native ES modules, and TypeScript is stripped in place (Node 24+ or Bun). It runs SSR + progressive enhancement by default.
|
|
17
|
+
|
|
18
|
+
**There is no server/client component split.** No RSC render tree, no Flight protocol, no `"use client"` boundary. Instead:
|
|
19
|
+
|
|
20
|
+
- **Pages and layouts** (`app/**/page.ts`, `app/**/layout.ts`) run **only on the server** to produce HTML. They do NOT hydrate, so their own markup cannot be interactive (an `@click` in a page template is dropped at SSR). They still LOAD in the browser so imported components register.
|
|
21
|
+
- **Components** (`WebComponent` custom elements) hydrate per element, islands-style. **All interactivity lives here**: `@event`, reactive property assignment, signal mutation.
|
|
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
|
+
- **`route.ts`** is a server-only HTTP handler (named `GET`/`POST` exports), the one routing file that is NOT isomorphic.
|
|
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.
|
|
26
|
+
|
|
27
|
+
## When To Use This Skill
|
|
28
|
+
|
|
29
|
+
- New features or refactors touching pages, routes, actions, components, data, auth, sessions, styling, or tests
|
|
30
|
+
- Reviewing WebJs code for correctness or framework usage
|
|
31
|
+
- Answering "how should this be structured in WebJs?"
|
|
32
|
+
- Finding the right export, reference doc, or default pattern for a task
|
|
33
|
+
|
|
34
|
+
## Load Only The References You Need
|
|
35
|
+
|
|
36
|
+
Classify the task first, then load the smallest useful reference set. Each reference starts with a "What This Covers" section; read that to confirm relevance before reading the rest. Loading more than two or three at once usually means the task is not narrowed yet.
|
|
37
|
+
|
|
38
|
+
| Task involves... | Start with |
|
|
39
|
+
| --------------------------------------------------------------------------- | --------------------------------------------- |
|
|
40
|
+
| Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` |
|
|
41
|
+
| Writing components: reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
|
|
42
|
+
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
|
|
43
|
+
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
|
|
44
|
+
| Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
|
|
45
|
+
| Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
|
|
46
|
+
| Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
|
|
47
|
+
| TypeScript at runtime, erasable syntax, full-stack types | `references/typescript.md` |
|
|
48
|
+
| Unit, browser, e2e tests, the `handle()` harness, Bun parity | `references/testing.md` |
|
|
49
|
+
| Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` |
|
|
50
|
+
| Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` |
|
|
51
|
+
| Offline support, an asset cache, the opt-in service worker | `references/service-worker.md` |
|
|
52
|
+
| A pattern that feels like Next.js or Lit but might not transfer | `references/muscle-memory-gotchas.md` |
|
|
53
|
+
|
|
54
|
+
Common bundles:
|
|
55
|
+
|
|
56
|
+
- **Form or CRUD feature** then routing-and-pages, data-and-actions, testing; add auth if user-specific
|
|
57
|
+
- **Interactive widget** then components, styling; add client-router-and-streaming only if it streams
|
|
58
|
+
- **Protected area** then auth-and-sessions, routing-and-pages, testing
|
|
59
|
+
- **Instant-feeling mutation** then data-and-actions, optimistic-ui
|
|
60
|
+
|
|
61
|
+
## Default Workflow
|
|
62
|
+
|
|
63
|
+
1. **Classify the change.** Route contract, data model, server mutation, auth, or only UI?
|
|
64
|
+
2. **Start from the server.** Add the page/route and its server action or query before wiring interactive UI. A page render or a `<form>` POST should already return correct HTML before any component hydrates.
|
|
65
|
+
3. **Put code in the narrowest owner.** Route-local first (`modules/<feature>/`), promote to `lib/` or `components/` only when reuse is real.
|
|
66
|
+
4. **Keep server-only code behind `.server.ts`.** The DB driver, secrets, and `node:*` never belong in a page, layout, or component.
|
|
67
|
+
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.
|
|
68
|
+
6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it.
|
|
69
|
+
7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`).
|
|
70
|
+
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
|
+
|
|
72
|
+
## Project Layout
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
app/ ROUTING ONLY (thin adapters importing from modules/)
|
|
76
|
+
layout.ts root layout (the ONLY file that may write <html>/<head>/<body>)
|
|
77
|
+
page.ts /
|
|
78
|
+
<segment>/page.ts /<segment>
|
|
79
|
+
[param]/page.ts dynamic route (params.param)
|
|
80
|
+
<path>/route.ts HTTP handler at /<path>
|
|
81
|
+
error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins)
|
|
82
|
+
middleware.ts root middleware
|
|
83
|
+
modules/<feature>/ actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts),
|
|
84
|
+
components/, utils/ (pure), types.ts
|
|
85
|
+
lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers
|
|
86
|
+
components/*.ts shared presentational custom elements (one per file)
|
|
87
|
+
db/*.server.ts Drizzle: schema, connection
|
|
88
|
+
public/* static assets, served at /public/<name>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
App-internal imports use the `#` root alias (`import { db } from '#db/connection.server.ts'`), Node's native `package.json` imports field, not deep `../../../` relatives. A same-directory import stays relative.
|
|
92
|
+
|
|
93
|
+
## Core WebJs Rules (invariants)
|
|
94
|
+
|
|
95
|
+
1. Server-only code lives in `.server.ts`, `route.ts`, or `middleware.ts`. Never in a page, layout, or component (it crashes the browser at module load).
|
|
96
|
+
2. `'use server'` exports are `async` functions returning serializer-safe values. Files without `'use server'` are server-only utilities.
|
|
97
|
+
3. Custom element tag names contain a hyphen. Pass the tag to `Class.register('tag-name')`.
|
|
98
|
+
4. Event (`@`), property (`.`), and boolean (`?`) holes in `html` are UNQUOTED: `@click=${fn}`, never `@click="${fn}"`.
|
|
99
|
+
5. Signals are the default state primitive. Import `signal` / `computed` from `@webjsdev/core`, read via `signal.get()` inside `render()`. The base-class factory `WebComponent({ ... })` is only for values riding an HTML attribute or arriving via SSR hydration.
|
|
100
|
+
6. Page and layout default exports are functions returning a value; they never call `render()` themselves.
|
|
101
|
+
7. Light-DOM components with custom CSS prefix every class selector with their tag name. Prefer Tailwind (unique by construction).
|
|
102
|
+
8. Only the root layout may write `<!doctype>` / `<html>` / `<head>` / `<body>`.
|
|
103
|
+
9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s).
|
|
104
|
+
10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators.
|
|
105
|
+
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).
|
|
106
|
+
|
|
107
|
+
## Export Map
|
|
108
|
+
|
|
109
|
+
Find the right export fast. Load the linked reference for full examples.
|
|
110
|
+
|
|
111
|
+
### `@webjsdev/core` (browser + isomorphic)
|
|
112
|
+
|
|
113
|
+
- `html` / `css` tagged templates. `WebComponent({ ... })` base-class factory; `prop(type?, opts?)` declares one reactive property. `register(tag, C)` / `Class.register('tag')`.
|
|
114
|
+
- `signal` / `computed` reactive state; `render(v, el)` client render.
|
|
115
|
+
- `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary.
|
|
116
|
+
- `Suspense({fallback, children})` page-level streaming; `<webjs-suspense>` component-level streaming.
|
|
117
|
+
- `optimistic()` optimistic UI; `navigate(url)` / `revalidate(url?)` client-router control; `connectWS` / `richFetch`.
|
|
118
|
+
- Types: `Metadata`, `PageProps<R>`, `LayoutProps<R>`, `RouteHandlerContext<R>`, `WebjsConfig`.
|
|
119
|
+
- `@webjsdev/core/server`: `renderToString` / `renderToStream` (Node side).
|
|
120
|
+
- `@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`.
|
|
121
|
+
|
|
122
|
+
### `@webjsdev/server` (server side)
|
|
123
|
+
|
|
124
|
+
- `createRequestHandler`, `cors()`, `route(action, opts?)` REST adapter, `sitemap()` / `sitemapIndex()`, `actionContext()`, `actionSignal()`, `requestId()`, `cache()` / `revalidateTag`.
|
|
125
|
+
- Data layer is Drizzle in `db/*.server.ts`. Auth, sessions, caching, rate limit, file storage are built in and pluggable (`references/built-ins.md`).
|
|
126
|
+
|
|
127
|
+
### File conventions
|
|
128
|
+
|
|
129
|
+
`page.ts` (server-only fn), `layout.ts` (embeds `children`), `route.ts` (HTTP handler), `middleware.ts`, `*.server.ts` (server boundary), `error.ts` / `loading.ts` / `not-found.ts` / `forbidden.ts` / `unauthorized.ts` (boundaries), metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `opengraph-image.ts`).
|
|
130
|
+
|
|
131
|
+
## Canonical Patterns
|
|
132
|
+
|
|
133
|
+
### A page
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// app/about/page.ts
|
|
137
|
+
import { html } from '@webjsdev/core';
|
|
138
|
+
export default function About() {
|
|
139
|
+
return html`<h1>About</h1>`;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### A dynamic route reading data through an action
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// app/users/[id]/page.ts
|
|
147
|
+
import { html } from '@webjsdev/core';
|
|
148
|
+
import { getUser } from '#modules/users/queries/get-user.server.ts';
|
|
149
|
+
export default async function User({ params }: { params: { id: string } }) {
|
|
150
|
+
const user = await getUser(params.id); // never import the DB directly into a page
|
|
151
|
+
return html`<h1>${user.name}</h1>`;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### A server action
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// modules/users/actions/update-profile.server.ts
|
|
159
|
+
'use server';
|
|
160
|
+
import { eq } from 'drizzle-orm';
|
|
161
|
+
import { db } from '#db/connection.server.ts';
|
|
162
|
+
import { users } from '#db/schema.server.ts';
|
|
163
|
+
export async function updateProfile(input: { id: string; name: string }) {
|
|
164
|
+
const name = String(input?.name || '').trim();
|
|
165
|
+
if (!name) return { success: false, error: 'name required', status: 400 };
|
|
166
|
+
const [row] = await db.update(users).set({ name }).where(eq(users.id, input.id)).returning();
|
|
167
|
+
return { success: true, data: row };
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Call it from a component via a normal import (rewritten to a typed RPC stub). Never hand-write `fetch()`.
|
|
172
|
+
|
|
173
|
+
### An interactive component
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
// components/counter.ts
|
|
177
|
+
import { WebComponent, prop, html } from '@webjsdev/core';
|
|
178
|
+
class Counter extends WebComponent({ count: prop(Number) }) {
|
|
179
|
+
constructor() { super(); this.count = 0; }
|
|
180
|
+
render() {
|
|
181
|
+
return html`<button @click=${() => this.count++}>${this.count}</button>`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
Counter.register('my-counter');
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### The no-JS write path (a page action)
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// app/contact/page.ts
|
|
191
|
+
export const action = async ({ formData }) => {
|
|
192
|
+
const email = String(formData.get('email') || '');
|
|
193
|
+
if (!email) return { success: false, fieldErrors: { email: 'required' } };
|
|
194
|
+
return { success: true, redirect: '/thanks' };
|
|
195
|
+
};
|
|
196
|
+
export default function Contact({ actionData }) { /* render form + actionData errors */ }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
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.
|
|
200
|
+
|
|
201
|
+
## Security And Session Defaults
|
|
202
|
+
|
|
203
|
+
- Never ship demo secrets. Require session and provider secrets from the environment and fail fast if missing.
|
|
204
|
+
- 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.
|
|
205
|
+
- 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.
|
|
206
|
+
- 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.
|
|
207
|
+
- For CORS use `cors()` from `@webjsdev/server`; `credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.
|
|
208
|
+
|
|
209
|
+
## Testing Defaults
|
|
210
|
+
|
|
211
|
+
- Prefer server/handler tests first: drive the app with `handle()` from `@webjsdev/server/testing` and assert on the `Response`.
|
|
212
|
+
- 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.
|
|
213
|
+
- 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.
|
|
214
|
+
- WebJs runs on Node 24+ AND Bun. Prove a runtime-sensitive change (serializer, listener, streams, `node:crypto`, the TS stripper) on both.
|
|
215
|
+
|
|
216
|
+
## Common Mistakes To Avoid
|
|
217
|
+
|
|
218
|
+
- Treating a page or layout like a React component and expecting its markup to hydrate. It runs server-only; put interactivity in a component.
|
|
219
|
+
- Importing a `.server.ts` utility (no `'use server'`) directly into a shipping component. Its browser stub throws at load; reach it through a `'use server'` action.
|
|
220
|
+
- Using a `static properties` block or a class-field initializer for reactive props instead of the `WebComponent({ ... })` factory.
|
|
221
|
+
- Quoting an event / property / boolean hole (`@click="${fn}"`).
|
|
222
|
+
- Writing `fetch()` to call your own server instead of importing the action.
|
|
223
|
+
- Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
|
|
224
|
+
- 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()`.
|
|
225
|
+
- A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
|
|
226
|
+
- Interpolating into a component's `<style>` / `<script>` body. Use `static styles` or Tailwind.
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Auth and Sessions
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
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
|
|
9
|
+
- `forbidden()` (403) vs `unauthorized()` (401) and their nearest-wins boundary files
|
|
10
|
+
- Returning an `ActionResult` for an auth failure inside a `'use server'` action (do NOT throw there)
|
|
11
|
+
- The Origin / `Sec-Fetch-Site` CSRF model (not a token cookie)
|
|
12
|
+
- Requiring secrets from the environment and failing fast
|
|
13
|
+
- `cors()` with an explicit allowlist when `credentials: true`
|
|
14
|
+
|
|
15
|
+
Read this when a task touches login, logout, who-can-see-a-page, session
|
|
16
|
+
state, or the CSRF / CORS posture of a mutation. Sibling refs:
|
|
17
|
+
`data-and-actions.md` (the `ActionResult` envelope, validation, the RPC
|
|
18
|
+
boundary), `routing-and-pages.md` (the `forbidden.ts` / `unauthorized.ts`
|
|
19
|
+
boundary files and control-flow throws), `built-ins.md` (env vars, Redis
|
|
20
|
+
scaling, the full caching surface).
|
|
21
|
+
|
|
22
|
+
## Sessions
|
|
23
|
+
|
|
24
|
+
Enable sessions in middleware, read and write them in a page or action.
|
|
25
|
+
|
|
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
|
|
30
|
+
|
|
31
|
+
// in a page or action
|
|
32
|
+
import { getSession } from '@webjsdev/server';
|
|
33
|
+
const s = getSession(req);
|
|
34
|
+
s.userId = user.id; // auto-saved after the response
|
|
35
|
+
```
|
|
36
|
+
|
|
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.
|
|
41
|
+
|
|
42
|
+
## Authentication (`createAuth`)
|
|
43
|
+
|
|
44
|
+
Configure providers once in a `.server.ts` file. `createAuth` returns
|
|
45
|
+
`auth` (read the session), `signIn` / `signOut` (the flows), and
|
|
46
|
+
`handlers` (the OAuth redirect endpoints).
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// lib/auth.server.ts
|
|
50
|
+
import { createAuth, Credentials, Google, GitHub } from '@webjsdev/server';
|
|
51
|
+
import { db } from '#db/connection.server.ts';
|
|
52
|
+
|
|
53
|
+
export const { auth, signIn, signOut, handlers } = createAuth({
|
|
54
|
+
providers: [
|
|
55
|
+
Credentials({
|
|
56
|
+
async authorize(credentials) {
|
|
57
|
+
const user = await db.query.users.findFirst({ where: { email: credentials.email } });
|
|
58
|
+
if (!user || !verifyPassword(credentials.password, user.passwordHash)) return null;
|
|
59
|
+
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
Google(), // reads AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET
|
|
63
|
+
GitHub(), // reads AUTH_GITHUB_ID, AUTH_GITHUB_SECRET
|
|
64
|
+
],
|
|
65
|
+
secret: process.env.AUTH_SECRET, // required, 32+ random chars, from the env
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Sessions are JWT by default (stateless, scales horizontally). OAuth
|
|
70
|
+
providers handle the full redirect flow. Read the session anywhere on the
|
|
71
|
+
server with `auth()`.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// in a page or action
|
|
75
|
+
import { redirect } from '@webjsdev/core';
|
|
76
|
+
const session = await auth();
|
|
77
|
+
if (!session) throw redirect('/login'); // page-render gate: 302
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`auth()` resolves `{ user }`, where `user` is `Record<string, unknown>` by
|
|
81
|
+
default. To read custom fields (`session.user.id`, `session.user.role`)
|
|
82
|
+
without a cast, type the session by augmenting the `AuthUser` interface
|
|
83
|
+
(types-only, opt-in). See `typescript.md`.
|
|
84
|
+
|
|
85
|
+
## Required secrets, fail fast
|
|
86
|
+
|
|
87
|
+
| Variable | Effect |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `AUTH_SECRET` | Required for auth JWT signing (32+ random chars) |
|
|
90
|
+
| `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` | Google OAuth (optional) |
|
|
91
|
+
| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | GitHub OAuth (optional) |
|
|
92
|
+
| `SESSION_SECRET` | Cookie session signing |
|
|
93
|
+
| `REDIS_URL` | When set, sessions and rate limit and cache use Redis |
|
|
94
|
+
|
|
95
|
+
Never ship a demo secret or an in-source default. Read each secret from
|
|
96
|
+
`process.env` and fail boot when a required one is absent (use the
|
|
97
|
+
optional `env.ts` schema file to validate at boot and name every bad var).
|
|
98
|
+
A guessable signing secret means forgeable sessions.
|
|
99
|
+
|
|
100
|
+
## Protecting a route
|
|
101
|
+
|
|
102
|
+
Gate at the top of the page or layout that owns the protected subtree.
|
|
103
|
+
Because a page runs only on the server, the check happens before any HTML
|
|
104
|
+
is produced, so a logged-out visitor never receives protected markup.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// app/dashboard/page.ts
|
|
108
|
+
import { html, redirect } from '@webjsdev/core';
|
|
109
|
+
import { auth } from '#lib/auth.server.ts';
|
|
110
|
+
|
|
111
|
+
export default async function Dashboard() {
|
|
112
|
+
const session = await auth();
|
|
113
|
+
if (!session) throw redirect('/login'); // not signed in
|
|
114
|
+
return html`<h1>Welcome, ${session.user.name}</h1>`;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Reading the session through `auth()` also auto-excludes the page from the
|
|
119
|
+
server HTML response cache, so a per-user page is never cached and served
|
|
120
|
+
to another visitor (see `built-ins.md`).
|
|
121
|
+
|
|
122
|
+
## `forbidden()` (403) vs `unauthorized()` (401)
|
|
123
|
+
|
|
124
|
+
Two control-flow throws from `@webjsdev/core`, mirroring the `notFound()`
|
|
125
|
+
model. Both render the NEAREST matching boundary (innermost wins), else a
|
|
126
|
+
default page.
|
|
127
|
+
|
|
128
|
+
- `unauthorized()` for a request that is NOT authenticated (no valid
|
|
129
|
+
session). Renders the nearest `unauthorized.ts`. Use it when the fix is
|
|
130
|
+
"log in".
|
|
131
|
+
- `forbidden()` for an authenticated user who LACKS permission for this
|
|
132
|
+
resource. Renders the nearest `forbidden.ts`. Use it when logging in as
|
|
133
|
+
a different account would not help.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// app/admin/page.ts
|
|
137
|
+
import { html, forbidden, unauthorized } from '@webjsdev/core';
|
|
138
|
+
import { auth } from '#lib/auth.server.ts';
|
|
139
|
+
|
|
140
|
+
export default async function Admin() {
|
|
141
|
+
const session = await auth();
|
|
142
|
+
if (!session) throw unauthorized(); // 401, renders unauthorized.ts
|
|
143
|
+
if (session.user.role !== 'admin') throw forbidden(); // 403, renders forbidden.ts
|
|
144
|
+
return html`<h1>Admin</h1>`;
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Both work from a page or layout render AND from a page `action` (the no-JS
|
|
149
|
+
write path). The boundary files (`app/forbidden.ts`, `app/unauthorized.ts`,
|
|
150
|
+
or nested variants) live alongside the routes they cover and each
|
|
151
|
+
default-export a function returning the boundary markup.
|
|
152
|
+
|
|
153
|
+
`forbidden()` / `unauthorized()` are for a page / layout render or a page
|
|
154
|
+
`action`. They are NOT for a `route.ts` handler (return a `Response` with
|
|
155
|
+
the status there), and NOT for a `'use server'` RPC action (a raw throw
|
|
156
|
+
becomes a generic 500). See the next section for the action case.
|
|
157
|
+
|
|
158
|
+
## Auth failure inside a `'use server'` action: return, do not throw
|
|
159
|
+
|
|
160
|
+
A `'use server'` RPC action must NOT throw `forbidden()` / `unauthorized()`
|
|
161
|
+
/ `redirect()` for an auth failure. A raw throw there is sanitized to a
|
|
162
|
+
generic 500 in production, so the caller loses the reason. Instead return
|
|
163
|
+
an `ActionResult` failure envelope with a status the client can act on.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// modules/posts/actions/delete-post.server.ts
|
|
167
|
+
'use server';
|
|
168
|
+
import { auth } from '#lib/auth.server.ts';
|
|
169
|
+
import { db } from '#db/connection.server.ts';
|
|
170
|
+
|
|
171
|
+
export async function deletePost(input: { id: string }) {
|
|
172
|
+
const session = await auth();
|
|
173
|
+
if (!session) return { success: false, error: 'Sign in to continue.', status: 401 };
|
|
174
|
+
const post = await db.query.posts.findFirst({ where: { id: input.id } });
|
|
175
|
+
if (post?.authorId !== session.user.id) {
|
|
176
|
+
return { success: false, error: 'Not your post.', status: 403 };
|
|
177
|
+
}
|
|
178
|
+
await db.delete(posts).where(eq(posts.id, input.id));
|
|
179
|
+
return { success: true };
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The user-facing message belongs on the envelope's `error` field, never on
|
|
184
|
+
a raw throw (prod strips a thrown message to a digest). See
|
|
185
|
+
`data-and-actions.md` for the full `ActionResult` shape.
|
|
186
|
+
|
|
187
|
+
## CSRF: an Origin / `Sec-Fetch-Site` check, not a token cookie
|
|
188
|
+
|
|
189
|
+
Action RPC CSRF protection is an Origin / `Sec-Fetch-Site` header check
|
|
190
|
+
(the Remix 3 / Go 1.25 model), NOT a token cookie. A state-changing verb
|
|
191
|
+
(POST / PUT / PATCH / DELETE) passes only when:
|
|
192
|
+
|
|
193
|
+
- `Sec-Fetch-Site` is `same-origin` or `none`, OR
|
|
194
|
+
- (older browsers with no `Sec-Fetch-Site`) the `Origin` host matches the
|
|
195
|
+
request host, OR
|
|
196
|
+
- the source is listed in `webjs.allowedOrigins`.
|
|
197
|
+
|
|
198
|
+
Otherwise the request is a 403. A safe GET action is CSRF-exempt. Because
|
|
199
|
+
there is no CSRF token cookie and no `Set-Cookie` rides the SSR HTML, a
|
|
200
|
+
page that opts into a public `Cache-Control` stays CDN-edge-cacheable.
|
|
201
|
+
|
|
202
|
+
A `route.ts` REST endpoint is NOT covered. Authenticate every mutating
|
|
203
|
+
endpoint yourself, run `export const validate`, log without secrets, and
|
|
204
|
+
rate-limit.
|
|
205
|
+
|
|
206
|
+
## CORS: explicit allowlist when `credentials: true`
|
|
207
|
+
|
|
208
|
+
For cross-origin requests use `cors()` from `@webjsdev/server` as
|
|
209
|
+
middleware. When `credentials: true` you MUST pass an explicit origin
|
|
210
|
+
allowlist. Never `'*'` with credentials (that would expose an
|
|
211
|
+
authenticated response to any origin).
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// middleware.ts
|
|
215
|
+
import { cors } from '@webjsdev/server';
|
|
216
|
+
export default cors({
|
|
217
|
+
origin: ['https://app.example.com', 'https://admin.example.com'],
|
|
218
|
+
credentials: true, // requires the explicit allowlist above
|
|
219
|
+
});
|
|
220
|
+
```
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Built-ins and Configuration
|
|
2
|
+
|
|
3
|
+
Env vars, caching, rate limiting, broadcast, file storage, and the `package.json` `"webjs"` config block plus observability. Everything here is imported from `@webjsdev/server`.
|
|
4
|
+
|
|
5
|
+
## What This Covers
|
|
6
|
+
|
|
7
|
+
- **Environment variables** and the `WEBJS_PUBLIC_` browser-exposed prefix.
|
|
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
|
+
- **Rate limiting** (`rateLimit()` middleware) and **broadcast** (`broadcast()` over WebSockets).
|
|
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.
|
|
12
|
+
- **Observability.** Access log, `requestId()`, the `onError` hook, `instrumentation.ts`, the build-info endpoint.
|
|
13
|
+
|
|
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`.
|
|
15
|
+
|
|
16
|
+
## Environment variables
|
|
17
|
+
|
|
18
|
+
`process.env.X` reads are **server-only**. `NODE_ENV` is defined both sides. A variable named with the `WEBJS_PUBLIC_` prefix is exposed to the browser via an inline `<script>` (no build step), so read it client-side as `process.env.WEBJS_PUBLIC_ANALYTICS_ID`.
|
|
19
|
+
|
|
20
|
+
| Variable | Effect |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `REDIS_URL` | When set, sessions, rate limit, and cache use Redis instead of memory |
|
|
23
|
+
| `SESSION_SECRET` / `AUTH_SECRET` | Session and auth signing (see `auth-and-sessions.md`) |
|
|
24
|
+
| `PORT` | Listen port. Precedence `--port` flag, then `PORT` (real env or `.env`), then `8080` |
|
|
25
|
+
|
|
26
|
+
Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
|
|
27
|
+
|
|
28
|
+
## Caching
|
|
29
|
+
|
|
30
|
+
### `cache()` for query and computation results
|
|
31
|
+
|
|
32
|
+
Wrap an async function so identical calls serve from the store until the TTL expires. Cached values and keys round-trip through the rich serializer, so a `Date` stays a `Date` on a warm hit.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// modules/posts/queries/list-posts.server.ts
|
|
36
|
+
'use server';
|
|
37
|
+
import { cache } from '@webjsdev/server';
|
|
38
|
+
import { db } from '#db/connection.server.ts';
|
|
39
|
+
|
|
40
|
+
export const postById = cache(
|
|
41
|
+
async (id: string) => db.query.posts.findFirst({ where: { id } }),
|
|
42
|
+
{ key: 'post', ttl: 300, tags: (id) => ['post:' + id] } // per-entity tag
|
|
43
|
+
);
|
|
44
|
+
export const listPosts = cache(
|
|
45
|
+
async () => db.query.posts.findMany(),
|
|
46
|
+
{ key: 'posts', ttl: 60, tags: ['posts'] } // static tag
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A mutation invalidates a tagged read from an unrelated module with `revalidateTag('post:' + id)` (evicts only that entry) or `revalidateTags([...])`. See `data-and-actions.md` for the mutation side. An untagged `cache()` is untouched by any `revalidateTag`.
|
|
51
|
+
|
|
52
|
+
### HTTP `Cache-Control`
|
|
53
|
+
|
|
54
|
+
Standard HTTP caching. Let browsers, CDNs, and proxies do the work. Set it on a `route.ts` `Response` or via page `metadata.cacheControl`.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
export const metadata = { cacheControl: 'public, max-age=60' };
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Server HTML response cache (`export const revalidate`)
|
|
61
|
+
|
|
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.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// app/blog/page.ts
|
|
66
|
+
export const revalidate = 60; // cache this page's HTML for 60s
|
|
67
|
+
```
|
|
68
|
+
|
|
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.
|
|
70
|
+
|
|
71
|
+
### Content-hash asset URLs and conditional GET
|
|
72
|
+
|
|
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).
|
|
74
|
+
|
|
75
|
+
## Rate limiting
|
|
76
|
+
|
|
77
|
+
`rateLimit()` is middleware backed by the pluggable cache store (memory by default, Redis when the global store is switched). Fixed-window.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// middleware.ts (or a per-segment middleware.ts)
|
|
81
|
+
import { rateLimit } from '@webjsdev/server';
|
|
82
|
+
export default rateLimit({ window: '1m', max: 60 });
|
|
83
|
+
```
|
|
84
|
+
|
|
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.
|
|
86
|
+
|
|
87
|
+
## Broadcast
|
|
88
|
+
|
|
89
|
+
Send data to every WebSocket client connected to a route path, from inside that route's `WS` handler.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// app/api/chat/route.ts
|
|
93
|
+
import { broadcast } from '@webjsdev/server';
|
|
94
|
+
export function WS(ws, req) {
|
|
95
|
+
ws.on('message', (data) => broadcast('/api/chat', data, { except: ws }));
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
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.
|
|
100
|
+
|
|
101
|
+
## File storage
|
|
102
|
+
|
|
103
|
+
WebJs round-trips a native `File` / `Blob` / `FormData` over the wire; the file-storage primitive decides where the bytes land. Same adapter pattern as cache and sessions: a `FileStore` interface, a default `diskStore`, and a `setFileStore` / `getFileStore` singleton so swapping the backend touches no call site.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { getFileStore, setFileStore, diskStore, generateKey, signedUrl, verifySignedUrl } from '@webjsdev/server';
|
|
107
|
+
// Default: <cwd>/.webjs/uploads served under /uploads. Override at startup:
|
|
108
|
+
setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`FileStore` methods (all web-standard, so an S3 / R2 adapter is a drop-in): `put(key, file, opts?)` streams to storage, `get(key)` returns a streaming handle (`{ body, size, contentType }`) or `null`, `delete(key)` (idempotent), `url(key)`, `has(key)`.
|
|
112
|
+
|
|
113
|
+
**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
|
+
|
|
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.
|
|
116
|
+
|
|
117
|
+
**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
|
+
|
|
119
|
+
## The `"webjs"` config block (package.json)
|
|
120
|
+
|
|
121
|
+
All keys are optional; a malformed entry is dropped at boot with a warning, never crashing the pipeline.
|
|
122
|
+
|
|
123
|
+
### Security headers
|
|
124
|
+
|
|
125
|
+
On by default (`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy`, `Permissions-Policy`, plus HSTS in prod over HTTPS). A default is set only when absent. Override per path:
|
|
126
|
+
|
|
127
|
+
```jsonc
|
|
128
|
+
{ "webjs": { "headers": [
|
|
129
|
+
{ "source": "/embed/:path*", "headers": [{ "key": "X-Frame-Options", "value": null }] }
|
|
130
|
+
] } }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`source` uses the native URLPattern syntax (`:param`, `:rest*`). A `value` of `null` disables a default. Precedence lowest to highest: secure defaults, then `webjs.headers`, then app middleware.
|
|
134
|
+
|
|
135
|
+
### CSP (opt-in, nonce)
|
|
136
|
+
|
|
137
|
+
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
|
+
|
|
139
|
+
### Redirects, trailing-slash, basePath, allowed origins
|
|
140
|
+
|
|
141
|
+
```jsonc
|
|
142
|
+
{ "webjs": {
|
|
143
|
+
"redirects": [{ "source": "/blog/:slug", "destination": "/posts/:slug" }],
|
|
144
|
+
"trailingSlash": "never",
|
|
145
|
+
"basePath": "/app",
|
|
146
|
+
"allowedOrigins": ["admin.example.com"],
|
|
147
|
+
"clientRouter": true
|
|
148
|
+
} }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- **`redirects`** run first in the pipeline. `permanent` defaults to `true` (308); set `statusCode` for a legacy code. The query string is preserved. No server-side loop guard, so keep a `destination` off another rule's `source`.
|
|
152
|
+
- **`trailingSlash`** picks a canonical form and 308-redirects the other. `"never"` (recommended), `"always"`, `"ignore"` (default). The root `/` is always exempt.
|
|
153
|
+
- **`basePath`** prefixes every framework-emitted URL for a sub-path mount and strips the prefix at ingress. Author-written `<a href>` links are NOT auto-prefixed. Empty default is a byte-identical no-op.
|
|
154
|
+
- **`allowedOrigins`** is the action-RPC CSRF allowlist (CSRF is an Origin / `Sec-Fetch-Site` check, not a token cookie). Default same-origin only. This is not CORS; use the `cors()` middleware for cross-origin `route.ts` reads.
|
|
155
|
+
- **`clientRouter: false`** opts the whole app out of SPA navigation (pure MPA) while components still hydrate. Per-page escape hatch: `disableClientRouter()`.
|
|
156
|
+
|
|
157
|
+
### Ingress caps
|
|
158
|
+
|
|
159
|
+
Inbound bodies and connection lifetimes are capped by default. Override in the block or via env; precedence is env, then package.json, then default. A value of `0` disables a cap.
|
|
160
|
+
|
|
161
|
+
| Cap | Default | Config key |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| JSON / RPC body | 1 MiB | `maxBodyBytes` |
|
|
164
|
+
| Form / multipart body | 10 MiB | `maxMultipartBytes` |
|
|
165
|
+
| Full request receive | 30s | `requestTimeoutMs` |
|
|
166
|
+
| Headers receive | 20s | `headersTimeoutMs` |
|
|
167
|
+
| Keep-alive idle | 5s | `keepAliveTimeoutMs` |
|
|
168
|
+
|
|
169
|
+
An over-limit body responds `413` without buffering the whole payload.
|
|
170
|
+
|
|
171
|
+
### Dev/start task orchestration
|
|
172
|
+
|
|
173
|
+
`webjs dev` and `webjs start` run per-environment tasks from the block, so the primitive matches `npm run dev` / `npm start`.
|
|
174
|
+
|
|
175
|
+
```jsonc
|
|
176
|
+
{ "webjs": {
|
|
177
|
+
"dev": { "before": ["webjs db migrate"], "parallel": ["tailwindcss ... --watch"], "watch": ["../blog"] },
|
|
178
|
+
"start": { "before": ["webjs db migrate"] }
|
|
179
|
+
} }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`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
|
+
|
|
184
|
+
## Observability
|
|
185
|
+
|
|
186
|
+
Wired at the single response funnel, covering pages, routes, actions, and assets uniformly.
|
|
187
|
+
|
|
188
|
+
- **Access log.** One structured `info` line per handled request (`method`, `path`, `status`, `durationMs`, `requestId`). Never logs bodies or secrets; framework `/__webjs/*` traffic is suppressed.
|
|
189
|
+
- **Request id.** Each request gets a `crypto.randomUUID()` correlation id, set as `X-Request-Id` (honoring a trusted inbound one) and readable server-side with `requestId()` from `@webjsdev/server` (returns `null` outside a request scope).
|
|
190
|
+
- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const app = await createRequestHandler({
|
|
194
|
+
appDir: process.cwd(),
|
|
195
|
+
onError(error, { requestId, phase }) { Sentry.captureException(error, { tags: { requestId, phase } }); },
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- **`instrumentation.ts`** (app root) default-exports or names a `register()` function run once at boot, before the route table builds. Inside it, `setOnError(fn)` composes with the handler option. A sibling `instrumentation-client.ts` runs first in the client boot script for browser-side init.
|
|
200
|
+
- **Build info.** `GET /__webjs/version` returns `{ version, build, node, uptime }` (`Cache-Control: no-store`), alongside the `/__webjs/health` and `/__webjs/ready` probes, so a deploy can confirm which build is live.
|