@webjsdev/cli 0.10.11 → 0.10.12
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/lib/mcp-docs.js +400 -0
- package/lib/mcp-source.js +244 -0
- package/lib/mcp.js +167 -18
- package/package.json +7 -2
- package/resources/AGENTS.md +404 -0
- package/resources/agent-docs/advanced.md +1090 -0
- package/resources/agent-docs/built-ins.md +367 -0
- package/resources/agent-docs/components.md +486 -0
- package/resources/agent-docs/configuration.md +207 -0
- package/resources/agent-docs/framework-dev.md +65 -0
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +456 -0
- package/resources/agent-docs/metadata.md +334 -0
- package/resources/agent-docs/recipes.md +440 -0
- package/resources/agent-docs/service-worker.md +100 -0
- package/resources/agent-docs/ssr-partial-nav-design.md +214 -0
- package/resources/agent-docs/styling.md +235 -0
- package/resources/agent-docs/testing.md +372 -0
- package/resources/agent-docs/typescript.md +334 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# Built-in essentials
|
|
2
|
+
|
|
3
|
+
`import { … } from '@webjsdev/server'`
|
|
4
|
+
|
|
5
|
+
Opinionated defaults: **set `REDIS_URL` and everything scales.**
|
|
6
|
+
|
|
7
|
+
## Caching (HTTP standards, Remix-style)
|
|
8
|
+
|
|
9
|
+
webjs uses standard HTTP caching via `Cache-Control` on responses. Let
|
|
10
|
+
browsers, CDNs, and reverse proxies handle caching. No framework cache
|
|
11
|
+
layer to debug.
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
// route handler
|
|
15
|
+
export async function GET() {
|
|
16
|
+
const posts = await prisma.post.findMany();
|
|
17
|
+
return new Response(JSON.stringify(posts), {
|
|
18
|
+
headers: {
|
|
19
|
+
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
|
|
20
|
+
'Content-Type': 'application/json',
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// SSR page metadata
|
|
26
|
+
export const metadata = {
|
|
27
|
+
cacheControl: 'public, max-age=60',
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
App-level caching (DB query results, expensive computations) uses the
|
|
32
|
+
cache store directly:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { getStore, setStore, redisStore } from '@webjsdev/server';
|
|
36
|
+
|
|
37
|
+
// Default: memory store
|
|
38
|
+
const store = getStore();
|
|
39
|
+
|
|
40
|
+
// At app startup, switch to Redis for horizontal scaling
|
|
41
|
+
setStore(redisStore({ url: process.env.REDIS_URL }));
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Query result caching (`cache()`) + tag-based invalidation
|
|
45
|
+
|
|
46
|
+
For DB query / expensive-computation results, wrap an async function with
|
|
47
|
+
`cache(fn, { key, ttl })`. Same function + same args serve from the store
|
|
48
|
+
until the TTL expires:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// modules/posts/queries/list-posts.server.ts
|
|
52
|
+
'use server';
|
|
53
|
+
import { cache } from '@webjsdev/server';
|
|
54
|
+
import { prisma } from '../../../lib/prisma.server.ts';
|
|
55
|
+
|
|
56
|
+
export const listPosts = cache(
|
|
57
|
+
async () => prisma.post.findMany({ orderBy: { createdAt: 'desc' } }),
|
|
58
|
+
{ key: 'posts', ttl: 60 }
|
|
59
|
+
);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
To invalidate a read from an UNRELATED mutation (without importing the
|
|
63
|
+
wrapper), add `tags`. It is either a static `string[]` or a function
|
|
64
|
+
`(...args) => string[]` so a per-entity read tags with the id:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
export const postById = cache(
|
|
68
|
+
async (id: string) => prisma.post.findUnique({ where: { id } }),
|
|
69
|
+
{ key: 'post', ttl: 300, tags: (id) => ['post:' + id] } // per-entity tag
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
export const listPosts = cache(
|
|
73
|
+
async () => prisma.post.findMany(),
|
|
74
|
+
{ key: 'posts', ttl: 60, tags: ['posts'] } // static tag
|
|
75
|
+
);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A mutating server action then calls `revalidateTag` after the write. It
|
|
79
|
+
works ACROSS modules (the comments module evicts a posts-module read with
|
|
80
|
+
no import of the wrapper):
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// modules/comments/actions/create-comment.server.ts
|
|
84
|
+
'use server';
|
|
85
|
+
import { revalidateTag, revalidatePath } from '@webjsdev/server';
|
|
86
|
+
|
|
87
|
+
export async function createComment(input) {
|
|
88
|
+
await prisma.comment.create({ data: input });
|
|
89
|
+
await revalidateTag('post:' + input.postId); // postById(postId) recomputes
|
|
90
|
+
await revalidateTag('posts'); // listPosts recomputes
|
|
91
|
+
await revalidatePath('/blog'); // also evict the cached HTML
|
|
92
|
+
return { success: true };
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`revalidateTag('post:5')` evicts ONLY the id-5 entry, leaving other ids
|
|
97
|
+
cached; `revalidateTags([...])` clears several tags at once. This is the
|
|
98
|
+
fix for the old arg-key leak: the no-args `wrapped.invalidate()` (still
|
|
99
|
+
supported) only clears the base key, so tag a per-arg read and evict the
|
|
100
|
+
exact id by tag. An untagged `cache()` is untouched by any `revalidateTag`.
|
|
101
|
+
|
|
102
|
+
`revalidateTag` evicts cached `cache()` DATA; `revalidatePath` (below)
|
|
103
|
+
evicts cached HTML. Together they are the **server cache invalidation
|
|
104
|
+
surface**, both imported from `@webjsdev/server`.
|
|
105
|
+
|
|
106
|
+
**Multi-instance note.** The tag index is a thin, non-atomic
|
|
107
|
+
read-modify-write of a JSON array in the store. With a shared Redis store,
|
|
108
|
+
`revalidateTag` reaches every instance for the keys it can see, but two
|
|
109
|
+
instances appending to one tag concurrently can lose an append, so a
|
|
110
|
+
freshly-stored key on a peer might miss eviction and live until its TTL.
|
|
111
|
+
The index entry carries the cache TTL so it self-prunes. For strict
|
|
112
|
+
cross-instance invalidation, prefer a short `ttl` as the floor.
|
|
113
|
+
|
|
114
|
+
### Server HTML response cache (`export const revalidate`, ISR for no-build)
|
|
115
|
+
|
|
116
|
+
For a page that renders the same HTML for every visitor, opt into the
|
|
117
|
+
server HTML response cache so the SSR pipeline runs once per window
|
|
118
|
+
instead of per request (webjs's no-build equivalent of Next.js's Full
|
|
119
|
+
Route Cache + ISR). Declare a revalidation window on the page module:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// app/blog/page.ts
|
|
123
|
+
export const revalidate = 60; // seconds: cache this page's HTML for 60s
|
|
124
|
+
|
|
125
|
+
export default async function Blog() {
|
|
126
|
+
const posts = await listPosts(); // via a server query
|
|
127
|
+
return html`...`;
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**SAFETY (read this).** Caching is OPT-IN and conservative because a
|
|
132
|
+
wrongly-cached per-user page is a data leak. `export const revalidate`
|
|
133
|
+
is you asserting **this page is the same for everyone for N seconds**.
|
|
134
|
+
The cache is keyed by the FULL URL (path + search) only, with no per-user
|
|
135
|
+
keying, so a page that reads `cookies()` / a session / per-user data MUST
|
|
136
|
+
NOT set `revalidate`. The framework also refuses to cache (defense in
|
|
137
|
+
depth) any response that is not a `200`, is a streamed Suspense body,
|
|
138
|
+
sets a non-framework `Set-Cookie` (the framework `webjs_csrf` cookie is
|
|
139
|
+
re-minted per response on a hit and does not block), or runs under CSP
|
|
140
|
+
(its body carries a per-request nonce). A cached page served to a brand
|
|
141
|
+
new visitor still gets a fresh CSRF cookie, so it stays correct.
|
|
142
|
+
|
|
143
|
+
**Framework defense, not just the contract.** When the render reads
|
|
144
|
+
per-user state through a framework helper (`cookies()`, `headers()`,
|
|
145
|
+
`getSession()`, or `auth()`), the framework auto-marks the request
|
|
146
|
+
dynamic and refuses to cache it even if you set `revalidate`, warning you
|
|
147
|
+
once with the page path. So a wrong `revalidate` on a cookie-reading or
|
|
148
|
+
`auth()`-gated page fails safe (served fresh) instead of leaking. A
|
|
149
|
+
saas-dashboard page that does `const session = await auth()` is
|
|
150
|
+
auto-excluded. **The loud caveat:** this only catches reads THROUGH those
|
|
151
|
+
helpers. A page that varies its body by an inbound auth cookie /
|
|
152
|
+
`Authorization` header but reads it RAW (not via `cookies()` /
|
|
153
|
+
`headers()` / `getSession()` / `auth()`) and sets no new `Set-Cookie`
|
|
154
|
+
WILL be cached and served to a logged-out visitor. Read per-user request
|
|
155
|
+
state through the framework helpers (which auto-exclude the page), or
|
|
156
|
+
never set `revalidate` on a per-user page.
|
|
157
|
+
|
|
158
|
+
Evict on a write with `revalidatePath`:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// modules/blog/actions/publish-post.server.ts
|
|
162
|
+
'use server';
|
|
163
|
+
import { revalidatePath } from '@webjsdev/server';
|
|
164
|
+
|
|
165
|
+
export async function publishPost(input) {
|
|
166
|
+
// ... persist via Prisma ...
|
|
167
|
+
await revalidatePath('/blog'); // next /blog request re-renders fresh
|
|
168
|
+
return { success: true };
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`revalidatePath(path)` evicts the SERVER HTML cache for one path;
|
|
173
|
+
`revalidateAll()` clears everything. This is distinct from the
|
|
174
|
+
client-side `revalidate()` from `@webjsdev/core`, which evicts the
|
|
175
|
+
BROWSER snapshot cache used by client navigation. Time-based eviction is
|
|
176
|
+
handled automatically by the store TTL (= the `revalidate` seconds).
|
|
177
|
+
|
|
178
|
+
**Multi-instance note.** `revalidatePath(path)` deletes a store key, so it
|
|
179
|
+
reaches every instance sharing a Redis store. `revalidateAll()` bumps an
|
|
180
|
+
in-process counter, so on a multi-instance deploy it only flushes the
|
|
181
|
+
instance it ran on; peers keep serving until their own TTL expires. For a
|
|
182
|
+
multi-instance (Redis) deploy, prefer a short `revalidate` TTL (the
|
|
183
|
+
time-based floor that always holds cross-instance), use `revalidatePath`
|
|
184
|
+
per mutation as the reliable cross-instance primitive, and treat
|
|
185
|
+
`revalidateAll()` as a single-instance / dev convenience.
|
|
186
|
+
|
|
187
|
+
## Content-hash asset URLs (`?v=<digest>`, immutable caching, prod) (#243)
|
|
188
|
+
|
|
189
|
+
Every served app module (`.js` / `.ts`) and `public/` asset used to ship `Cache-Control: public, max-age=3600` because its URL was un-versioned (the dev.js comment explains why `immutable` is unsafe without a per-file fingerprint, citing a real regression after a core version bump). The importmap build id does NOT change on an app-module byte change, so it cannot be the per-asset fingerprint. In PRODUCTION the framework instead appends a PER-FILE content hash, computed at serve time (no build step).
|
|
190
|
+
|
|
191
|
+
- **Emit (prod only).** `withAssetHash(url)` (in `packages/server/src/asset-hash.js`) appends `?v=<hash>` to a framework-emitted SAME-ORIGIN absolute URL: the importmap targets (`importmap.js` `buildImportMap`), the `<link rel="modulepreload">` hrefs, the boot script's module specifiers + lazy entries (`ssr.js`), AND the 103 Early Hints preloads (`dev.js` `routeFor`, so the hint warms exactly the URL the body requests). The hash is a 12-hex prefix of a sha-256 over the file BYTES, memoized in a `Map<absPath, hash>` and cleared on the fs.watch rebuild (so a changed file re-hashes). It is a NO-OP in dev (so dev output is byte-identical), a NO-OP for a CROSS-ORIGIN URL (a `https://` jspm vendor target, which jspm already versions and whose #235 SRI key is the un-hashed URL, plus already-version-named `/__webjs/vendor/*` bundles), and composes with `withBasePath` (basePath THEN `?v`, so a sub-path app emits `<basePath>/app/foo.js?v=hash`). The framework's own `/__webjs/core/*` runtime is fingerprinted too (it changes across core versions, the exact regression cited).
|
|
192
|
+
- **Nested relative imports in a served module are versioned to match the preload (#369).** A layout/page/component imports its dependencies with a bare relative specifier (`import '../components/x.ts'`). The browser resolves that against the importer's `?v=`-versioned URL, but a `?v` query is NOT inherited across relative resolution, so without intervention it fetches the un-versioned URL: a DIFFERENT cache key from the `?v=`-versioned `modulepreload` hint, which wastes the preload and downloads the module a second time (with the 1h fallback cache instead of `immutable`). So the prod serve path rewrites every same-origin relative / root-absolute static-import specifier in a served module to carry the target's `?v=<hash>` (the same hash its modulepreload href uses), collapsing both onto one immutable URL fetched once. Bare specifiers stay untouched (importmap-resolved, versioned at their target); `.server.*` imports stay bare (served as a stub, never preloaded). A no-op in dev, so dev source is byte-faithful.
|
|
193
|
+
- **App-module body is elision-aware, so the hash folds in the elision verdict.** An app module's SERVED body is not its raw source: the elision pass (#169) strips a side-effect import to a display-only component. That strip is a property of the IMPORTED component's verdict, so a component flipping display-only to interactive changes the importer's served body while its source stays byte-identical. Hashing the source alone would keep the same `?v` and a returning client would hold the stale immutable importer (the now-interactive component never imported, so never hydrated). So a relativized digest of the elidable + inert set is folded into every APP-module hash (`setElisionFingerprint` in `asset-hash.js`, set from `ensureReady`): a verdict flip busts every app module's `?v`. The fingerprint is empty when nothing is elidable, so a no-elision app's hash stays exactly `sha256(bytes)`; core / `public/` files are never elision-transformed, so they hash over their bytes alone.
|
|
194
|
+
- **Serve.** A request carrying a `?v=` query is served `Cache-Control: public, max-age=31536000, immutable` (the pathname, query stripped, resolves the file as today; only the cache header changes). An un-fingerprinted request keeps the 1h fallback. Dev stays `no-cache`.
|
|
195
|
+
- **Deploy-busts (the safety invariant).** A deploy that changes a module's bytes changes its hash, so its emitted URL changes, so a returning client fetches the new URL instead of serving the stale immutable copy. The build id stays a stable per-deploy fingerprint (the internal `importMapHash()` computation excludes the `?v`), so #241's HTML-cache keying is unaffected.
|
|
196
|
+
|
|
197
|
+
See `agent-docs/advanced.md` (content-hash caching + the preconnect hints) and `docs/app/docs/no-build/page.ts`.
|
|
198
|
+
|
|
199
|
+
## Conditional GET (ETag + If-None-Match -> 304, on by default) (#240)
|
|
200
|
+
|
|
201
|
+
Every CACHEABLE response carries a content-hash `ETag`, and a repeat request whose `If-None-Match` matches it gets a `304 Not Modified` with no body (RFC 7232). So a client holding an identical copy revalidates with a tiny 304 instead of re-transferring the whole body. Wired once at the response funnel in `dev.js`'s `handle()` (mechanism: `applyConditionalGet` in `packages/server/src/conditional-get.js`), so it covers SSR HTML pages, static assets in `public/`, app source modules, and the core / vendor runtime modules uniformly.
|
|
202
|
+
|
|
203
|
+
The ETag is WEAK (`W/"..."`). It hashes the UNCOMPRESSED body and the prod compression step reuses it across the identity / gzip / br codings, which a STRONG validator may not do (RFC 7232 2.3.3); `If-None-Match` already weak-compares, so a `304` still fires. The funnel only hashes a body the framework positively marked as buffered. A user `route.{js,ts}` handler returning a `ReadableStream` (and especially an SSE `text/event-stream`, whose stream never ends) carries no such marker, so the funnel never buffers it (no memory blow-up, no hang) and never ETags it.
|
|
204
|
+
|
|
205
|
+
**What gets an ETag + honors 304:**
|
|
206
|
+
|
|
207
|
+
| Response | ETag? | Why |
|
|
208
|
+
|---|---|---|
|
|
209
|
+
| A page with a PUBLIC `metadata.cacheControl` (e.g. `public, max-age=60`) | yes | Explicitly opted into caching; a repeat read 304s |
|
|
210
|
+
| Static assets (`public/*`), app `.js` / `.ts` modules, core / vendor modules | yes | Content-addressable; the ETag is the body hash |
|
|
211
|
+
|
|
212
|
+
**What is EXCLUDED (no ETag, never 304):**
|
|
213
|
+
|
|
214
|
+
| Response | Why excluded |
|
|
215
|
+
|---|---|
|
|
216
|
+
| A `no-store` page (the DEFAULT for dynamic / per-user pages) | Private content must never get a cross-session 304: a shared cache keyed on the URL could replay one user's validator to another |
|
|
217
|
+
| A `private` `Cache-Control` response | Same private-content reasoning |
|
|
218
|
+
| A streamed Suspense response (pending boundaries) | An unflushed stream cannot be hashed cheaply; the SSR pipeline flags it internally and the funnel skips it. Streaming responses are not conditional-GET cached |
|
|
219
|
+
| A `route.{js,ts}` handler returning a `ReadableStream` (incl. an SSE `text/event-stream`) | The body is not marked buffered, so the funnel never reads it. Buffering a stream would blow up memory, and an SSE stream never ends so the read would hang forever |
|
|
220
|
+
| Non-GET / non-HEAD, and any status other than 200 | A validator is only meaningful for a successful, replayable read |
|
|
221
|
+
|
|
222
|
+
**Stable-body handling.** The ETag is computed over the response's OWN body bytes, so an identical body yields an identical ETag across requests. Per-response varying bits that ride RESPONSE HEADERS (the `x-webjs-build` id, the `set-cookie` CSRF token, the CSP nonce on the header) are NOT part of the body hash, so they do not destabilise the ETag. The one body-level varying input is the CSP nonce stamped INTO the inline boot script: with CSP enabled the HTML body changes every request, so its ETag changes every request and a 304 is simply never produced for that page (correct, not a bug). CSP is off by default, so the common cacheable-page case has a stable body and a stable ETag. The 304 preserves the validators and caching headers (`ETag`, `Cache-Control`, `Vary`, plus the framework's `X-Webjs-Build` / `X-Request-Id` and any `Set-Cookie`) and drops only the body-describing headers (`Content-Length`, `Content-Type`, `Content-Encoding`), so a shared cache and the client router behave identically to a 200.
|
|
223
|
+
|
|
224
|
+
## Sessions
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
// middleware.js: enable on all routes
|
|
228
|
+
import { session } from '@webjsdev/server';
|
|
229
|
+
export default session(); // auto: REDIS_URL → server-side, else → cookie
|
|
230
|
+
|
|
231
|
+
// in a page or action
|
|
232
|
+
import { getSession } from '@webjsdev/server';
|
|
233
|
+
const s = getSession(req);
|
|
234
|
+
s.userId = user.id; // auto-saved after response
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Cookie sessions (default): signed + encrypted, no server state. Store
|
|
238
|
+
sessions (with Redis): session ID in cookie, data in Redis. Requires
|
|
239
|
+
`SESSION_SECRET` env var.
|
|
240
|
+
|
|
241
|
+
## Authentication (NextAuth-style)
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
// lib/auth.server.ts
|
|
245
|
+
import { createAuth, Credentials, Google, GitHub } from '@webjsdev/server';
|
|
246
|
+
|
|
247
|
+
export const { auth, signIn, signOut, handlers } = createAuth({
|
|
248
|
+
providers: [
|
|
249
|
+
Credentials({
|
|
250
|
+
async authorize(credentials) {
|
|
251
|
+
const user = await prisma.user.findUnique({ where: { email: credentials.email } });
|
|
252
|
+
if (!user || !verifyPassword(credentials.password, user.passwordHash)) return null;
|
|
253
|
+
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
Google(), // reads AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET
|
|
257
|
+
GitHub(), // reads AUTH_GITHUB_ID, AUTH_GITHUB_SECRET
|
|
258
|
+
],
|
|
259
|
+
secret: process.env.AUTH_SECRET,
|
|
260
|
+
callbacks: {
|
|
261
|
+
async jwt({ token, user }) {
|
|
262
|
+
if (user) { token.sub = user.id; token.role = user.role; }
|
|
263
|
+
return token;
|
|
264
|
+
},
|
|
265
|
+
async session({ session, token }) {
|
|
266
|
+
session.user.id = token.sub;
|
|
267
|
+
session.user.role = token.role;
|
|
268
|
+
return session;
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// in a page or action
|
|
274
|
+
const session = await auth();
|
|
275
|
+
if (!session) throw redirect('/login');
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
JWT sessions by default (stateless, scales horizontally). OAuth providers handle the full redirect flow.
|
|
279
|
+
|
|
280
|
+
## File storage (`FileStore` + `diskStore`)
|
|
281
|
+
|
|
282
|
+
webjs round-trips a native `File` / `Blob` / `FormData` over the wire, and the file-storage primitive decides WHERE the bytes land. The model mirrors the cache / session adapters: a documented `FileStore` interface, a default local-disk adapter (`diskStore`), and a module singleton (`setFileStore` / `getFileStore`) so an app swaps the backend in one call without touching any call site.
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
import { getFileStore, generateKey, signedUrl, verifySignedUrl } from '@webjsdev/server';
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### The `FileStore` interface
|
|
289
|
+
|
|
290
|
+
Every method operates on web-standard objects, so an S3-compatible adapter is a drop-in (see below).
|
|
291
|
+
|
|
292
|
+
| Method | Shape |
|
|
293
|
+
|---|---|
|
|
294
|
+
| `put(key, file, opts?)` | Stream a `File` / `Blob` / `ReadableStream` / `Uint8Array` to storage. Returns `{ key, size, contentType }`. |
|
|
295
|
+
| `get(key)` | Returns `{ body, size, contentType }` (a STREAMING handle) or `null`. The serving route does `new Response(handle.body, { headers })`. |
|
|
296
|
+
| `delete(key)` | Remove the object. Idempotent (a missing key is not an error). |
|
|
297
|
+
| `url(key)` | The served URL (`<baseUrl>/<key>` for `diskStore`). |
|
|
298
|
+
| `has(key)` | Whether the key exists (optional). |
|
|
299
|
+
|
|
300
|
+
`get()` returns a STREAMING handle (`body` is a stream), not a `Blob`, so a serving route streams the file to the client without reading it into memory. The write path is streaming too: `put` pipes `file.stream()` -> `Readable.fromWeb` -> `createWriteStream` via `pipeline`, so a large upload uses constant memory. The upstream body-size cap (#237, `maxMultipartBytes`, default 10 MiB) bounds the upload BEFORE the bytes reach the store; the store does not re-implement that limit, it only stays streaming.
|
|
301
|
+
|
|
302
|
+
### `diskStore` (the default adapter)
|
|
303
|
+
|
|
304
|
+
```js
|
|
305
|
+
import { setFileStore, diskStore } from '@webjsdev/server';
|
|
306
|
+
// Default: <cwd>/.webjs/uploads, served under /uploads. Override at startup:
|
|
307
|
+
setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The default store is a `diskStore` rooted at `<cwd>/.webjs/uploads`. Add the uploads directory to `.gitignore` (it holds user data, not source).
|
|
311
|
+
|
|
312
|
+
### Traversal-safe keys (security guarantee)
|
|
313
|
+
|
|
314
|
+
Every key is resolved to an absolute path under `dir` and REJECTED if it escapes, using the same `resolve` + `startsWith(dir + sep)` containment guard the `/public/*` serve path uses. A key with `..`, an absolute path, a leading slash, a NUL byte, a backslash, or the reserved `.meta` suffix (used for the content-type sidecar) throws (`assertSafeKey`) BEFORE any filesystem operation. Never trust a user-supplied filename as a key; use `generateKey`:
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
const key = generateKey(file.name); // <uuid>.<ext>, opaque + safe
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`generateKey(filename?)` returns a random `crypto.randomUUID()` key, preserving only a whitelisted, sanitized extension from the original filename (a malicious `'../../x.sh'` yields a bare opaque key with no path and no unsafe extension).
|
|
321
|
+
|
|
322
|
+
### Signed URLs (gated serving)
|
|
323
|
+
|
|
324
|
+
`signedUrl` / `verifySignedUrl` mint and verify an expiring HMAC-SHA256 (base64url) signature over the exact key plus its expiry, so a serving route can gate access without a session lookup. Neither the key nor the expiry can be tampered with (both are signed), and the comparison is constant-time.
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
const url = signedUrl(key, { secret: process.env.AUTH_SECRET, expiresIn: 3600 });
|
|
328
|
+
// in the serving route.js:
|
|
329
|
+
const check = verifySignedUrl(new URL(request.url).searchParams, process.env.AUTH_SECRET);
|
|
330
|
+
if (!check.valid) return new Response('Forbidden', { status: 403 });
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
An explicit `expiresIn` of `0` or a negative number fails CLOSED (the minted URL is already expired), so a "no access" intent never silently becomes a 1-hour grant. The 1-hour default applies only when `expiresIn` is omitted.
|
|
334
|
+
|
|
335
|
+
### Serving user uploads safely (content-type XSS)
|
|
336
|
+
|
|
337
|
+
The content-type a store records is the one the BROWSER sent at upload time, so it is ATTACKER-CONTROLLED. A serving route that reflects it inline lets an attacker run script in your origin (stored XSS) by uploading HTML or `image/svg+xml` tagged `text/html` under an innocent-looking key. The serving route MUST send `X-Content-Type-Options: nosniff`, and SHOULD send `Content-Disposition: attachment` for anything a user uploaded (the recipe does both). Only serve a user upload inline when you have validated the bytes server-side and emit a content-type from a strict inert allowlist (`image/png`, `image/jpeg`, ...), never `text/html` / `image/svg+xml`. Serving uploads from a separate cookieless origin is the strongest mitigation. See the recipe for the hardened route.
|
|
338
|
+
|
|
339
|
+
### S3-pluggability (call-site stability)
|
|
340
|
+
|
|
341
|
+
The interface operates on web-standard objects only, so an S3 / R2 / GCS / MinIO adapter is a drop-in: it implements the same `put` (PutObject, streaming the body), `get` (GetObject, returning the SDK's response stream as `body`), `delete` (DeleteObject), and `url` (the object / CDN URL). Because the shape is identical, `setFileStore(s3Store({ ... }))` switches the whole app with no call-site change. webjs ships no S3 SDK (no new dependency); the adapter is a thin wrapper an app provides.
|
|
342
|
+
|
|
343
|
+
See the "Receive and persist an uploaded file" recipe in `agent-docs/recipes.md` for the no-JS `<form>` upload + serving route end to end.
|
|
344
|
+
|
|
345
|
+
## Environment variables
|
|
346
|
+
|
|
347
|
+
| Variable | Effect |
|
|
348
|
+
|---|---|
|
|
349
|
+
| `AUTH_SECRET` | Required for auth JWT signing (32+ random chars) |
|
|
350
|
+
| `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` | Google OAuth (optional) |
|
|
351
|
+
| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | GitHub OAuth (optional) |
|
|
352
|
+
| `SESSION_SECRET` | Cookie session signing |
|
|
353
|
+
| `REDIS_URL` | When set, sessions + rate limit + cache use Redis |
|
|
354
|
+
| `PORT` | Server port (default 8080) |
|
|
355
|
+
|
|
356
|
+
## Scaling to multiple instances
|
|
357
|
+
|
|
358
|
+
Defaults are single-instance (memory stores). For horizontal scaling
|
|
359
|
+
configure Redis explicitly where needed:
|
|
360
|
+
|
|
361
|
+
```js
|
|
362
|
+
import { setStore, redisStore } from '@webjsdev/server';
|
|
363
|
+
setStore(redisStore({ url: process.env.REDIS_URL }));
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
One-time setup. The user decides what scales via Redis and what stays
|
|
367
|
+
in-memory.
|