@webjsdev/cli 0.10.9 → 0.10.10

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/create.js CHANGED
@@ -636,6 +636,15 @@ export type ActionResult<T> =
636
636
  if (existsSync(tailwindSrc)) {
637
637
  await cp(tailwindSrc, join(publicDir, 'tailwind-browser.js'));
638
638
  }
639
+ // Progressive-enhancement service worker (#271): ship the opt-in offline
640
+ // primitive (the worker + its offline fallback) into the UI scaffolds
641
+ // (full-stack / saas; this block is api-excluded since api has no UI).
642
+ // Dormant until the app registers it (see agent-docs/service-worker.md);
643
+ // it never changes the JS-disabled baseline.
644
+ for (const swFile of ['sw.js', 'offline.html']) {
645
+ const swSrc = join(TEMPLATES, 'public', swFile);
646
+ if (existsSync(swSrc)) await cp(swSrc, join(publicDir, swFile));
647
+ }
639
648
 
640
649
  const utilsDir = join(appDir, 'lib', 'utils');
641
650
  await mkdir(utilsDir, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.9",
3
+ "version": "0.10.10",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -1,24 +1,32 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # PreToolUse hook (scaffolded by `webjs create`): block a `git commit`
3
+ # PreToolUse hook (scaffolded by `webjs create`): WARN on a `git commit`
4
4
  # that adds or changes application code without any accompanying test.
5
5
  #
6
- # webjs is AI-first: most apps are built with an AI agent, and the
7
- # easiest corner to cut is shipping a feature with no test. This gate
8
- # makes "every change ships with a test" a hard floor, not a suggestion.
6
+ # webjs is AI-first, and "every change ships with a test" is the right
7
+ # default. But it is a CONVENTION, not a correctness check: a sensible
8
+ # app can legitimately want a test-less commit (a spike, a vendored
9
+ # file, a pure refactor). The convention-vs-check principle in this
10
+ # app's AGENTS.md and CONVENTIONS.md says guidance like this WARNS, it
11
+ # does not hard-block by default. So this hook surfaces a loud reminder
12
+ # and lets the commit proceed.
9
13
  #
10
14
  # What a hook CANNOT do: judge WHICH test layer a change needs (a unit
11
- # test vs a browser/e2e test is a judgement call). So it enforces the
12
- # floor (some real test must accompany app code) and reminds you to add
13
- # browser/e2e coverage for interactive surfaces. `webjs test` runs the
14
- # actual suite in the commit hook.
15
+ # test vs a browser/e2e test is a judgement call). So it nudges toward
16
+ # the floor (some real test should accompany app code) and reminds you
17
+ # to add browser/e2e coverage for interactive surfaces. The actual test
18
+ # suite runs in CI (.github/workflows/ci.yml), which is the real gate.
15
19
  #
16
20
  # Scope: fires only on `git commit`. Inspects the STAGED diff.
17
21
  #
18
- # Blocks (exit 2) when the staged diff changes app code (app/, modules/,
19
- # components/, lib/) but stages no test (test/** or *.test.* / *.spec.*).
20
- # Allowed: commits with no app-code change, commits that stage a test
21
- # alongside, and WEBJS_NO_TEST_GATE=1 for a genuine non-code commit.
22
+ # Behavior when the staged diff changes app code (app/, modules/,
23
+ # components/, lib/) but stages no test (test/** or *.test.* / *.spec.*):
24
+ # - Default: WARN via additionalContext, then allow the commit (exit 0).
25
+ # - WEBJS_TEST_GATE=block: restore the old hard floor (print BLOCKED,
26
+ # exit 2), for a project that wants the strict gate. Set it in
27
+ # .claude/settings.json env, your shell, or CI.
28
+ # - WEBJS_NO_TEST_GATE=1: skip entirely (no warn, no block), for a
29
+ # genuine non-code commit (docs, config).
22
30
  #
23
31
  # Bypass (humans, emergencies): git commit --no-verify.
24
32
 
@@ -52,7 +60,9 @@ test_staged=$(printf '%s\n' "$staged" \
52
60
  | grep -E '(^|/)test/|\.test\.[mc]?[jt]sx?$|\.spec\.[mc]?[jt]sx?$' || true)
53
61
 
54
62
  if [ -z "$test_staged" ]; then
55
- cat >&2 <<'EOF'
63
+ # Hard-mode opt-in: restore the old block when the project asks for it.
64
+ if [ "${WEBJS_TEST_GATE:-}" = "block" ] || [ "${WEBJS_TEST_GATE:-}" = "hard" ]; then
65
+ cat >&2 <<'EOF'
56
66
  BLOCKED: this commit changes app code but stages no test.
57
67
 
58
68
  You staged application code (app/, modules/, components/, lib/) with no
@@ -66,11 +76,21 @@ Pick the layer the change needs (a unit test is not always enough):
66
76
  real behaviour in a browser, not just the function in isolation.
67
77
 
68
78
  See `webjs test` and the testing guide. Genuine non-code commit (docs,
69
- config) that needs no test? Re-run with WEBJS_NO_TEST_GATE=1.
79
+ config) that needs no test? Re-run with WEBJS_NO_TEST_GATE=1. Hard mode is
80
+ on because WEBJS_TEST_GATE=block is set; unset it to fall back to a warning.
70
81
 
71
82
  Hook: .claude/hooks/require-tests-with-src.sh
72
83
  EOF
73
- exit 2
84
+ exit 2
85
+ fi
86
+
87
+ # Default: warn loudly via additionalContext, then allow the commit.
88
+ # A missing test for app code subsumes the interactive-component
89
+ # reminder, so emit this warning alone and skip that reminder below.
90
+ jq -n --arg ctx "Heads up: this commit stages app code (app/, modules/, components/, lib/) with no test. Every change should ship with a test (it is a convention, not a hard gate). Pick the layer the change needs: a unit test for logic/actions/queries/utils, and a browser or e2e test for a component, hydration, the client router, or a server action called from the client. The suite runs in CI regardless. To enforce a hard block locally, set WEBJS_TEST_GATE=block. To silence this for a genuine non-code commit, set WEBJS_NO_TEST_GATE=1." '{
91
+ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $ctx }
92
+ }'
93
+ exit 0
74
94
  fi
75
95
 
76
96
  # Reminder for interactive surfaces: a unit test alone rarely covers them.
@@ -765,12 +765,116 @@ return html`
765
765
 
766
766
  The router's `closest('webjs-frame')` detection takes precedence over
767
767
  layout markers. Only the frame's content swaps. Use this sparingly,
768
- folder-based layouts handle 99% of cases. When a frame nav's response
769
- lacks the matching `<webjs-frame id>` (e.g. an auth redirect), the router
770
- fires a cancelable, bubbling `webjs:frame-missing` event (detail
771
- `{ frameId, url, document }`) and leaves the frame unchanged rather than
772
- silently swapping the whole page; call `preventDefault()` to take over
773
- the outcome (e.g. `location.assign(e.detail.url)`).
768
+ folder-based layouts handle 99% of cases.
769
+
770
+ **External targeting + `_top` (Turbo-style).** A trigger does not have to be
771
+ nested in the frame it drives. An `<a>` or `<form>` (or any ancestor)
772
+ carrying `data-webjs-frame="<id>"` drives the frame with that id from
773
+ anywhere (an external sidebar/nav link, a filter form), resolved via
774
+ `getElementById`. The reserved token `data-webjs-frame="_top"` on a trigger
775
+ INSIDE a frame breaks OUT to a full-page navigation. An id that does not
776
+ resolve to a live `<webjs-frame>` warns once and falls back to a normal nav
777
+ (never throws). With JS disabled a `data-webjs-frame` link is an inert
778
+ attribute on a plain `<a href>`, so the click is a normal full navigation.
779
+
780
+ **Busy state.** While a frame nav is in flight the router sets the native
781
+ `aria-busy="true"` on the frame (cleared to `"false"` on any exit: success,
782
+ error, abort, or a missing frame), so AT announces it and CSS can style
783
+ `webjs-frame[aria-busy="true"]`. It also dispatches a bubbling
784
+ `webjs:frame-busy` event on the frame at start and finish (detail
785
+ `{ frameId, busy }`).
786
+
787
+ **Self-loading (`src` + `loading`).** A frame can fetch its OWN content:
788
+ `<webjs-frame id="comments" src="/posts/42/comments" loading="lazy">` self-fetches
789
+ that URL as a frame nav and applies the matching `<webjs-frame id>` subtree into
790
+ itself, through the same frame-swap path (so the busy lifecycle + navigation-error
791
+ recovery + frame-missing fallback all apply). `loading="eager"` (or absent)
792
+ fetches on connect; `loading="lazy"` fetches on viewport entry. The request sends
793
+ the `x-webjs-frame` header, so the SERVER returns ONLY the matched subtree (not
794
+ the full page), falling back to the full page when the frame is absent. A `src` is
795
+ JS-DEPENDENT (the browser does not natively fetch a `<webjs-frame src>`), so with
796
+ JS off the frame shows only the children rendered into it; use it for DEFERRED
797
+ content (comments, a recommendations rail) where a no-JS placeholder is fine, and
798
+ render content server-side into the frame when it must exist without JS.
799
+
800
+ **View Transitions + persistent elements (opt-in).** Add
801
+ `<meta name="view-transition" content="same-origin">` to the page head and the
802
+ router wraps every swap (the layout-marker swap, the `<webjs-frame>` swap, and
803
+ the full-body fallback) in `document.startViewTransition` for an animated
804
+ crossfade. OFF by default (no animation surprise); a browser without the API
805
+ falls back to the identical synchronous swap. To keep a live element running
806
+ across a navigation (a playing `<audio>` / `<video>`, a map, a stateful
807
+ widget), mark it `data-webjs-permanent` AND give it an `id`: the router keeps
808
+ the SAME DOM node by identity across the swap instead of recreating it (Turbo's
809
+ permanent-element behaviour). Inert with JS off.
810
+
811
+ When a frame nav's response lacks the matching `<webjs-frame id>` (e.g. an
812
+ auth redirect), the router fires a cancelable, bubbling `webjs:frame-missing`
813
+ event (detail `{ frameId, url, document }`) and leaves the frame unchanged
814
+ rather than silently swapping the whole page; call `preventDefault()` to take
815
+ over the outcome (e.g. `location.assign(e.detail.url)`).
816
+
817
+ ### 5. Stream actions for surgical element-level updates
818
+
819
+ When a region swap is too coarse (append ONE comment, remove ONE row, bump a
820
+ count, insert a toast), a server response can declare per-element actions as
821
+ plain HTML, a `<webjs-stream action target>` wrapping one `<template>`:
822
+
823
+ ```html
824
+ <webjs-stream action="append" target="comments">
825
+ <template><li>Nice post!</li></template>
826
+ </webjs-stream>
827
+ ```
828
+
829
+ Actions (Turbo's set): `append` / `prepend` (last / first child of the target
830
+ id), `before` / `after` (sibling), `replace` (the target element), `update`
831
+ (its children), `remove` (delete it). The `<webjs-stream>` element self-applies
832
+ on connect and removes itself. ONE applier serves two paths:
833
+
834
+ - **A content-negotiated `<form>`.** The router adds `Accept:
835
+ text/vnd.webjs-stream.html` on a JS-driven submission, so the server returns a
836
+ stream only then (apply it surgically) and a JS-OFF form gets a normal
837
+ render/redirect. Additive and progressive-enhancement-safe.
838
+ - **A live channel.** `renderStream(message)` from a `connectWS` handler applies
839
+ a `broadcast()`ed payload, so chat / notifications reuse the same applier.
840
+
841
+ Build the payload server-side and apply it client-side:
842
+
843
+ ```ts
844
+ // app/posts/[id]/route.ts
845
+ import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
846
+ export async function POST(req: Request, { params }) {
847
+ const c = await addComment(params.id, await req.formData());
848
+ const html = stream.append('comments', `<li>${escapeHtml(c.text)}</li>`);
849
+ broadcast(`post:${params.id}`, html); // fan out to other viewers
850
+ if (acceptsStream(req)) return streamResponse(html); // JS client: surgical
851
+ return Response.redirect(`/posts/${params.id}`, 303); // no-JS: normal render
852
+ }
853
+ ```
854
+
855
+ ```ts
856
+ // a component, for the live channel
857
+ import { connectWS, renderStream } from '@webjsdev/core';
858
+ connectWS(`/posts/${id}/feed`, { onMessage: (m) => renderStream(m) });
859
+ ```
860
+
861
+ `stream.*` escapes the target id but NOT the content (server-authored HTML, like
862
+ an `html` hole, so escape any user substring yourself). `renderStream` is
863
+ auto-registered by the client router.
864
+
865
+ **Failed navigations recover in place, never a destructive full reload.** A
866
+ successful swap and an HTML error body of any status (e.g. a `422` re-rendered
867
+ form) both apply in place. For the remaining failure cases (a non-HTML error
868
+ response like a `500` with a JSON body, or a transport/parse failure) the
869
+ router fires a cancelable, bubbling `webjs:navigation-error` event on
870
+ `document` (detail `{ url, status, error }`, where `status` is the HTTP status
871
+ or `null`, and `error` is the `Error` or `null`). `preventDefault()` hands
872
+ recovery to you and leaves the page exactly as it is (shell, scroll, focus,
873
+ client state preserved); otherwise the router renders a minimal in-place
874
+ `<div role="alert">` into the deepest layout children slot (outer chrome
875
+ preserved), only hard-loading as a last resort when there is no shared layout
876
+ marker. An AbortError (a superseding nav) is a normal supersede and never fires
877
+ the event.
774
878
 
775
879
  ### 5. `loading.ts` for per-segment skeletons
776
880
 
@@ -800,6 +904,32 @@ default export, scoped to that boundary (outer layouts stay alive).
800
904
 
801
905
  Full reference: see the [Client Router docs](https://docs.webjs.dev/docs/client-router) and the framework AGENTS.md "Client navigation" section.
802
906
 
907
+ ## Offline support (opt-in service worker)
908
+
909
+ The UI scaffolds (full-stack and saas) ship a progressive-enhancement service
910
+ worker at `public/sw.js` plus a `public/offline.html` fallback (the api template
911
+ has no UI, so it omits them). They are **dormant until you register them**, so
912
+ the JS-disabled baseline is unchanged. To enable offline support, add the opt-in
913
+ registration snippet to the root layout `<head>`:
914
+
915
+ ```html
916
+ <script>
917
+ if ('serviceWorker' in navigator) {
918
+ addEventListener('load', () => {
919
+ const tag = document.querySelector('script[type="importmap"]');
920
+ const build = (tag && tag.dataset.webjsBuild) || '';
921
+ navigator.serviceWorker.register('/sw.js' + (build ? '?v=' + build : ''));
922
+ });
923
+ }
924
+ </script>
925
+ ```
926
+
927
+ Navigations become network-first (fresh server HTML, with an offline fallback to
928
+ a cached page or `/offline.html`); same-origin assets are stale-while-revalidate.
929
+ The cache version ties to the deploy via the `?v=<build>` id, so a new deploy
930
+ evicts the old cache automatically. `sw.js` is YOUR file, so edit the strategy as
931
+ needed. Full reference: `agent-docs/service-worker.md`.
932
+
803
933
  ## Metadata (per-page)
804
934
 
805
935
  The `metadata` export is Next.js-compatible. Common fields shown below;
@@ -963,13 +1093,16 @@ composition, so a nested shell ends up dropped by the HTML parser.
963
1093
  feature surface changed, `webjs check` passing. A unit test is not
964
1094
  always enough: a component, hydration, the client router, or a server
965
1095
  action called from the client needs a browser test
966
- (`webjs test --browser`) asserting the behaviour in a real browser. A
967
- commit that stages app code (`app/`, `modules/`, `components/`, `lib/`)
968
- with no test is blocked for Claude Code by
969
- `.claude/hooks/require-tests-with-src.sh`. The test suite itself runs in
970
- CI (`.github/workflows/ci.yml`), not in the pre-commit hook, so `git
971
- commit` stays fast and the gate cannot be skipped with a local
972
- `--no-verify`.
1096
+ (`webjs test --browser`) asserting the behaviour in a real browser. For
1097
+ Claude Code, a commit that stages app code (`app/`, `modules/`,
1098
+ `components/`, `lib/`) with no test WARNS via
1099
+ `.claude/hooks/require-tests-with-src.sh` (every change should still ship
1100
+ with a test, but that is a convention, not a hard gate). A project that
1101
+ wants the strict floor opts into a hard block by setting
1102
+ `WEBJS_TEST_GATE=block` (in `.claude/settings.json` env, your shell, or
1103
+ CI). The real enforcement is CI: the test suite runs in
1104
+ `.github/workflows/ci.yml`, not in the pre-commit hook, so `git commit`
1105
+ stays fast and the gate cannot be skipped with a local `--no-verify`.
973
1106
  3. Commit and push **per logical unit**, not at the end. A logical unit is one
974
1107
  feature, one fix, one rename, one doc rewrite. If you have 5+ unstaged files
975
1108
  spanning different concerns, commit the current group before continuing.
@@ -494,14 +494,19 @@ This is also why the auth test lives at `test/auth/auth.test.ts` (the
494
494
  feature-folder convention), NOT `test/unit/auth.test.ts`. Test KIND is a
495
495
  subfolder inside a feature, never the top level.
496
496
 
497
- **Every change ships with a test.** For Claude Code, a commit that
498
- stages app code (`app/`, `modules/`, `components/`, `lib/`) without
499
- staging a test is blocked by `.claude/hooks/require-tests-with-src.sh`.
500
- A unit test alone is not enough for interactive or component code: add
501
- the browser test that asserts the rendered/hydrated behaviour. The test
502
- suite itself runs in CI (`.github/workflows/ci.yml`) on every PR and
503
- push to main, not in the local pre-commit hook, so `git commit` stays
504
- fast and the gate cannot be skipped with a local `--no-verify`.
497
+ **Every change ships with a test.** This is a convention, not a hard
498
+ gate, consistent with the convention-vs-check principle this file
499
+ states (a sensible app can legitimately want a test-less commit for a
500
+ spike, a vendored file, or a pure refactor). For Claude Code, a commit
501
+ that stages app code (`app/`, `modules/`, `components/`, `lib/`) without
502
+ staging a test WARNS via `.claude/hooks/require-tests-with-src.sh`, then
503
+ lets the commit through. A project that wants the strict floor opts into
504
+ a hard block by setting `WEBJS_TEST_GATE=block` (in
505
+ `.claude/settings.json` env, your shell, or CI). A unit test alone is
506
+ not enough for interactive or component code: add the browser test that
507
+ asserts the rendered/hydrated behaviour. The real enforcement is CI: the
508
+ test suite runs in `.github/workflows/ci.yml` on every PR and push to
509
+ main, so the gate cannot be skipped with a local `--no-verify`.
505
510
 
506
511
  ### Choosing a feature folder
507
512
 
@@ -0,0 +1,34 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Offline</title>
7
+ <style>
8
+ :root { color-scheme: light dark; }
9
+ body {
10
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
11
+ font: 16px/1.6 system-ui, sans-serif; background: #fafafa; color: #1a1a1a;
12
+ }
13
+ @media (prefers-color-scheme: dark) { body { background: #0d0d10; color: #e6e6e6; } }
14
+ main { max-width: 28rem; padding: 2rem; text-align: center; }
15
+ h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
16
+ p { margin: 0 0 1.5rem; opacity: 0.8; }
17
+ .retry {
18
+ display: inline-block; font: inherit; padding: 0.6rem 1.2rem;
19
+ border-radius: 0.5rem; background: #1a1a1a; color: #fff;
20
+ text-decoration: none; cursor: pointer;
21
+ }
22
+ @media (prefers-color-scheme: dark) { .retry { background: #e6e6e6; color: #0d0d10; } }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <main>
27
+ <h1>You are offline</h1>
28
+ <p>This page is not available without a network connection. Pages you have already visited still work offline.</p>
29
+ <!-- An empty href reloads the current URL (the page the user tried to
30
+ reach), so retry works with NO inline JS, staying CSP-compatible. -->
31
+ <a class="retry" href="">Try again</a>
32
+ </main>
33
+ </body>
34
+ </html>
@@ -0,0 +1,106 @@
1
+ /*
2
+ * webjs progressive-enhancement service worker (OPT-IN, #271).
3
+ *
4
+ * This adds an offline fallback and an asset cache WITHOUT changing the
5
+ * JavaScript-disabled baseline: with JS off no service worker registers, so
6
+ * pages, links, and forms behave exactly as they do today. It is registered
7
+ * explicitly (see the opt-in snippet in agent-docs/service-worker.md), never
8
+ * automatically.
9
+ *
10
+ * Strategy:
11
+ * - Navigations are NETWORK-FIRST: always try the network so the user sees
12
+ * fresh server-rendered HTML, caching each successful page (the SSR shell)
13
+ * so a later OFFLINE visit to a page you have seen still renders. When the
14
+ * network fails and nothing is cached, serve /offline.html.
15
+ * - Same-origin static assets (the per-file ESM modules, the framework
16
+ * runtime under /__webjs/core/, vendor bundles, public assets) are
17
+ * stale-while-revalidate, so a repeat visit works offline. In production
18
+ * these URLs carry a ?v=<hash> content fingerprint, so a changed file gets
19
+ * a new URL and the cache can never serve stale bytes.
20
+ *
21
+ * Versioning ties to the deploy. The page registers this worker as
22
+ * `/sw.js?v=<data-webjs-build>` (the importmap build id), so a new deploy
23
+ * changes the worker's own URL, the browser fetches the new worker, and its
24
+ * `activate` deletes every cache that is not the current version. The cache
25
+ * name is derived from that `?v=` below.
26
+ *
27
+ * NEVER cached: non-GET requests, cross-origin requests, the action RPC
28
+ * endpoint (/__webjs/action/), the dev live-reload SSE (/__webjs/events) and
29
+ * dev reload client (/__webjs/reload.js).
30
+ */
31
+
32
+ const BUILD = new URL(self.location.href).searchParams.get('v') || 'dev';
33
+ const CACHE = 'webjs-' + BUILD;
34
+ const OFFLINE_URL = '/offline.html';
35
+
36
+ self.addEventListener('install', (event) => {
37
+ event.waitUntil((async () => {
38
+ const cache = await caches.open(CACHE);
39
+ // Precache the offline fallback. `reload` bypasses the HTTP cache so the
40
+ // freshly-deployed offline page is stored, not a stale one.
41
+ await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
42
+ await self.skipWaiting();
43
+ })());
44
+ });
45
+
46
+ self.addEventListener('activate', (event) => {
47
+ event.waitUntil((async () => {
48
+ const keys = await caches.keys();
49
+ await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)));
50
+ await self.clients.claim();
51
+ })());
52
+ });
53
+
54
+ /** Decide whether a GET request to a same-origin path is a cacheable asset. */
55
+ function isCacheableAsset(pathname) {
56
+ if (pathname.startsWith('/__webjs/action/')) return false; // RPC, never cache
57
+ if (pathname === '/__webjs/events' || pathname === '/__webjs/reload.js') return false; // dev
58
+ if (pathname.startsWith('/__webjs/core/') || pathname.startsWith('/__webjs/vendor/')) return true;
59
+ return /\.(?:js|mjs|ts|css|woff2?|png|jpe?g|svg|webp|gif|ico|json|map)$/.test(pathname);
60
+ }
61
+
62
+ self.addEventListener('fetch', (event) => {
63
+ const req = event.request;
64
+ if (req.method !== 'GET') return; // never cache writes
65
+ const url = new URL(req.url);
66
+ if (url.origin !== self.location.origin) return; // only same-origin
67
+
68
+ // Network-first for page navigations: fresh server HTML, cache it for offline,
69
+ // fall back to the cached page then the offline page.
70
+ if (req.mode === 'navigate') {
71
+ event.respondWith((async () => {
72
+ try {
73
+ const fresh = await fetch(req);
74
+ // Cache ONLY a successful page (never a 404/500 error page, or an
75
+ // offline visit would serve the cached error instead of the fallback).
76
+ // waitUntil keeps the worker alive until the write lands (a worker can
77
+ // be terminated the moment respondWith settles).
78
+ if (fresh && fresh.ok) {
79
+ const copy = fresh.clone();
80
+ event.waitUntil(caches.open(CACHE).then((cache) => cache.put(req, copy)));
81
+ }
82
+ return fresh;
83
+ } catch (_err) {
84
+ const cache = await caches.open(CACHE);
85
+ const cached = await cache.match(req);
86
+ return cached || (await cache.match(OFFLINE_URL)) || Response.error();
87
+ }
88
+ })());
89
+ return;
90
+ }
91
+
92
+ // Stale-while-revalidate for static assets.
93
+ if (isCacheableAsset(url.pathname)) {
94
+ event.respondWith((async () => {
95
+ const cache = await caches.open(CACHE);
96
+ const cached = await cache.match(req);
97
+ const network = fetch(req)
98
+ .then((res) => { if (res && res.ok) cache.put(req, res.clone()); return res; })
99
+ .catch(() => cached);
100
+ // Keep the worker alive for the background revalidation + write, which
101
+ // would otherwise be a floating promise lost on worker termination.
102
+ event.waitUntil(network.catch(() => {}));
103
+ return cached || network;
104
+ })());
105
+ }
106
+ });