@ultimat3/pwa 1.0.0

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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The four caching strategies as named functions, plus the render-mode → strategy table.
3
+ * You never choose a strategy by hand: the route's render mode already encodes how fresh
4
+ * its bytes have to be, so the mapping is derived and the override is the exception.
5
+ */
6
+
7
+ import { PwaStrategyExhaustedError } from './errors';
8
+
9
+ export type StrategyName =
10
+ | 'cache-first'
11
+ | 'network-first'
12
+ | 'stale-while-revalidate'
13
+ | 'network-only';
14
+
15
+ export const STRATEGY_NAMES: readonly StrategyName[] = [
16
+ 'cache-first',
17
+ 'network-first',
18
+ 'stale-while-revalidate',
19
+ 'network-only',
20
+ ];
21
+
22
+ export type PwaRenderMode = 'static' | 'isr' | 'ssr' | 'stream' | 'spa';
23
+ export type PwaOfflineStrategy = 'precache' | 'runtime' | 'network-only';
24
+
25
+ /**
26
+ * Structural view of `@ultimat3/render`'s `RouteDescriptor`. Tier-4 packages must not
27
+ * import each other, so route data arrives as data and this is the shape it must have.
28
+ */
29
+ export interface PwaRoute {
30
+ readonly path: string;
31
+ readonly surface: 'site' | 'app' | 'api';
32
+ readonly mode: PwaRenderMode;
33
+ readonly offline: PwaOfflineStrategy;
34
+ readonly dynamic?: boolean;
35
+ /** Explicit per-route override; wins over the derived strategy. */
36
+ readonly strategy?: StrategyName;
37
+ /** Content hash of the built HTML — the precache revision. */
38
+ readonly revision?: string;
39
+ readonly bytes?: number;
40
+ /** Companion data endpoint precached alongside the HTML. */
41
+ readonly dataUrl?: string;
42
+ }
43
+
44
+ /** Render mode → runtime strategy. The whole reason `sw.js` is generated, not written. */
45
+ export const MODE_STRATEGY: Readonly<Record<PwaRenderMode, StrategyName>> = Object.freeze({
46
+ static: 'cache-first',
47
+ isr: 'stale-while-revalidate',
48
+ ssr: 'network-first',
49
+ stream: 'stale-while-revalidate',
50
+ spa: 'cache-first',
51
+ });
52
+
53
+ export function strategyFor(route: PwaRoute): StrategyName {
54
+ if (route.strategy !== undefined) return route.strategy;
55
+ // `network-only` is a declaration that this URL must never be answered from a cache.
56
+ if (route.offline === 'network-only') return 'network-only';
57
+ return MODE_STRATEGY[route.mode];
58
+ }
59
+
60
+ export interface StrategyEnv {
61
+ /** The named cache this strategy reads and writes. */
62
+ open(cacheName: string): Promise<StrategyCache>;
63
+ fetch(request: Request): Promise<Response>;
64
+ }
65
+
66
+ export interface StrategyCache {
67
+ match(request: Request): Promise<Response | undefined>;
68
+ put(request: Request, response: Response): Promise<void>;
69
+ }
70
+
71
+ export interface StrategyOptions {
72
+ readonly cacheName: string;
73
+ /** Served when the network fails and the cache is empty. */
74
+ readonly fallback?: () => Promise<Response>;
75
+ }
76
+
77
+ export async function cacheFirst(
78
+ request: Request,
79
+ env: StrategyEnv,
80
+ options: StrategyOptions,
81
+ ): Promise<Response> {
82
+ const cache = await env.open(options.cacheName);
83
+ const hit = await cache.match(request);
84
+ if (hit !== undefined) return hit;
85
+ return fetchAndStore(request, env, cache, options);
86
+ }
87
+
88
+ export async function networkFirst(
89
+ request: Request,
90
+ env: StrategyEnv,
91
+ options: StrategyOptions,
92
+ ): Promise<Response> {
93
+ const cache = await env.open(options.cacheName);
94
+ try {
95
+ const response = await env.fetch(request);
96
+ if (response.ok) await cache.put(request, response.clone());
97
+ return response;
98
+ } catch (error) {
99
+ const hit = await cache.match(request);
100
+ if (hit !== undefined) return hit;
101
+ if (options.fallback !== undefined) return options.fallback();
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ /** Answer from the cache immediately, refresh behind the response. */
107
+ export async function staleWhileRevalidate(
108
+ request: Request,
109
+ env: StrategyEnv,
110
+ options: StrategyOptions,
111
+ ): Promise<Response> {
112
+ const cache = await env.open(options.cacheName);
113
+ const hit = await cache.match(request);
114
+ const refresh = env
115
+ .fetch(request)
116
+ .then(async (response) => {
117
+ if (response.ok) await cache.put(request, response.clone());
118
+ return response;
119
+ })
120
+ .catch(async () => (hit !== undefined ? hit : fallbackOrThrow(options)));
121
+
122
+ if (hit !== undefined) {
123
+ void refresh.catch(() => undefined);
124
+ return hit;
125
+ }
126
+ return refresh;
127
+ }
128
+
129
+ export async function networkOnly(
130
+ request: Request,
131
+ env: StrategyEnv,
132
+ options: StrategyOptions,
133
+ ): Promise<Response> {
134
+ try {
135
+ return await env.fetch(request);
136
+ } catch (error) {
137
+ if (options.fallback !== undefined) return options.fallback();
138
+ throw error;
139
+ }
140
+ }
141
+
142
+ export const STRATEGY_FNS: Readonly<
143
+ Record<
144
+ StrategyName,
145
+ (request: Request, env: StrategyEnv, options: StrategyOptions) => Promise<Response>
146
+ >
147
+ > = Object.freeze({
148
+ 'cache-first': cacheFirst,
149
+ 'network-first': networkFirst,
150
+ 'stale-while-revalidate': staleWhileRevalidate,
151
+ 'network-only': networkOnly,
152
+ });
153
+
154
+ async function fetchAndStore(
155
+ request: Request,
156
+ env: StrategyEnv,
157
+ cache: StrategyCache,
158
+ options: StrategyOptions,
159
+ ): Promise<Response> {
160
+ try {
161
+ const response = await env.fetch(request);
162
+ if (response.ok) await cache.put(request, response.clone());
163
+ return response;
164
+ } catch (error) {
165
+ if (options.fallback !== undefined) return options.fallback();
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ async function fallbackOrThrow(options: StrategyOptions): Promise<Response> {
171
+ if (options.fallback !== undefined) return options.fallback();
172
+ throw new PwaStrategyExhaustedError({ cacheName: options.cacheName });
173
+ }
174
+
175
+ /**
176
+ * The emitted counterpart of the functions above. Kept as source strings because the
177
+ * service worker is a generated artifact with no bundler in the loop — the shapes are
178
+ * identical on purpose and `strategies.test.ts` asserts both halves stay in step.
179
+ */
180
+ export const STRATEGY_SOURCE: Readonly<Record<StrategyName, string>> = Object.freeze({
181
+ 'cache-first': `async function cacheFirst(req,cn,fb){
182
+ const c=await caches.open(cn);const hit=await c.match(req);if(hit)return hit;
183
+ try{const r=await fetch(req);if(r.ok)await c.put(req,r.clone());return r}catch(e){if(fb)return fb();throw e}
184
+ }`,
185
+ 'network-first': `async function networkFirst(req,cn,fb){
186
+ const c=await caches.open(cn);
187
+ try{const r=await fetch(req);if(r.ok)await c.put(req,r.clone());return r}
188
+ catch(e){const hit=await c.match(req);if(hit)return hit;if(fb)return fb();throw e}
189
+ }`,
190
+ 'stale-while-revalidate': `async function staleWhileRevalidate(req,cn,fb){
191
+ const c=await caches.open(cn);const hit=await c.match(req);
192
+ const refresh=fetch(req).then(async(r)=>{if(r.ok)await c.put(req,r.clone());return r})
193
+ .catch(()=>hit||(fb?fb():Response.error()));
194
+ if(hit){refresh.catch(()=>{});return hit}
195
+ return refresh
196
+ }`,
197
+ 'network-only': `async function networkOnly(req,cn,fb){
198
+ try{return await fetch(req)}catch(e){if(fb)return fb();throw e}
199
+ }`,
200
+ });
201
+
202
+ export const STRATEGY_FN_NAMES: Readonly<Record<StrategyName, string>> = Object.freeze({
203
+ 'cache-first': 'cacheFirst',
204
+ 'network-first': 'networkFirst',
205
+ 'stale-while-revalidate': 'staleWhileRevalidate',
206
+ 'network-only': 'networkOnly',
207
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Version skew — what actually breaks PWAs. A client that loaded build A keeps running
3
+ * for hours; build B deletes A's chunks; the next lazy import 404s and the app dies with
4
+ * a blank screen. Nothing here is optional:
5
+ *
6
+ * immutable build id per deploy → the client sends it on every request →
7
+ * old builds' assets are retained for N deploys → a stale client gets
8
+ * `AppUpdateAvailable`, never a 404 → forced reload only after a grace period.
9
+ */
10
+
11
+ import { BuildIdMissingError } from './errors';
12
+
13
+ export const BUILD_ID_HEADER = 'x-ultimate-build';
14
+ export const BUILD_ID_META = 'x-ultimate-build';
15
+
16
+ export type DeployChannel = 'production' | 'preview' | 'branch';
17
+
18
+ export interface BuildIdInput {
19
+ /** Preferred source: the commit is the deploy's real identity. */
20
+ readonly gitSha?: string;
21
+ /** Milliseconds; only used when there is no sha. */
22
+ readonly timestamp?: number;
23
+ readonly channel?: DeployChannel;
24
+ /** Branch or PR slug; keeps preview ids from ever colliding with production. */
25
+ readonly ref?: string;
26
+ }
27
+
28
+ /**
29
+ * Deterministic for a given input — two builds of the same commit on the same channel
30
+ * produce the same id, so a rebuild does not needlessly evict every client's cache.
31
+ */
32
+ export function buildId(input: BuildIdInput = {}): string {
33
+ const channel = input.channel ?? 'production';
34
+ const base = input.gitSha ?? String(input.timestamp ?? 0);
35
+ if (base === '' || base === '0') {
36
+ throw new BuildIdMissingError(
37
+ 'cannot derive a build id: no gitSha and no timestamp',
38
+ 'set GIT_SHA in the build environment (docker build --build-arg GIT_SHA=$(git rev-parse HEAD))',
39
+ );
40
+ }
41
+ const short = base.slice(0, 12);
42
+ const ref = input.ref === undefined ? '' : `-${slug(input.ref)}`;
43
+ return channel === 'production' ? short : `${channel}${ref}-${short}`;
44
+ }
45
+
46
+ function slug(value: string): string {
47
+ return value
48
+ .toLowerCase()
49
+ .replace(/[^a-z0-9]+/g, '-')
50
+ .replace(/^-|-$/g, '')
51
+ .slice(0, 24);
52
+ }
53
+
54
+ export function assertBuildId(value: string | undefined | null): asserts value is string {
55
+ if (value === undefined || value === null || value.trim() === '') {
56
+ throw new BuildIdMissingError(
57
+ 'the build id is empty, so caches cannot be keyed and skew cannot be detected',
58
+ 'set GIT_SHA in the build environment and pass it to generateServiceWorker(routes, config, buildId)',
59
+ );
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Every cache name carries the build id, so a preview deploy physically cannot write into
65
+ * the production cache even on the same origin — the classic way a branch deploy poisons
66
+ * production for everyone who visited it once.
67
+ */
68
+ export function cacheNamespace(id: string, kind: 'precache' | 'runtime' | 'pages'): string {
69
+ assertBuildId(id);
70
+ return `x-${kind}-${id}`;
71
+ }
72
+
73
+ export interface Deploy {
74
+ readonly buildId: string;
75
+ /** Epoch milliseconds. */
76
+ readonly deployedAt: number;
77
+ readonly channel?: DeployChannel;
78
+ }
79
+
80
+ export interface RetentionPlan {
81
+ readonly retain: readonly string[];
82
+ readonly evict: readonly string[];
83
+ readonly caches: readonly string[];
84
+ }
85
+
86
+ /**
87
+ * Keep the last N builds' assets alive. N is how many deploys a tab may sit open across
88
+ * before it is allowed to break; 3 is the sane default for a daily-deploy team.
89
+ */
90
+ export function retentionPlan(deploys: readonly Deploy[], keep = 3): RetentionPlan {
91
+ const ordered = [...deploys].sort((a, b) => b.deployedAt - a.deployedAt);
92
+ const retained = ordered.slice(0, Math.max(1, keep));
93
+ const evicted = ordered.slice(Math.max(1, keep));
94
+ return {
95
+ retain: retained.map((d) => d.buildId),
96
+ evict: evicted.map((d) => d.buildId),
97
+ caches: retained.flatMap((d) => [
98
+ cacheNamespace(d.buildId, 'precache'),
99
+ cacheNamespace(d.buildId, 'runtime'),
100
+ cacheNamespace(d.buildId, 'pages'),
101
+ ]),
102
+ };
103
+ }
104
+
105
+ export type SkewState = 'current' | 'stale' | 'unknown';
106
+
107
+ /** `unknown` means "no id sent" — a first load, a crawler, or a cache-busted client. */
108
+ export function detectSkew(
109
+ clientBuildId: string | null | undefined,
110
+ serverBuildId: string,
111
+ ): SkewState {
112
+ if (clientBuildId === null || clientBuildId === undefined || clientBuildId.trim() === '') {
113
+ return 'unknown';
114
+ }
115
+ return clientBuildId === serverBuildId ? 'current' : 'stale';
116
+ }
117
+
118
+ export type ForceReason = 'security' | 'breaking-protocol' | 'never';
119
+
120
+ export interface UpdatePolicyInput {
121
+ /** How long a stale client may keep running before the reload is forced. */
122
+ readonly graceMs?: number;
123
+ readonly forceOn?: readonly ForceReason[];
124
+ }
125
+
126
+ export interface UpdatePolicy {
127
+ readonly graceMs: number;
128
+ readonly forceOn: readonly ForceReason[];
129
+ shouldForce(reason: ForceReason, staleForMs: number): boolean;
130
+ }
131
+
132
+ export const DEFAULT_GRACE_MS = 6 * 60 * 60 * 1000;
133
+
134
+ export function updatePolicy(input: UpdatePolicyInput = {}): UpdatePolicy {
135
+ const graceMs = input.graceMs ?? DEFAULT_GRACE_MS;
136
+ const forceOn = input.forceOn ?? ['security'];
137
+ return {
138
+ graceMs,
139
+ forceOn,
140
+ // A security patch still respects the grace window; it just does not wait forever.
141
+ shouldForce: (reason, staleForMs) => forceOn.includes(reason) && staleForMs >= graceMs,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * The client-side contract. The SW posts this to every controlled page; the app shows an
147
+ * unobtrusive "refresh to update" affordance and reloads on the user's terms — unless
148
+ * `forced`, in which case it reloads at `deadlineAt`.
149
+ */
150
+ export interface AppUpdateAvailable {
151
+ readonly type: 'AppUpdateAvailable';
152
+ readonly from: string;
153
+ readonly to: string;
154
+ readonly forced: boolean;
155
+ /** Epoch milliseconds after which the client reloads itself. Null when not forced. */
156
+ readonly deadlineAt: number | null;
157
+ }
158
+
159
+ export const APP_UPDATE_AVAILABLE = 'AppUpdateAvailable' as const;
160
+
161
+ export interface UpdateSignalInput {
162
+ readonly clientBuildId: string | null | undefined;
163
+ readonly serverBuildId: string;
164
+ readonly policy: UpdatePolicy;
165
+ readonly reason?: ForceReason;
166
+ readonly staleForMs?: number;
167
+ readonly now?: number;
168
+ }
169
+
170
+ /** Null when the client is current or unknown — no signal, no nag. */
171
+ export function updateSignal(input: UpdateSignalInput): AppUpdateAvailable | null {
172
+ const state = detectSkew(input.clientBuildId, input.serverBuildId);
173
+ if (state !== 'stale') return null;
174
+
175
+ const reason = input.reason ?? 'never';
176
+ const staleForMs = input.staleForMs ?? 0;
177
+ const forced = input.policy.shouldForce(reason, staleForMs);
178
+ const now = input.now ?? Date.now();
179
+
180
+ return {
181
+ type: APP_UPDATE_AVAILABLE,
182
+ from: String(input.clientBuildId),
183
+ to: input.serverBuildId,
184
+ forced,
185
+ deadlineAt: forced ? now : null,
186
+ };
187
+ }