@braidlabs/sw 0.1.1

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 ADDED
@@ -0,0 +1,160 @@
1
+ # @braidlabs/sw
2
+
3
+ Skew-aware asset serving for composed pages.
4
+
5
+ The classic micro-frontend white screen: a user has the page open, a deploy lands, they click
6
+ something that lazy-loads `main.abc123.js` — and it is gone, because the new build wrote
7
+ `main.def456.js`. Nothing on the page can recover. The request comes from the module loader, the
8
+ response is a 404, and the route dies.
9
+
10
+ A service worker is the **only** layer that can answer that request from a copy it kept.
11
+
12
+ Braid makes this materially cleaner than it is for a monolith. Every fragment's assets live under
13
+ `/__braid/frag/:id/*`, so each fragment gets **its own cache partition keyed by its own build**.
14
+ Fragment A at build 5 and fragment B at build 12 coexist, with no shared cache generation for one
15
+ deploy to invalidate on the other's behalf. A monolith's worker has one bucket and one answer.
16
+
17
+ ---
18
+
19
+ ## Two shapes
20
+
21
+ Both ship, because "the app already has a service worker" is not a safe assumption — least of all
22
+ for internal enterprise apps.
23
+
24
+ **A handler, for a worker you already own.** Braid never takes your `fetch` event:
25
+
26
+ ```js
27
+ // your sw.js
28
+ import { braidFetchHandler } from '@braidlabs/sw';
29
+ const braid = braidFetchHandler({ buildId: BUILD_ID });
30
+
31
+ self.addEventListener('fetch', (event) => {
32
+ const handled = braid(event.request); // null for anything not ours
33
+ if (handled) event.respondWith(handled);
34
+ });
35
+ ```
36
+
37
+ **A complete worker, for shells with none.** Same handler, already wired:
38
+
39
+ ```js
40
+ // sw.js
41
+ import { setupBraidWorker } from '@braidlabs/sw';
42
+ setupBraidWorker({ buildId: BUILD_ID, precache: ['billing', 'notifications'] });
43
+ ```
44
+
45
+ ## Let the gateway serve it
46
+
47
+ ```ts
48
+ createGateway({ registry, serviceWorker: true }); // scope '/'
49
+ createGateway({ registry, serviceWorker: { scope: '/apps/' } }); // path-mounted deployments
50
+ ```
51
+
52
+ ```ts
53
+ import { registerBraidServiceWorker } from '@braidlabs/core';
54
+ await registerBraidServiceWorker({ buildId: BUILD_ID });
55
+ ```
56
+
57
+ **A worker's scope is capped by the path it is served from.** A script at `/__braid/sw.js` defaults
58
+ to controlling `/__braid/` — enough for fragment assets, useless for the shell's own, which is the
59
+ chunk-failure case that matters most. Widening it needs `Service-Worker-Allowed` on the script
60
+ response, and the gateway is the one component already in front of the origin that can send it
61
+ without touching infrastructure config.
62
+
63
+ Claiming `/` sounds broad and costs nothing: scope precedence is longest-match, so a worker
64
+ registered at `/legacy/` still controls `/legacy/` whatever Braid claims. The root is a fallback,
65
+ not an exclusion — and the handler returns `null` outside the namespace anyway.
66
+
67
+ The generated script is **byte-stable across registry publishes**. Baking the snapshot id into it
68
+ would make every publish a worker update with its own waiting and activation lifecycle;
69
+ configuration churn must not become worker churn.
70
+
71
+ ## What it will and will not do
72
+
73
+ | | |
74
+ | --- | --- |
75
+ | Serves a cached chunk when the origin 404s it | ✅ the deploy case |
76
+ | Serves from cache when the network is gone | ✅ offline |
77
+ | Answers a 500 from cache | ❌ that is an outage, and hiding it makes it someone else's mystery |
78
+ | Answers a POST | ❌ a cache cannot, and pretending turns a failed write into a stale success |
79
+ | Touches anything outside `/__braid/` | ❌ never |
80
+ | Caches fragment **documents** | ❌ composed markup's freshness is the gateway's business |
81
+
82
+ ## Offline composition
83
+
84
+ Opt-in, and the piece with the most leverage: the worker runs **the gateway's own piercing**.
85
+
86
+ ```js
87
+ setupBraidWorker({
88
+ buildId: BUILD_ID,
89
+ offline: { snapshotUrl: '/__braid/registry/pinned.json' },
90
+ });
91
+ ```
92
+
93
+ The gateway core is runtime-neutral — no `node:` imports in the gateway, the registry, or the
94
+ rewriter — and a service worker is a web-standard runtime. So the worker holds the shell and the
95
+ fragment documents and interleaves them with the same `pierceShellHtml` the server runs. A second
96
+ implementation of piercing would be a second set of piercing bugs, found offline.
97
+
98
+ **It holds the parts, not the finished page.** Caching composed HTML would be simpler and much less
99
+ useful: the composed page is the cross-product of shell, fragments, and route. Holding the parts
100
+ means a route visited once composes offline afterwards, a fragment updates its own part, and a
101
+ fragment with nothing cached degrades to a placeholder instead of failing the page.
102
+
103
+ The shell is re-fetched with `sec-fetch-dest: empty`, which is how the gateway already distinguishes
104
+ a soft-navigation fetch from a document request — the same distinction every pierced response
105
+ declares in its `Vary` header, used from the other side.
106
+
107
+ Two things it will not do: compose while the network is reachable (the gateway is authoritative),
108
+ and cache a composed response (a page pierced against a registry that has since changed is exactly
109
+ the stale artifact this package exists to avoid). Composed pages carry
110
+ `x-braid-composed: offline` so a shell can say so rather than look merely slow.
111
+
112
+ ## The worker is itself a skew vector
113
+
114
+ It is a long-lived deployment artifact that updates on its own schedule — a version boundary added
115
+ to a system whose purpose is managing version boundaries. So it gets the same discipline: it knows
116
+ its build and the snapshot it serves, and says so when they disagree with the page.
117
+
118
+ ```ts
119
+ await registerBraidServiceWorker({
120
+ buildId: BUILD_ID,
121
+ onVersion: (report) => telemetry.record(report),
122
+ });
123
+ ```
124
+
125
+ `claimClients` is off by default for the same reason. Claiming swaps the worker underneath a page
126
+ that is already running, so a page mid-session starts being served by a build it did not load
127
+ against. Waiting for the next navigation is the boring, correct behaviour.
128
+
129
+ ## Reports that survive the tab closing
130
+
131
+ ```js
132
+ setupBraidWorker({
133
+ buildId: BUILD_ID,
134
+ reports: { endpoint: '/__telemetry', driver: indexedDbRecordDriver({ database: 'braid-sw' }) },
135
+ });
136
+ ```
137
+
138
+ The worker knows things nobody else can see — that a chunk was served from cache after the origin
139
+ 404'd it, that its build disagrees with the page's. Handed to `onReport` those facts are
140
+ fire-and-forget, and the ones that go missing are disproportionately the interesting ones: a user who
141
+ hits a broken deploy is a user who closes the tab, and a `fetch` started during teardown loses that
142
+ race.
143
+
144
+ Queued reports are flushed on a Background Sync event — the browser wakes the worker when it judges
145
+ the network is back, with no page open and nothing racing teardown. Background Sync is Chromium-only,
146
+ so every activation also flushes opportunistically; a browser without it gets that path and nothing
147
+ else.
148
+
149
+ The queue is `@braidlabs/data`'s outbox rather than a second implementation: one record per entry, so
150
+ appending never reads the queue first and two contexts cannot lose each other's writes. A refused
151
+ batch stays queued in full — partial credit would need the endpoint to say which records it took,
152
+ and no telemetry endpoint does.
153
+
154
+ ## Not a data cache
155
+
156
+ The Cache API stores `Request → Response`. The data layer needs per-entity keys, versioned
157
+ envelopes, and migrate-on-read; the Cache API offers none of the three, and storing enveloped
158
+ entities as opaque response bodies would defeat the projection the whole design rests on.
159
+
160
+ **Cache API for assets and stubs. IndexedDB (`@braidlabs/data`) for entities.**
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@braidlabs/sw",
3
+ "version": "0.1.1",
4
+ "description": "Braid service worker — skew-aware asset serving for composed pages: each fragment gets its own build-keyed cache partition, so a page already open keeps getting the chunks it was built against after a deploy lands. Ships as a handler to compose into an existing worker, and as a complete worker.",
5
+ "keywords": [
6
+ "micro-frontends",
7
+ "service-worker",
8
+ "version-skew",
9
+ "cache",
10
+ "offline"
11
+ ],
12
+ "license": "MIT",
13
+ "engines": {
14
+ "node": ">=24"
15
+ },
16
+ "sideEffects": false,
17
+ "type": "module",
18
+ "main": "./src/index.js",
19
+ "types": "./src/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/index.d.ts",
23
+ "default": "./src/index.js"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/braidjs/braid.git",
33
+ "directory": "libs/braid-sw"
34
+ },
35
+ "homepage": "https://github.com/braidjs/braid#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/braidjs/braid/issues"
38
+ },
39
+ "module": "./src/index.js"
40
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@braidlabs/sw` — skew-aware asset serving for composed pages.
3
+ *
4
+ * The classic micro-frontend white screen is a deploy landing under an open page: a lazy chunk is
5
+ * requested, the build that wrote it is gone, and nothing on the page can recover. A service worker
6
+ * is the only layer that can answer that request from a copy it kept — and because every fragment's
7
+ * assets live under `/__braid/frag/:id/*`, each fragment gets its own build-keyed cache partition
8
+ * rather than a monolith's single bucket with a single answer.
9
+ *
10
+ * Ships in both shapes on purpose: a handler to compose into a worker you already own, and a
11
+ * complete worker for the (common) case where you have none.
12
+ */
13
+ export { braidFetchHandler, pruneFragmentCaches } from './lib/asset-handler.js';
14
+ export type { BraidFetchHandlerOptions, AssetOutcome, AssetReport } from './lib/asset-handler.js';
15
+ export { setupBraidWorker } from './lib/worker.js';
16
+ export type { BraidWorkerOptions } from './lib/worker.js';
17
+ export { braidNavigationHandler } from './lib/compose.js';
18
+ export type { OfflineCompositionOptions } from './lib/compose.js';
19
+ export { precacheRealmStubs } from './lib/precache.js';
20
+ export type { PrecacheOptions, PrecacheResult } from './lib/precache.js';
21
+ export { createReportQueue, BRAID_REPORT_SYNC_TAG } from './lib/reports.js';
22
+ export type { ReportQueue, ReportQueueOptions, BraidReport, FlushOutcome } from './lib/reports.js';
23
+ export { diagnoseSkew, BRAID_BUILD_HEADER, BRAID_SNAPSHOT_HEADER } from './lib/skew-report.js';
24
+ export type { SkewDiagnosis, SkewSeverity, WorkerVersion, PageVersion } from './lib/skew-report.js';
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@braidlabs/sw` — skew-aware asset serving for composed pages.
3
+ *
4
+ * The classic micro-frontend white screen is a deploy landing under an open page: a lazy chunk is
5
+ * requested, the build that wrote it is gone, and nothing on the page can recover. A service worker
6
+ * is the only layer that can answer that request from a copy it kept — and because every fragment's
7
+ * assets live under `/__braid/frag/:id/*`, each fragment gets its own build-keyed cache partition
8
+ * rather than a monolith's single bucket with a single answer.
9
+ *
10
+ * Ships in both shapes on purpose: a handler to compose into a worker you already own, and a
11
+ * complete worker for the (common) case where you have none.
12
+ */
13
+ export { braidFetchHandler, pruneFragmentCaches } from './lib/asset-handler.js';
14
+ export { setupBraidWorker } from './lib/worker.js';
15
+ export { braidNavigationHandler } from './lib/compose.js';
16
+ export { precacheRealmStubs } from './lib/precache.js';
17
+ export { createReportQueue, BRAID_REPORT_SYNC_TAG } from './lib/reports.js';
18
+ export { diagnoseSkew, BRAID_BUILD_HEADER, BRAID_SNAPSHOT_HEADER } from './lib/skew-report.js';
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../libs/braid-sw/src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAGhF,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAGnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAGvD,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAG5E,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Skew-aware asset serving: the one layer that can keep an open page working after a deploy.
3
+ *
4
+ * The failure this exists for is the classic micro-frontend white screen. A user has the page open,
5
+ * a deploy lands, they click something that lazy-loads `main.abc123.js` — and it is gone from the
6
+ * origin, because the new build wrote `main.def456.js` instead. Nothing on the page can recover:
7
+ * the request is made by the module loader, the response is a 404, and the route dies.
8
+ *
9
+ * A service worker can, because it saw that chunk go past on the way in and kept a copy.
10
+ *
11
+ * **Braid makes this materially cleaner than it is for a monolith.** Every fragment's assets live
12
+ * under `/__braid/frag/:id/*`, so each fragment gets its own cache partition keyed by its own build.
13
+ * Fragment A at build 5 and fragment B at build 12 coexist with no shared cache generation to
14
+ * invalidate — a monolith's worker has one bucket and one answer for the whole page.
15
+ *
16
+ * Scope discipline: this handler answers for the Braid namespace and returns `null` for everything
17
+ * else. A shell composing it into its own worker keeps ownership of its own routes, and a shell
18
+ * using the prebuilt worker gets a worker that does nothing surprising to the rest of its origin.
19
+ */
20
+ /** How a request was answered — the input to the diagnostics a silent worker would deny you. */
21
+ export type AssetOutcome = 'network' | 'cache'
22
+ /** Served from cache *after* the network said the asset is gone — the deploy-skew save. */
23
+ | 'cache-after-404' | 'offline';
24
+ export interface AssetReport {
25
+ fragmentId: string;
26
+ url: string;
27
+ outcome: AssetOutcome;
28
+ /** The cache partition that answered, or would have. */
29
+ partition: string;
30
+ }
31
+ export interface BraidFetchHandlerOptions {
32
+ /**
33
+ * The build this worker was shipped as.
34
+ *
35
+ * A worker is itself a long-lived deployment artifact that updates on its own schedule, so it is
36
+ * a skew vector in a system whose purpose is managing them. Naming its build is what lets a
37
+ * disagreement be reported instead of silently serving yesterday's assets to today's document.
38
+ */
39
+ buildId?: string;
40
+ /** Cache name prefix. One cache per fragment, so eviction is per fragment too. */
41
+ cachePrefix?: string;
42
+ /** Called for every namespace request this handler answers. Wire it to your telemetry. */
43
+ onReport?: (report: AssetReport) => void;
44
+ /** Injectable for tests. Defaults to the worker's own `caches`. */
45
+ caches?: CacheStorage;
46
+ /** Injectable for tests. Defaults to the worker's own `fetch`. */
47
+ fetch?: typeof fetch;
48
+ }
49
+ /**
50
+ * Builds a fetch handler for the Braid namespace.
51
+ *
52
+ * ```js
53
+ * const braid = braidFetchHandler();
54
+ * self.addEventListener('fetch', (event) => {
55
+ * const handled = braid(event.request); // null for anything not ours
56
+ * if (handled) event.respondWith(handled);
57
+ * });
58
+ * ```
59
+ */
60
+ export declare function braidFetchHandler(options?: BraidFetchHandlerOptions): (request: Request) => Promise<Response> | null;
61
+ /**
62
+ * Removes cache partitions for fragments that are no longer registered.
63
+ *
64
+ * Per fragment rather than per build: a fragment's own chunks are content-hashed, so its partition
65
+ * grows by a bounded amount per deploy and shrinking it on activate would throw away exactly the
66
+ * old chunks this handler exists to keep. A fragment that has left the registry, on the other hand,
67
+ * is never coming back for them.
68
+ */
69
+ export declare function pruneFragmentCaches(keep: readonly string[], options?: {
70
+ cachePrefix?: string;
71
+ caches?: CacheStorage;
72
+ }): Promise<string[]>;
@@ -0,0 +1,92 @@
1
+ import { parseBraidPathname } from '@braidlabs/gateway';
2
+ const DEFAULT_CACHE_PREFIX = 'braid-frag';
3
+ /**
4
+ * Builds a fetch handler for the Braid namespace.
5
+ *
6
+ * ```js
7
+ * const braid = braidFetchHandler();
8
+ * self.addEventListener('fetch', (event) => {
9
+ * const handled = braid(event.request); // null for anything not ours
10
+ * if (handled) event.respondWith(handled);
11
+ * });
12
+ * ```
13
+ */
14
+ export function braidFetchHandler(options = {}) {
15
+ const cachePrefix = options.cachePrefix ?? DEFAULT_CACHE_PREFIX;
16
+ const cacheStorage = options.caches ?? globalThis.caches;
17
+ const networkFetch = options.fetch ?? globalThis.fetch;
18
+ return (request) => {
19
+ // GET only. A cache cannot answer a POST, and pretending otherwise is how a worker turns a
20
+ // failed write into a stale success.
21
+ if (request.method !== 'GET')
22
+ return null;
23
+ const url = new URL(request.url);
24
+ const route = parseBraidPathname(url.pathname);
25
+ // Assets and realm stubs only. A fragment *document* is composed markup whose freshness is the
26
+ // gateway's business — caching one here would serve a page built against a registry that has
27
+ // since changed, which is the failure this package exists to prevent rather than cause.
28
+ if (!route || (route.kind !== 'fragment' && route.kind !== 'realm'))
29
+ return null;
30
+ return serve(request, url, route.fragmentId);
31
+ };
32
+ async function serve(request, url, fragmentId) {
33
+ const partition = `${cachePrefix}:${fragmentId}`;
34
+ const report = (outcome) => options.onReport?.({ fragmentId, url: url.href, outcome, partition });
35
+ const cache = await cacheStorage?.open(partition);
36
+ try {
37
+ const response = await networkFetch(request);
38
+ // 404 is the deploy case, and the only status worth reaching into the cache for. A 500 is the
39
+ // server having a bad moment about an asset that still exists; answering it from cache would
40
+ // paper over an outage and make it someone else's mystery.
41
+ if (response.status === 404) {
42
+ const cached = await cache?.match(request);
43
+ if (cached) {
44
+ report('cache-after-404');
45
+ return cached;
46
+ }
47
+ }
48
+ // Only successful, non-partial responses are worth keeping. A cached 206 answers a range
49
+ // request that was never asked for.
50
+ if (response.ok && response.status === 200 && cache) {
51
+ // Cached without awaiting: the page is waiting on this response, and the copy is an
52
+ // optimization for a future load rather than a precondition for this one.
53
+ void cache.put(request, response.clone()).catch(() => undefined);
54
+ }
55
+ report('network');
56
+ return response;
57
+ }
58
+ catch (error) {
59
+ // The network is gone, not merely unhappy. This is the offline path.
60
+ const cached = await cache?.match(request);
61
+ if (cached) {
62
+ report('offline');
63
+ return cached;
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ }
69
+ /**
70
+ * Removes cache partitions for fragments that are no longer registered.
71
+ *
72
+ * Per fragment rather than per build: a fragment's own chunks are content-hashed, so its partition
73
+ * grows by a bounded amount per deploy and shrinking it on activate would throw away exactly the
74
+ * old chunks this handler exists to keep. A fragment that has left the registry, on the other hand,
75
+ * is never coming back for them.
76
+ */
77
+ export async function pruneFragmentCaches(keep, options = {}) {
78
+ const cachePrefix = options.cachePrefix ?? DEFAULT_CACHE_PREFIX;
79
+ const cacheStorage = options.caches ?? globalThis.caches;
80
+ if (!cacheStorage)
81
+ return [];
82
+ const kept = new Set(keep.map((id) => `${cachePrefix}:${id}`));
83
+ const removed = [];
84
+ for (const name of await cacheStorage.keys()) {
85
+ if (!name.startsWith(`${cachePrefix}:`) || kept.has(name))
86
+ continue;
87
+ await cacheStorage.delete(name);
88
+ removed.push(name);
89
+ }
90
+ return removed;
91
+ }
92
+ //# sourceMappingURL=asset-handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-handler.js","sourceRoot":"","sources":["../../../../../libs/braid-sw/src/lib/asset-handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Offline composition: the gateway's own piercing, run inside the worker.
3
+ *
4
+ * This falls out of decisions already made rather than needing new machinery. The gateway core is
5
+ * runtime-neutral — no `node:` imports in the gateway, the registry, or the rewriter — and a service
6
+ * worker is a web-standard runtime. So the worker can hold the shell and the fragment documents and
7
+ * interleave them with the **same `pierceShellHtml` the server runs**, which matters more than the
8
+ * code reuse: a second implementation would be a second set of piercing bugs, discovered offline.
9
+ *
10
+ * **Why hold the pieces rather than the composed page.** Caching the finished HTML would be simpler
11
+ * and much less useful: the composed page is the cross-product of shell, fragments, and route, so a
12
+ * cache of finished pages only ever answers for routes the user already visited with the fragment
13
+ * versions they had. Holding the parts means a route visited once composes offline afterwards, and
14
+ * a fragment that updated on its own schedule updates its part alone.
15
+ *
16
+ * Two things this deliberately does not do. It never composes when the network is reachable — the
17
+ * gateway is authoritative and its answer is always preferred — and it never caches a *composed*
18
+ * response, because a page pierced against a registry that has since changed is precisely the stale
19
+ * artifact the rest of this package exists to avoid.
20
+ */
21
+ export interface OfflineCompositionOptions {
22
+ /**
23
+ * Where the pinned registry snapshot is served from.
24
+ *
25
+ * Content-addressed and immutable, so it is cacheable under its id forever with no revalidation —
26
+ * which is what makes an offline compose possible at all: piercing needs to know which fragments
27
+ * claim this URL, and that answer must survive with no server to ask.
28
+ */
29
+ snapshotUrl: string;
30
+ cachePrefix?: string;
31
+ caches?: CacheStorage;
32
+ fetch?: typeof fetch;
33
+ /** Called when a page is composed offline, so a shell can say so rather than look merely slow. */
34
+ onCompose?: (event: {
35
+ pathname: string;
36
+ fragmentIds: string[];
37
+ }) => void;
38
+ }
39
+ /**
40
+ * A navigation handler that composes from cache when the network is gone.
41
+ *
42
+ * ```js
43
+ * const compose = braidNavigationHandler({ snapshotUrl: '/__braid/registry/pinned.json' });
44
+ * self.addEventListener('fetch', (event) => {
45
+ * const handled = compose(event.request);
46
+ * if (handled) event.respondWith(handled);
47
+ * });
48
+ * ```
49
+ */
50
+ export declare function braidNavigationHandler(options: OfflineCompositionOptions): (request: Request) => Promise<Response> | null;
@@ -0,0 +1,153 @@
1
+ import { BRAID_DOCUMENT_PREFIX, Registry, pierceShellHtml, } from '@braidlabs/gateway';
2
+ const DEFAULT_CACHE_PREFIX = 'braid-frag';
3
+ const SHELL_CACHE = 'braid-shell';
4
+ const SNAPSHOT_CACHE = 'braid-snapshot';
5
+ /**
6
+ * A navigation handler that composes from cache when the network is gone.
7
+ *
8
+ * ```js
9
+ * const compose = braidNavigationHandler({ snapshotUrl: '/__braid/registry/pinned.json' });
10
+ * self.addEventListener('fetch', (event) => {
11
+ * const handled = compose(event.request);
12
+ * if (handled) event.respondWith(handled);
13
+ * });
14
+ * ```
15
+ */
16
+ export function braidNavigationHandler(options) {
17
+ const cachePrefix = options.cachePrefix ?? DEFAULT_CACHE_PREFIX;
18
+ const cacheStorage = options.caches ?? globalThis.caches;
19
+ const networkFetch = options.fetch ?? globalThis.fetch;
20
+ return (request) => {
21
+ if (request.method !== 'GET' || !isNavigation(request))
22
+ return null;
23
+ return handle(request);
24
+ };
25
+ async function handle(request) {
26
+ try {
27
+ const response = await networkFetch(request);
28
+ // Kept for the next outage rather than awaited: the user is waiting on this navigation, and
29
+ // refreshing the parts is work for a future load.
30
+ void refresh(request).catch(() => undefined);
31
+ return response;
32
+ }
33
+ catch (error) {
34
+ const composed = await composeFromCache(request);
35
+ if (composed)
36
+ return composed;
37
+ throw error;
38
+ }
39
+ }
40
+ /**
41
+ * Re-fetches the parts a compose would need: the unpierced shell, and each matching fragment's
42
+ * prepared document.
43
+ *
44
+ * The shell is requested with `sec-fetch-dest: empty`, which is how the gateway distinguishes a
45
+ * soft-navigation fetch from a document request — so it answers with the shell alone. That is not
46
+ * a trick; it is the same distinction the `Vary` header on every pierced response already
47
+ * declares, used from the other side.
48
+ */
49
+ async function refresh(request) {
50
+ if (!cacheStorage)
51
+ return;
52
+ const url = new URL(request.url);
53
+ const manifests = await loadSnapshot(url.origin);
54
+ if (!manifests)
55
+ return;
56
+ const registry = new Registry([...manifests]);
57
+ const matches = await registry.matchPierceRoutes(url.pathname);
58
+ const shell = await networkFetch(new Request(request.url, { headers: { 'sec-fetch-dest': 'empty' } }));
59
+ if (shell.ok) {
60
+ const shellCache = await cacheStorage.open(SHELL_CACHE);
61
+ await shellCache.put(shellKey(url), shell.clone());
62
+ }
63
+ for (const manifest of matches) {
64
+ const documentUrl = `${url.origin}${BRAID_DOCUMENT_PREFIX}${encodeURIComponent(manifest.id)}${documentPath(manifest, url)}`;
65
+ const response = await networkFetch(documentUrl);
66
+ if (!response.ok)
67
+ continue;
68
+ // In the fragment's own partition, so pruning a retired fragment takes its documents too.
69
+ const cache = await cacheStorage.open(`${cachePrefix}:${manifest.id}`);
70
+ await cache.put(documentUrl, response.clone());
71
+ }
72
+ }
73
+ async function composeFromCache(request) {
74
+ if (!cacheStorage)
75
+ return null;
76
+ const url = new URL(request.url);
77
+ const shellCache = await cacheStorage.open(SHELL_CACHE);
78
+ const shell = await shellCache.match(shellKey(url));
79
+ if (!shell?.body)
80
+ return null;
81
+ const manifests = await loadSnapshot(url.origin);
82
+ if (!manifests)
83
+ return null;
84
+ const registry = new Registry([...manifests]);
85
+ const matches = await registry.matchPierceRoutes(url.pathname);
86
+ const fragments = [];
87
+ for (const manifest of matches) {
88
+ const cache = await cacheStorage.open(`${cachePrefix}:${manifest.id}`);
89
+ const documentUrl = `${url.origin}${BRAID_DOCUMENT_PREFIX}${encodeURIComponent(manifest.id)}${documentPath(manifest, url)}`;
90
+ const cached = await cache.match(documentUrl);
91
+ // A fragment with nothing cached pierces nothing and is marked, exactly as a failed fetch is
92
+ // server-side: the slot degrades to a placeholder rather than the page failing.
93
+ fragments.push({
94
+ fragmentId: manifest.id,
95
+ content: cached?.body ?? null,
96
+ ...(cached?.body ? {} : { fallbackReason: 'placeholder' }),
97
+ ...(manifest.src === undefined ? {} : { src: manifest.src }),
98
+ });
99
+ }
100
+ options.onCompose?.({ pathname: url.pathname, fragmentIds: matches.map((manifest) => manifest.id) });
101
+ return new Response(pierceShellHtml({ shell: shell.body, fragments }), {
102
+ headers: {
103
+ 'content-type': 'text/html; charset=utf-8',
104
+ // Says plainly where this page came from. A composed-offline page that looks identical to a
105
+ // served one is a page nobody can debug.
106
+ 'x-braid-composed': 'offline',
107
+ },
108
+ });
109
+ }
110
+ /** The snapshot, from cache first — offline is the case that needs it. */
111
+ async function loadSnapshot(origin) {
112
+ if (!cacheStorage)
113
+ return null;
114
+ const url = new URL(options.snapshotUrl, origin).href;
115
+ const cache = await cacheStorage.open(SNAPSHOT_CACHE);
116
+ let response = await cache.match(url);
117
+ if (!response) {
118
+ try {
119
+ const fresh = await networkFetch(url);
120
+ if (!fresh.ok)
121
+ return null;
122
+ await cache.put(url, fresh.clone());
123
+ response = fresh;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ try {
130
+ const snapshot = (await response.json());
131
+ return Array.isArray(snapshot) ? snapshot : (snapshot.manifests ?? null);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ }
138
+ /** Unbound fragments are fetched at their own path, bound ones at the page's — as on the server. */
139
+ function documentPath(manifest, url) {
140
+ return manifest.bound === false && manifest.src ? manifest.src : `${url.pathname}${url.search}`;
141
+ }
142
+ /** Keyed by path alone: a shell does not vary by query, and keying on one would cache N copies. */
143
+ function shellKey(url) {
144
+ return `${url.origin}${url.pathname}`;
145
+ }
146
+ function isNavigation(request) {
147
+ if (request.mode === 'navigate')
148
+ return true;
149
+ // `mode` is not always populated outside a browser; the accept header is the same signal the
150
+ // gateway itself falls back to.
151
+ return request.headers.get('accept')?.includes('text/html') ?? false;
152
+ }
153
+ //# sourceMappingURL=compose.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compose.js","sourceRoot":"","sources":["../../../../../libs/braid-sw/src/lib/compose.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,QAAQ,EACR,eAAe,GAGhB,MAAM,oBAAoB,CAAC;AAuC5B,MAAM,oBAAoB,GAAG,YAAY,CAAC;AAC1C,MAAM,WAAW,GAAG,aAAa,CAAC;AAClC,MAAM,cAAc,GAAG,gBAAgB,CAAC;AAExC;;;;;;;;;;GAUG;AACH,MAAM,UAAU,sBAAsB,CACpC,OAAkC;IAElC,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,oBAAoB,CAAC;IAChE,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,IAAK,UAAwC,CAAC,MAAM,CAAC;IACxF,MAAM,YAAY,GAAG,OAAO,CAAC,KAAK,IAAI,UAAU,CAAC,KAAK,CAAC;IAEvD,OAAO,CAAC,OAAgB,EAAE,EAAE;QAC1B,IAAI,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QACpE,OAAO,MAAM,CAAC,OAAO,CAAC,CAAC;IACzB,CAAC,CAAC;IAEF,KAAK,UAAU,MAAM,CAAC,OAAgB;QACpC,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,OAAO,CAAC,CAAC;YAC7C,4FAA4F;YAC5F,kDAAkD;YAClD,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YAC7C,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,QAAQ,GAAG,MAAM,gBAAgB,CAAC,OAAO,CAAC,CAAC;YACjD,IAAI,QAAQ;gBAAE,OAAO,QAAQ,CAAC;YAC9B,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED;;;;;;;;OAQG;IACH,KAAK,UAAU,OAAO,CAAC,OAAgB;QACrC,IAAI,CAAC,YAAY;YAAE,OAAO;QAE1B,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjC,MAAM,SAAS,GAAG,MAAM,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACjD,IAAI,CAAC,SAAS;YAAE,OAAO;QAEvB,MAAM,QAAQ,GAAG,IAAI,QAAQ,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC;QAC9C,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,iBAAiB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAE/D,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,IAAI,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,gBAAgB,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;QACvG,IAAI,KAAK,CAAC,EAAE,EAAE,CAAC;YACb,MAAM,UAAU,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YACxD,MAAM,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC;QACrD,CAAC;QAED,KAAK,MAAM,QAAQ,IAAI,OAAO,EAAE,CAAC;YAC/B,MAAM,WAAW,GAAG,GAAG,GAAG,CAAC,MAAM,GAAG,qBAAqB,GAAG,kBAAkB,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,YAAY,CACxG,QAAQ,EACR,GAAG,CACJ,EAAE,CAAC;YACJ,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,WAAW,CAAC,CAAC;YACjD,IAAI,CAAC,QAAQ,CAAC,EAAE;gBAAE,SAAS;YAE3B,0FAA0F;YAC1F,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,GAAG,WAAW,IAAI,QAAQ,CAAC,EAAE,EAAE,CAAC,CAAC;YACvE,MAAM,KAAK,CAAC,GAAG,CAAC,WAAW,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;QACjD,CAAC;IACH,CAAC;IAED,KAAK,UAAU,gBAAgB,CAAC,OAAgB;QAC9C,IAAI,CAAC,YAAY;YAAE,OAAO,IAAI,CAAC;QAE/B,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjC,MAAM,UAAU,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QACxD,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;QACpD,IAAI,CAAC,KAAK,EAAE,IAAI;YAAE,OAAO,IAAI,CAAC;QAE9B,MAAM,SAAS,GAAG,MAAM,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACjD,IAAI,CAAC,SAAS;YAAE,OAAO,IAAI,CAAC;QAE5B,MAAM,QAAQ,GAAG,IAAI,QAAQ,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC;QAC9C,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,iBAAiB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAE/D,MAAM,SAAS,GAAmB,EAAE,CAAC;QACrC,KAAK,MAAM,QAAQ,IAAI,OAAO,EAAE,CAAC;YAC/B,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,GAAG,WAAW,IAAI,QAAQ,CAAC,EAAE,EAAE,CAAC,CAAC;YACvE,MAAM,WAAW,GAAG,GAAG,GAAG,CAAC,MAAM,GAAG,qBAAqB,GAAG,kBAAkB,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,YAAY,CACxG,QAAQ,EACR,GAAG,CACJ,EAAE,CAAC;YACJ,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;YAE9C,6FAA6F;YAC7F,gFAAgF;YAChF,SAAS,CAAC,IAAI,CAAC;gBACb,UAAU,EAAE,QAAQ,CAAC,EAAE;gBACvB,OAAO,EAAE,MAAM,EAAE,IAAI,IAAI,IAAI;gBAC7B,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,aAAa,EAAE,CAAC;gBAC1D,GAAG,CAAC,QAAQ,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,CAAC;aAC7D,CAAC,CAAC;QACL,CAAC;QAED,OAAO,CAAC,SAAS,EAAE,CAAC,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAErG,OAAO,IAAI,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,SAAS,EAAE,CAAC,EAAE;YACrE,OAAO,EAAE;gBACP,cAAc,EAAE,0BAA0B;gBAC1C,4FAA4F;gBAC5F,yCAAyC;gBACzC,kBAAkB,EAAE,SAAS;aAC9B;SACF,CAAC,CAAC;IACL,CAAC;IAED,0EAA0E;IAC1E,KAAK,UAAU,YAAY,CAAC,MAAc;QACxC,IAAI,CAAC,YAAY;YAAE,OAAO,IAAI,CAAC;QAC/B,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,IAAI,CAAC;QACtD,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAEtD,IAAI,QAAQ,GAAG,MAAM,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACtC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,IAAI,CAAC;gBACH,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,GAAG,CAAC,CAAC;gBACtC,IAAI,CAAC,KAAK,CAAC,EAAE;oBAAE,OAAO,IAAI,CAAC;gBAC3B,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC;gBACpC,QAAQ,GAAG,KAAK,CAAC;YACnB,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,IAAI,CAAC;YACd,CAAC;QACH,CAAC;QAED,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAA4D,CAAC;YACpG,OAAO,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,SAAS,IAAI,IAAI,CAAC,CAAC;QAC3E,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;AACH,CAAC;AAED,oGAAoG;AACpG,SAAS,YAAY,CAAC,QAA0B,EAAE,GAAQ;IACxD,OAAO,QAAQ,CAAC,KAAK,KAAK,KAAK,IAAI,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;AAClG,CAAC;AAED,mGAAmG;AACnG,SAAS,QAAQ,CAAC,GAAQ;IACxB,OAAO,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,QAAQ,EAAE,CAAC;AACxC,CAAC;AAED,SAAS,YAAY,CAAC,OAAgB;IACpC,IAAI,OAAO,CAAC,IAAI,KAAK,UAAU;QAAE,OAAO,IAAI,CAAC;IAC7C,6FAA6F;IAC7F,gCAAgC;IAChC,OAAO,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,IAAI,KAAK,CAAC;AACvE,CAAC"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Realm stub precaching: a fragment boot with no round trip in it.
3
+ *
4
+ * A realm stub is the tiny document a fragment's realm is created from. They are identical per URL
5
+ * and vary on nothing — which is what the protocol's path split bought — so they are the one thing
6
+ * in this system that can be fetched before anybody asks for it without guessing.
7
+ *
8
+ * Kept deliberately small in scope: stubs only. Precaching a fragment's *application* bundles would
9
+ * mean downloading every fragment on the origin whether or not the user ever visits a page that
10
+ * composes them, which trades a round trip the user might make for bandwidth they certainly spend.
11
+ */
12
+ export interface PrecacheOptions {
13
+ cachePrefix?: string;
14
+ caches?: CacheStorage;
15
+ fetch?: typeof fetch;
16
+ /** Where the stubs live. Defaults to the current origin. */
17
+ origin?: string;
18
+ }
19
+ export interface PrecacheResult {
20
+ cached: string[];
21
+ /** Stubs that could not be fetched, with why. A precache failure must not fail the install. */
22
+ failed: {
23
+ url: string;
24
+ reason: string;
25
+ }[];
26
+ }
27
+ /**
28
+ * Fetches and caches each fragment's realm stub.
29
+ *
30
+ * Call it from `install`, wrapped in `waitUntil`. Failures are collected rather than thrown: a
31
+ * worker that refuses to install because one fragment's origin was briefly unreachable is worse
32
+ * than a worker that installs and fetches that stub on demand like it used to.
33
+ */
34
+ export declare function precacheRealmStubs(fragmentIds: readonly string[], options?: PrecacheOptions): Promise<PrecacheResult>;
@@ -0,0 +1,36 @@
1
+ import { BRAID_REALM_PREFIX } from '@braidlabs/gateway';
2
+ const DEFAULT_CACHE_PREFIX = 'braid-frag';
3
+ /**
4
+ * Fetches and caches each fragment's realm stub.
5
+ *
6
+ * Call it from `install`, wrapped in `waitUntil`. Failures are collected rather than thrown: a
7
+ * worker that refuses to install because one fragment's origin was briefly unreachable is worse
8
+ * than a worker that installs and fetches that stub on demand like it used to.
9
+ */
10
+ export async function precacheRealmStubs(fragmentIds, options = {}) {
11
+ const cachePrefix = options.cachePrefix ?? DEFAULT_CACHE_PREFIX;
12
+ const cacheStorage = options.caches ?? globalThis.caches;
13
+ const networkFetch = options.fetch ?? globalThis.fetch;
14
+ const origin = options.origin ?? globalThis.location?.origin ?? '';
15
+ const result = { cached: [], failed: [] };
16
+ if (!cacheStorage)
17
+ return result;
18
+ for (const fragmentId of fragmentIds) {
19
+ const url = `${origin}${BRAID_REALM_PREFIX}${fragmentId}/`;
20
+ try {
21
+ const response = await networkFetch(url);
22
+ if (!response.ok) {
23
+ result.failed.push({ url, reason: `HTTP ${response.status}` });
24
+ continue;
25
+ }
26
+ const cache = await cacheStorage.open(`${cachePrefix}:${fragmentId}`);
27
+ await cache.put(url, response);
28
+ result.cached.push(url);
29
+ }
30
+ catch (error) {
31
+ result.failed.push({ url, reason: error instanceof Error ? error.message : String(error) });
32
+ }
33
+ }
34
+ return result;
35
+ }
36
+ //# sourceMappingURL=precache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"precache.js","sourceRoot":"","sources":["../../../../../libs/braid-sw/src/lib/precache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AA4BxD,MAAM,oBAAoB,GAAG,YAAY,CAAC;AAE1C;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,WAA8B,EAC9B,UAA2B,EAAE;IAE7B,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,oBAAoB,CAAC;IAChE,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,IAAK,UAAwC,CAAC,MAAM,CAAC;IACxF,MAAM,YAAY,GAAG,OAAO,CAAC,KAAK,IAAI,UAAU,CAAC,KAAK,CAAC;IACvD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAK,UAAgD,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,CAAC;IAE1G,MAAM,MAAM,GAAmB,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IAC1D,IAAI,CAAC,YAAY;QAAE,OAAO,MAAM,CAAC;IAEjC,KAAK,MAAM,UAAU,IAAI,WAAW,EAAE,CAAC;QACrC,MAAM,GAAG,GAAG,GAAG,MAAM,GAAG,kBAAkB,GAAG,UAAU,GAAG,CAAC;QAC3D,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,GAAG,CAAC,CAAC;YACzC,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;gBAC/D,SAAS;YACX,CAAC;YACD,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,GAAG,WAAW,IAAI,UAAU,EAAE,CAAC,CAAC;YACtE,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;YAC/B,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC1B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC9F,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,54 @@
1
+ import { type RecordDriver } from '@braidlabs/data';
2
+ import type { AssetReport } from './asset-handler.js';
3
+ import type { SkewDiagnosis } from './skew-report.js';
4
+ /**
5
+ * Durable reports: telemetry that survives the tab being closed.
6
+ *
7
+ * The worker already knows things nobody else can see — that a chunk was served from cache after the
8
+ * origin 404'd it, that its build disagrees with the page's. Handed to an `onReport` callback those
9
+ * facts are fire-and-forget: a `fetch` started as the tab is closing races page teardown and usually
10
+ * loses, and the reports that go missing are disproportionately the interesting ones. A user hitting
11
+ * a broken deploy is a user who closes the tab.
12
+ *
13
+ * Background Sync fixes exactly that: records are queued durably and flushed when the browser next
14
+ * says it can, whether or not any page is open. It is **Chromium-only**, so this is an enhancement
15
+ * to sending reports rather than a replacement for it — the queue flushes opportunistically too, and
16
+ * a browser without `sync` simply gets the opportunistic path.
17
+ *
18
+ * The queue itself is `@braidlabs/data`'s outbox rather than a second implementation: one record per
19
+ * entry, so appending never reads the queue first and two contexts cannot lose each other's writes.
20
+ * That problem was solved once already, and solving it again here would mean debugging it again too.
21
+ */
22
+ export type BraidReport = {
23
+ kind: 'asset';
24
+ at: string;
25
+ report: AssetReport;
26
+ } | {
27
+ kind: 'skew';
28
+ at: string;
29
+ diagnosis: SkewDiagnosis;
30
+ };
31
+ export interface ReportQueueOptions {
32
+ /** Where reports are POSTed, as JSON. */
33
+ endpoint: string;
34
+ /** Storage for the queue. In a worker, `indexedDbRecordDriver()`. */
35
+ driver: RecordDriver;
36
+ /** Distinguishes this worker's reports from anything else sharing the origin's storage. */
37
+ owner?: string;
38
+ /** Batch size per flush. Bounded so one flush cannot become an unbounded upload. */
39
+ batchSize?: number;
40
+ fetch?: typeof fetch;
41
+ }
42
+ export interface FlushOutcome {
43
+ sent: number;
44
+ /** Entries left queued — the endpoint refused them, and they are still owed. */
45
+ remaining: number;
46
+ }
47
+ export interface ReportQueue {
48
+ record(report: BraidReport): Promise<void>;
49
+ flush(): Promise<FlushOutcome>;
50
+ pending(): Promise<number>;
51
+ }
52
+ export declare function createReportQueue(options: ReportQueueOptions): ReportQueue;
53
+ /** The Background Sync tag this queue registers and answers to. */
54
+ export declare const BRAID_REPORT_SYNC_TAG = "braid-sw:reports";
@@ -0,0 +1,59 @@
1
+ import { createOutbox } from '@braidlabs/data';
2
+ const REPORT_MUTATION = 'braid-sw.report';
3
+ export function createReportQueue(options) {
4
+ const networkFetch = options.fetch ?? globalThis.fetch;
5
+ const batchSize = options.batchSize ?? 50;
6
+ const outbox = createOutbox({
7
+ driver: options.driver,
8
+ owner: options.owner ?? 'braid-sw',
9
+ collection: 'braid-sw-reports',
10
+ });
11
+ return {
12
+ async record(report) {
13
+ await outbox.enqueue({ mutationId: REPORT_MUTATION, input: report });
14
+ },
15
+ async pending() {
16
+ return (await outbox.mine()).length;
17
+ },
18
+ /**
19
+ * Sends what is queued, oldest first, and removes only what the endpoint accepted.
20
+ *
21
+ * One request per batch rather than per record: a worker waking to flush forty reports should
22
+ * not make forty requests, and the endpoint learns more from a batch than from its parts. A
23
+ * refused batch stays queued in full — partial credit would need the endpoint to say which
24
+ * records it took, and no telemetry endpoint does.
25
+ */
26
+ async flush() {
27
+ const entries = (await outbox.mine()).slice(0, batchSize);
28
+ if (entries.length === 0)
29
+ return { sent: 0, remaining: 0 };
30
+ try {
31
+ const response = await networkFetch(options.endpoint, {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ reports: entries.map((entry) => entry.input) }),
35
+ // Reports are diagnostics, not a session: sending credentials with them would attach
36
+ // identity to data nobody asked to have attached.
37
+ credentials: 'omit',
38
+ keepalive: true,
39
+ });
40
+ if (!response.ok) {
41
+ await Promise.all(entries.map((entry) => outbox.recordFailure(entry.id, `HTTP ${response.status}`)));
42
+ return { sent: 0, remaining: (await outbox.mine()).length };
43
+ }
44
+ await Promise.all(entries.map((entry) => outbox.remove(entry.id)));
45
+ return { sent: entries.length, remaining: (await outbox.mine()).length };
46
+ }
47
+ catch (error) {
48
+ // Still offline. The whole point of the queue is that this is survivable, so the entries
49
+ // stay exactly where they are.
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ await Promise.all(entries.map((entry) => outbox.recordFailure(entry.id, message)));
52
+ return { sent: 0, remaining: (await outbox.mine()).length };
53
+ }
54
+ },
55
+ };
56
+ }
57
+ /** The Background Sync tag this queue registers and answers to. */
58
+ export const BRAID_REPORT_SYNC_TAG = 'braid-sw:reports';
59
+ //# sourceMappingURL=reports.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reports.js","sourceRoot":"","sources":["../../../../../libs/braid-sw/src/lib/reports.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The worker's own version boundary.
3
+ *
4
+ * A service worker is a long-lived deployment artifact that updates on its own schedule, which
5
+ * makes it a skew vector inside a system whose entire purpose is managing skew. It gets the same
6
+ * discipline as everything else here: it knows its build and the registry snapshot it is serving,
7
+ * and when those disagree with the page it is answering for, it says so.
8
+ *
9
+ * The alternative is the failure worth designing against — a worker that quietly serves yesterday's
10
+ * assets to today's document. That does not look like a worker problem to anyone debugging it. It
11
+ * looks like an application bug, in an application that is fine.
12
+ */
13
+ export type SkewSeverity = 'ok' | 'worker-behind' | 'worker-ahead' | 'snapshot-mismatch';
14
+ export interface WorkerVersion {
15
+ /** The build this worker script was shipped as. */
16
+ buildId?: string;
17
+ /** The registry snapshot id this worker is composing against. */
18
+ snapshotId?: string;
19
+ }
20
+ export interface PageVersion {
21
+ buildId?: string;
22
+ snapshotId?: string;
23
+ }
24
+ export interface SkewDiagnosis {
25
+ severity: SkewSeverity;
26
+ message: string;
27
+ worker: WorkerVersion;
28
+ page: PageVersion;
29
+ /** What to do about it, phrased as an instruction. */
30
+ fixHint?: string;
31
+ }
32
+ /**
33
+ * Compares the worker's version with the page's.
34
+ *
35
+ * Deliberately not an error: a mismatch is usually transient and self-healing — a deploy landed
36
+ * while a tab was open, and the worker updates on the next navigation. What it must never be is
37
+ * invisible, so this returns something a host can log, report, or show, and the prebuilt worker
38
+ * always logs it.
39
+ */
40
+ export declare function diagnoseSkew(worker: WorkerVersion, page: PageVersion): SkewDiagnosis;
41
+ /**
42
+ * The header a gateway-served worker can read its page's build from.
43
+ *
44
+ * A header rather than a query parameter on the script URL, on purpose: baking the build into the
45
+ * worker's URL would change the URL on every deploy, and a changed worker URL is a new worker with
46
+ * its own install-and-wait lifecycle. Configuration churn should not become worker churn.
47
+ */
48
+ export declare const BRAID_BUILD_HEADER = "x-braid-build";
49
+ export declare const BRAID_SNAPSHOT_HEADER = "x-braid-snapshot";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The worker's own version boundary.
3
+ *
4
+ * A service worker is a long-lived deployment artifact that updates on its own schedule, which
5
+ * makes it a skew vector inside a system whose entire purpose is managing skew. It gets the same
6
+ * discipline as everything else here: it knows its build and the registry snapshot it is serving,
7
+ * and when those disagree with the page it is answering for, it says so.
8
+ *
9
+ * The alternative is the failure worth designing against — a worker that quietly serves yesterday's
10
+ * assets to today's document. That does not look like a worker problem to anyone debugging it. It
11
+ * looks like an application bug, in an application that is fine.
12
+ */
13
+ /**
14
+ * Compares the worker's version with the page's.
15
+ *
16
+ * Deliberately not an error: a mismatch is usually transient and self-healing — a deploy landed
17
+ * while a tab was open, and the worker updates on the next navigation. What it must never be is
18
+ * invisible, so this returns something a host can log, report, or show, and the prebuilt worker
19
+ * always logs it.
20
+ */
21
+ export function diagnoseSkew(worker, page) {
22
+ const base = { worker, page };
23
+ if (worker.snapshotId && page.snapshotId && worker.snapshotId !== page.snapshotId) {
24
+ return {
25
+ ...base,
26
+ severity: 'snapshot-mismatch',
27
+ message: `braid-sw: this worker is composing against registry snapshot "${worker.snapshotId}" and the ` +
28
+ `page was rendered against "${page.snapshotId}" — the two disagree about which fragments exist`,
29
+ fixHint: 'the worker updates on the next navigation; if it persists, the gateway is serving a worker ' +
30
+ 'pinned to a snapshot the shell no longer deploys',
31
+ };
32
+ }
33
+ if (worker.buildId && page.buildId && worker.buildId !== page.buildId) {
34
+ // Which side is stale is not decidable from two opaque ids, and guessing would produce a
35
+ // confident, wrong instruction. Both directions are named; the fix is the same either way.
36
+ return {
37
+ ...base,
38
+ severity: 'worker-behind',
39
+ message: `braid-sw: worker build "${worker.buildId}" is serving a page built as "${page.buildId}" — ` +
40
+ 'assets may be answered from the older build',
41
+ fixHint: 'reload once the worker has activated, or call skipWaiting in your own install handler',
42
+ };
43
+ }
44
+ return { ...base, severity: 'ok', message: 'braid-sw: worker and page agree' };
45
+ }
46
+ /**
47
+ * The header a gateway-served worker can read its page's build from.
48
+ *
49
+ * A header rather than a query parameter on the script URL, on purpose: baking the build into the
50
+ * worker's URL would change the URL on every deploy, and a changed worker URL is a new worker with
51
+ * its own install-and-wait lifecycle. Configuration churn should not become worker churn.
52
+ */
53
+ export const BRAID_BUILD_HEADER = 'x-braid-build';
54
+ export const BRAID_SNAPSHOT_HEADER = 'x-braid-snapshot';
55
+ //# sourceMappingURL=skew-report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skew-report.js","sourceRoot":"","sources":["../../../../../libs/braid-sw/src/lib/skew-report.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAyBH;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,MAAqB,EAAE,IAAiB;IACnE,MAAM,IAAI,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;IAE9B,IAAI,MAAM,CAAC,UAAU,IAAI,IAAI,CAAC,UAAU,IAAI,MAAM,CAAC,UAAU,KAAK,IAAI,CAAC,UAAU,EAAE,CAAC;QAClF,OAAO;YACL,GAAG,IAAI;YACP,QAAQ,EAAE,mBAAmB;YAC7B,OAAO,EACL,iEAAiE,MAAM,CAAC,UAAU,YAAY;gBAC9F,8BAA8B,IAAI,CAAC,UAAU,kDAAkD;YACjG,OAAO,EACL,6FAA6F;gBAC7F,kDAAkD;SACrD,CAAC;IACJ,CAAC;IAED,IAAI,MAAM,CAAC,OAAO,IAAI,IAAI,CAAC,OAAO,IAAI,MAAM,CAAC,OAAO,KAAK,IAAI,CAAC,OAAO,EAAE,CAAC;QACtE,yFAAyF;QACzF,2FAA2F;QAC3F,OAAO;YACL,GAAG,IAAI;YACP,QAAQ,EAAE,eAAe;YACzB,OAAO,EACL,2BAA2B,MAAM,CAAC,OAAO,iCAAiC,IAAI,CAAC,OAAO,MAAM;gBAC5F,6CAA6C;YAC/C,OAAO,EAAE,uFAAuF;SACjG,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,GAAG,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,iCAAiC,EAAE,CAAC;AACjF,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,eAAe,CAAC;AAClD,MAAM,CAAC,MAAM,qBAAqB,GAAG,kBAAkB,CAAC"}
@@ -0,0 +1,61 @@
1
+ import { type BraidFetchHandlerOptions } from './asset-handler.js';
2
+ import { type OfflineCompositionOptions } from './compose.js';
3
+ import { type ReportQueueOptions } from './reports.js';
4
+ /**
5
+ * The complete worker, for shells that do not already have one.
6
+ *
7
+ * Both shapes ship because the assumption that an application already has a service worker does not
8
+ * hold — least of all for internal enterprise apps, which is much of the audience. A shell that has
9
+ * a worker composes {@link braidFetchHandler} into it and keeps ownership of its own `fetch` event;
10
+ * a shell that has none calls this and authors nothing.
11
+ */
12
+ export interface BraidWorkerOptions extends BraidFetchHandlerOptions {
13
+ /** Fragment ids whose realm stubs are precached at install. */
14
+ precache?: readonly string[];
15
+ /** The registry snapshot this worker composes against, for the skew diagnostic. */
16
+ snapshotId?: string;
17
+ /**
18
+ * Take over open pages as soon as this worker activates.
19
+ *
20
+ * Off by default, and that default is the conservative one for a reason: claiming clients swaps
21
+ * the worker underneath a page that is already running, so a page mid-session starts being served
22
+ * by a build it did not load against. Waiting for the next navigation is the boring, correct
23
+ * behaviour; turn this on when you would rather have the update than the continuity.
24
+ */
25
+ claimClients?: boolean;
26
+ /**
27
+ * Compose pages from cache when the network is gone.
28
+ *
29
+ * Opt-in, because it changes what a failed navigation *is*: without it an offline navigation is
30
+ * the browser's offline page, which is at least unambiguous. With it, a route the user has
31
+ * visited before comes back composed — so a shell turning this on should say so on the page, and
32
+ * the `x-braid-composed: offline` header is there to make that possible.
33
+ */
34
+ offline?: OfflineCompositionOptions;
35
+ /**
36
+ * Send the worker's own reports durably, surviving a closed tab.
37
+ *
38
+ * Without this, `onReport` is fire-and-forget and the reports most likely to be lost are the ones
39
+ * worth having: a user who hits a broken deploy is a user who closes the tab.
40
+ */
41
+ reports?: Omit<ReportQueueOptions, 'fetch'>;
42
+ }
43
+ /** The subset of the worker global this uses, so a test can supply one without a worker. */
44
+ export interface WorkerScopeLike {
45
+ addEventListener(type: string, listener: (event: never) => void): void;
46
+ skipWaiting?(): Promise<void>;
47
+ clients?: {
48
+ claim(): Promise<void>;
49
+ };
50
+ registration?: unknown;
51
+ }
52
+ /**
53
+ * Installs Braid's handlers on a worker scope.
54
+ *
55
+ * ```js
56
+ * // sw.js
57
+ * import { setupBraidWorker } from '@braidlabs/sw';
58
+ * setupBraidWorker({ buildId: BUILD_ID, precache: ['billing', 'notifications'] });
59
+ * ```
60
+ */
61
+ export declare function setupBraidWorker(options?: BraidWorkerOptions): void;
@@ -0,0 +1,96 @@
1
+ import { braidFetchHandler, pruneFragmentCaches } from './asset-handler.js';
2
+ import { braidNavigationHandler } from './compose.js';
3
+ import { precacheRealmStubs } from './precache.js';
4
+ import { diagnoseSkew } from './skew-report.js';
5
+ import { BRAID_REPORT_SYNC_TAG, createReportQueue } from './reports.js';
6
+ /**
7
+ * Installs Braid's handlers on a worker scope.
8
+ *
9
+ * ```js
10
+ * // sw.js
11
+ * import { setupBraidWorker } from '@braidlabs/sw';
12
+ * setupBraidWorker({ buildId: BUILD_ID, precache: ['billing', 'notifications'] });
13
+ * ```
14
+ */
15
+ export function setupBraidWorker(options = {}) {
16
+ const scope = globalThis;
17
+ const reports = options.reports
18
+ ? createReportQueue({ ...options.reports, ...(options.fetch ? { fetch: options.fetch } : {}) })
19
+ : null;
20
+ const handler = braidFetchHandler({
21
+ ...options,
22
+ ...(reports
23
+ ? {
24
+ onReport: (report) => {
25
+ options.onReport?.(report);
26
+ // Queued, not sent: the send is the part that loses races with page teardown.
27
+ void reports.record({ kind: 'asset', at: new Date().toISOString(), report });
28
+ },
29
+ }
30
+ : {}),
31
+ });
32
+ const version = {
33
+ ...(options.buildId === undefined ? {} : { buildId: options.buildId }),
34
+ ...(options.snapshotId === undefined ? {} : { snapshotId: options.snapshotId }),
35
+ };
36
+ scope.addEventListener('install', (event) => {
37
+ if (!options.precache?.length)
38
+ return;
39
+ // The precache is best-effort by construction: a worker that refuses to install because one
40
+ // fragment's origin blinked is worse than one that installs and fetches that stub on demand.
41
+ event.waitUntil?.(precacheRealmStubs(options.precache, options).then((result) => {
42
+ for (const failure of result.failed) {
43
+ console.warn(`braid-sw: could not precache ${failure.url} — ${failure.reason}`);
44
+ }
45
+ }));
46
+ });
47
+ scope.addEventListener('activate', (event) => {
48
+ const work = [];
49
+ if (options.precache?.length)
50
+ work.push(pruneFragmentCaches(options.precache, options));
51
+ if (options.claimClients && scope.clients)
52
+ work.push(scope.clients.claim());
53
+ // Background Sync is Chromium-only, so every activation also flushes opportunistically. A
54
+ // browser without `sync` gets this path and nothing else; one with it gets both.
55
+ if (reports)
56
+ work.push(reports.flush());
57
+ if (work.length)
58
+ event.waitUntil?.(Promise.all(work));
59
+ });
60
+ /**
61
+ * The durable path: the browser wakes the worker when it judges the network is back, with no page
62
+ * open and nothing racing teardown.
63
+ */
64
+ if (reports) {
65
+ scope.addEventListener('sync', (event) => {
66
+ if (event.tag !== BRAID_REPORT_SYNC_TAG)
67
+ return;
68
+ event.waitUntil?.(reports.flush());
69
+ });
70
+ }
71
+ const navigate = options.offline ? braidNavigationHandler({ ...options, ...options.offline }) : null;
72
+ scope.addEventListener('fetch', (event) => {
73
+ // Assets first: they are the overwhelming majority, and a navigation is never one.
74
+ const handled = handler(event.request) ?? navigate?.(event.request);
75
+ if (handled)
76
+ event.respondWith?.(handled);
77
+ });
78
+ /**
79
+ * The page introduces itself, and the worker answers with what it is.
80
+ *
81
+ * A page cannot read a worker's build any other way, and the mismatch this surfaces is precisely
82
+ * the one that otherwise presents as an application bug in an application that is fine.
83
+ */
84
+ scope.addEventListener('message', (event) => {
85
+ const data = event.data;
86
+ if (data?.type !== 'braid-sw:hello')
87
+ return;
88
+ const diagnosis = diagnoseSkew(version, data.page ?? {});
89
+ if (diagnosis.severity !== 'ok') {
90
+ console.warn(diagnosis.message);
91
+ void reports?.record({ kind: 'skew', at: new Date().toISOString(), diagnosis });
92
+ }
93
+ event.source?.postMessage?.({ type: 'braid-sw:version', diagnosis });
94
+ });
95
+ }
96
+ //# sourceMappingURL=worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.js","sourceRoot":"","sources":["../../../../../libs/braid-sw/src/lib/worker.ts"],"names":[],"mappings":"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"}