1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3/**
4 * Vendored from `@astrojs/cloudflare` v14.1.3 (`src/utils/headers.ts`).
5 *
6 * Build-side `_headers` synthesis: injects an immutable `Cache-Control`
7 * rule for Astro's content-hashed assets directory (`_astro/` by default)
8 * unless the user's existing `_headers` file already sets (or detaches)
9 * `Cache-Control` on a rule matching that path.
10 */
11
12/**
13 * Convert a Cloudflare `_headers` URL pattern to a RegExp.
14 *
15 * Supports `*` (matches any sequence, including `/`) and `:placeholder`
16 * segments (match one path segment). Everything else is literal.
17 */
18function cfHeadersPatternToRegex(pattern: string): RegExp {
19 let regexStr = "";
20 let i = 0;
21 while (i < pattern.length) {
22 const ch = pattern[i];
23 if (ch === "*") {
24 regexStr += ".*";
25 i++;
26 } else if (ch === ":" && /[A-Za-z]/.test(pattern[i + 1] ?? "")) {
27 i++;
28 while (i < pattern.length && /\w/.test(pattern[i]!)) i++;
29 regexStr += "[^/]+";
30 } else {
31 regexStr += ch!.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
32 i++;
33 }
34 }
35 return new RegExp(`^${regexStr}$`);
36}
37
38/**
39 * Whether an existing `_headers` file already sets (or explicitly detaches)
40 * `Cache-Control` on a rule matching `path`.
41 */
42export function headersFileHasCacheControlForPath(
43 content: string,
44 path: string,
45): boolean {
46 let matchesCurrentSection = false;
47 for (const rawLine of content.split("\n")) {
48 const trimmed = rawLine.trim();
49 if (!trimmed || trimmed.startsWith("#")) continue;
50 const isSectionHeader = !/^\s/.test(rawLine);
51 if (isSectionHeader) {
52 const pathOnly = trimmed.replace(/^https?:\/\/[^/]+/, "");
53 try {
54 matchesCurrentSection = cfHeadersPatternToRegex(pathOnly).test(path);
55 } catch {
56 matchesCurrentSection = false;
57 }
58 } else if (
59 matchesCurrentSection &&
60 // Either `Cache-Control: value` (set) or `! Cache-Control` (detach).
61 /^\s+(?:!\s+cache-control\s*$|cache-control\s*:)/i.test(rawLine)
62 ) {
63 return true;
64 }
65 }
66 return false;
67}
68
69export interface BuildAssetsHeadersOptions {
70 /** Astro's hashed-assets directory name (`config.build.assets`, default `_astro`). */
71 readonly assetsDir: string;
72 /** The site base without its trailing slash (empty string for `/`). */
73 readonly basePrefix: string;
74 /** Path (or URL) of the `_headers` file, passed to `readFile`. */
75 readonly headersPath: string | URL;
76}
77
78/**
79 * Compute the new `_headers` content with the immutable `Cache-Control`
80 * block for the hashed-assets directory prepended. Returns `null` when the
81 * existing file already covers the assets path (nothing to do).
82 */
83export async function buildAssetsHeadersContent(
84 opts: BuildAssetsHeadersOptions,
85 readFile: (path: string | URL) => Promise<string>,
86): Promise<{ content: string; assetsPattern: string } | null> {
87 const { assetsDir, basePrefix, headersPath } = opts;
88 const assetsPattern = `${basePrefix}/${assetsDir}/*`;
89 const probePath = `${basePrefix}/${assetsDir}/probe`;
90
91 let existingHeaders = "";
92 try {
93 existingHeaders = await readFile(headersPath);
94 } catch {
95 // No existing _headers file.
96 }
97
98 if (headersFileHasCacheControlForPath(existingHeaders, probePath)) {
99 return null;
100 }
101
102 const cacheBlock = `${assetsPattern}\n Cache-Control: public, max-age=31536000, immutable\n`;
103 const normalizedExisting =
104 existingHeaders && !existingHeaders.endsWith("\n")
105 ? existingHeaders + "\n"
106 : existingHeaders;
107 const content = normalizedExisting
108 ? `${cacheBlock}\n${normalizedExisting}`
109 : cacheBlock;
110 return { content, assetsPattern };
111}