@webjsdev/mcp 0.1.18 → 0.1.20
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/README.md +18 -99
- package/package.json +5 -10
- package/src/brief.js +52 -0
- package/src/catalog.js +82 -0
- package/src/guides.js +319 -0
- package/src/mcp-init.js +139 -0
- package/src/mcp-source.js +104 -9
- package/src/mcp.js +33 -144
- package/resources/AGENTS.md +0 -704
- package/resources/SKILL.md +0 -287
- package/resources/corpus.json +0 -6
- package/resources/references/auth-and-sessions.md +0 -300
- package/resources/references/built-ins.md +0 -346
- package/resources/references/client-router-and-streaming.md +0 -317
- package/resources/references/components.md +0 -505
- package/resources/references/data-and-actions.md +0 -325
- package/resources/references/module-structure.md +0 -229
- package/resources/references/muscle-memory-gotchas.md +0 -362
- package/resources/references/optimistic-ui.md +0 -179
- package/resources/references/routing-and-pages.md +0 -323
- package/resources/references/runtime.md +0 -113
- package/resources/references/service-worker.md +0 -80
- package/resources/references/styling.md +0 -312
- package/resources/references/testing.md +0 -223
- package/resources/references/typescript.md +0 -231
- package/resources/references/ui-kit.md +0 -161
- package/src/mcp-docs.js +0 -643
|
@@ -1,346 +0,0 @@
|
|
|
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**, the `WEBJS_PUBLIC_` browser-exposed prefix, and `env.ts` boot validation.
|
|
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, the doctor severity gate.
|
|
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`. The public set is read **once, at the first render of a build**: set it before boot (`.env`, the deploy environment, an `env.js` default), because a `process.env` mutation made at runtime is not reflected in the browser until the next restart or dev rebuild.
|
|
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`. A taken port fails fast: `webjs dev` / `webjs start` exit 98 with `port 8080 is already in use by PID <n> (<command>)`, on Node and Bun alike |
|
|
25
|
-
| `WEBJS_REUSE_PORT` | `1` lets several `webjs dev` / `webjs start` processes share one port (`SO_REUSEPORT`, Linux; the kernel balances connections across them). Off by default, so a second server on a taken port fails instead of silently taking a share of the requests |
|
|
26
|
-
| `WEBJS_SOURCE_LOCATIONS` | `webjs dev` only. `1` stamps `data-webjs-src="<app-relative-file>:<line>"` on the elements of the app's `html` templates (see below); `0` turns off a config default. Same as `webjs.dev.sourceLocations: true`. Ignored by `webjs start` |
|
|
27
|
-
| `WEBJS_EMBED_ORIGINS` | `webjs dev` only. Comma-separated parent origins (`https://builder.dev,http://localhost:8080`) allowed to frame the dev server and receive the embed bridge's messages (see below). Replaces `webjs.dev.embedOrigins` when set. Ignored by `webjs start` |
|
|
28
|
-
| `WEBJS_DEV_RELOAD_IDLE` | `webjs dev` only. Seconds of no edit and no interaction after which the live-reload stream closes so an idle host can sleep (`webjs.dev.reloadIdle`; this wins). Off by default; see `runtime.md` |
|
|
29
|
-
|
|
30
|
-
**Source locations for tooling (`webjs.dev.sourceLocations: true` or `WEBJS_SOURCE_LOCATIONS=1`, dev only, off by default).** A tool that hosts the app (an inspector, click-to-edit in an embedding builder) can map a clicked element back to the line that wrote it. With the variable set, `webjs dev` adds `data-webjs-src="components/todo-list.ts:12"` to every element opening tag written in an `html` template inside the app, both in the SSR markup and in client renders (one source transform applied to the served module and to the module the server imports, so the two agree and hydration is unaffected). Read it with `el.closest('[data-webjs-src]')`. Not annotated: `*.server.*` modules, `node_modules`, `css` / `svg` tagged templates, `html` / `head` / `body` / head-only and raw-text elements, and the descendants of `svg` / `math`. Lines are exact; nothing reaches production. The importmap `<script>` in `<head>` carries an unrelated `data-webjs-src` (the app-source deploy id), so match the `file:line` value shape when querying the whole document.
|
|
31
|
-
|
|
32
|
-
**Embed bridge for iframe previews (`webjs.dev.embedOrigins` or `WEBJS_EMBED_ORIGINS`, dev only, off by default).** A tool that previews the app inside an iframe (an app builder, a docs playground) lists its own origin(s) in `package.json` (`"webjs": { "dev": { "embedOrigins": ["https://builder.example"] } }`) or sets `WEBJS_EMBED_ORIGINS` (comma-separated, replaces the config list for that run). `webjs dev` then (1) drops `X-Frame-Options` and adds those origins to a CSP `frame-ancestors`, so the frame loads without the app stripping headers in `webjs.headers`, and (2) inlines a small nonce-signed script into every document that, when framed by a listed origin, posts to `window.parent` (with that exact target origin, never `*`):
|
|
33
|
-
|
|
34
|
-
```js
|
|
35
|
-
{ source: 'webjs-embed', type: 'ready', path, title } // document parsed
|
|
36
|
-
{ source: 'webjs-embed', type: 'navigate', path, title } // every client-router navigation + popstate
|
|
37
|
-
{ source: 'webjs-embed', type: 'console', level: 'error' | 'warn', message, dropped? } // max 20/s
|
|
38
|
-
{ source: 'webjs-embed', type: 'error', message, stack, file, line, column } // window error + unhandledrejection
|
|
39
|
-
{ source: 'webjs-embed', type: 'network', method, url, status, error? } // fetch/XHR status >= 500, or status 0 on failure
|
|
40
|
-
{ source: 'webjs-embed', type: 'server-error', kind, message, file, line, path } // the dev error overlay went up
|
|
41
|
-
{ source: 'webjs-embed', type: 'select', src, tag, text, rect: { x, y, width, height }, shiftKey, metaKey, altKey, ctrlKey } // a click in inspect mode (modifiers for multi-select, #1532)
|
|
42
|
-
{ source: 'webjs-embed', type: 'hold', enabled } // acknowledges a host hold command (#1532)
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
and accepts, only from `window.parent` on a listed origin: `{ source: 'webjs-embed-host', type: 'navigate', path }` (a local path; a soft navigation when the client router is on), `{ source: 'webjs-embed-host', type: 'reload' }`, `{ source: 'webjs-embed-host', type: 'resume' }` (reopens a live-reload stream closed by `webjs.dev.reloadIdle`; every host command does this too), and `{ source: 'webjs-embed-host', type: 'inspect', enabled: true | false }` (hover highlight plus click capture that posts `select`; `src` is the nearest `data-webjs-src`, so pair it with `WEBJS_SOURCE_LOCATIONS=1` for click-to-edit), and `{ source: 'webjs-embed-host', type: 'hold', enabled: true | false }` (#1532: while held, live-reload signals are collected instead of applied, so a builder's agent can edit many files without the preview flickering through each save; releasing applies ONE reload at the strongest verdict collected, or none when nothing changed; the hold is kept in `sessionStorage`, so a new document in the same tab starts held, and only the host's release ends it). A full dev reload or a host `reload` keeps the scroll position of the path it reloads (#1532). Paths are app paths (`/api/x`, `/components/x.ts`). Unset adds zero bytes; `webjs start` never injects it nor relaxes a header.
|
|
46
|
-
|
|
47
|
-
Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
|
|
48
|
-
|
|
49
|
-
**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:
|
|
50
|
-
|
|
51
|
-
```ts
|
|
52
|
-
// env.ts
|
|
53
|
-
export default {
|
|
54
|
-
DATABASE_URL: 'url',
|
|
55
|
-
SESSION_SECRET: { type: 'string', minLength: 16 },
|
|
56
|
-
PORT: { type: 'number', default: 8080 },
|
|
57
|
-
LOG_LEVEL: { type: 'enum', values: ['debug', 'info', 'warn'], default: 'info' },
|
|
58
|
-
};
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## Caching
|
|
62
|
-
|
|
63
|
-
### `cache()` for query and computation results
|
|
64
|
-
|
|
65
|
-
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.
|
|
66
|
-
|
|
67
|
-
```ts
|
|
68
|
-
// modules/posts/queries/list-posts.server.ts
|
|
69
|
-
'use server';
|
|
70
|
-
import { cache } from '@webjsdev/server';
|
|
71
|
-
import { db } from '#db/connection.server.ts';
|
|
72
|
-
|
|
73
|
-
export const postById = cache(
|
|
74
|
-
async (id: string) => db.query.posts.findFirst({ where: { id } }),
|
|
75
|
-
{ key: 'post', ttl: 300, tags: (id) => ['post:' + id] } // per-entity tag
|
|
76
|
-
);
|
|
77
|
-
export const listPosts = cache(
|
|
78
|
-
async () => db.query.posts.findMany(),
|
|
79
|
-
{ key: 'posts', ttl: 60, tags: ['posts'] } // static tag
|
|
80
|
-
);
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
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`.
|
|
84
|
-
|
|
85
|
-
### HTTP `Cache-Control`
|
|
86
|
-
|
|
87
|
-
Standard HTTP caching. Let browsers, CDNs, and proxies do the work. Set it on a `route.ts` `Response` or via page `metadata.cacheControl`.
|
|
88
|
-
|
|
89
|
-
```ts
|
|
90
|
-
export const metadata = { cacheControl: 'public, max-age=60' };
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
### Server HTML response cache (`export const revalidate`)
|
|
94
|
-
|
|
95
|
-
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.
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
// app/blog/page.ts
|
|
99
|
-
export const revalidate = 60; // cache this page's HTML for 60s
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
**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.
|
|
103
|
-
|
|
104
|
-
### Content-hash asset URLs and conditional GET
|
|
105
|
-
|
|
106
|
-
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.
|
|
107
|
-
|
|
108
|
-
A `public/` asset you reference yourself is opt-in, via `asset()` (#1194):
|
|
109
|
-
|
|
110
|
-
```ts
|
|
111
|
-
import { html, asset } from '@webjsdev/core';
|
|
112
|
-
html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
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.
|
|
116
|
-
|
|
117
|
-
`asset()` is a PROVIDER SEAM: `@webjsdev/server` installs the resolver at boot by importing `@webjsdev/core` and calling a setter, and that only reaches your app when both sides load the SAME copy of core. Two copies on disk are two independent sets of module-scope state, so the setter lands on one and your `asset()` reads the other, which returns bare paths and never says why. `cspNonce()` and the bound-form identity resolver behind `<form action=${fn}>` sit on the same seam and go inert together. `@webjsdev/server` therefore declares core as a PEER dependency, so npm resolves it against your app's copy and reports a genuine conflict at install time rather than nesting a second one. If you ever do end up with two (a hand-rolled install, a vendored copy), the symptom is those three features quietly doing nothing rather than any error, so reach for `npm ls @webjsdev/core` and `npm dedupe` before looking anywhere else.
|
|
118
|
-
|
|
119
|
-
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.
|
|
120
|
-
|
|
121
|
-
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).
|
|
122
|
-
|
|
123
|
-
**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.
|
|
124
|
-
|
|
125
|
-
## Rate limiting
|
|
126
|
-
|
|
127
|
-
`rateLimit()` is middleware backed by the pluggable cache store (memory by default, Redis when the global store is switched). Fixed-window.
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
// middleware.ts (or a per-segment middleware.ts)
|
|
131
|
-
import { rateLimit } from '@webjsdev/server';
|
|
132
|
-
export default rateLimit({ window: '1m', max: 60 });
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
Options: `window` (ms or a string like `'1m'`), `max`, `key` (a string prefix or a `(req) => string` function, defaults to the framework-stamped socket peer), `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.
|
|
136
|
-
|
|
137
|
-
**The default key is the socket PEER, which is the visitor only when the browser connects to you directly.** Deploy behind a CDN or a platform router and the peer is that proxy, so `trustProxy: true` is what a deployed limiter almost always wants. Get it wrong and nothing looks broken: a single shared proxy buckets every visitor together, and a proxy POOL (the common case) hands out one full allowance PER proxy, so the effective limit is multiplied by the pool size while `X-RateLimit-Remaining` still counts down convincingly inside each bucket. Diagnose it by sending the requests over ONE keep-alive connection, which pins them to one peer: counts that descend there but reset on a fresh connection mean you are bucketing proxies. `trustProxy: true` has one precondition, that the proxy in front strips an inbound `X-Forwarded-For` before adding its own (Cloudflare, Railway, Fly, Render, and Vercel do; nginx and Caddy only if configured), or a client can forge the header and choose its own bucket.
|
|
138
|
-
|
|
139
|
-
**Behind a CDN, `trustProxy: true` alone is usually still wrong, so name the header: `rateLimit({ trustProxy: true, clientIpHeader: 'cf-connecting-ip' })`.** The default chain starts at the leftmost `X-Forwarded-For` entry, which behind Cloudflare is Cloudflare's EGRESS address rather than the visitor. Cloudflare pins one egress IP per connection, so the symptom is a limiter that counts down correctly for a button that pings on one connection and never refuses anyone who opens a new one. When `clientIpHeader` is set it is the only wire header read (falling back to the peer, then `_anon_`), a blank value falls through rather than becoming a key every visitor shares, and a comma chain is split so an appending proxy cannot mint a bucket per hop. The framework will NOT prefer `CF-Connecting-IP` on its own, because Cloudflare overwrites it, which makes it unforgeable behind Cloudflare and forgeable anywhere else: on an nginx or bare-platform deploy a client could then send it and outrank the header the real proxy set. Name the header YOUR edge sets and overwrites.
|
|
140
|
-
|
|
141
|
-
## Broadcast
|
|
142
|
-
|
|
143
|
-
Send data to every WebSocket client connected to a route path, from inside that route's `WS` handler.
|
|
144
|
-
|
|
145
|
-
```ts
|
|
146
|
-
// app/api/chat/route.ts
|
|
147
|
-
import { broadcast } from '@webjsdev/server';
|
|
148
|
-
export function WS(ws, req) {
|
|
149
|
-
ws.on('message', (data) => broadcast('/api/chat', data, { except: ws }));
|
|
150
|
-
}
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
`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.
|
|
154
|
-
|
|
155
|
-
## File storage
|
|
156
|
-
|
|
157
|
-
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.
|
|
158
|
-
|
|
159
|
-
```ts
|
|
160
|
-
import { getFileStore, setFileStore, diskStore, generateKey, signedUrl, verifySignedUrl } from '@webjsdev/server';
|
|
161
|
-
// Default: <cwd>/.webjs/uploads served under /uploads. Override at startup:
|
|
162
|
-
setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
`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)`.
|
|
166
|
-
|
|
167
|
-
**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.
|
|
168
|
-
|
|
169
|
-
**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 })`.
|
|
170
|
-
|
|
171
|
-
**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`.
|
|
172
|
-
|
|
173
|
-
## The `"webjs"` config block (package.json)
|
|
174
|
-
|
|
175
|
-
All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot rather than crashing the pipeline, some readers warning as they drop it. `headers` and `redirects` warn on every drop. Both warn for the key itself when it is present but not an array, and for each rule or entry inside it; `headers` has a third level and warns per header directive too. An absent key is the default and says nothing. An UNKNOWN top-level key no longer passes in silence either (#1300): the block is validated against the published JSON Schema once per boot, in dev and in prod alike, and one aggregated warning names what was ignored. So a `"redirect"` typed for `"redirects"` now says so in the server output instead of silently leaving the feature at its default. It never fails the boot. Know its exact reach, since what it passes over looks like what it catches. It reports any unknown TOP-LEVEL key, whatever it is called, and it checks the VALUE of 9 of the 17 known keys, being the one `enum` and the `boolean` / `integer` leaves. It does not descend into a nested object, so a misspelling inside `dev` or `start` is not reported, and it does not type-check the other 8 (`headers`, `redirects`, `basePath`, `allowedOrigins`, `csp`, `dev`, `start`, `doctor`), whose schemas are the free-form ones a blunt check would start refusing working configs over. Whether anything else notices a value this check passes over is up to that value's own reader, and it varies by reader. `doctor.gate` varies furthest, since the boot check never descends into any key and the `webjs doctor` CLI, not the server, is what reads this one, rejecting 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.
|
|
176
|
-
|
|
177
|
-
### Security headers
|
|
178
|
-
|
|
179
|
-
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:
|
|
180
|
-
|
|
181
|
-
```jsonc
|
|
182
|
-
{ "webjs": { "headers": [
|
|
183
|
-
{ "source": "/embed/:path*", "headers": [{ "key": "X-Frame-Options", "value": null }] }
|
|
184
|
-
] } }
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
`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.
|
|
188
|
-
|
|
189
|
-
### CSP (opt-in, nonce)
|
|
190
|
-
|
|
191
|
-
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>`.
|
|
192
|
-
|
|
193
|
-
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.
|
|
194
|
-
|
|
195
|
-
### Redirects, trailing-slash, basePath, allowed origins
|
|
196
|
-
|
|
197
|
-
```jsonc
|
|
198
|
-
{ "webjs": {
|
|
199
|
-
"redirects": [{ "source": "/blog/:slug", "destination": "/posts/:slug" }],
|
|
200
|
-
"trailingSlash": "never",
|
|
201
|
-
"basePath": "/app",
|
|
202
|
-
"allowedOrigins": ["admin.example.com"],
|
|
203
|
-
"clientRouter": true
|
|
204
|
-
} }
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
- **`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`.
|
|
208
|
-
- **`trailingSlash`** picks a canonical form and 308-redirects the other. `"never"` (recommended), `"always"`, `"ignore"` (default). The root `/` is always exempt.
|
|
209
|
-
- **`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.
|
|
210
|
-
- **`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.
|
|
211
|
-
- **`clientRouter: false`** opts the whole app out of SPA navigation (pure MPA) while components still hydrate. Per-page escape hatch: `disableClientRouter()`.
|
|
212
|
-
|
|
213
|
-
### Ingress caps
|
|
214
|
-
|
|
215
|
-
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.
|
|
216
|
-
|
|
217
|
-
| Cap | Default | Config key |
|
|
218
|
-
|---|---|---|
|
|
219
|
-
| JSON / RPC body | 1 MiB | `maxBodyBytes` |
|
|
220
|
-
| Form / multipart body | 10 MiB | `maxMultipartBytes` |
|
|
221
|
-
| Full request receive | 30s | `requestTimeoutMs` |
|
|
222
|
-
| Headers receive | 20s | `headersTimeoutMs` |
|
|
223
|
-
| Keep-alive idle | 5s | `keepAliveTimeoutMs` |
|
|
224
|
-
|
|
225
|
-
An over-limit body responds `413` without buffering the whole payload.
|
|
226
|
-
|
|
227
|
-
### Dev/start task orchestration
|
|
228
|
-
|
|
229
|
-
`webjs dev` and `webjs start` run per-environment tasks from the block, so the primitive matches `npm run dev` / `npm start`.
|
|
230
|
-
|
|
231
|
-
```jsonc
|
|
232
|
-
{ "webjs": {
|
|
233
|
-
"dev": {
|
|
234
|
-
"before": ["webjs db migrate", "webjs regenerate"],
|
|
235
|
-
"regenerate": [{ "output": "public/tailwind.css",
|
|
236
|
-
"command": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify",
|
|
237
|
-
"inputs": ["app", "components", "modules", "lib", "public/input.css"] }],
|
|
238
|
-
"watch": ["../blog"]
|
|
239
|
-
},
|
|
240
|
-
"start": { "before": ["webjs db migrate", "webjs regenerate"] }
|
|
241
|
-
} }
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
`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.
|
|
245
|
-
|
|
246
|
-
`regenerate` (#967) keeps a served build output fresh without a watcher: when its `output` is requested in dev and is not fresh, the server runs `command` to completion before serving it. Prefer it over a `tailwindcss --watch` in `parallel`, which can die mid-session and serve stale CSS with no error. Fresh is decided by a RECORD (#1672): every build writes, in `.webjs/regenerate.json`, the command, a fingerprint of the files under `inputs` taken before it ran, and the output it produced, and an output is fresh while all three still match. So a rebuild whose bytes did not change (the Tailwind CLI skips the write) is fresh, a file saved during the compile is not, and an input dated in the future hides nothing. The command must WRITE `output`: the output is moved aside first, so a rule whose `output` is not the path its command writes is reported on every request instead of silently serving stale CSS. The output never counts as its own input, but a prepare step inside the command that writes into an input directory (a copy of shared sources, say) must write a file only when its content changed: a file rewritten with the same bytes changes the input fingerprint on every build, so the output would never read as fresh.
|
|
247
|
-
|
|
248
|
-
Build the outputs with `webjs regenerate` in `dev.before` and `start.before` (and in a CI step before `webjs doctor`), never with the raw compile command: it runs every rule that is not fresh and records each build, so a boot compiles only what changed and the first request after it compiles nothing. `webjs regenerate --force` rebuilds every rule. A compile run by hand (`npm run css:build`) writes no record, so that output is rebuilt once on its next dev request and `webjs doctor` reports it unrecorded until then.
|
|
249
|
-
|
|
250
|
-
### Local CI (`webjs.ci`)
|
|
251
|
-
|
|
252
|
-
`webjs ci` runs the step list the block declares, the Rails 8.1 `bin/ci` posture: your machine is the first CI runner, and a cloud pipeline runs the SAME list by calling `npm run ci`, so the two cannot drift. Each step prints a heading, then `✅ <title> passed in 2.11s` or `❌ <title> failed in 0.01s`; the run ends with every failure listed and one total line, and exits 1 on any failure.
|
|
253
|
-
|
|
254
|
-
```jsonc
|
|
255
|
-
{ "webjs": { "ci": { "steps": [
|
|
256
|
-
{ "title": "Setup", "run": "webjs db migrate" },
|
|
257
|
-
{ "title": "Checks", "parallel": 2, "steps": [ // two at a time
|
|
258
|
-
"webjs check", // a string is a command titled by itself
|
|
259
|
-
{ "title": "Types", "run": "webjs typecheck" },
|
|
260
|
-
{ "title": "Tests", "steps": [ // a nested group takes ONE slot, runs in order
|
|
261
|
-
{ "title": "Tests: server", "run": "webjs test --server" },
|
|
262
|
-
{ "title": "Tests: e2e", "run": "webjs test --server", "env": { "WEBJS_E2E": "1" } }
|
|
263
|
-
] }
|
|
264
|
-
] }
|
|
265
|
-
] } } }
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
A step is a string, a `{ title, run, env? }` command, or a `{ title, steps, parallel? }` group. `parallel` is a slot count (default 1); a parallel group captures each step's output and replays it whole when the step finishes, so two steps never interleave, and a group nested inside it takes one slot and runs sequentially (it cannot declare `parallel`, which the reader reports rather than honours). `env` is per-step, so the e2e opt-in does not depend on a shell prefix. Every child runs with `CI=true`, `node_modules/.bin` on PATH, and `.env` loaded first (a real env var wins), so a `webjs db migrate` step sees `DATABASE_URL`.
|
|
269
|
-
|
|
270
|
-
Flags: `-f` / `--fail-fast` stops after the first failure (the default runs everything and lists every failure), `--only <title>` runs one step or group by title (repeatable, an unknown title is an error rather than an empty green run), `--json` emits one document on stdout (`{ ok, seconds, steps: [{ title, run, group, ok, code, seconds, output? }] }`, failed steps carrying their captured output, the human report on stderr) for an agent loop, and `--signoff` runs `gh signoff` after a green run. To hold a merge until a LOCAL run is green, install `basecamp/gh-signoff`, run `gh signoff install` once (a branch-protection rule requiring the `signoff` status), and run `npm run ci -- --signoff`; a red run posts nothing.
|
|
271
|
-
|
|
272
|
-
Under GitHub Actions each step is a `::group::` in the log, a failed step is an `::error::` annotation, and a step table is appended to the job summary, so a single job running the whole list still names the layer that broke. The scaffold's workflow is exactly that one job; `webjs create --skip-ci` omits it and the local list always ships. A malformed block (a group with no title, a nested `parallel`, an unknown key) refuses to run and names every problem by JSON path, because a silently dropped step is a check that never ran. Nothing declared is exit 1 too, naming any workspace member that declares one, since "ran zero steps" would read as green.
|
|
273
|
-
|
|
274
|
-
### Bring your own ORM (`webjs.db`)
|
|
275
|
-
|
|
276
|
-
Drizzle is the scaffold DEFAULT, not lock-in. The runtime never imports it, `db/connection.server.ts` is the app's own file, and `webjs db` is adapter-driven: a `db` block maps each verb to the shell command `webjs db <verb>` runs instead of the drizzle-kit default (node_modules/.bin on PATH like a `before` step, extra CLI args appended).
|
|
277
|
-
|
|
278
|
-
```jsonc
|
|
279
|
-
{ "webjs": {
|
|
280
|
-
"db": {
|
|
281
|
-
"generate": "prisma migrate dev --create-only",
|
|
282
|
-
"migrate": "prisma migrate deploy",
|
|
283
|
-
"push": "prisma db push",
|
|
284
|
-
"studio": "prisma studio",
|
|
285
|
-
"reset": "prisma migrate reset --force"
|
|
286
|
-
}
|
|
287
|
-
} }
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
Any key is a verb (`reset` above adds `webjs db reset`). A verb the block does not name keeps its default (drizzle-kit for `generate` / `migrate` / `push` / `studio`, `db/seed.server.ts` for `seed`), so an app with no block is unchanged and the scaffold emits none. The payoff is that `webjs db migrate` stays one spelling across ORMs, so the scaffolded `dev.before` / `start.before`, the Dockerfile, CI, and the deploy docs all keep working after a swap. Write the bare binary (`prisma migrate deploy`), not `npx prisma ...`, since a pure Bun image has no `npx`. The swap itself is the app's own files: replace `db/connection.server.ts` with the new client, drop `drizzle.config.ts` / `db/columns.server.ts`, and keep server-only imports behind `.server.ts` as before.
|
|
291
|
-
|
|
292
|
-
### Doctor severity gate
|
|
293
|
-
|
|
294
|
-
`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` plus `FRAMEWORK_LINKS` depend on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries.
|
|
295
|
-
|
|
296
|
-
```jsonc
|
|
297
|
-
{ "webjs": {
|
|
298
|
-
"doctor": { "gate": {
|
|
299
|
-
"UNMARKED_ASSET_LINKS": "error", // fail the exit on this one
|
|
300
|
-
"ELISION_CARRIERS": "off" // silence it entirely, even under --strict
|
|
301
|
-
} }
|
|
302
|
-
} }
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
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`.
|
|
306
|
-
|
|
307
|
-
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.
|
|
308
|
-
|
|
309
|
-
`APP_ICON` warns while the favicon is still the scaffold's placeholder `app/icon.svg` (marked `data-webjs-placeholder`). Replace it with the app's own icon (`references/routing-and-pages.md`, "App icon and manifest").
|
|
310
|
-
|
|
311
|
-
### Dependency audit allowlist
|
|
312
|
-
|
|
313
|
-
`webjs audit` runs `npm audit` or `bun audit` (by the nearest lockfile, so a workspace member uses the root's) and fails on any advisory at or above `webjs.audit.level` (default `high`) that `webjs.audit.ignore` does not list. The scaffold's `Security: dependency audit` CI step runs it.
|
|
314
|
-
|
|
315
|
-
```jsonc
|
|
316
|
-
{ "webjs": {
|
|
317
|
-
"audit": {
|
|
318
|
-
"level": "high",
|
|
319
|
-
"ignore": [
|
|
320
|
-
{ "id": "GHSA-vfj7-8cjw-p6xm", "reason": "braces has no patched release; reached only through dev tooling" }
|
|
321
|
-
]
|
|
322
|
-
}
|
|
323
|
-
} }
|
|
324
|
-
```
|
|
325
|
-
|
|
326
|
-
The allowlist is the ONE place an accepted advisory lives, each with its reason. Accept only an advisory with no patched release that the app's users cannot reach; upgrade anything that has a fix. Never "fix" a red audit with `npm audit fix --force`, which proposes breaking majors that often keep the same vulnerable chain. A malformed block (unknown key, bad level, an entry without an id or a reason) exits 1, and an id the audit stops reporting prints as stale, so remove it then.
|
|
327
|
-
|
|
328
|
-
Overrides apply only at a WORKSPACE ROOT. The scaffold's `overrides` block (the `puppeteer-core` and `basic-ftp` security floors) is ignored once the app is a member of an npm or bun workspace, so move it into the root `package.json`; `webjs doctor` warns with `WORKSPACE_OVERRIDES` until you do.
|
|
329
|
-
|
|
330
|
-
## Observability
|
|
331
|
-
|
|
332
|
-
Wired at the single response funnel, covering pages, routes, actions, and assets uniformly.
|
|
333
|
-
|
|
334
|
-
- **Access log.** One structured `info` line per handled request (`method`, `path`, `status`, `durationMs`, `requestId`). Never logs bodies or secrets; framework `/__webjs/*` traffic is suppressed.
|
|
335
|
-
- **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).
|
|
336
|
-
- **`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. It also carries two framework DIAGNOSTICS that are not request failures, each with an `err.code` to group or filter on, both under `phase: 'action'`: `WEBJS_FORM_SUBMITTED_AS_GET` (a page GET carrying the reserved `__webjs_action` field in its query string, so a submission holding a bound action's identity went out as a GET and the action never ran, #1307; a bound submitter carries its own `formmethod="post"`, so what reaches this is an explicit `formmethod="get"` / `method="get"` the author wrote and the renderer honours rather than refuses) and `WEBJS_FORM_ACTION_MISSING` (a PARSEABLE form body carrying no identity, the 405; an `enctype="text/plain"` submission is answered before its body is read, so it stays a bare 405). Both are detect-only, so the 200 and the 405 are unchanged; both carry `method`, `pathname`, and for the second the submitted field NAMES, never the values; and both are deduplicated per process on the code, the method, and the matched ROUTE (not the request pathname, so crafted urls on a dynamic route cannot exhaust the 256-entry cap and silence the diagnostics), since either is reachable by an unauthenticated request and an uncapped report would be a free amplifier into a paid sink.
|
|
337
|
-
|
|
338
|
-
```ts
|
|
339
|
-
const app = await createRequestHandler({
|
|
340
|
-
appDir: process.cwd(),
|
|
341
|
-
onError(error, { requestId, phase }) { Sentry.captureException(error, { tags: { requestId, phase } }); },
|
|
342
|
-
});
|
|
343
|
-
```
|
|
344
|
-
|
|
345
|
-
- **`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.
|
|
346
|
-
- **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.
|