@webjsdev/cli 0.10.12 → 0.10.13

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.
@@ -1,440 +0,0 @@
1
- # Recipes
2
-
3
- Copy-paste patterns for the most common webjs tasks. Each recipe is the
4
- canonical shape, follow it rather than inventing a variant. The full API
5
- reference lives in the root `AGENTS.md`.
6
-
7
- ## Schema-first: from scaffold to product (do this FIRST)
8
-
9
- A freshly scaffolded app ships an EXAMPLE `User` model, an example
10
- `app/page.ts`, and an example component. They are starting-point references,
11
- not the product. The first thing to do for a real app is replace the example
12
- schema with the real domain models, then build features on top. This is the
13
- transition agents most often get wrong, so it is the first recipe.
14
-
15
- > **Two non-negotiables.** NEVER leave the example `User` model in
16
- > `schema.prisma` if the app does not actually have users (delete or replace
17
- > it). NEVER persist app data in JSON files (`data/todos.json`, `db.json`), in
18
- > a module-scope array or `Map`, or in `localStorage`. Those reset on every
19
- > reload and cannot scale. Every piece of stored data is a Prisma model.
20
-
21
- 1. **Edit `prisma/schema.prisma`** to the real domain. Replace the example
22
- `User` model with the models the app needs.
23
-
24
- ```prisma
25
- // prisma/schema.prisma
26
- model Post {
27
- id String @id @default(cuid())
28
- title String
29
- body String
30
- published Boolean @default(false)
31
- createdAt DateTime @default(now())
32
- }
33
- ```
34
-
35
- 2. **Migrate.** Run the npm script (not the `webjs`/`prisma` binary directly,
36
- so the `predev` / `db:*` hooks fire):
37
-
38
- ```sh
39
- npm run db:migrate -- --name add_post
40
- ```
41
-
42
- This creates the migration, applies it to the dev SQLite database, and
43
- regenerates the Prisma client.
44
-
45
- 3. **Generate one query and one action per operation**, one exported function
46
- per file, named after the file, under the feature module. Reads go in
47
- `queries/`, mutations in `actions/`. Both are `.server.ts` with
48
- `'use server'`, so their browser imports become typed RPC stubs.
49
-
50
- ```ts
51
- // modules/posts/queries/list-posts.server.ts
52
- 'use server';
53
- import { prisma } from '../../../lib/prisma.server.ts';
54
- export async function listPosts() {
55
- return prisma.post.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' } });
56
- }
57
- ```
58
-
59
- ```ts
60
- // modules/posts/actions/create-post.server.ts
61
- 'use server';
62
- import { prisma } from '../../../lib/prisma.server.ts';
63
- export async function createPost(input: { title: string; body: string }) {
64
- const title = String(input?.title || '').trim();
65
- if (!title) return { success: false, error: 'title required', status: 400 };
66
- const post = await prisma.post.create({ data: { title, body: String(input?.body || '') } });
67
- return { success: true, data: post };
68
- }
69
- ```
70
-
71
- 4. **Wire it into a page** by calling the query (the page runs on the server,
72
- so it imports the `.server` query directly and awaits it). Never import
73
- `@prisma/client` into a page; reach the database through the query.
74
-
75
- ```ts
76
- // app/posts/page.ts
77
- import { html } from '@webjsdev/core';
78
- import { listPosts } from '../../modules/posts/queries/list-posts.server.ts';
79
- export default async function Posts() {
80
- const posts = await listPosts();
81
- return html`<ul>${posts.map((p) => html`<li>${p.title}</li>`)}</ul>`;
82
- }
83
- ```
84
-
85
- For the write path, pair `create-post.server.ts` with a `<form>` plus a page
86
- `action` or a `route.ts` POST handler (see the form-mutation recipe below), so
87
- it works without JavaScript and the client router upgrades it automatically.
88
-
89
- ## Add a page
90
-
91
- ```ts
92
- // app/about/page.ts
93
- import { html } from '@webjsdev/core';
94
- export default function About() {
95
- return html`<h1>About</h1>`;
96
- }
97
- ```
98
-
99
- ## Add a dynamic route
100
-
101
- ```ts
102
- // app/users/[id]/page.ts
103
- import { html } from '@webjsdev/core';
104
- export default async function User({ params }: { params: { id: string } }) {
105
- const user = await fetchUser(params.id); // via a server action, never import the DB directly
106
- return html`<h1>${user.name}</h1>`;
107
- }
108
- ```
109
-
110
- ## Add a server action (RPC from a client component)
111
-
112
- ```ts
113
- // modules/users/actions/update-profile.server.ts
114
- 'use server';
115
- import { prisma } from '../../../lib/prisma.server.ts';
116
- export async function updateProfile(input: { name: string }) {
117
- const name = String(input?.name || '').trim();
118
- if (!name) return { success: false, error: 'name required', status: 400 };
119
- const row = await prisma.user.update({ where: { id: me.id }, data: { name } });
120
- return { success: true, data: row };
121
- }
122
- ```
123
-
124
- Call it from a client component via a normal import. The dev server
125
- rewrites the import to a typed RPC stub.
126
-
127
- ## Validate a server action's input once, for both call paths (#245)
128
-
129
- `validateInput(fn, validate)` attaches an input validator that runs
130
- SERVER-SIDE before the action body on EVERY call path (the RPC path a
131
- client component import takes AND the `expose()` REST route if the action
132
- has one). On failure it returns a structured `ActionResult`
133
- (`{ success: false, fieldErrors, status: 422 }`) the client reads as
134
- `result.fieldErrors`. The framework ships no validation library; the
135
- validator is a plain function (or a three-line zod adapter).
136
-
137
- ```ts
138
- // modules/posts/actions/create-post.server.ts
139
- 'use server';
140
- import { validateInput } from '@webjsdev/core';
141
- import { prisma } from '../../../lib/prisma.server.ts';
142
-
143
- export const createPost = validateInput(
144
- // the action body: runs ONLY when validation passes
145
- async (input: { title: string; body: string }) => {
146
- const row = await prisma.post.create({ data: input });
147
- return { success: true, data: row };
148
- },
149
- // the validator: receives the action's FIRST argument
150
- (input) => {
151
- const fieldErrors: Record<string, string> = {};
152
- const title = String(input?.title || '').trim();
153
- if (!title) fieldErrors.title = 'Title is required';
154
- if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short';
155
- if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
156
- return { success: true, data: { title, body: String(input.body) } }; // coerced input
157
- },
158
- );
159
- ```
160
-
161
- Reading the structured failure in a client component is just a property
162
- read on the returned object (an invalid call resolves with the failure
163
- envelope, it does NOT throw):
164
-
165
- ```ts
166
- // components/post-form.ts (browser)
167
- import { createPost } from '../modules/posts/actions/create-post.server.ts';
168
-
169
- const result = await createPost({ title: this.title, body: this.body });
170
- if (!result.success) {
171
- this.errors = result.fieldErrors ?? {}; // { title: 'Title is required', ... }
172
- return;
173
- }
174
- // result.data is the created row
175
- ```
176
-
177
- **Zod adapter (keeps the framework zod-free):** wrap `safeParse` so its
178
- result becomes the contract envelope.
179
-
180
- ```ts
181
- import { z } from 'zod';
182
- const Schema = z.object({ title: z.string().min(1), body: z.string().min(10) });
183
-
184
- export const createPost = validateInput(
185
- async (input) => { /* ... */ },
186
- (i) => {
187
- const r = Schema.safeParse(i);
188
- return r.success
189
- ? { success: true, data: r.data }
190
- : { success: false, fieldErrors: r.error.flatten().fieldErrors };
191
- },
192
- );
193
- ```
194
-
195
- To ALSO expose the action as REST with the SAME validator, pass `validate`
196
- to `expose()` instead of using `validateInput`:
197
- `expose('POST /api/posts', fn, { validate })`. A `{ success: false,
198
- fieldErrors }` return becomes a 422 JSON response there; a validator that
199
- THROWS (the classic `Schema.parse` style) becomes a 400, and a non-envelope
200
- return transforms the input (back-compat).
201
-
202
- ## Add a component
203
-
204
- ```ts
205
- // components/hello-world.ts
206
- import { WebComponent, html } from '@webjsdev/core';
207
- export class HelloWorld extends WebComponent {
208
- render() { return html`<p>Hello!</p>`; }
209
- }
210
- HelloWorld.register('hello-world');
211
- ```
212
-
213
- ## Form mutation with server-side validation (no JS required)
214
-
215
- This is webjs's progressive-enhancement write-path. A `<form method="POST">`
216
- posts to a page `action` that validates on the server, re-renders the page
217
- with field errors on failure (preserving the user's input), and redirects
218
- on success. It works with JS disabled, and the client router upgrades it to
219
- an in-place swap when JS is on, same UI either way. No form library.
220
-
221
- A `page.{js,ts}` may export an `action` next to its default render
222
- function. A non-GET/HEAD submission to the page's own URL runs the action,
223
- wrapped in the page's segment middleware.
224
-
225
- ```ts
226
- // app/contact/page.ts
227
- import { html } from '@webjsdev/core';
228
- import { sendMessage } from '../../modules/contact/actions/send-message.server.ts';
229
-
230
- // Runs only on the server. Receives the already-parsed `formData` plus the
231
- // raw `request`, `params`, `searchParams`, and `url`.
232
- export async function action({ formData }: { formData: FormData }) {
233
- const email = String(formData.get('email') || '').trim();
234
- const body = String(formData.get('body') || '').trim();
235
- const values = { email, body };
236
- const fieldErrors: Record<string, string> = {};
237
- if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
238
- if (body.length < 10) fieldErrors.body = 'Message is too short';
239
- if (Object.keys(fieldErrors).length) {
240
- return { success: false, fieldErrors, values, status: 422 };
241
- }
242
- await sendMessage({ email, body });
243
- return { success: true, redirect: '/contact/thanks' };
244
- }
245
-
246
- export default function Contact({ actionData }: {
247
- actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
248
- }) {
249
- const errors = actionData?.fieldErrors || {};
250
- const values = actionData?.values || {};
251
- return html`
252
- <form method="POST" class="flex flex-col gap-3">
253
- <input name="email" type="email" value=${values.email || ''} required>
254
- ${errors.email ? html`<p class="text-sm text-red-600">${errors.email}</p>` : ''}
255
- <textarea name="body" required>${values.body || ''}</textarea>
256
- ${errors.body ? html`<p class="text-sm text-red-600">${errors.body}</p>` : ''}
257
- <button type="submit">Send</button>
258
- </form>
259
- `;
260
- }
261
- ```
262
-
263
- ### How the result is interpreted (server side)
264
-
265
- | Action outcome | HTTP response |
266
- |---|---|
267
- | success result (see the failure rule below) | `303 See Other` to a same-site `redirect` if present, else the page's own path (Post/Redirect/Get) |
268
- | thrown `redirect('/x')` | `307`/`308` (keeps the status `redirect()` was called with) |
269
- | thrown `notFound()` | `404` rendered via `not-found.{js,ts}` |
270
- | failure result (`success: false`, or `fieldErrors`, or an `error`) | re-SSR the SAME page with `status` (default `422`) and the result on `ctx.actionData` |
271
-
272
- **Failure detection is robust.** A result is treated as a FAILURE (re-render)
273
- when ANY of these hold, so an error is never swallowed just because the author
274
- omitted a literal `success: false`:
275
-
276
- - `result.success === false`, OR
277
- - `result.fieldErrors` is present, OR
278
- - `result.error` is present AND `result.success !== true`.
279
-
280
- Everything else is a success (explicit `success: true`, or a bare value /
281
- `undefined` / `null` with no error markers), which PRG-redirects.
282
-
283
- **`result.redirect` must be a same-site local path.** It is honored only when
284
- it begins with a single `/` (a relative path like `/login` or `/a?b=1#c`). A
285
- protocol-relative `//host` and any absolute `scheme://host` URL are rejected and
286
- the redirect falls back to the page's own path, because a user-controlled
287
- redirect target is an open-redirect vector. For a legitimate EXTERNAL redirect,
288
- throw `redirect(absoluteUrl)` (the nav sentinel, author-controlled) instead of
289
- returning it as `result.redirect`.
290
-
291
- ### The `ActionResult` shape
292
-
293
- The envelope is additive over the existing `{ success, data, error, status }`:
294
-
295
- ```ts
296
- type ActionResult<T> =
297
- | { success: true; data?: T; redirect?: string } // redirect MUST be a same-site local path
298
- | {
299
- success: false;
300
- error?: string;
301
- fieldErrors?: Record<string, string>; // per-field messages, keyed by input `name`
302
- values?: Record<string, string>; // the submitted values (text fields), to repopulate inputs
303
- status?: number; // defaults to 422 on the re-render
304
- };
305
- ```
306
-
307
- The page reads `ctx.actionData?.fieldErrors?.<name>` for the message and
308
- `ctx.actionData?.values?.<name>` to set a native `value=`. On a plain GET
309
- render `actionData` is `undefined`, so the page renders empty inputs and no
310
- error blocks. (`values` carries text fields as strings; for a file upload see
311
- the "Receive and persist an uploaded file" recipe below.)
312
-
313
- ### Why no `fetch` in a `@click` handler here
314
-
315
- Native `<input value=...>` repopulation plus the browser's Constraint
316
- Validation API (`required`, `type="email"`, `minlength`) cover the input
317
- side, and the server action result carries the field-level errors. Reaching
318
- for `fetch` + a JS submit handler would break the no-JS baseline. Use a
319
- `<form>` + a page `action` for any write-path that a form can express.
320
-
321
- See `agent-docs/advanced.md` for the client-router side (how the enhanced
322
- 303/422 swap works) and the rest of the form-submission behavior.
323
-
324
- ## Receive and persist an uploaded file
325
-
326
- A file upload is just a `<form enctype="multipart/form-data">` posting to a page
327
- `action`. With JS disabled it is a native round-trip; with JS the client router
328
- upgrades it in place. No upload library, no `fetch`. The bytes are STREAMED to
329
- storage via the file-storage primitive (`getFileStore()`), never buffered whole,
330
- and a `route.{js,ts}` serves them back through a signed URL.
331
-
332
- ```ts
333
- // app/avatar/page.ts
334
- import { html } from '@webjsdev/core';
335
- import { saveAvatar } from '../../modules/avatar/actions/save-avatar.server.ts';
336
-
337
- export async function action({ formData }: { formData: FormData }) {
338
- const file = formData.get('avatar'); // a web `File`
339
- if (!(file instanceof File) || file.size === 0) {
340
- return { success: false, fieldErrors: { avatar: 'Choose an image' }, status: 422 };
341
- }
342
- const result = await saveAvatar(file); // persists + returns the key
343
- if (!result.success) return result;
344
- return { success: true, redirect: '/avatar' };
345
- }
346
-
347
- export default function Avatar({ actionData }: {
348
- actionData?: { fieldErrors?: Record<string, string> };
349
- }) {
350
- const errors = actionData?.fieldErrors || {};
351
- return html`
352
- <form method="POST" enctype="multipart/form-data" class="flex flex-col gap-3">
353
- <input name="avatar" type="file" accept="image/*" required>
354
- ${errors.avatar ? html`<p class="text-sm text-red-600">${errors.avatar}</p>` : ''}
355
- <button type="submit">Upload</button>
356
- </form>
357
- `;
358
- }
359
- ```
360
-
361
- The action delegates to a `.server` action that streams the file to storage with
362
- a generated, traversal-safe key and persists that key on the DB row. Never use
363
- the user-supplied filename as a key; `generateKey` makes an opaque one.
364
-
365
- ```ts
366
- // modules/avatar/actions/save-avatar.server.ts
367
- 'use server';
368
- import { getFileStore, generateKey } from '@webjsdev/server';
369
- import { prisma } from '../../../lib/prisma.server.ts';
370
-
371
- export async function saveAvatar(file: File) {
372
- const key = generateKey(file.name); // <uuid>.<ext>, safe
373
- const { size, contentType } = await getFileStore().put(key, file); // streams to disk
374
- if (size > 5 * 1024 * 1024) { // app-level policy check
375
- await getFileStore().delete(key);
376
- return { success: false, fieldErrors: { avatar: 'Max 5 MB' }, status: 422 };
377
- }
378
- await prisma.user.update({ where: { id: 'me' }, data: { avatarKey: key } });
379
- return { success: true, data: { key, contentType } };
380
- }
381
- ```
382
-
383
- Serve the stored file from a `route.{js,ts}`, streaming `get(key)` and (optionally)
384
- gating it behind a signed URL so the object is not world-readable by key alone.
385
-
386
- ```ts
387
- // app/files/[key]/route.ts
388
- import { getFileStore, verifySignedUrl } from '@webjsdev/server';
389
-
390
- export async function GET(request: Request, { params }: { params: { key: string } }) {
391
- const check = verifySignedUrl(new URL(request.url).searchParams, process.env.AUTH_SECRET!);
392
- if (!check.valid || check.key !== params.key) {
393
- return new Response('Forbidden', { status: 403 });
394
- }
395
- const handle = await getFileStore().get(params.key);
396
- if (!handle) return new Response('Not Found', { status: 404 });
397
- return new Response(handle.body, { // streams; never reads the file into memory
398
- headers: {
399
- 'content-type': handle.contentType,
400
- 'content-length': String(handle.size),
401
- // SECURITY (do NOT drop these for user-uploaded bytes). The stored
402
- // content-type came from the UPLOAD, which is client-controlled, so an
403
- // attacker can upload HTML/SVG tagged `text/html` under an innocent key.
404
- // `nosniff` stops the browser MIME-sniffing it into HTML, and
405
- // `attachment` forces a download instead of rendering it in your origin,
406
- // which is what turns an upload into stored XSS.
407
- 'x-content-type-options': 'nosniff',
408
- 'content-disposition': 'attachment',
409
- },
410
- });
411
- }
412
- ```
413
-
414
- Mint the signed URL where you render the link (a page or component):
415
-
416
- ```ts
417
- import { signedUrl } from '@webjsdev/server';
418
- const href = signedUrl(user.avatarKey, { secret: process.env.AUTH_SECRET!, expiresIn: 3600 });
419
- ```
420
-
421
- > **Serving user uploads safely (the canonical upload vulnerability).** The
422
- > content-type a store records is the one the BROWSER sent at upload time, so it
423
- > is attacker-controlled. Serving it inline lets an attacker run script in your
424
- > origin (stored XSS) via an HTML or `image/svg+xml` payload under an innocent
425
- > key. ALWAYS send `X-Content-Type-Options: nosniff`, and prefer
426
- > `Content-Disposition: attachment` for anything a user uploaded. Only serve a
427
- > user upload INLINE (no `attachment`) when you have validated the bytes
428
- > server-side and are emitting a content-type from a strict inert allowlist
429
- > (e.g. `image/png`, `image/jpeg`), never reflecting `text/html` or
430
- > `image/svg+xml`. Best of all, serve user uploads from a SEPARATE origin / cookieless
431
- > subdomain so even a sniffing bypass cannot reach your session.
432
-
433
- For a public asset you control, you may drop the signature and serve
434
- `getFileStore().get(key)` directly, but keep `nosniff` + `attachment` for
435
- anything a user supplied. To point storage at a custom directory or an
436
- S3-compatible backend, call `setFileStore(diskStore({ dir, baseUrl }))` (or a
437
- custom adapter) once at startup; the call sites above do not change. The default
438
- uploads directory is `<cwd>/.webjs/uploads`, which the app should `.gitignore`.
439
- See the "File storage" section in `agent-docs/built-ins.md` for the full
440
- interface and the traversal-safety + signed-URL guarantees.
@@ -1,100 +0,0 @@
1
- # Service worker / offline primitive (opt-in, #271)
2
-
3
- webjs ships a hand-authored service worker and an offline fallback into the UI
4
- scaffolds (`public/sw.js`, `public/offline.html`; the full-stack and saas
5
- templates, since the api template has no UI). It adds an offline experience
6
- and an asset cache **without changing the JavaScript-disabled baseline**: the
7
- worker only ever registers from JavaScript, so with JS off no worker exists and
8
- pages, links, and forms behave exactly as before. It is **opt-in**: the files
9
- ship dormant and do nothing until the app registers the worker.
10
-
11
- This is a thin, hand-readable worker built directly on the native Service
12
- Worker and Cache Storage APIs. There is no Workbox, no precache framework, and
13
- no bundler step, matching webjs's no-build, close-to-web-standards posture.
14
-
15
- ## Enabling it (the opt-in registration snippet)
16
-
17
- Add this inline script to the root layout's `<head>` (`app/layout.{js,ts}`). It
18
- registers the worker after load, and only when JS is present, so it is
19
- progressive-enhancement-safe:
20
-
21
- ```html
22
- <script>
23
- if ('serviceWorker' in navigator) {
24
- addEventListener('load', () => {
25
- // Tie the worker version to the deploy: read the importmap build id and
26
- // register /sw.js?v=<build>, so a new deploy registers a "new" worker.
27
- const tag = document.querySelector('script[type="importmap"]');
28
- const build = (tag && tag.dataset.webjsBuild) || '';
29
- navigator.serviceWorker.register('/sw.js' + (build ? '?v=' + build : ''));
30
- });
31
- }
32
- </script>
33
- ```
34
-
35
- With webjs's CSP enabled (`webjs.csp` in package.json), stamp the nonce on the
36
- script. Read it in the layout with `import { cspNonce } from '@webjsdev/core'`
37
- and emit `<script nonce="${cspNonce()}">…`.
38
-
39
- ## What the worker does (scope + strategy)
40
-
41
- Registered from `/sw.js`, the worker's scope is the site root (`/`), so it sees
42
- every navigation and same-origin asset request.
43
-
44
- - **Navigations are network-first.** The worker always tries the network first,
45
- so the user sees fresh server-rendered HTML, and caches each successful page
46
- (the SSR shell). When the network fails, it serves the cached page if you have
47
- visited it, otherwise `/offline.html`. Network-first means the cache never
48
- makes a page go stale; it is purely an offline safety net.
49
- - **Static assets are stale-while-revalidate.** Same-origin modules (the per-file
50
- ESM the no-build runtime serves), the framework runtime under `/__webjs/core/`,
51
- vendor bundles under `/__webjs/vendor/`, and `public/` assets are served from
52
- cache when present and refreshed in the background. In production these URLs
53
- carry a `?v=<hash>` content fingerprint (#243), so a changed file gets a new
54
- URL and the cache can never serve stale bytes.
55
-
56
- **Never cached:** non-GET requests (writes), cross-origin requests, the action
57
- RPC endpoint (`/__webjs/action/`), and the dev-only `/__webjs/events` (SSE) and
58
- `/__webjs/reload.js`.
59
-
60
- ## Versioning ties to the deploy (`data-webjs-build`)
61
-
62
- The cache name is `webjs-<build>`, where `<build>` is the `?v=` query the
63
- registration passes (the importmap build id read from
64
- `data-webjs-build`). When a deploy changes the build id:
65
-
66
- 1. the page registers `/sw.js?v=<new-build>`, a different worker URL, so the
67
- browser fetches and installs the new worker;
68
- 2. the new worker's `activate` deletes every cache whose name is not the current
69
- `webjs-<new-build>`, evicting the prior deploy's cache.
70
-
71
- So a deploy refreshes the offline cache automatically, with no manual cache
72
- busting. Without a `?v=` (e.g. a dev registration), the cache name is
73
- `webjs-dev`.
74
-
75
- ## Updating the worker itself
76
-
77
- The browser re-checks `/sw.js` on navigation and replaces the worker when its
78
- bytes change. Because the registration URL carries the build id, a deploy always
79
- changes that URL and triggers the update. The worker calls `skipWaiting()` +
80
- `clients.claim()`, so a new version takes control promptly. To change the
81
- caching strategy, edit `public/sw.js`; it is your file, not a framework
82
- internal.
83
-
84
- ## Removing it
85
-
86
- Delete the registration snippet (and optionally `public/sw.js` /
87
- `public/offline.html`). To also un-register an already-installed worker on
88
- clients, ship a one-line `navigator.serviceWorker.getRegistrations().then(rs =>
89
- rs.forEach(r => r.unregister()))` for a release, or rely on the worker's own
90
- update lifecycle.
91
-
92
- ## Tests
93
-
94
- `test/service-worker/sw.test.mjs` runs the REAL `public/sw.js` source in a
95
- `node:vm` sandbox with mocked service-worker globals and drives its handlers:
96
- the install precache, the activate cache eviction, the network-first navigation
97
- (fresh + cached), the offline-cached + offline-fallback paths, and the
98
- never-cache rules (non-GET, cross-origin, the RPC endpoint), plus
99
- stale-while-revalidate. `test/scaffolds/scaffold-integration.test.js` asserts
100
- both files ship into a scaffolded app.