@cloudflare/pages-shared 0.0.0-ship-js

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