|
| 1 | +/** |
| 2 | + * Post-build step: inject the chunk-load recovery script as a real, |
| 3 | + * parser-blocking inline <script> at the very top of <head> in every exported |
| 4 | + * HTML file. |
| 5 | + * |
| 6 | + * Why a post-build step instead of rendering it in the React tree: under |
| 7 | + * `output: "export"` (Next.js static export + Turbopack + React 19) the RSC |
| 8 | + * renderer serializes any inline <script> — whether authored via next/script |
| 9 | + * or a plain <script> tag — into the React Flight payload (__next_f) rather |
| 10 | + * than emitting an executable tag. Such a script only runs after hydration, |
| 11 | + * which depends on the very app chunks it is meant to recover. Injecting into |
| 12 | + * the emitted HTML guarantees it executes before the async chunk scripts. |
| 13 | + */ |
| 14 | +import { promises as fs } from "node:fs"; |
| 15 | +import path from "node:path"; |
| 16 | +import { fileURLToPath } from "node:url"; |
| 17 | +import { chunkLoadRecoveryScript } from "../src/lib/chunk-load-recovery-script"; |
| 18 | + |
| 19 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 20 | +const OUT_DIR = path.resolve(__dirname, "../out"); |
| 21 | +const MARKER_ID = "chunk-load-recovery"; |
| 22 | +const SCRIPT_TAG = `<script id="${MARKER_ID}">${chunkLoadRecoveryScript}</script>`; |
| 23 | + |
| 24 | +async function collectHtmlFiles(dir: string): Promise<string[]> { |
| 25 | + const entries = await fs.readdir(dir, { withFileTypes: true }); |
| 26 | + const files = await Promise.all( |
| 27 | + entries.map(async (entry) => { |
| 28 | + const full = path.join(dir, entry.name); |
| 29 | + if (entry.isDirectory()) return collectHtmlFiles(full); |
| 30 | + return entry.isFile() && entry.name.endsWith(".html") ? [full] : []; |
| 31 | + }), |
| 32 | + ); |
| 33 | + return files.flat(); |
| 34 | +} |
| 35 | + |
| 36 | +async function main() { |
| 37 | + try { |
| 38 | + await fs.access(OUT_DIR); |
| 39 | + } catch { |
| 40 | + throw new Error( |
| 41 | + `Output directory not found: ${OUT_DIR}. Run "next build" first.`, |
| 42 | + ); |
| 43 | + } |
| 44 | + |
| 45 | + const htmlFiles = await collectHtmlFiles(OUT_DIR); |
| 46 | + if (htmlFiles.length === 0) { |
| 47 | + throw new Error(`No HTML files found under ${OUT_DIR}.`); |
| 48 | + } |
| 49 | + |
| 50 | + let injected = 0; |
| 51 | + let skipped = 0; |
| 52 | + const missingHead: string[] = []; |
| 53 | + |
| 54 | + for (const file of htmlFiles) { |
| 55 | + const html = await fs.readFile(file, "utf8"); |
| 56 | + |
| 57 | + if (html.includes(`id="${MARKER_ID}"`)) { |
| 58 | + skipped += 1; |
| 59 | + continue; |
| 60 | + } |
| 61 | + |
| 62 | + const headIndex = html.indexOf("<head>"); |
| 63 | + if (headIndex === -1) { |
| 64 | + missingHead.push(path.relative(OUT_DIR, file)); |
| 65 | + continue; |
| 66 | + } |
| 67 | + |
| 68 | + const insertAt = headIndex + "<head>".length; |
| 69 | + const next = html.slice(0, insertAt) + SCRIPT_TAG + html.slice(insertAt); |
| 70 | + await fs.writeFile(file, next); |
| 71 | + injected += 1; |
| 72 | + } |
| 73 | + |
| 74 | + console.log( |
| 75 | + `[inject-chunk-load-recovery] injected into ${injected} file(s), ` + |
| 76 | + `skipped ${skipped} already-injected file(s).`, |
| 77 | + ); |
| 78 | + |
| 79 | + if (missingHead.length > 0) { |
| 80 | + throw new Error( |
| 81 | + `No <head> found in ${missingHead.length} HTML file(s): ` + |
| 82 | + missingHead.slice(0, 10).join(", ") + |
| 83 | + (missingHead.length > 10 ? ", …" : ""), |
| 84 | + ); |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +main().catch((error) => { |
| 89 | + console.error("[inject-chunk-load-recovery]", error); |
| 90 | + process.exit(1); |
| 91 | +}); |
0 commit comments