@cloudflare/pages-shared 0.0.3 → 0.0.4
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/asset-server/handler.js +395 -0
- package/dist/environment-polyfills/index.js +10 -0
- package/dist/environment-polyfills/miniflare.js +13 -0
- package/dist/environment-polyfills/types.js +1 -0
- package/package.json +6 -2
- package/src/asset-server/handler.ts +600 -0
- package/src/asset-server/rulesEngine.ts +0 -2
- package/src/environment-polyfills/index.ts +14 -0
- package/src/environment-polyfills/miniflare.ts +14 -0
- package/src/environment-polyfills/types.ts +44 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FoundResponse,
|
|
3
|
+
InternalServerErrorResponse,
|
|
4
|
+
MethodNotAllowedResponse,
|
|
5
|
+
MovedPermanentlyResponse,
|
|
6
|
+
NotAcceptableResponse,
|
|
7
|
+
NotFoundResponse,
|
|
8
|
+
NotModifiedResponse,
|
|
9
|
+
OkResponse,
|
|
10
|
+
PermanentRedirectResponse,
|
|
11
|
+
SeeOtherResponse,
|
|
12
|
+
TemporaryRedirectResponse
|
|
13
|
+
} from "./responses";
|
|
14
|
+
import { generateRulesMatcher, replacer } from "./rulesEngine";
|
|
15
|
+
export const ASSET_PRESERVATION_CACHE = "assetPreservationCache";
|
|
16
|
+
const CACHE_CONTROL_PRESERVATION = "public, s-maxage=604800";
|
|
17
|
+
export const CACHE_CONTROL_BROWSER = "public, max-age=0, must-revalidate";
|
|
18
|
+
export const REDIRECTS_VERSION = 1;
|
|
19
|
+
export const HEADERS_VERSION = 2;
|
|
20
|
+
export const HEADERS_VERSION_V1 = 1;
|
|
21
|
+
export const ANALYTICS_VERSION = 1;
|
|
22
|
+
export function normaliseHeaders(headers) {
|
|
23
|
+
if (headers.version === HEADERS_VERSION) {
|
|
24
|
+
return headers.rules;
|
|
25
|
+
} else if (headers.version === HEADERS_VERSION_V1) {
|
|
26
|
+
return Object.keys(headers.rules).reduce(
|
|
27
|
+
(acc, key) => {
|
|
28
|
+
acc[key] = {
|
|
29
|
+
set: headers.rules[key]
|
|
30
|
+
};
|
|
31
|
+
return acc;
|
|
32
|
+
},
|
|
33
|
+
{}
|
|
34
|
+
);
|
|
35
|
+
} else {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function generateHandler({
|
|
40
|
+
request,
|
|
41
|
+
metadata,
|
|
42
|
+
xServerEnvHeader,
|
|
43
|
+
logError,
|
|
44
|
+
findAssetEntryForPath,
|
|
45
|
+
getAssetKey,
|
|
46
|
+
negotiateContent,
|
|
47
|
+
fetchAsset,
|
|
48
|
+
generateNotFoundResponse = async (notFoundRequest, notFoundFindAssetEntryForPath, notFoundServeAsset) => {
|
|
49
|
+
let assetEntry;
|
|
50
|
+
if (assetEntry = await notFoundFindAssetEntryForPath("/index.html")) {
|
|
51
|
+
return notFoundServeAsset(assetEntry, { preserve: false });
|
|
52
|
+
}
|
|
53
|
+
return new NotFoundResponse();
|
|
54
|
+
},
|
|
55
|
+
attachAdditionalHeaders = () => {
|
|
56
|
+
},
|
|
57
|
+
caches,
|
|
58
|
+
waitUntil
|
|
59
|
+
}) {
|
|
60
|
+
const url = new URL(request.url);
|
|
61
|
+
const { protocol, host, search } = url;
|
|
62
|
+
let { pathname } = url;
|
|
63
|
+
const earlyHintsCache = metadata.deploymentId ? await caches?.open(`eh:${metadata.deploymentId}`) : void 0;
|
|
64
|
+
const headerRules = metadata.headers ? normaliseHeaders(metadata.headers) : {};
|
|
65
|
+
const staticRules = metadata.redirects?.version === REDIRECTS_VERSION ? metadata.redirects.staticRules || {} : {};
|
|
66
|
+
const staticRedirectsMatcher = () => {
|
|
67
|
+
const withHostMatch = staticRules[`https://${host}${pathname}`];
|
|
68
|
+
const withoutHostMatch = staticRules[pathname];
|
|
69
|
+
if (withHostMatch && withoutHostMatch) {
|
|
70
|
+
if (withHostMatch.lineNumber < withoutHostMatch.lineNumber) {
|
|
71
|
+
return withHostMatch;
|
|
72
|
+
} else {
|
|
73
|
+
return withoutHostMatch;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return withHostMatch || withoutHostMatch;
|
|
77
|
+
};
|
|
78
|
+
const generateRedirectsMatcher = () => generateRulesMatcher(
|
|
79
|
+
metadata.redirects?.version === REDIRECTS_VERSION ? metadata.redirects.rules : {},
|
|
80
|
+
({ status, to }, replacements) => ({
|
|
81
|
+
status,
|
|
82
|
+
to: replacer(to, replacements)
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
let assetEntry;
|
|
86
|
+
async function generateResponse() {
|
|
87
|
+
const match = staticRedirectsMatcher() || generateRedirectsMatcher()({ request })[0];
|
|
88
|
+
if (match) {
|
|
89
|
+
const { status, to } = match;
|
|
90
|
+
const destination = new URL(to, request.url);
|
|
91
|
+
const location = destination.origin === new URL(request.url).origin ? `${destination.pathname}${destination.search || search}${destination.hash}` : `${destination.href}${destination.search ? "" : search}${destination.hash}`;
|
|
92
|
+
switch (status) {
|
|
93
|
+
case 301:
|
|
94
|
+
return new MovedPermanentlyResponse(location);
|
|
95
|
+
case 303:
|
|
96
|
+
return new SeeOtherResponse(location);
|
|
97
|
+
case 307:
|
|
98
|
+
return new TemporaryRedirectResponse(location);
|
|
99
|
+
case 308:
|
|
100
|
+
return new PermanentRedirectResponse(location);
|
|
101
|
+
case 302:
|
|
102
|
+
default:
|
|
103
|
+
return new FoundResponse(location);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!request.method.match(/^(get|head)$/i)) {
|
|
107
|
+
return new MethodNotAllowedResponse();
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
pathname = globalThis.decodeURIComponent(pathname);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
}
|
|
113
|
+
if (pathname.endsWith("/")) {
|
|
114
|
+
if (assetEntry = await findAssetEntryForPath(`${pathname}index.html`)) {
|
|
115
|
+
return serveAsset(assetEntry);
|
|
116
|
+
} else if (pathname.endsWith("/index/")) {
|
|
117
|
+
return new PermanentRedirectResponse(
|
|
118
|
+
`/${pathname.slice(1, -"index/".length)}${search}`
|
|
119
|
+
);
|
|
120
|
+
} else if (assetEntry = await findAssetEntryForPath(
|
|
121
|
+
`${pathname.replace(/\/$/, ".html")}`
|
|
122
|
+
)) {
|
|
123
|
+
return new PermanentRedirectResponse(
|
|
124
|
+
`/${pathname.slice(1, -1)}${search}`
|
|
125
|
+
);
|
|
126
|
+
} else {
|
|
127
|
+
return notFound();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (assetEntry = await findAssetEntryForPath(pathname)) {
|
|
131
|
+
if (pathname.endsWith(".html")) {
|
|
132
|
+
const extensionlessPath = pathname.slice(0, -".html".length);
|
|
133
|
+
if (extensionlessPath.endsWith("/index")) {
|
|
134
|
+
return new PermanentRedirectResponse(
|
|
135
|
+
`${extensionlessPath.replace(/\/index$/, "/")}${search}`
|
|
136
|
+
);
|
|
137
|
+
} else if (await findAssetEntryForPath(extensionlessPath) || extensionlessPath === "/") {
|
|
138
|
+
return serveAsset(assetEntry);
|
|
139
|
+
} else {
|
|
140
|
+
return new PermanentRedirectResponse(`${extensionlessPath}${search}`);
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
return serveAsset(assetEntry);
|
|
144
|
+
}
|
|
145
|
+
} else if (pathname.endsWith("/index")) {
|
|
146
|
+
return new PermanentRedirectResponse(
|
|
147
|
+
`/${pathname.slice(1, -"index".length)}${search}`
|
|
148
|
+
);
|
|
149
|
+
} else if (assetEntry = await findAssetEntryForPath(`${pathname}.html`)) {
|
|
150
|
+
return serveAsset(assetEntry);
|
|
151
|
+
} else if (hasFileExtension(pathname)) {
|
|
152
|
+
return notFound();
|
|
153
|
+
}
|
|
154
|
+
if (assetEntry = await findAssetEntryForPath(`${pathname}/index.html`)) {
|
|
155
|
+
return new PermanentRedirectResponse(`${pathname}/${search}`);
|
|
156
|
+
} else {
|
|
157
|
+
return notFound();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function attachHeaders(response) {
|
|
161
|
+
const existingHeaders = new Headers(response.headers);
|
|
162
|
+
const extraHeaders = new Headers({
|
|
163
|
+
"access-control-allow-origin": "*",
|
|
164
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
165
|
+
...existingHeaders.has("content-type") ? { "x-content-type-options": "nosniff" } : {}
|
|
166
|
+
});
|
|
167
|
+
const headers = new Headers({
|
|
168
|
+
...Object.fromEntries(existingHeaders.entries()),
|
|
169
|
+
...Object.fromEntries(extraHeaders.entries())
|
|
170
|
+
});
|
|
171
|
+
const headersMatcher = generateRulesMatcher(
|
|
172
|
+
headerRules,
|
|
173
|
+
({ set = {}, unset = [] }, replacements) => {
|
|
174
|
+
const replacedSet = {};
|
|
175
|
+
Object.keys(set).forEach((key) => {
|
|
176
|
+
replacedSet[key] = replacer(set[key], replacements);
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
set: replacedSet,
|
|
180
|
+
unset
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
const matches = headersMatcher({ request });
|
|
185
|
+
const setMap = /* @__PURE__ */ new Set();
|
|
186
|
+
matches.forEach(({ set = {}, unset = [] }) => {
|
|
187
|
+
Object.keys(set).forEach((key) => {
|
|
188
|
+
if (setMap.has(key.toLowerCase())) {
|
|
189
|
+
headers.append(key, set[key]);
|
|
190
|
+
} else {
|
|
191
|
+
headers.set(key, set[key]);
|
|
192
|
+
setMap.add(key.toLowerCase());
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
unset.forEach((key) => {
|
|
196
|
+
headers.delete(key);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
if (earlyHintsCache) {
|
|
200
|
+
const preEarlyHintsHeaders = new Headers(headers);
|
|
201
|
+
const earlyHintsCacheKey = `${protocol}//${host}${pathname}`;
|
|
202
|
+
const earlyHintsResponse = await earlyHintsCache.match(
|
|
203
|
+
earlyHintsCacheKey
|
|
204
|
+
);
|
|
205
|
+
if (earlyHintsResponse) {
|
|
206
|
+
const earlyHintsLinkHeader = earlyHintsResponse.headers.get("Link");
|
|
207
|
+
if (earlyHintsLinkHeader) {
|
|
208
|
+
headers.set("Link", earlyHintsLinkHeader);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const clonedResponse = response.clone();
|
|
212
|
+
if (waitUntil) {
|
|
213
|
+
waitUntil(
|
|
214
|
+
(async () => {
|
|
215
|
+
try {
|
|
216
|
+
const links = [];
|
|
217
|
+
const transformedResponse = new HTMLRewriter().on("link[rel=preconnect],link[rel=preload]", {
|
|
218
|
+
element(element) {
|
|
219
|
+
const href = element.getAttribute("href") || void 0;
|
|
220
|
+
const rel = element.getAttribute("rel") || void 0;
|
|
221
|
+
const as = element.getAttribute("as") || void 0;
|
|
222
|
+
if (href && !href.startsWith("data:") && rel) {
|
|
223
|
+
links.push({ href, rel, as });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}).transform(clonedResponse);
|
|
227
|
+
await transformedResponse.text();
|
|
228
|
+
links.forEach(({ href, rel, as }) => {
|
|
229
|
+
let link = `<${href}>; rel="${rel}"`;
|
|
230
|
+
if (as) {
|
|
231
|
+
link += `; as=${as}`;
|
|
232
|
+
}
|
|
233
|
+
preEarlyHintsHeaders.append("Link", link);
|
|
234
|
+
});
|
|
235
|
+
const linkHeader = preEarlyHintsHeaders.get("Link");
|
|
236
|
+
if (linkHeader) {
|
|
237
|
+
await earlyHintsCache.put(
|
|
238
|
+
earlyHintsCacheKey,
|
|
239
|
+
new Response(null, { headers: { Link: linkHeader } })
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
}
|
|
244
|
+
})()
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return new Response(
|
|
249
|
+
[101, 204, 205, 304].includes(response.status) ? null : response.body,
|
|
250
|
+
{
|
|
251
|
+
headers,
|
|
252
|
+
status: response.status,
|
|
253
|
+
statusText: response.statusText
|
|
254
|
+
}
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return await attachHeaders(await generateResponse());
|
|
258
|
+
async function serveAsset(servingAssetEntry, options = { preserve: true }) {
|
|
259
|
+
let content;
|
|
260
|
+
try {
|
|
261
|
+
content = negotiateContent(request, servingAssetEntry);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return new NotAcceptableResponse();
|
|
264
|
+
}
|
|
265
|
+
const assetKey = getAssetKey(servingAssetEntry, content);
|
|
266
|
+
const etag = `"${assetKey}"`;
|
|
267
|
+
const weakEtag = `W/${etag}`;
|
|
268
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
269
|
+
if (ifNoneMatch === weakEtag || ifNoneMatch === etag) {
|
|
270
|
+
return new NotModifiedResponse();
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const asset = await fetchAsset(assetKey);
|
|
274
|
+
const headers = {
|
|
275
|
+
etag,
|
|
276
|
+
"content-type": asset.contentType
|
|
277
|
+
};
|
|
278
|
+
let encodeBody = "automatic";
|
|
279
|
+
if (xServerEnvHeader) {
|
|
280
|
+
headers["x-server-env"] = xServerEnvHeader;
|
|
281
|
+
}
|
|
282
|
+
if (content.encoding) {
|
|
283
|
+
encodeBody = "manual";
|
|
284
|
+
headers["cache-control"] = "no-transform";
|
|
285
|
+
headers["content-encoding"] = content.encoding;
|
|
286
|
+
}
|
|
287
|
+
const response = new OkResponse(
|
|
288
|
+
request.method === "HEAD" ? null : asset.body,
|
|
289
|
+
{
|
|
290
|
+
headers,
|
|
291
|
+
encodeBody
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
if (isCacheable(request)) {
|
|
295
|
+
response.headers.append("cache-control", CACHE_CONTROL_BROWSER);
|
|
296
|
+
}
|
|
297
|
+
attachAdditionalHeaders(response, content, servingAssetEntry, asset);
|
|
298
|
+
if (isPreview(new URL(request.url))) {
|
|
299
|
+
response.headers.set("x-robots-tag", "noindex");
|
|
300
|
+
}
|
|
301
|
+
if (options.preserve) {
|
|
302
|
+
const preservedResponse = new Response(
|
|
303
|
+
[101, 204, 205, 304].includes(response.status) ? null : response.clone().body,
|
|
304
|
+
response
|
|
305
|
+
);
|
|
306
|
+
preservedResponse.headers.set(
|
|
307
|
+
"cache-control",
|
|
308
|
+
CACHE_CONTROL_PRESERVATION
|
|
309
|
+
);
|
|
310
|
+
preservedResponse.headers.set("x-robots-tag", "noindex");
|
|
311
|
+
if (waitUntil && caches) {
|
|
312
|
+
waitUntil(
|
|
313
|
+
caches.open(ASSET_PRESERVATION_CACHE).then(
|
|
314
|
+
(assetPreservationCache) => assetPreservationCache.put(request.url, preservedResponse)
|
|
315
|
+
).catch((err) => {
|
|
316
|
+
logError(err);
|
|
317
|
+
})
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (asset.contentType.startsWith("text/html") && metadata.analytics?.version === ANALYTICS_VERSION) {
|
|
322
|
+
return new HTMLRewriter().on("body", {
|
|
323
|
+
element(e) {
|
|
324
|
+
e.append(
|
|
325
|
+
`<!-- Cloudflare Pages Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "${metadata.analytics?.token}"}'><\/script><!-- Cloudflare Pages Analytics -->`,
|
|
326
|
+
{ html: true }
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}).transform(response);
|
|
330
|
+
}
|
|
331
|
+
return response;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
logError(err);
|
|
334
|
+
return new InternalServerErrorResponse(err);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function notFound() {
|
|
338
|
+
if (caches) {
|
|
339
|
+
const assetPreservationCache = await caches.open(
|
|
340
|
+
ASSET_PRESERVATION_CACHE
|
|
341
|
+
);
|
|
342
|
+
const preservedResponse = await assetPreservationCache.match(request.url);
|
|
343
|
+
if (preservedResponse) {
|
|
344
|
+
return preservedResponse;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
let cwd = pathname;
|
|
348
|
+
while (cwd) {
|
|
349
|
+
cwd = cwd.slice(0, cwd.lastIndexOf("/"));
|
|
350
|
+
if (assetEntry = await findAssetEntryForPath(`${cwd}/404.html`)) {
|
|
351
|
+
let content;
|
|
352
|
+
try {
|
|
353
|
+
content = negotiateContent(request, assetEntry);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
return new NotAcceptableResponse();
|
|
356
|
+
}
|
|
357
|
+
const assetKey = getAssetKey(assetEntry, content);
|
|
358
|
+
try {
|
|
359
|
+
const { body, contentType } = await fetchAsset(assetKey);
|
|
360
|
+
const response = new NotFoundResponse(body);
|
|
361
|
+
response.headers.set("content-type", contentType);
|
|
362
|
+
return response;
|
|
363
|
+
} catch (err) {
|
|
364
|
+
logError(err);
|
|
365
|
+
return new InternalServerErrorResponse(err);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return await generateNotFoundResponse(
|
|
370
|
+
request,
|
|
371
|
+
findAssetEntryForPath,
|
|
372
|
+
serveAsset
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
export function parseQualityWeightedList(list = "") {
|
|
377
|
+
const items = {};
|
|
378
|
+
list.replace(/\s/g, "").split(",").forEach((el) => {
|
|
379
|
+
const [item, weight] = el.split(";q=");
|
|
380
|
+
items[item] = weight ? parseFloat(weight) : 1;
|
|
381
|
+
});
|
|
382
|
+
return items;
|
|
383
|
+
}
|
|
384
|
+
function isCacheable(request) {
|
|
385
|
+
return !request.headers.has("authorization") && !request.headers.has("range");
|
|
386
|
+
}
|
|
387
|
+
function hasFileExtension(path) {
|
|
388
|
+
return /\/.+\.[a-z0-9]+$/i.test(path);
|
|
389
|
+
}
|
|
390
|
+
function isPreview(url) {
|
|
391
|
+
if (url.hostname.endsWith(".pages.dev")) {
|
|
392
|
+
return url.hostname.split(".").length > 3 ? true : false;
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetch as miniflareFetch,
|
|
3
|
+
Headers as MiniflareHeaders,
|
|
4
|
+
Request as MiniflareRequest,
|
|
5
|
+
Response as MiniflareResponse
|
|
6
|
+
} from "@miniflare/core";
|
|
7
|
+
import { polyfill } from ".";
|
|
8
|
+
polyfill({
|
|
9
|
+
fetch: miniflareFetch,
|
|
10
|
+
Headers: MiniflareHeaders,
|
|
11
|
+
Request: MiniflareRequest,
|
|
12
|
+
Response: MiniflareResponse
|
|
13
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudflare/pages-shared",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"files": [
|
|
5
5
|
"tsconfig.json",
|
|
6
6
|
"src/**/*",
|
|
@@ -35,8 +35,12 @@
|
|
|
35
35
|
"node_modules/(?!find-up|locate-path|p-locate|p-limit|p-timeout|p-queue|yocto-queue|path-exists|execa|strip-final-newline|npm-run-path|path-key|onetime|mimic-fn|human-signals|is-stream|get-port|supports-color|pretty-bytes)"
|
|
36
36
|
]
|
|
37
37
|
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@miniflare/core": "2.8.1"
|
|
40
|
+
},
|
|
38
41
|
"devDependencies": {
|
|
39
|
-
"@
|
|
42
|
+
"@miniflare/cache": "2.8.1",
|
|
43
|
+
"@miniflare/html-rewriter": "2.8.1",
|
|
40
44
|
"@types/service-worker-mock": "^2.0.1",
|
|
41
45
|
"concurrently": "^7.3.0",
|
|
42
46
|
"glob": "^8.0.3",
|
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FoundResponse,
|
|
3
|
+
InternalServerErrorResponse,
|
|
4
|
+
MethodNotAllowedResponse,
|
|
5
|
+
MovedPermanentlyResponse,
|
|
6
|
+
NotAcceptableResponse,
|
|
7
|
+
NotFoundResponse,
|
|
8
|
+
NotModifiedResponse,
|
|
9
|
+
OkResponse,
|
|
10
|
+
PermanentRedirectResponse,
|
|
11
|
+
SeeOtherResponse,
|
|
12
|
+
TemporaryRedirectResponse,
|
|
13
|
+
} from "./responses";
|
|
14
|
+
import { generateRulesMatcher, replacer } from "./rulesEngine";
|
|
15
|
+
import type {
|
|
16
|
+
Metadata,
|
|
17
|
+
MetadataHeadersEntries,
|
|
18
|
+
MetadataHeadersRulesV2,
|
|
19
|
+
MetadataHeadersV1,
|
|
20
|
+
MetadataHeadersV2,
|
|
21
|
+
} from "./metadata";
|
|
22
|
+
|
|
23
|
+
type BodyEncoding = "manual" | "automatic";
|
|
24
|
+
|
|
25
|
+
// Before serving a 404, we check the cache to see if we've served this asset recently
|
|
26
|
+
// and if so, serve it from the cache instead of responding with a 404.
|
|
27
|
+
// This gives a bit of a grace period between deployments for any clients browsing the old deployment.
|
|
28
|
+
export const ASSET_PRESERVATION_CACHE = "assetPreservationCache";
|
|
29
|
+
const CACHE_CONTROL_PRESERVATION = "public, s-maxage=604800"; // 1 week
|
|
30
|
+
|
|
31
|
+
export const CACHE_CONTROL_BROWSER = "public, max-age=0, must-revalidate"; // have the browser check in with the server to make sure its local cache is valid before using it
|
|
32
|
+
export const REDIRECTS_VERSION = 1;
|
|
33
|
+
export const HEADERS_VERSION = 2;
|
|
34
|
+
export const HEADERS_VERSION_V1 = 1;
|
|
35
|
+
export const ANALYTICS_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
// Takes metadata headers and "normalise" them
|
|
38
|
+
// to the latest version
|
|
39
|
+
export function normaliseHeaders(
|
|
40
|
+
headers: MetadataHeadersV1 | MetadataHeadersV2
|
|
41
|
+
): MetadataHeadersRulesV2 {
|
|
42
|
+
if (headers.version === HEADERS_VERSION) {
|
|
43
|
+
return headers.rules;
|
|
44
|
+
} else if (headers.version === HEADERS_VERSION_V1) {
|
|
45
|
+
return Object.keys(headers.rules).reduce(
|
|
46
|
+
(acc: MetadataHeadersRulesV2, key) => {
|
|
47
|
+
acc[key] = {
|
|
48
|
+
set: headers.rules[key] as MetadataHeadersEntries,
|
|
49
|
+
};
|
|
50
|
+
return acc;
|
|
51
|
+
},
|
|
52
|
+
{}
|
|
53
|
+
);
|
|
54
|
+
} else {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type FindAssetEntryForPath<AssetEntry> = (
|
|
60
|
+
path: string
|
|
61
|
+
) => Promise<null | AssetEntry>;
|
|
62
|
+
|
|
63
|
+
type ServeAsset<AssetEntry> = (
|
|
64
|
+
assetEntry: AssetEntry,
|
|
65
|
+
options?: { preserve: boolean }
|
|
66
|
+
) => Promise<Response>;
|
|
67
|
+
|
|
68
|
+
type FullHandlerContext<AssetEntry, ContentNegotiation, Asset> = {
|
|
69
|
+
request: Request;
|
|
70
|
+
metadata: Metadata;
|
|
71
|
+
xServerEnvHeader?: string;
|
|
72
|
+
logError: (err: Error) => void;
|
|
73
|
+
findAssetEntryForPath: FindAssetEntryForPath<AssetEntry>;
|
|
74
|
+
getAssetKey(assetEntry: AssetEntry, content: ContentNegotiation): string;
|
|
75
|
+
negotiateContent(
|
|
76
|
+
request: Request,
|
|
77
|
+
assetEntry: AssetEntry
|
|
78
|
+
): ContentNegotiation;
|
|
79
|
+
fetchAsset: (assetKey: string) => Promise<Asset>;
|
|
80
|
+
generateNotFoundResponse?: (
|
|
81
|
+
request: Request,
|
|
82
|
+
findAssetEntryForPath: FindAssetEntryForPath<AssetEntry>,
|
|
83
|
+
serveAsset: ServeAsset<AssetEntry>
|
|
84
|
+
) => Promise<Response>;
|
|
85
|
+
attachAdditionalHeaders?: (
|
|
86
|
+
response: Response,
|
|
87
|
+
content: ContentNegotiation,
|
|
88
|
+
assetEntry: AssetEntry,
|
|
89
|
+
asset: Asset
|
|
90
|
+
) => void;
|
|
91
|
+
caches: CacheStorage;
|
|
92
|
+
waitUntil: (promise: Promise<unknown>) => void;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type HandlerContext<AssetEntry, ContentNegotiation, Asset> =
|
|
96
|
+
| FullHandlerContext<AssetEntry, ContentNegotiation, Asset>
|
|
97
|
+
| (Omit<
|
|
98
|
+
FullHandlerContext<AssetEntry, ContentNegotiation, Asset>,
|
|
99
|
+
"caches" | "waitUntil"
|
|
100
|
+
> & {
|
|
101
|
+
caches?: undefined;
|
|
102
|
+
waitUntil?: undefined;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export async function generateHandler<
|
|
106
|
+
AssetEntry,
|
|
107
|
+
ContentNegotiation extends { encoding: string | null } = {
|
|
108
|
+
encoding: string | null;
|
|
109
|
+
},
|
|
110
|
+
Asset extends { body: ReadableStream | null; contentType: string } = {
|
|
111
|
+
body: ReadableStream | null;
|
|
112
|
+
contentType: string;
|
|
113
|
+
}
|
|
114
|
+
>({
|
|
115
|
+
request,
|
|
116
|
+
metadata,
|
|
117
|
+
xServerEnvHeader,
|
|
118
|
+
logError,
|
|
119
|
+
findAssetEntryForPath,
|
|
120
|
+
getAssetKey,
|
|
121
|
+
negotiateContent,
|
|
122
|
+
fetchAsset,
|
|
123
|
+
generateNotFoundResponse = async (
|
|
124
|
+
notFoundRequest,
|
|
125
|
+
notFoundFindAssetEntryForPath,
|
|
126
|
+
notFoundServeAsset
|
|
127
|
+
) => {
|
|
128
|
+
let assetEntry: AssetEntry | null;
|
|
129
|
+
// No custom 404 page, so try serving as a single-page app
|
|
130
|
+
if ((assetEntry = await notFoundFindAssetEntryForPath("/index.html"))) {
|
|
131
|
+
return notFoundServeAsset(assetEntry, { preserve: false });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return new NotFoundResponse();
|
|
135
|
+
},
|
|
136
|
+
attachAdditionalHeaders = () => {},
|
|
137
|
+
caches,
|
|
138
|
+
waitUntil,
|
|
139
|
+
}: HandlerContext<AssetEntry, ContentNegotiation, Asset>) {
|
|
140
|
+
const url = new URL(request.url);
|
|
141
|
+
const { protocol, host, search } = url;
|
|
142
|
+
let { pathname } = url;
|
|
143
|
+
|
|
144
|
+
const earlyHintsCache = metadata.deploymentId
|
|
145
|
+
? await caches?.open(`eh:${metadata.deploymentId}`)
|
|
146
|
+
: undefined;
|
|
147
|
+
|
|
148
|
+
const headerRules = metadata.headers
|
|
149
|
+
? normaliseHeaders(metadata.headers)
|
|
150
|
+
: {};
|
|
151
|
+
|
|
152
|
+
const staticRules =
|
|
153
|
+
metadata.redirects?.version === REDIRECTS_VERSION
|
|
154
|
+
? metadata.redirects.staticRules || {}
|
|
155
|
+
: {};
|
|
156
|
+
|
|
157
|
+
const staticRedirectsMatcher = () => {
|
|
158
|
+
const withHostMatch = staticRules[`https://${host}${pathname}`];
|
|
159
|
+
const withoutHostMatch = staticRules[pathname];
|
|
160
|
+
|
|
161
|
+
if (withHostMatch && withoutHostMatch) {
|
|
162
|
+
if (withHostMatch.lineNumber < withoutHostMatch.lineNumber) {
|
|
163
|
+
return withHostMatch;
|
|
164
|
+
} else {
|
|
165
|
+
return withoutHostMatch;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return withHostMatch || withoutHostMatch;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const generateRedirectsMatcher = () =>
|
|
173
|
+
generateRulesMatcher(
|
|
174
|
+
metadata.redirects?.version === REDIRECTS_VERSION
|
|
175
|
+
? metadata.redirects.rules
|
|
176
|
+
: {},
|
|
177
|
+
({ status, to }, replacements) => ({
|
|
178
|
+
status,
|
|
179
|
+
to: replacer(to, replacements),
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
let assetEntry: AssetEntry | null;
|
|
184
|
+
|
|
185
|
+
async function generateResponse(): Promise<Response> {
|
|
186
|
+
const match =
|
|
187
|
+
staticRedirectsMatcher() || generateRedirectsMatcher()({ request })[0];
|
|
188
|
+
|
|
189
|
+
if (match) {
|
|
190
|
+
const { status, to } = match;
|
|
191
|
+
const destination = new URL(to, request.url);
|
|
192
|
+
const location =
|
|
193
|
+
destination.origin === new URL(request.url).origin
|
|
194
|
+
? `${destination.pathname}${destination.search || search}${
|
|
195
|
+
destination.hash
|
|
196
|
+
}`
|
|
197
|
+
: `${destination.href}${destination.search ? "" : search}${
|
|
198
|
+
destination.hash
|
|
199
|
+
}`;
|
|
200
|
+
switch (status) {
|
|
201
|
+
case 301:
|
|
202
|
+
return new MovedPermanentlyResponse(location);
|
|
203
|
+
case 303:
|
|
204
|
+
return new SeeOtherResponse(location);
|
|
205
|
+
case 307:
|
|
206
|
+
return new TemporaryRedirectResponse(location);
|
|
207
|
+
case 308:
|
|
208
|
+
return new PermanentRedirectResponse(location);
|
|
209
|
+
case 302:
|
|
210
|
+
default:
|
|
211
|
+
return new FoundResponse(location);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!request.method.match(/^(get|head)$/i)) {
|
|
216
|
+
return new MethodNotAllowedResponse();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
pathname = globalThis.decodeURIComponent(pathname);
|
|
221
|
+
} catch (err) {}
|
|
222
|
+
|
|
223
|
+
if (pathname.endsWith("/")) {
|
|
224
|
+
if ((assetEntry = await findAssetEntryForPath(`${pathname}index.html`))) {
|
|
225
|
+
return serveAsset(assetEntry);
|
|
226
|
+
} else if (pathname.endsWith("/index/")) {
|
|
227
|
+
return new PermanentRedirectResponse(
|
|
228
|
+
`/${pathname.slice(1, -"index/".length)}${search}`
|
|
229
|
+
);
|
|
230
|
+
} else if (
|
|
231
|
+
(assetEntry = await findAssetEntryForPath(
|
|
232
|
+
`${pathname.replace(/\/$/, ".html")}`
|
|
233
|
+
))
|
|
234
|
+
) {
|
|
235
|
+
return new PermanentRedirectResponse(
|
|
236
|
+
`/${pathname.slice(1, -1)}${search}`
|
|
237
|
+
);
|
|
238
|
+
} else {
|
|
239
|
+
return notFound();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if ((assetEntry = await findAssetEntryForPath(pathname))) {
|
|
244
|
+
if (pathname.endsWith(".html")) {
|
|
245
|
+
const extensionlessPath = pathname.slice(0, -".html".length);
|
|
246
|
+
// Don't redirect to an extensionless URL if another asset exists there
|
|
247
|
+
// or if pathname is /.html
|
|
248
|
+
// FIXME: this doesn't handle files in directories ie: /foobar/.html
|
|
249
|
+
if (extensionlessPath.endsWith("/index")) {
|
|
250
|
+
return new PermanentRedirectResponse(
|
|
251
|
+
`${extensionlessPath.replace(/\/index$/, "/")}${search}`
|
|
252
|
+
);
|
|
253
|
+
} else if (
|
|
254
|
+
(await findAssetEntryForPath(extensionlessPath)) ||
|
|
255
|
+
extensionlessPath === "/"
|
|
256
|
+
) {
|
|
257
|
+
return serveAsset(assetEntry);
|
|
258
|
+
} else {
|
|
259
|
+
return new PermanentRedirectResponse(`${extensionlessPath}${search}`);
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
return serveAsset(assetEntry);
|
|
263
|
+
}
|
|
264
|
+
} else if (pathname.endsWith("/index")) {
|
|
265
|
+
return new PermanentRedirectResponse(
|
|
266
|
+
`/${pathname.slice(1, -"index".length)}${search}`
|
|
267
|
+
);
|
|
268
|
+
} else if ((assetEntry = await findAssetEntryForPath(`${pathname}.html`))) {
|
|
269
|
+
return serveAsset(assetEntry);
|
|
270
|
+
} else if (hasFileExtension(pathname)) {
|
|
271
|
+
return notFound();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if ((assetEntry = await findAssetEntryForPath(`${pathname}/index.html`))) {
|
|
275
|
+
return new PermanentRedirectResponse(`${pathname}/${search}`);
|
|
276
|
+
} else {
|
|
277
|
+
return notFound();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function attachHeaders(response: Response) {
|
|
282
|
+
const existingHeaders = new Headers(response.headers);
|
|
283
|
+
|
|
284
|
+
const extraHeaders = new Headers({
|
|
285
|
+
"access-control-allow-origin": "*",
|
|
286
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
287
|
+
...(existingHeaders.has("content-type")
|
|
288
|
+
? { "x-content-type-options": "nosniff" }
|
|
289
|
+
: {}),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const headers = new Headers({
|
|
293
|
+
// But we intentionally override existing headers
|
|
294
|
+
...Object.fromEntries(existingHeaders.entries()),
|
|
295
|
+
...Object.fromEntries(extraHeaders.entries()),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Iterate through rules and find rules that match the path
|
|
299
|
+
const headersMatcher = generateRulesMatcher(
|
|
300
|
+
headerRules,
|
|
301
|
+
({ set = {}, unset = [] }, replacements) => {
|
|
302
|
+
const replacedSet: Record<string, string> = {};
|
|
303
|
+
Object.keys(set).forEach((key) => {
|
|
304
|
+
replacedSet[key] = replacer(set[key], replacements);
|
|
305
|
+
});
|
|
306
|
+
return {
|
|
307
|
+
set: replacedSet,
|
|
308
|
+
unset,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
const matches = headersMatcher({ request });
|
|
313
|
+
|
|
314
|
+
// This keeps track of every header that we've set from _headers
|
|
315
|
+
// because we want to combine user declared headers but overwrite
|
|
316
|
+
// existing and extra ones
|
|
317
|
+
const setMap = new Set();
|
|
318
|
+
// Apply every matched rule in order
|
|
319
|
+
matches.forEach(({ set = {}, unset = [] }) => {
|
|
320
|
+
Object.keys(set).forEach((key) => {
|
|
321
|
+
if (setMap.has(key.toLowerCase())) {
|
|
322
|
+
headers.append(key, set[key]);
|
|
323
|
+
} else {
|
|
324
|
+
headers.set(key, set[key]);
|
|
325
|
+
setMap.add(key.toLowerCase());
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
unset.forEach((key) => {
|
|
329
|
+
headers.delete(key);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (earlyHintsCache) {
|
|
334
|
+
const preEarlyHintsHeaders = new Headers(headers);
|
|
335
|
+
|
|
336
|
+
// "Early Hints cache entries are keyed by request URI and ignore query strings."
|
|
337
|
+
// https://developers.cloudflare.com/cache/about/early-hints/
|
|
338
|
+
const earlyHintsCacheKey = `${protocol}//${host}${pathname}`;
|
|
339
|
+
const earlyHintsResponse = await earlyHintsCache.match(
|
|
340
|
+
earlyHintsCacheKey
|
|
341
|
+
);
|
|
342
|
+
if (earlyHintsResponse) {
|
|
343
|
+
const earlyHintsLinkHeader = earlyHintsResponse.headers.get("Link");
|
|
344
|
+
if (earlyHintsLinkHeader) {
|
|
345
|
+
headers.set("Link", earlyHintsLinkHeader);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const clonedResponse = response.clone();
|
|
350
|
+
|
|
351
|
+
if (waitUntil) {
|
|
352
|
+
waitUntil(
|
|
353
|
+
(async () => {
|
|
354
|
+
try {
|
|
355
|
+
const links: { href: string; rel: string; as?: string }[] = [];
|
|
356
|
+
|
|
357
|
+
const transformedResponse = new HTMLRewriter()
|
|
358
|
+
.on("link[rel=preconnect],link[rel=preload]", {
|
|
359
|
+
element(element) {
|
|
360
|
+
const href = element.getAttribute("href") || undefined;
|
|
361
|
+
const rel = element.getAttribute("rel") || undefined;
|
|
362
|
+
const as = element.getAttribute("as") || undefined;
|
|
363
|
+
if (href && !href.startsWith("data:") && rel) {
|
|
364
|
+
links.push({ href, rel, as });
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
.transform(clonedResponse);
|
|
369
|
+
|
|
370
|
+
// Needed to actually execute the HTMLRewriter handlers
|
|
371
|
+
await transformedResponse.text();
|
|
372
|
+
|
|
373
|
+
links.forEach(({ href, rel, as }) => {
|
|
374
|
+
let link = `<${href}>; rel="${rel}"`;
|
|
375
|
+
if (as) {
|
|
376
|
+
link += `; as=${as}`;
|
|
377
|
+
}
|
|
378
|
+
preEarlyHintsHeaders.append("Link", link);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const linkHeader = preEarlyHintsHeaders.get("Link");
|
|
382
|
+
if (linkHeader) {
|
|
383
|
+
await earlyHintsCache.put(
|
|
384
|
+
earlyHintsCacheKey,
|
|
385
|
+
new Response(null, { headers: { Link: linkHeader } })
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
// Nbd if we fail here in the deferred 'waitUntil' work. We're probably trying to parse a malformed page or something.
|
|
390
|
+
// Totally fine to skip over any errors.
|
|
391
|
+
// If we need to debug something, you can uncomment the following:
|
|
392
|
+
// logError(err)
|
|
393
|
+
}
|
|
394
|
+
})()
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// https://fetch.spec.whatwg.org/#null-body-status
|
|
400
|
+
return new Response(
|
|
401
|
+
[101, 204, 205, 304].includes(response.status) ? null : response.body,
|
|
402
|
+
{
|
|
403
|
+
headers: headers,
|
|
404
|
+
status: response.status,
|
|
405
|
+
statusText: response.statusText,
|
|
406
|
+
}
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return await attachHeaders(await generateResponse());
|
|
411
|
+
|
|
412
|
+
async function serveAsset(
|
|
413
|
+
servingAssetEntry: AssetEntry,
|
|
414
|
+
options = { preserve: true }
|
|
415
|
+
): Promise<Response> {
|
|
416
|
+
let content: ContentNegotiation;
|
|
417
|
+
try {
|
|
418
|
+
content = negotiateContent(request, servingAssetEntry);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
return new NotAcceptableResponse();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const assetKey = getAssetKey(servingAssetEntry, content);
|
|
424
|
+
|
|
425
|
+
// https://support.cloudflare.com/hc/en-us/articles/218505467-Using-ETag-Headers-with-Cloudflare
|
|
426
|
+
// We sometimes remove etags unless they are wrapped in quotes
|
|
427
|
+
const etag = `"${assetKey}"`;
|
|
428
|
+
const weakEtag = `W/${etag}`;
|
|
429
|
+
|
|
430
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
431
|
+
|
|
432
|
+
// We sometimes downgrade strong etags to a weak ones, so we need to check for both
|
|
433
|
+
if (ifNoneMatch === weakEtag || ifNoneMatch === etag) {
|
|
434
|
+
return new NotModifiedResponse();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const asset = await fetchAsset(assetKey);
|
|
439
|
+
const headers: Record<string, string> = {
|
|
440
|
+
etag,
|
|
441
|
+
"content-type": asset.contentType,
|
|
442
|
+
};
|
|
443
|
+
let encodeBody: BodyEncoding = "automatic";
|
|
444
|
+
|
|
445
|
+
if (xServerEnvHeader) {
|
|
446
|
+
headers["x-server-env"] = xServerEnvHeader;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (content.encoding) {
|
|
450
|
+
encodeBody = "manual";
|
|
451
|
+
headers["cache-control"] = "no-transform";
|
|
452
|
+
headers["content-encoding"] = content.encoding;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const response = new OkResponse(
|
|
456
|
+
request.method === "HEAD" ? null : asset.body,
|
|
457
|
+
{
|
|
458
|
+
headers,
|
|
459
|
+
encodeBody,
|
|
460
|
+
}
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
if (isCacheable(request)) {
|
|
464
|
+
response.headers.append("cache-control", CACHE_CONTROL_BROWSER);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
attachAdditionalHeaders(response, content, servingAssetEntry, asset);
|
|
468
|
+
|
|
469
|
+
if (isPreview(new URL(request.url))) {
|
|
470
|
+
response.headers.set("x-robots-tag", "noindex");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (options.preserve) {
|
|
474
|
+
// https://fetch.spec.whatwg.org/#null-body-status
|
|
475
|
+
const preservedResponse = new Response(
|
|
476
|
+
[101, 204, 205, 304].includes(response.status)
|
|
477
|
+
? null
|
|
478
|
+
: response.clone().body,
|
|
479
|
+
response
|
|
480
|
+
);
|
|
481
|
+
preservedResponse.headers.set(
|
|
482
|
+
"cache-control",
|
|
483
|
+
CACHE_CONTROL_PRESERVATION
|
|
484
|
+
);
|
|
485
|
+
preservedResponse.headers.set("x-robots-tag", "noindex");
|
|
486
|
+
|
|
487
|
+
if (waitUntil && caches) {
|
|
488
|
+
waitUntil(
|
|
489
|
+
caches
|
|
490
|
+
.open(ASSET_PRESERVATION_CACHE)
|
|
491
|
+
.then((assetPreservationCache) =>
|
|
492
|
+
assetPreservationCache.put(request.url, preservedResponse)
|
|
493
|
+
)
|
|
494
|
+
.catch((err) => {
|
|
495
|
+
logError(err);
|
|
496
|
+
})
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (
|
|
502
|
+
asset.contentType.startsWith("text/html") &&
|
|
503
|
+
metadata.analytics?.version === ANALYTICS_VERSION
|
|
504
|
+
) {
|
|
505
|
+
return new HTMLRewriter()
|
|
506
|
+
.on("body", {
|
|
507
|
+
element(e) {
|
|
508
|
+
e.append(
|
|
509
|
+
`<!-- Cloudflare Pages Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "${metadata.analytics?.token}"}'></script><!-- Cloudflare Pages Analytics -->`,
|
|
510
|
+
{ html: true }
|
|
511
|
+
);
|
|
512
|
+
},
|
|
513
|
+
})
|
|
514
|
+
.transform(response);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return response;
|
|
518
|
+
} catch (err) {
|
|
519
|
+
logError(err as Error);
|
|
520
|
+
return new InternalServerErrorResponse(err as Error);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function notFound(): Promise<Response> {
|
|
525
|
+
if (caches) {
|
|
526
|
+
const assetPreservationCache = await caches.open(
|
|
527
|
+
ASSET_PRESERVATION_CACHE
|
|
528
|
+
);
|
|
529
|
+
const preservedResponse = await assetPreservationCache.match(request.url);
|
|
530
|
+
if (preservedResponse) {
|
|
531
|
+
return preservedResponse;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Traverse upwards from the current path looking for a custom 404 page
|
|
536
|
+
let cwd = pathname;
|
|
537
|
+
while (cwd) {
|
|
538
|
+
cwd = cwd.slice(0, cwd.lastIndexOf("/"));
|
|
539
|
+
|
|
540
|
+
if ((assetEntry = await findAssetEntryForPath(`${cwd}/404.html`))) {
|
|
541
|
+
let content: ContentNegotiation;
|
|
542
|
+
try {
|
|
543
|
+
content = negotiateContent(request, assetEntry);
|
|
544
|
+
} catch (err) {
|
|
545
|
+
return new NotAcceptableResponse();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const assetKey = getAssetKey(assetEntry, content);
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
const { body, contentType } = await fetchAsset(assetKey);
|
|
552
|
+
const response = new NotFoundResponse(body);
|
|
553
|
+
response.headers.set("content-type", contentType);
|
|
554
|
+
return response;
|
|
555
|
+
} catch (err) {
|
|
556
|
+
logError(err as Error);
|
|
557
|
+
return new InternalServerErrorResponse(err as Error);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return await generateNotFoundResponse(
|
|
563
|
+
request,
|
|
564
|
+
findAssetEntryForPath,
|
|
565
|
+
serveAsset
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Parses a list such as "deflate, gzip;q=1.0, *;q=0.5" into
|
|
571
|
+
// {deflate: 1, gzip: 1, *: 0.5}
|
|
572
|
+
export function parseQualityWeightedList(list = "") {
|
|
573
|
+
const items: Record<string, number> = {};
|
|
574
|
+
list
|
|
575
|
+
.replace(/\s/g, "")
|
|
576
|
+
.split(",")
|
|
577
|
+
.forEach((el) => {
|
|
578
|
+
const [item, weight] = el.split(";q=");
|
|
579
|
+
items[item] = weight ? parseFloat(weight) : 1;
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
return items;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function isCacheable(request: Request) {
|
|
586
|
+
return !request.headers.has("authorization") && !request.headers.has("range");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function hasFileExtension(path: string) {
|
|
590
|
+
return /\/.+\.[a-z0-9]+$/i.test(path);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Parses a request URL hostname to determine if the request
|
|
594
|
+
// is from a project served in "preview" mode.
|
|
595
|
+
function isPreview(url: URL): boolean {
|
|
596
|
+
if (url.hostname.endsWith(".pages.dev")) {
|
|
597
|
+
return url.hostname.split(".").length > 3 ? true : false;
|
|
598
|
+
}
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
declare type Request = { url: string };
|
|
2
|
-
|
|
3
1
|
// Taken from https://stackoverflow.com/a/3561711
|
|
4
2
|
// which is everything from the tc39 proposal, plus the following two characters: ^/
|
|
5
3
|
// It's also everything included in the URLPattern escape (https://wicg.github.io/urlpattern/#escape-a-regexp-string), plus the following: -
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PolyfilledRuntimeEnvironment } from "./types";
|
|
2
|
+
|
|
3
|
+
export const polyfill = (
|
|
4
|
+
environment: Record<keyof PolyfilledRuntimeEnvironment, unknown>
|
|
5
|
+
) => {
|
|
6
|
+
Object.entries(environment).map(([name, value]) => {
|
|
7
|
+
Object.defineProperty(globalThis, name, {
|
|
8
|
+
value,
|
|
9
|
+
configurable: true,
|
|
10
|
+
enumerable: true,
|
|
11
|
+
writable: true,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetch as miniflareFetch,
|
|
3
|
+
Headers as MiniflareHeaders,
|
|
4
|
+
Request as MiniflareRequest,
|
|
5
|
+
Response as MiniflareResponse,
|
|
6
|
+
} from "@miniflare/core";
|
|
7
|
+
import { polyfill } from ".";
|
|
8
|
+
|
|
9
|
+
polyfill({
|
|
10
|
+
fetch: miniflareFetch,
|
|
11
|
+
Headers: MiniflareHeaders,
|
|
12
|
+
Request: MiniflareRequest,
|
|
13
|
+
Response: MiniflareResponse,
|
|
14
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Headers as MiniflareHeaders,
|
|
3
|
+
Request as MiniflareRequest,
|
|
4
|
+
Response as MiniflareResponse,
|
|
5
|
+
} from "@miniflare/core";
|
|
6
|
+
import { HTMLRewriter as MiniflareHTMLRewriter } from "@miniflare/html-rewriter";
|
|
7
|
+
import type { CacheInterface as MiniflareCacheInterface } from "@miniflare/cache";
|
|
8
|
+
import type { fetch as miniflareFetch } from "@miniflare/core";
|
|
9
|
+
import type { ReadableStream as SimilarReadableStream } from "stream/web";
|
|
10
|
+
|
|
11
|
+
declare global {
|
|
12
|
+
const fetch: typeof miniflareFetch;
|
|
13
|
+
class Headers extends MiniflareHeaders {}
|
|
14
|
+
class Request extends MiniflareRequest {}
|
|
15
|
+
class Response extends MiniflareResponse {}
|
|
16
|
+
|
|
17
|
+
type CacheInterface = Omit<MiniflareCacheInterface, "match"> & {
|
|
18
|
+
match(
|
|
19
|
+
...args: Parameters<MiniflareCacheInterface["match"]>
|
|
20
|
+
): Promise<Response | undefined>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
class CacheStorage {
|
|
24
|
+
get default(): CacheInterface;
|
|
25
|
+
open(cacheName: string): Promise<CacheInterface>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class HTMLRewriter extends MiniflareHTMLRewriter {
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
30
|
+
// @ts-ignore
|
|
31
|
+
transform(response: Response): Response;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ReadableStream = SimilarReadableStream;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PolyfilledRuntimeEnvironment = {
|
|
38
|
+
fetch: typeof fetch;
|
|
39
|
+
Headers: typeof Headers;
|
|
40
|
+
Request: typeof Request;
|
|
41
|
+
Response: typeof Response;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export { fetch, Headers, Request, Response };
|