| 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 | */ |
| 18 | function 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 | */ |
| 42 | export 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 | |
| 69 | export 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 | */ |
| 83 | export 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 | } |