@mercuryworkshop/proxy-bootstrap 0.0.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/dist/.downloads/controller/package/dist/controller.api.js +44 -0
- package/dist/.downloads/controller/package/dist/controller.api.js.map +1 -0
- package/dist/.downloads/controller/package/dist/controller.inject.js +2 -0
- package/dist/.downloads/controller/package/dist/controller.inject.js.map +1 -0
- package/dist/.downloads/controller/package/dist/controller.sw.js +2 -0
- package/dist/.downloads/controller/package/dist/controller.sw.js.map +1 -0
- package/dist/.downloads/controller/package/dist/types/cache.d.ts +39 -0
- package/dist/.downloads/controller/package/dist/types/index.d.ts +74 -0
- package/dist/.downloads/controller/package/dist/types/inject.d.ts +16 -0
- package/dist/.downloads/controller/package/dist/types/sw.d.ts +2 -0
- package/dist/.downloads/controller/package/dist/types/symbols.d.ts +1 -0
- package/dist/.downloads/controller/package/dist/types/typesEntry.d.ts +5 -0
- package/dist/.downloads/controller/package/package.json +16 -0
- package/dist/.downloads/controller/package/src/cache.ts +473 -0
- package/dist/.downloads/controller/package/src/index.ts +809 -0
- package/dist/.downloads/controller/package/src/inject.ts +370 -0
- package/dist/.downloads/controller/package/src/sw.ts +231 -0
- package/dist/.downloads/controller/package/src/symbols.ts +1 -0
- package/dist/.downloads/controller/package/src/types.d.ts +139 -0
- package/dist/.downloads/controller/package/src/typesEntry.ts +6 -0
- package/dist/.downloads/controller/package/tsconfig.json +24 -0
- package/dist/.downloads/controller/package/tsconfig.types.json +16 -0
- package/dist/.downloads/libcurl-transport/package/LICENSE +661 -0
- package/dist/.downloads/libcurl-transport/package/README.md +52 -0
- package/dist/.downloads/libcurl-transport/package/dist/index.d.ts +25 -0
- package/dist/.downloads/libcurl-transport/package/dist/index.js +6500 -0
- package/dist/.downloads/libcurl-transport/package/dist/index.mjs +6481 -0
- package/dist/.downloads/libcurl-transport/package/package.json +37 -0
- package/dist/.downloads/scramjet/package/dist/167400cb144aab22.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/2919e49b986edf8c.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/5aed1d5e48aab205.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/882d77912a3c8e3a.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/ac6aa30297a80464.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/c10a57758af882c8.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/cfd04aaae6955b67.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/d06a90fd413b36cf.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/dda06914899a6c28.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/scramjet.js +34 -0
- package/dist/.downloads/scramjet/package/dist/scramjet.js.map +1 -0
- package/dist/.downloads/scramjet/package/dist/scramjet.mjs +34 -0
- package/dist/.downloads/scramjet/package/dist/scramjet.mjs.map +1 -0
- package/dist/.downloads/scramjet/package/dist/scramjet.wasm +0 -0
- package/dist/.downloads/scramjet/package/dist/scramjet_bundled.js +34 -0
- package/dist/.downloads/scramjet/package/dist/scramjet_bundled.js.map +1 -0
- package/dist/.downloads/scramjet/package/dist/scramjet_bundled.mjs +34 -0
- package/dist/.downloads/scramjet/package/dist/scramjet_bundled.mjs.map +1 -0
- package/dist/.downloads/scramjet/package/dist/types/Tap.d.ts +32 -0
- package/dist/.downloads/scramjet/package/dist/types/client/client.d.ts +115 -0
- package/dist/.downloads/scramjet/package/dist/types/client/entry.d.ts +5 -0
- package/dist/.downloads/scramjet/package/dist/types/client/events.d.ts +10 -0
- package/dist/.downloads/scramjet/package/dist/types/client/global.d.ts +4 -0
- package/dist/.downloads/scramjet/package/dist/types/client/helpers.d.ts +1 -0
- package/dist/.downloads/scramjet/package/dist/types/client/index.d.ts +7 -0
- package/dist/.downloads/scramjet/package/dist/types/client/location.d.ts +2 -0
- package/dist/.downloads/scramjet/package/dist/types/client/shared/eval.d.ts +3 -0
- package/dist/.downloads/scramjet/package/dist/types/client/shared/sourcemaps.d.ts +19 -0
- package/dist/.downloads/scramjet/package/dist/types/client/shared/unproxy.d.ts +19 -0
- package/dist/.downloads/scramjet/package/dist/types/client/shared/wrap.d.ts +4 -0
- package/dist/.downloads/scramjet/package/dist/types/client/singletonbox.d.ts +16 -0
- package/dist/.downloads/scramjet/package/dist/types/client/unproxy.generated.d.ts +50 -0
- package/dist/.downloads/scramjet/package/dist/types/fetch/body.d.ts +3 -0
- package/dist/.downloads/scramjet/package/dist/types/fetch/fetch.d.ts +7 -0
- package/dist/.downloads/scramjet/package/dist/types/fetch/headers.d.ts +19 -0
- package/dist/.downloads/scramjet/package/dist/types/fetch/index.d.ts +128 -0
- package/dist/.downloads/scramjet/package/dist/types/fetch/parse.d.ts +22 -0
- package/dist/.downloads/scramjet/package/dist/types/fetch/util.d.ts +7 -0
- package/dist/.downloads/scramjet/package/dist/types/index.d.ts +11 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/cookie.d.ts +26 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/headers.d.ts +13 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/htmlRules.d.ts +6 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/index.d.ts +51 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/mime.d.ts +39 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/refresh.d.ts +7 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/rewriters/css.d.ts +4 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/rewriters/html.d.ts +33 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/rewriters/index.d.ts +6 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/rewriters/js.d.ts +11 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/rewriters/url.d.ts +25 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/rewriters/wasm.d.ts +7 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/rewriters/worker.d.ts +3 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/set-cookie-parser.d.ts +20 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/snapshot.d.ts +236 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/sniffEncoding.d.ts +65 -0
- package/dist/.downloads/scramjet/package/dist/types/shared/util.d.ts +2 -0
- package/dist/.downloads/scramjet/package/dist/types/symbols.d.ts +6 -0
- package/dist/.downloads/scramjet/package/dist/types/types.d.ts +68 -0
- package/dist/.downloads/scramjet/package/lib/index.cjs +7 -0
- package/dist/.downloads/scramjet/package/lib/index.d.ts +8 -0
- package/dist/.downloads/scramjet/package/lib/types.d.ts +20 -0
- package/dist/.downloads/scramjet/package/package.json +93 -0
- package/dist/bootstrap-client.js +169 -0
- package/dist/bootstrap-client.js.map +1 -0
- package/dist/bootstrap-server.js +406 -0
- package/dist/bootstrap-server.js.map +1 -0
- package/dist/bootstrap-static.js +476 -0
- package/dist/bootstrap-static.js.map +1 -0
- package/dist/types/client.d.ts +4 -0
- package/dist/types/clientcommon.d.ts +2 -0
- package/dist/types/common.d.ts +30 -0
- package/dist/types/server.d.ts +24 -0
- package/dist/types/static.d.ts +1 -0
- package/package.json +30 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// HTTP cache plugin for ScramjetFetchHandler.
|
|
2
|
+
//
|
|
3
|
+
// Service-worker `fetch` ignores the browser's HTTP cache, so without this
|
|
4
|
+
// every navigation re-runs the full network fetch even for unchanged
|
|
5
|
+
// resources. This plugin caches the **upstream** response (the BareResponse
|
|
6
|
+
// as received from the network, BEFORE rewriteResponseHeaders / rewriteBody
|
|
7
|
+
// run). On a hit we hand that same untouched response to the pipeline, which
|
|
8
|
+
// then re-rewrites with the current Frame's prefix.
|
|
9
|
+
//
|
|
10
|
+
// Storing pre-rewrite means:
|
|
11
|
+
// - The cache is shared across Frames, Controllers, and page reloads --
|
|
12
|
+
// one Frame's hit serves another Frame's request because the stored
|
|
13
|
+
// bytes contain only the upstream's URLs, not any frame-bound prefix.
|
|
14
|
+
// - Redirect Location / Content-Location and Link headers come out of
|
|
15
|
+
// `rewriteResponseHeaders` correctly on each hit, because that runs
|
|
16
|
+
// on the cache-derived response just like a fresh one.
|
|
17
|
+
// - We don't skip the rewriter on hit; we only skip the network. That's
|
|
18
|
+
// where the win actually is for service-worker proxying.
|
|
19
|
+
//
|
|
20
|
+
// Implementation aims for RFC 9111 (HTTP caching) compliance for a
|
|
21
|
+
// PRIVATE cache (browser-local, single-user):
|
|
22
|
+
//
|
|
23
|
+
// - Only GET / HEAD are cached.
|
|
24
|
+
// - Cacheable status codes per RFC 9110 §15.1: 200 203 204 206 300 301 308
|
|
25
|
+
// 404 405 410 414 501. Other statuses pass through.
|
|
26
|
+
// - `Cache-Control: no-store` and `Vary: *` opt out.
|
|
27
|
+
// - Freshness:
|
|
28
|
+
// 1. `Cache-Control: s-maxage` (private cache treats this same as
|
|
29
|
+
// max-age),
|
|
30
|
+
// 2. `Cache-Control: max-age`,
|
|
31
|
+
// 3. `Expires`,
|
|
32
|
+
// 4. heuristic 10% × (Date - Last-Modified) per RFC 9111 §4.2.2.
|
|
33
|
+
// - `Cache-Control: no-cache` / `Pragma: no-cache` / `Cache-Control:
|
|
34
|
+
// immutable` are honoured.
|
|
35
|
+
// - `Vary` is honoured by storing one entry per (URL × selected-headers)
|
|
36
|
+
// pair via the underlying Cache API's built-in matching.
|
|
37
|
+
//
|
|
38
|
+
// 304 revalidation isn't handled here yet -- stale entries fall through to
|
|
39
|
+
// a full refetch. Adding it cleanly requires a hook position that lets us
|
|
40
|
+
// substitute the cached body AFTER the network 304 arrives but BEFORE
|
|
41
|
+
// `rewriteBody` runs, without going through `rewriteBody` again. That can
|
|
42
|
+
// come later.
|
|
43
|
+
|
|
44
|
+
import type * as ScramjetGlobal from "@mercuryworkshop/scramjet";
|
|
45
|
+
import { BareResponse } from "@mercuryworkshop/proxy-transports";
|
|
46
|
+
declare const $scramjet: typeof ScramjetGlobal;
|
|
47
|
+
|
|
48
|
+
export const CACHE_NAME = "scramjet-http-cache-v2";
|
|
49
|
+
|
|
50
|
+
/** Header recording when this entry entered the cache (ms since epoch). */
|
|
51
|
+
const STORED_AT_HEADER = "x-sj-cached-at";
|
|
52
|
+
|
|
53
|
+
/** Status codes RFC 9110 §15.1 marks as "cacheable by default". */
|
|
54
|
+
const DEFAULT_CACHEABLE_STATUSES = new Set([
|
|
55
|
+
200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501,
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Statuses for which the Fetch spec forbids a body. The Response constructor
|
|
60
|
+
* throws TypeError if you pair any of these with a body -- even an empty
|
|
61
|
+
* string or 0-byte buffer.
|
|
62
|
+
*/
|
|
63
|
+
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
64
|
+
|
|
65
|
+
interface CacheControlDirectives {
|
|
66
|
+
"no-store"?: boolean;
|
|
67
|
+
"no-cache"?: boolean;
|
|
68
|
+
"must-revalidate"?: boolean;
|
|
69
|
+
"proxy-revalidate"?: boolean;
|
|
70
|
+
private?: boolean;
|
|
71
|
+
public?: boolean;
|
|
72
|
+
"max-age"?: number;
|
|
73
|
+
"s-maxage"?: number;
|
|
74
|
+
"stale-while-revalidate"?: number;
|
|
75
|
+
"stale-if-error"?: number;
|
|
76
|
+
immutable?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseCacheControl(value: string | null): CacheControlDirectives {
|
|
80
|
+
const out: CacheControlDirectives = {};
|
|
81
|
+
if (!value) return out;
|
|
82
|
+
for (const raw of value.split(",")) {
|
|
83
|
+
const part = raw.trim();
|
|
84
|
+
if (!part) continue;
|
|
85
|
+
const eq = part.indexOf("=");
|
|
86
|
+
const name = (eq === -1 ? part : part.slice(0, eq))
|
|
87
|
+
.trim()
|
|
88
|
+
.toLowerCase() as keyof CacheControlDirectives;
|
|
89
|
+
if (eq === -1) {
|
|
90
|
+
(out as any)[name] = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
let v = part.slice(eq + 1).trim();
|
|
94
|
+
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
95
|
+
if (
|
|
96
|
+
name === "max-age" ||
|
|
97
|
+
name === "s-maxage" ||
|
|
98
|
+
name === "stale-while-revalidate" ||
|
|
99
|
+
name === "stale-if-error"
|
|
100
|
+
) {
|
|
101
|
+
const n = parseInt(v, 10);
|
|
102
|
+
if (Number.isFinite(n) && n >= 0) (out as any)[name] = n;
|
|
103
|
+
} else {
|
|
104
|
+
(out as any)[name] = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* RFC 9111 §4.2.1 freshness lifetime calculation, simplified for a private
|
|
112
|
+
* cache (so s-maxage is treated identically to max-age).
|
|
113
|
+
*/
|
|
114
|
+
function freshnessLifetimeSeconds(
|
|
115
|
+
headers: Headers,
|
|
116
|
+
cc: CacheControlDirectives,
|
|
117
|
+
dateMs: number
|
|
118
|
+
): number | null {
|
|
119
|
+
if (cc["s-maxage"] !== undefined) return cc["s-maxage"];
|
|
120
|
+
if (cc["max-age"] !== undefined) return cc["max-age"];
|
|
121
|
+
|
|
122
|
+
const expires = headers.get("expires");
|
|
123
|
+
if (expires) {
|
|
124
|
+
const expMs = Date.parse(expires);
|
|
125
|
+
if (Number.isFinite(expMs)) {
|
|
126
|
+
return Math.max(0, (expMs - dateMs) / 1000);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const lastModified = headers.get("last-modified");
|
|
131
|
+
if (lastModified) {
|
|
132
|
+
const lmMs = Date.parse(lastModified);
|
|
133
|
+
if (Number.isFinite(lmMs) && lmMs <= dateMs) {
|
|
134
|
+
// RFC 9111 §4.2.2 heuristic: 10% of the time since Last-Modified.
|
|
135
|
+
return ((dateMs - lmMs) * 0.1) / 1000;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Current age (seconds) of a stored response per RFC 9111 §4.2.3. */
|
|
143
|
+
function currentAgeSeconds(headers: Headers, storedAtMs: number): number {
|
|
144
|
+
const ageHeader = headers.get("age");
|
|
145
|
+
const initialAge = ageHeader ? parseInt(ageHeader, 10) || 0 : 0;
|
|
146
|
+
const residentTime = (Date.now() - storedAtMs) / 1000;
|
|
147
|
+
return initialAge + residentTime;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isCacheableMethod(method: string): boolean {
|
|
151
|
+
return method === "GET" || method === "HEAD";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether a response (status + Cache-Control + Vary) is allowed to be stored.
|
|
156
|
+
* RFC 9110 §15.1 + RFC 9111 §3. `headers` is the upstream's raw response
|
|
157
|
+
* headers, not yet through scramjet's response-header rewriter.
|
|
158
|
+
*/
|
|
159
|
+
function responseIsStorable(
|
|
160
|
+
status: number,
|
|
161
|
+
headers: Headers,
|
|
162
|
+
method: string
|
|
163
|
+
): boolean {
|
|
164
|
+
if (!isCacheableMethod(method)) return false;
|
|
165
|
+
if (!DEFAULT_CACHEABLE_STATUSES.has(status)) return false;
|
|
166
|
+
|
|
167
|
+
const cc = parseCacheControl(headers.get("cache-control"));
|
|
168
|
+
if (cc["no-store"]) return false;
|
|
169
|
+
|
|
170
|
+
// "Vary: *" means "never reusable".
|
|
171
|
+
const vary = headers.get("vary");
|
|
172
|
+
if (vary && vary.split(",").some((v) => v.trim() === "*")) return false;
|
|
173
|
+
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Build a synthetic cache-key Request keyed by the *underlying* URL. */
|
|
178
|
+
function buildCacheKeyRequest(
|
|
179
|
+
parsedUrl: string,
|
|
180
|
+
headers: ScramjetGlobal.ScramjetHeaders
|
|
181
|
+
): Request {
|
|
182
|
+
const native = new Headers();
|
|
183
|
+
for (const [k, v] of headers.toRawHeaders()) {
|
|
184
|
+
try {
|
|
185
|
+
native.append(k, v);
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
const cacheKeyUrl =
|
|
189
|
+
"https://sj-cache.invalid/" + encodeURIComponent(parsedUrl);
|
|
190
|
+
return new Request(cacheKeyUrl, { method: "GET", headers: native });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Rebuild a Headers object from the BareResponse's rawHeaders array. */
|
|
194
|
+
function nativeHeadersFromRaw(
|
|
195
|
+
raw: ReadonlyArray<readonly [string, string]>
|
|
196
|
+
): Headers {
|
|
197
|
+
const h = new Headers();
|
|
198
|
+
for (const [k, v] of raw) {
|
|
199
|
+
try {
|
|
200
|
+
h.append(k, v);
|
|
201
|
+
} catch {
|
|
202
|
+
// some upstream headers (e.g. malformed Set-Cookie) are rejected
|
|
203
|
+
// by the native Headers; just drop them.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return h;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Strip our internal bookkeeping from a stored Response's headers. */
|
|
210
|
+
function strippedHeadersFromStored(stored: Response): Headers {
|
|
211
|
+
const out = new Headers();
|
|
212
|
+
for (const [k, v] of stored.headers.entries()) {
|
|
213
|
+
if (k.toLowerCase() === STORED_AT_HEADER) continue;
|
|
214
|
+
try {
|
|
215
|
+
out.append(k, v);
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Turn an upstream BareResponse into a BareResponse that:
|
|
223
|
+
* - has the same headers/status/statusText
|
|
224
|
+
* - has its body replaced with a buffered ArrayBuffer (so the pipeline can
|
|
225
|
+
* read it again after we've consumed the original stream for the cache)
|
|
226
|
+
* Returns the buffered bytes too so the caller can hand them off elsewhere.
|
|
227
|
+
*/
|
|
228
|
+
async function rebuildBareResponseWithBuffer(
|
|
229
|
+
bare: BareResponse
|
|
230
|
+
): Promise<{ replacement: BareResponse; bodyBuffer: ArrayBuffer | null }> {
|
|
231
|
+
const status = bare.status;
|
|
232
|
+
const isNullBody = NULL_BODY_STATUSES.has(status);
|
|
233
|
+
|
|
234
|
+
const headers = nativeHeadersFromRaw(bare.rawHeaders);
|
|
235
|
+
|
|
236
|
+
if (isNullBody) {
|
|
237
|
+
return {
|
|
238
|
+
replacement: BareResponse.fromNativeResponse(
|
|
239
|
+
new Response(null, {
|
|
240
|
+
status,
|
|
241
|
+
statusText: bare.statusText,
|
|
242
|
+
headers,
|
|
243
|
+
})
|
|
244
|
+
),
|
|
245
|
+
bodyBuffer: null,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const buf = await bare.arrayBuffer();
|
|
250
|
+
return {
|
|
251
|
+
replacement: BareResponse.fromNativeResponse(
|
|
252
|
+
new Response(buf, {
|
|
253
|
+
status,
|
|
254
|
+
statusText: bare.statusText,
|
|
255
|
+
headers,
|
|
256
|
+
})
|
|
257
|
+
),
|
|
258
|
+
bodyBuffer: buf,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Build a `Response` to put in the Cache API. Tags it with our internal
|
|
264
|
+
* STORED_AT_HEADER so freshness can be computed on later lookups.
|
|
265
|
+
*/
|
|
266
|
+
function buildStorableResponse(
|
|
267
|
+
body: ArrayBuffer | null,
|
|
268
|
+
status: number,
|
|
269
|
+
statusText: string,
|
|
270
|
+
rawHeaders: ReadonlyArray<readonly [string, string]>
|
|
271
|
+
): Response {
|
|
272
|
+
const native = nativeHeadersFromRaw(rawHeaders);
|
|
273
|
+
native.set(STORED_AT_HEADER, String(Date.now()));
|
|
274
|
+
return new Response(NULL_BODY_STATUSES.has(status) ? null : body, {
|
|
275
|
+
status,
|
|
276
|
+
statusText,
|
|
277
|
+
headers: native,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface HttpCachePluginOptions {
|
|
282
|
+
/** Name of the underlying Cache API entry. Defaults to CACHE_NAME. */
|
|
283
|
+
cacheName?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* RFC-9111-ish HTTP cache for ScramjetFetchHandler. Subclasses
|
|
288
|
+
* `$scramjet.Plugin` so it composes with the same hook plumbing every other
|
|
289
|
+
* scramjet plugin uses; `install(target)` wires it onto a Frame (or any
|
|
290
|
+
* object exposing a `fetchHandler`), and `bust()` drops the underlying
|
|
291
|
+
* `caches` entry.
|
|
292
|
+
*
|
|
293
|
+
* One instance can be installed onto multiple Frames -- the WeakMap of
|
|
294
|
+
* "did this request come from cache?" book-keeping is per-instance, not
|
|
295
|
+
* per-Frame, so nothing leaks across installs.
|
|
296
|
+
*/
|
|
297
|
+
export class HttpCachePlugin extends $scramjet.Plugin {
|
|
298
|
+
readonly cacheName: string;
|
|
299
|
+
|
|
300
|
+
private cachePromise: Promise<Cache> | null = null;
|
|
301
|
+
// Marks requests whose `earlyResponse` we sourced from the cache, so the
|
|
302
|
+
// preresponse hook below knows not to re-store them. WeakMap keys are
|
|
303
|
+
// the request objects so entries clean themselves up automatically.
|
|
304
|
+
private cameFromCache = new WeakMap<
|
|
305
|
+
ScramjetGlobal.ScramjetFetchRequest,
|
|
306
|
+
true
|
|
307
|
+
>();
|
|
308
|
+
|
|
309
|
+
constructor(options: HttpCachePluginOptions = {}) {
|
|
310
|
+
super("scramjet-http-cache");
|
|
311
|
+
this.cacheName = options.cacheName ?? CACHE_NAME;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Lazy-open the underlying Cache. Memoized for the plugin's lifetime. */
|
|
315
|
+
private openCache(): Promise<Cache> {
|
|
316
|
+
if (!this.cachePromise) {
|
|
317
|
+
this.cachePromise = caches.open(this.cacheName);
|
|
318
|
+
}
|
|
319
|
+
return this.cachePromise;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Wire the cache up to a Frame (or anything exposing `fetchHandler`).
|
|
324
|
+
* Safe to call multiple times across different Frames.
|
|
325
|
+
*/
|
|
326
|
+
install(target: { fetchHandler: ScramjetGlobal.ScramjetFetchHandler }): void {
|
|
327
|
+
const hooks = target.fetchHandler.hooks.fetch;
|
|
328
|
+
|
|
329
|
+
// ----- request: cache lookup --------------------------------------
|
|
330
|
+
this.tap(hooks.request, async (ctx, props) => {
|
|
331
|
+
const req = ctx.request;
|
|
332
|
+
if (!isCacheableMethod(req.method)) return;
|
|
333
|
+
const reqCache = req.cache as string;
|
|
334
|
+
// Honour the request's own cache mode where it asks for fresh data.
|
|
335
|
+
if (reqCache === "no-store" || reqCache === "reload") return;
|
|
336
|
+
// Don't undo an earlyResponse another plugin already set.
|
|
337
|
+
if (props.earlyResponse) return;
|
|
338
|
+
|
|
339
|
+
const cache = await this.openCache();
|
|
340
|
+
const stored = await cache.match(
|
|
341
|
+
buildCacheKeyRequest(ctx.parsed.url.href, req.initialHeaders)
|
|
342
|
+
);
|
|
343
|
+
if (!stored) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const storedAt = parseInt(
|
|
348
|
+
stored.headers.get(STORED_AT_HEADER) ?? "0",
|
|
349
|
+
10
|
|
350
|
+
);
|
|
351
|
+
const cc = parseCacheControl(stored.headers.get("cache-control"));
|
|
352
|
+
|
|
353
|
+
const pragmaNoCache = (stored.headers.get("pragma") ?? "")
|
|
354
|
+
.toLowerCase()
|
|
355
|
+
.includes("no-cache");
|
|
356
|
+
const mustRevalidateBeforeUse =
|
|
357
|
+
cc["no-cache"] === true || pragmaNoCache || reqCache === "no-cache";
|
|
358
|
+
|
|
359
|
+
const dateMs = (() => {
|
|
360
|
+
const d = stored.headers.get("date");
|
|
361
|
+
if (d) {
|
|
362
|
+
const v = Date.parse(d);
|
|
363
|
+
if (Number.isFinite(v)) return v;
|
|
364
|
+
}
|
|
365
|
+
return storedAt || Date.now();
|
|
366
|
+
})();
|
|
367
|
+
|
|
368
|
+
const lifetime = freshnessLifetimeSeconds(stored.headers, cc, dateMs);
|
|
369
|
+
const age = currentAgeSeconds(stored.headers, storedAt);
|
|
370
|
+
const fresh =
|
|
371
|
+
!mustRevalidateBeforeUse && lifetime !== null && age < lifetime;
|
|
372
|
+
|
|
373
|
+
// `immutable` short-circuits the freshness check (RFC 8246)
|
|
374
|
+
// provided the client hasn't asked for a forced revalidation.
|
|
375
|
+
const immutable =
|
|
376
|
+
cc.immutable === true &&
|
|
377
|
+
reqCache !== "no-cache" &&
|
|
378
|
+
reqCache !== "reload";
|
|
379
|
+
|
|
380
|
+
if (!fresh && !immutable) {
|
|
381
|
+
// Stale; fall through to the network. (TODO: 304 revalidation.)
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Build a BareResponse around the stored bytes/headers and hand
|
|
386
|
+
// it to doNetworkFetch via earlyResponse. The pipeline will then
|
|
387
|
+
// run rewriteResponseHeaders/rewriteBody/etc. as if we'd just
|
|
388
|
+
// fetched it.
|
|
389
|
+
const headers = strippedHeadersFromStored(stored);
|
|
390
|
+
// Recompute Age the consumer sees so it isn't stuck at storage
|
|
391
|
+
// time.
|
|
392
|
+
if (storedAt) {
|
|
393
|
+
headers.set("age", String(Math.floor((Date.now() - storedAt) / 1000)));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const isNullBody = NULL_BODY_STATUSES.has(stored.status);
|
|
397
|
+
const earlyBody = isNullBody ? null : await stored.arrayBuffer();
|
|
398
|
+
|
|
399
|
+
const earlyResponse = BareResponse.fromNativeResponse(
|
|
400
|
+
new Response(earlyBody, {
|
|
401
|
+
status: stored.status,
|
|
402
|
+
statusText: stored.statusText,
|
|
403
|
+
headers,
|
|
404
|
+
})
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
this.cameFromCache.set(req, true);
|
|
408
|
+
props.earlyResponse = earlyResponse;
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ----- preresponse: cache store -----------------------------------
|
|
412
|
+
this.tap(hooks.preresponse, async (ctx, props) => {
|
|
413
|
+
const req = ctx.request;
|
|
414
|
+
// Skip if this body came back via cache.match -- restoring it
|
|
415
|
+
// would just rewrite the same bytes with a fresh STORED_AT_HEADER
|
|
416
|
+
// (resetting the freshness clock).
|
|
417
|
+
if (this.cameFromCache.has(req)) {
|
|
418
|
+
this.cameFromCache.delete(req);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if ((req.cache as string) === "no-store") return;
|
|
423
|
+
if (!isCacheableMethod(req.method)) return;
|
|
424
|
+
|
|
425
|
+
const headers = nativeHeadersFromRaw(props.response.rawHeaders);
|
|
426
|
+
if (!responseIsStorable(props.response.status, headers, req.method))
|
|
427
|
+
return;
|
|
428
|
+
|
|
429
|
+
// Drain the stream once and rebuild the BareResponse around the
|
|
430
|
+
// buffered copy so the rest of doHandleFetch can still read it.
|
|
431
|
+
const { replacement, bodyBuffer } = await rebuildBareResponseWithBuffer(
|
|
432
|
+
props.response
|
|
433
|
+
);
|
|
434
|
+
props.response = replacement;
|
|
435
|
+
|
|
436
|
+
const cacheKey = buildCacheKeyRequest(
|
|
437
|
+
ctx.parsed.url.href,
|
|
438
|
+
req.initialHeaders
|
|
439
|
+
);
|
|
440
|
+
const toStore = buildStorableResponse(
|
|
441
|
+
bodyBuffer,
|
|
442
|
+
props.response.status,
|
|
443
|
+
props.response.statusText,
|
|
444
|
+
props.response.rawHeaders
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const cache = await this.openCache();
|
|
449
|
+
await cache.put(cacheKey, toStore);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
// Cache.put can fail on opaque or oddly-headered responses;
|
|
452
|
+
// don't let a cache write failure break the actual fetch.
|
|
453
|
+
console.warn("[scramjet-http-cache] cache.put failed:", err);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Drop every entry in the HTTP cache. Returns whether the underlying
|
|
460
|
+
* Cache existed and was deleted.
|
|
461
|
+
*/
|
|
462
|
+
async bust(): Promise<boolean> {
|
|
463
|
+
try {
|
|
464
|
+
// Drop the memoized handle too; the next install will re-open
|
|
465
|
+
// against a fresh empty cache.
|
|
466
|
+
this.cachePromise = null;
|
|
467
|
+
return await caches.delete(this.cacheName);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
console.error("[scramjet-http-cache] bust failed:", err);
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|