From 187bebd6bb3e392c8f6c8e4d1feeb5d2660e28ff Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 19 Oct 2026 18:34:52 +0900 Subject: [PATCH] fix(landing): stop vinext prerender from silently skipping pages Refs #683 Also carries the identical changepack fix from #788 so the changepack guard passes on this base. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_transient_props.json | 1 - apps/landing/check-prerender.ts | 9 + apps/landing/package.json | 12 +- apps/landing/verify-prerender.ts | 46 ++++ apps/vinext-rsc-css-repro/package.json | 10 +- benchmark/vinext-devup-ui/package.json | 10 +- bun.lock | 79 +++---- package.json | 5 +- .../src/__tests__/landing-prerender.test.ts | 111 +++++++++ .../vinext-prerender-transport.helper.mjs | 208 ++++++++++++++++ .../vinext-prerender-transport.test.ts | 83 +++++++ patches/vinext@1.2.1.patch | 223 ++++++++++++++++++ 12 files changed, 736 insertions(+), 61 deletions(-) create mode 100644 apps/landing/check-prerender.ts create mode 100644 apps/landing/verify-prerender.ts create mode 100644 packages/vite-plugin/src/__tests__/landing-prerender.test.ts create mode 100644 packages/vite-plugin/src/__tests__/vinext-prerender-transport.helper.mjs create mode 100644 packages/vite-plugin/src/__tests__/vinext-prerender-transport.test.ts create mode 100644 patches/vinext@1.2.1.patch diff --git a/.changepacks/changepack_log_transient_props.json b/.changepacks/changepack_log_transient_props.json index cbe2e2dd9..c4971dd57 100644 --- a/.changepacks/changepack_log_transient_props.json +++ b/.changepacks/changepack_log_transient_props.json @@ -1,6 +1,5 @@ { "changes": { - "apps/landing/package.json": "Patch", "bindings/devup-ui-wasm/package.json": "Patch", "packages/react/package.json": "Patch" }, diff --git a/apps/landing/check-prerender.ts b/apps/landing/check-prerender.ts new file mode 100644 index 000000000..e7fdb91b6 --- /dev/null +++ b/apps/landing/check-prerender.ts @@ -0,0 +1,9 @@ +import { fileURLToPath } from 'node:url' + +import { EXPECTED_EXPORTED_ROUTES } from '../../e2e/exported-routes' +import { verifyPrerender } from './verify-prerender' + +verifyPrerender( + fileURLToPath(new URL('./dist/client/', import.meta.url)), + EXPECTED_EXPORTED_ROUTES, +) diff --git a/apps/landing/package.json b/apps/landing/package.json index 88dd58369..c7f3c9be4 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "node ./script.js && vinext dev", "search": "node ./script.js", - "build": "node ./script.js && vinext build", + "build": "node ./script.js && vinext build && bun ./check-prerender.ts", "build:next": "node ./script.js && LANDING_BUILD_MODE=next next build", "start": "npx serve ./dist/client", "lint": "eslint" @@ -22,12 +22,12 @@ "clsx": "^2.1", "lenis": "1.3", "next": "^16.3", - "react": "^19.3", - "react-dom": "^19.3", + "react": "^19.3.1", + "react-dom": "^19.3.1", "react-markdown": "^10.1", - "react-server-dom-webpack": "^19.3", + "react-server-dom-webpack": "^19.3.1", "react-syntax-highlighter": "^16.1", - "vinext": "^1.0.0-beta.12" + "vinext": "^1.2.1" }, "devDependencies": { "@devup-ui/next-plugin": "workspace:^", @@ -40,7 +40,7 @@ "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5", "@vitejs/plugin-react": "^6.1", - "@vitejs/plugin-rsc": "^0.5.35", + "@vitejs/plugin-rsc": "^0.5.38", "babel-plugin-react-compiler": "^1.0", "typescript": "^7.0.2", "vite": "^8.3" diff --git a/apps/landing/verify-prerender.ts b/apps/landing/verify-prerender.ts new file mode 100644 index 000000000..401be9b48 --- /dev/null +++ b/apps/landing/verify-prerender.ts @@ -0,0 +1,46 @@ +import { statSync } from 'node:fs' +import { resolve } from 'node:path' + +import { + getOutputPath, + getRscOutputPath, +} from 'vinext/internal/utils/prerender-output-paths' + +export class PrerenderArtifactError extends Error { + constructor( + readonly route: string, + readonly path: string, + readonly reason: 'missing' | 'empty' | 'not-file', + ) { + super(`Prerender artifact ${reason}: route ${route}, artifact ${path}`) + this.name = 'PrerenderArtifactError' + } +} + +function requireArtifact(route: string, path: string): void { + const stat = statSync(path, { throwIfNoEntry: false }) + if (!stat) throw new PrerenderArtifactError(route, path, 'missing') + if (!stat.isFile()) throw new PrerenderArtifactError(route, path, 'not-file') + if (stat.size === 0) throw new PrerenderArtifactError(route, path, 'empty') +} + +export function verifyPrerender( + outputDirectory: string, + expectedRoutes: readonly string[], +): void { + // Match landing's output: 'export', trailingSlash: false, empty basePath. + for (const route of expectedRoutes) { + requireArtifact( + route, + resolve(outputDirectory, getOutputPath(route, false)), + ) + requireArtifact( + route, + resolve( + outputDirectory, + getRscOutputPath(route, { mode: 'export', trailingSlash: false }), + ), + ) + } + requireArtifact('/404', resolve(outputDirectory, '404.html')) +} diff --git a/apps/vinext-rsc-css-repro/package.json b/apps/vinext-rsc-css-repro/package.json index 08e528ec5..d2285a28e 100644 --- a/apps/vinext-rsc-css-repro/package.json +++ b/apps/vinext-rsc-css-repro/package.json @@ -9,17 +9,17 @@ }, "dependencies": { "@devup-ui/react": "workspace:^", - "react": "^19.3", - "react-dom": "^19.3", - "react-server-dom-webpack": "^19.3", - "vinext": "1.0.0" + "react": "^19.3.1", + "react-dom": "^19.3.1", + "react-server-dom-webpack": "^19.3.1", + "vinext": "^1.2.1" }, "devDependencies": { "@devup-ui/vite-plugin": "workspace:^", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.1", - "@vitejs/plugin-rsc": "0.5.35", + "@vitejs/plugin-rsc": "^0.5.38", "typescript": "^7.0.2", "vite": "8.3.1" } diff --git a/benchmark/vinext-devup-ui/package.json b/benchmark/vinext-devup-ui/package.json index a9841be6a..dd2a66534 100644 --- a/benchmark/vinext-devup-ui/package.json +++ b/benchmark/vinext-devup-ui/package.json @@ -9,9 +9,9 @@ "start": "vite preview" }, "dependencies": { - "react": "^19.3", - "react-dom": "^19.3", - "vinext": "^1.0.0-beta.12", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "vinext": "^1.2.1", "@devup-ui/react": "workspace:^" }, "devDependencies": { @@ -19,8 +19,8 @@ "@types/node": "^26", "@types/react": "^19", "@types/react-dom": "^19", - "@vitejs/plugin-rsc": "^0.5.35", - "react-server-dom-webpack": "^19.3", + "@vitejs/plugin-rsc": "^0.5.38", + "react-server-dom-webpack": "^19.3.1", "typescript": "^7.0.2", "vite": "^8.3" } diff --git a/bun.lock b/bun.lock index 1f7b6c2be..4cae1c9e6 100644 --- a/bun.lock +++ b/bun.lock @@ -36,12 +36,12 @@ "clsx": "^2.1", "lenis": "1.3", "next": "^16.3", - "react": "^19.3", - "react-dom": "^19.3", + "react": "^19.3.1", + "react-dom": "^19.3.1", "react-markdown": "^10.1", - "react-server-dom-webpack": "^19.3", + "react-server-dom-webpack": "^19.3.1", "react-syntax-highlighter": "^16.1", - "vinext": "^1.0.0-beta.12", + "vinext": "^1.2.1", }, "devDependencies": { "@devup-ui/next-plugin": "workspace:^", @@ -54,7 +54,7 @@ "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5", "@vitejs/plugin-react": "^6.1", - "@vitejs/plugin-rsc": "^0.5.35", + "@vitejs/plugin-rsc": "^0.5.38", "babel-plugin-react-compiler": "^1.0", "typescript": "^7.0.2", "vite": "^8.3", @@ -116,17 +116,17 @@ "version": "0.0.0", "dependencies": { "@devup-ui/react": "workspace:^", - "react": "^19.3", - "react-dom": "^19.3", - "react-server-dom-webpack": "^19.3", - "vinext": "1.0.0", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "react-server-dom-webpack": "^19.3.1", + "vinext": "^1.2.1", }, "devDependencies": { "@devup-ui/vite-plugin": "workspace:^", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.1", - "@vitejs/plugin-rsc": "0.5.35", + "@vitejs/plugin-rsc": "^0.5.38", "typescript": "^7.0.2", "vite": "8.3.1", }, @@ -491,17 +491,17 @@ "version": "0.1.0", "dependencies": { "@devup-ui/react": "workspace:^", - "react": "^19.3", - "react-dom": "^19.3", - "vinext": "^1.0.0-beta.12", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "vinext": "^1.2.1", }, "devDependencies": { "@devup-ui/vite-plugin": "workspace:^", "@types/node": "^26", "@types/react": "^19", "@types/react-dom": "^19", - "@vitejs/plugin-rsc": "^0.5.35", - "react-server-dom-webpack": "^19.3", + "@vitejs/plugin-rsc": "^0.5.38", + "react-server-dom-webpack": "^19.3.1", "typescript": "^7.0.2", "vite": "^8.3", }, @@ -675,6 +675,9 @@ }, }, }, + "patchedDependencies": { + "vinext@1.2.1": "patches/vinext@1.2.1.patch", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -1342,7 +1345,7 @@ "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], - "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.0", "", {}, "sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg=="], + "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.1", "", {}, "sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg=="], "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.10", "", { "os": "android", "cpu": "arm" }, "sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg=="], @@ -1742,13 +1745,13 @@ "@vanilla-extract/webpack-plugin": ["@vanilla-extract/webpack-plugin@2.3.27", "", { "dependencies": { "@vanilla-extract/integration": "^8.0.10", "debug": "^4.3.1", "loader-utils": "^2.0.0", "picocolors": "^1.0.0" }, "peerDependencies": { "webpack": "^4.30.0 || ^5.20.2" } }, "sha512-i/GFQPifTkGYXYpkVYzScBPwXB82f+5GMLg9rh9UBgTG6M/YUebIDVjVOC9Mn5VMvc8Fk2w/FmagDRLugHA09w=="], - "@vercel/og": ["@vercel/og@0.8.6", "", { "dependencies": { "@resvg/resvg-wasm": "2.4.0", "satori": "0.16.0" } }, "sha512-hBcWIOppZV14bi+eAmCZj8Elj8hVSUZJTpf1lgGBhVD85pervzQ1poM/qYfFUlPraYSZYP+ASg6To5BwYmUSGQ=="], + "@vercel/og": ["@vercel/og@1.0.3", "", { "dependencies": { "@resvg/resvg-wasm": "2.4.1", "satori": "0.33.5" }, "optionalDependencies": { "sharp": "^0.35.3" } }, "sha512-/iYJ34PDu4lty1TXdL86qHEw8viveHTllG1PWLuJX8tSFfqKL7kyVJeUORJmQ250bHB0h+alcalXUlza3c/NSA=="], - "@vinext/types": ["@vinext/types@1.0.0-beta.2", "", {}, "sha512-kbKY/H1RWlf7yox4G6Vwk1JeM6x0Tv1EAv28f3tDYkK3brq+89vp5eZHbdw3L/pZMbAhG4oFyhT9C976y+jkhQ=="], + "@vinext/types": ["@vinext/types@1.0.1", "", {}, "sha512-zH4WbVl47rNGxG8Y7XoWJAc8e0VQFQ+r8Cuh0crv1L2SuNovDD3jMtxQDjzoS1b9hYaZXbds0CjRo5tSm7F9Zw=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], - "@vitejs/plugin-rsc": ["@vitejs/plugin-rsc@0.5.35", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "es-module-lexer": "^2.3.2", "estree-walker": "^3.0.3", "magic-string": "^1.2.3", "obug": "^3.0.0", "srvx": "^0.12.7", "strip-literal": "^4.0.0", "turbo-stream": "^3.2.1", "vitefu": "^1.1.3" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-server-dom-webpack": "*", "vite": "*" }, "optionalPeers": ["react-server-dom-webpack"] }, "sha512-OZtJ57o5luSeNb2lqi3xeHHF41W8jCgm4nGb/1qmKjLb5gk/szIHS0Wxfo82WWQkKIqBBu6M697ohKWXrP0qqg=="], + "@vitejs/plugin-rsc": ["@vitejs/plugin-rsc@0.5.38", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "es-module-lexer": "^3.0.3", "estree-walker": "^3.0.3", "magic-string": "^1.4.3", "obug": "^3.0.0", "srvx": "^1.0.5", "strip-literal": "^4.0.0", "turbo-stream": "^3.2.1", "vitefu": "^1.1.3" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-server-dom-webpack": "*", "vite": "*" }, "optionalPeers": ["react-server-dom-webpack"] }, "sha512-SoiRBJBVd3Qg6Frnvdk6LwugJYlQKgee+uqySxKz5T/KsCZUgdw529Klzg4IDJuP/EOfsIJ2YiOKAFY8tnr1kg=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -2114,7 +2117,7 @@ "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], - "css-gradient-parser": ["css-gradient-parser@0.0.16", "", {}, "sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA=="], + "css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], "css-mediaquery": ["css-mediaquery@0.1.2", "", {}, "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q=="], @@ -2188,7 +2191,7 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es-module-lexer": ["es-module-lexer@3.0.4", "", {}, "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA=="], "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], @@ -2538,7 +2541,7 @@ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], - "magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="], + "magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], @@ -2830,7 +2833,7 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], "react-18-example": ["react-18-example@workspace:apps/react-18"], @@ -2838,7 +2841,7 @@ "react-docgen-typescript": ["react-docgen-typescript@2.4.0", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg=="], - "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], "react-icons": ["react-icons@5.7.0", "", { "peerDependencies": { "react": "*" } }, "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw=="], @@ -2848,7 +2851,7 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "react-server-dom-webpack": ["react-server-dom-webpack@19.3.0", "", { "dependencies": { "acorn-loose": "^8.3.0", "neo-async": "^2.6.1", "webpack-sources": "^3.2.0" }, "peerDependencies": { "react": "^19.3.0", "react-dom": "^19.3.0", "webpack": "^5.59.0" } }, "sha512-ymt9RkvWtW4OpIhWf1wj4u5CxnBaVbDCkz/bw3/QFmOE7+/wg8C30uiJ/QqkEdWn/UqT5c7/rOUW/eWYMwceCg=="], + "react-server-dom-webpack": ["react-server-dom-webpack@19.3.1", "", { "dependencies": { "acorn-loose": "^8.3.0", "neo-async": "^2.6.1", "webpack-sources": "^3.2.0" }, "peerDependencies": { "react": "^19.3.1", "react-dom": "^19.3.1", "webpack": "^5.59.0" } }, "sha512-I1Smf6Yc+389rFWPwyYjvCQVK1XFWpb2M9gvgYgfpbZ+LPqkZtb0tT9Fwdk0K+wknK2E+ns82lC0DCYLnXCVGA=="], "react-syntax-highlighter": ["react-syntax-highlighter@16.1.1", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA=="], @@ -2930,7 +2933,7 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "satori": ["satori@0.16.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.16", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ=="], + "satori": ["satori@0.33.5", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "fflate": "0.7.3", "harfbuzzjs": "0.10.0", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-PNW6ktrQ8SM6kk7u4aOuTkkUewkzyTOrj+QeEzXSsnNMAdff4ire9CAqIz8+4C3rCiBtK8QTELv52AZEd1PbRw=="], "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], @@ -2970,7 +2973,7 @@ "spdx-license-ids": ["spdx-license-ids@3.0.24", "", {}, "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ=="], - "srvx": ["srvx@0.12.8", "", { "bin": { "srvx": "./bin/srvx.mjs" } }, "sha512-7k9HoDhluTlD7UJ+F+OopZoPZXB2J8f/5nW2VMLaB8KsSlZlRQxyyoX0hWEQCxB+F+3ZvB5QX3LqB6xSBRfxQQ=="], + "srvx": ["srvx@1.0.6", "", { "bin": { "srvx": "./bin/srvx.mjs" } }, "sha512-QeTKgAhk8OSsfeq60bl3wuVs9PmBftzmk7owBInA/F1YQne+TRw0UcPymNpURldAmx8hfmM2PNNGH/zgo3GpLA=="], "storybook": ["storybook@10.6.0", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.3", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "11.21.2", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.21.1" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15 || ^0.2.0" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-H48X6AURAFTpfN2zEux6vNesee5Oq8Gxj1IanYmblPZ218sfKSeyyerF1NC7GD7BrruoC8L4/cU0zII4y+d6zw=="], @@ -3132,7 +3135,7 @@ "vfile-statistics": ["vfile-statistics@3.0.0", "", { "dependencies": { "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-/qlwqwWBWFOmpXujL/20P+Iuydil0rZZNglR+VNm6J0gpLHwuVM5s7g2TfVoswbXjZ4HuIhLMySEyIw5i7/D8w=="], - "vinext": ["vinext@1.0.0-beta.12", "", { "dependencies": { "@unpic/react": "^1.0.2", "@vercel/og": "^0.8.6", "@vinext/types": "^1.0.0-beta.2", "ipaddr.js": "^2.1.0", "magic-string": "^0.30.21", "vite-plugin-commonjs": "^0.10.4", "web-vitals": "^4.2.4" }, "peerDependencies": { "@mdx-js/rollup": "^3.0.0", "@vitejs/plugin-react": "^5.1.4 || ^6.0.0", "@vitejs/plugin-rsc": "^0.5.34", "react": "^19.2.6", "react-dom": "^19.2.6", "react-server-dom-webpack": "^19.2.6", "vite": "^8.0.0" }, "optionalPeers": ["@mdx-js/rollup", "@vitejs/plugin-rsc", "react-server-dom-webpack"], "bin": { "vinext": "dist/cli.js" } }, "sha512-iN+MfpieZ+Mhgt7BeEz/vHYBULdRylaIGMoqKucKydb95AKfHoDvp5dfwfjOQp7pr1jKwGtWqZaJnbjj4+uBWA=="], + "vinext": ["vinext@1.2.1", "", { "dependencies": { "@unpic/react": "^1.0.2", "@vercel/og": "1.0.3", "@vinext/types": "^1.0.1", "ipaddr.js": "^2.1.0", "magic-string": "^0.30.21", "vite-plugin-commonjs": "^0.10.4", "web-vitals": "^4.2.4" }, "peerDependencies": { "@mdx-js/rollup": "^3.0.0", "@vitejs/plugin-react": "^5.1.4 || ^6.0.0", "@vitejs/plugin-rsc": "^0.5.37", "react": "^19.3.1", "react-dom": "^19.3.1", "react-server-dom-webpack": "^19.3.1", "vite": "^8.0.0" }, "optionalPeers": ["@mdx-js/rollup", "@vitejs/plugin-rsc", "react-server-dom-webpack"], "bin": { "vinext": "dist/cli.js" } }, "sha512-cB8ViQ4JuxoM2jcsvEB2kdVNSm4zWfNgpYU8Bx6781mRuArlgDsI0Y4w5EqQwkK8e3BzMMHeOreiUbMc8xLLiA=="], "vinext-devup-ui-benchmark": ["vinext-devup-ui-benchmark@workspace:benchmark/vinext-devup-ui"], @@ -3164,7 +3167,7 @@ "webpack": ["webpack@5.111.1", "", { "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.25.0", "es-module-lexer": "^2.1.0", "events": "^3.2.0", "graceful-fs": "^4.2.11", "mime-db": "^1.54.0", "minimizer-webpack-plugin": "^5.7.0", "schema-utils": "^4.5.0", "tapable": "^2.3.0", "watchpack": "^2.5.2", "webpack-sources": "^3.5.1" }, "peerDependencies": { "webpack-cli": "*" }, "optionalPeers": ["webpack-cli"], "bin": { "webpack": "bin/webpack.js" } }, "sha512-cNypaz0RP+S4cvQVi1M/3bRUkvNP0xZpL0TjFx+c6jg64VbxD+2weWDgY9UnjRI6dhZgtaj8DU76GGFcJ79xPQ=="], - "webpack-sources": ["webpack-sources@3.5.1", "", {}, "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw=="], + "webpack-sources": ["webpack-sources@3.6.0", "", {}, "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -3836,10 +3839,10 @@ "vinext/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "vinext-rsc-css-repro/vinext": ["vinext@1.0.0", "", { "dependencies": { "@unpic/react": "^1.0.2", "@vercel/og": "1.0.3", "@vinext/types": "^1.0.0", "ipaddr.js": "^2.1.0", "magic-string": "^0.30.21", "vite-plugin-commonjs": "^0.10.4", "web-vitals": "^4.2.4" }, "peerDependencies": { "@mdx-js/rollup": "^3.0.0", "@vitejs/plugin-react": "^5.1.4 || ^6.0.0", "@vitejs/plugin-rsc": "^0.5.34", "react": "^19.2.6", "react-dom": "^19.2.6", "react-server-dom-webpack": "^19.2.6", "vite": "^8.0.0" }, "optionalPeers": ["@mdx-js/rollup", "@vitejs/plugin-rsc", "react-server-dom-webpack"], "bin": { "vinext": "dist/cli.js" } }, "sha512-0PeWLqMoJO6mkPvprneY1mLY12nxxhaGTdr3+sTfVTD9xncwaIXQyhVWy+vrvgMlTOkg2e8MMgOiI3qnEdMp2w=="], - "vite-node/cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "vite-node/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "vite-node/obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], "vite-plugin-commonjs/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -3850,6 +3853,8 @@ "webpack/browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="], + "webpack/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -5690,12 +5695,6 @@ "vfile-reporter/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "vinext-rsc-css-repro/vinext/@vercel/og": ["@vercel/og@1.0.3", "", { "dependencies": { "@resvg/resvg-wasm": "2.4.1", "satori": "0.33.5" }, "optionalDependencies": { "sharp": "^0.35.3" } }, "sha512-/iYJ34PDu4lty1TXdL86qHEw8viveHTllG1PWLuJX8tSFfqKL7kyVJeUORJmQ250bHB0h+alcalXUlza3c/NSA=="], - - "vinext-rsc-css-repro/vinext/@vinext/types": ["@vinext/types@1.0.1", "", {}, "sha512-zH4WbVl47rNGxG8Y7XoWJAc8e0VQFQ+r8Cuh0crv1L2SuNovDD3jMtxQDjzoS1b9hYaZXbds0CjRo5tSm7F9Zw=="], - - "vinext-rsc-css-repro/vinext/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "@babel/helper-create-class-features-plugin/@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -6642,10 +6641,6 @@ "vfile-reporter/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - "vinext-rsc-css-repro/vinext/@vercel/og/@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.1", "", {}, "sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg=="], - - "vinext-rsc-css-repro/vinext/@vercel/og/satori": ["satori@0.33.5", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "fflate": "0.7.3", "harfbuzzjs": "0.10.0", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-PNW6ktrQ8SM6kk7u4aOuTkkUewkzyTOrj+QeEzXSsnNMAdff4ire9CAqIz8+4C3rCiBtK8QTELv52AZEd1PbRw=="], - "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@kuma-ui/compiler/ts-morph/@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], @@ -6662,8 +6657,6 @@ "unified-engine/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "vinext-rsc-css-repro/vinext/@vercel/og/satori/css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], - "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "@kuma-ui/compiler/ts-morph/@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 795d7a498..0ad2be1ea 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,8 @@ "apps/*", "bindings/*", "benchmark/*" - ] + ], + "patchedDependencies": { + "vinext@1.2.1": "patches/vinext@1.2.1.patch" + } } diff --git a/packages/vite-plugin/src/__tests__/landing-prerender.test.ts b/packages/vite-plugin/src/__tests__/landing-prerender.test.ts new file mode 100644 index 000000000..2a8e96fbe --- /dev/null +++ b/packages/vite-plugin/src/__tests__/landing-prerender.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'bun:test' + +import { + PrerenderArtifactError, + verifyPrerender, +} from '../../../../apps/landing/verify-prerender' + +const routes = ['/', '/nested/leaf'] as const +const artifacts = [ + ['/', 'index.html'], + ['/', 'index.txt'], + ['/nested/leaf', 'nested/leaf.html'], + ['/nested/leaf', 'nested/leaf.txt'], + ['/404', '404.html'], +] as const + +function temporaryOutput() { + const root = mkdtempSync(join(tmpdir(), 'landing-prerender-')) + return { + root, + [Symbol.dispose]() { + rmSync(root, { recursive: true, force: true }) + }, + } +} + +function writeCompleteOutput(root: string): void { + mkdirSync(join(root, 'nested')) + // Independent fixture paths, not generated by Vinext or the verifier. + writeFileSync(join(root, 'index.html'), 'home') + writeFileSync(join(root, 'index.txt'), 'home Flight') + writeFileSync(join(root, 'nested/leaf.html'), 'leaf') + writeFileSync(join(root, 'nested/leaf.txt'), 'leaf Flight') + writeFileSync(join(root, '404.html'), 'not found') +} + +it('accepts complete route pairs when the 404 has HTML only', () => { + // Given + using output = temporaryOutput() + writeCompleteOutput(output.root) + // When / Then + expect(() => verifyPrerender(output.root, routes)).not.toThrow() +}) + +describe.each(artifacts)('route %s artifact %s', (route, artifact) => { + it('rejects the artifact when it is missing', () => { + // Given + using output = temporaryOutput() + writeCompleteOutput(output.root) + const path = join(output.root, artifact) + rmSync(path) + // When / Then + expect(() => verifyPrerender(output.root, routes)).toThrow( + expect.objectContaining({ + constructor: PrerenderArtifactError, + route, + path, + reason: 'missing', + }), + ) + }) + + it('rejects the artifact when it is empty', () => { + // Given + using output = temporaryOutput() + writeCompleteOutput(output.root) + const path = join(output.root, artifact) + writeFileSync(path, '') + // When / Then + expect(() => verifyPrerender(output.root, routes)).toThrow( + expect.objectContaining({ + constructor: PrerenderArtifactError, + route, + path, + reason: 'empty', + }), + ) + }) + + it('rejects the artifact when it is a directory', () => { + // Given + using output = temporaryOutput() + writeCompleteOutput(output.root) + const path = join(output.root, artifact) + rmSync(path) + mkdirSync(path) + // When / Then + expect(() => verifyPrerender(output.root, routes)).toThrow( + expect.objectContaining({ + constructor: PrerenderArtifactError, + route, + path, + reason: 'not-file', + }), + ) + }) +}) + +it('propagates native filesystem errors when the path contains a null byte', () => { + // Given + using output = temporaryOutput() + const path = join(output.root, '\0') + // When / Then + expect(() => verifyPrerender(path, routes)).toThrow( + expect.objectContaining({ code: 'ERR_INVALID_ARG_VALUE' }), + ) +}) diff --git a/packages/vite-plugin/src/__tests__/vinext-prerender-transport.helper.mjs b/packages/vite-plugin/src/__tests__/vinext-prerender-transport.helper.mjs new file mode 100644 index 000000000..fe22c742a --- /dev/null +++ b/packages/vite-plugin/src/__tests__/vinext-prerender-transport.helper.mjs @@ -0,0 +1,208 @@ +// Runs Vinext's published prerenderApp in Node, the runtime of `vinext build`, +// against servers started by Vinext's own startProdServer, and prints a JSON +// summary for vinext-prerender-transport.test.ts. +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const [prodServerPath, prerenderPath] = process.argv.slice(2) +const { startProdServer } = await import(pathToFileURL(prodServerPath).href) +const { prerenderApp } = await import(pathToFileURL(prerenderPath).href) + +const flight = '0:["$","main",null,{"children":"victim"}]\n' +const html = `
victim
` +const entry = `export default async function handler(request) { + const { pathname } = new URL(request.url) + if (pathname === '/warm') return new Response('warm') + if (pathname === '/victim') return new Response(${JSON.stringify(html)}, { headers: { 'content-type': 'text/html; charset=utf-8' } }) + if (pathname === '/dynamic') return new Response('dynamic', { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } }) + return new Response('404', { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } }) +} +` + +function causeChain(error) { + const chain = [] + for (let cause = error; cause instanceof Error; cause = cause.cause) + chain.push({ name: cause.name, message: cause.message, code: cause.code }) + return chain +} + +function summarize(outDir, outcome) { + const victimHtml = join(outDir, 'victim.html') + const victimFlight = join(outDir, 'victim.txt') + return { + routes: + outcome.routes?.map(({ route, status, reason }) => + [route, status, reason].filter(Boolean).join(':'), + ) ?? null, + error: outcome.error ?? null, + html: existsSync(victimHtml) && readFileSync(victimHtml, 'utf8') === html, + flight: + existsSync(victimFlight) && readFileSync(victimFlight, 'utf8') === flight, + written: ['victim.html', 'victim.txt', 'dynamic.html'].filter((file) => + existsSync(join(outDir, file)), + ), + } +} + +function classifyVictim({ routes, error, html, flight, written }) { + if (error) + return error.name === 'AggregateError' && + error.message.endsWith('\n /victim: fetch failed (ECONNRESET)') && + written.length === 0 + ? 'failed-closed' + : error.message + return routes.includes('/victim:rendered') && html && flight + ? 'rendered' + : routes.find((route) => route.startsWith('/victim')) +} + +function prerender(prodServer, outDir, serverDir, rscBundlePath, routes) { + return prerenderApp({ + routes, + metadataRoutes: [], + outDir, + serverDir, + rscBundlePath, + mode: 'export', + config: { basePath: '', trailingSlash: false }, + _prodServer: prodServer, + }).then( + (result) => summarize(outDir, { routes: result.routes }), + (error) => + summarize(outDir, { + error: { + name: error.name, + message: error.message, + causes: causeChain(error.cause), + errors: error.errors?.length ?? 0, + }, + }), + ) +} + +const root = mkdtempSync(join(tmpdir(), 'vinext-prerender-transport-')) +try { + writeFileSync(join(root, 'package.json'), '{"type":"module"}') + const serverDir = join(root, 'server') + mkdirSync(serverDir) + mkdirSync(join(root, 'client')) + const rscBundlePath = join(serverDir, 'index.js') + writeFileSync(rscBundlePath, entry) + const routes = ['victim', 'dynamic'].map((name) => { + const pagePath = join(root, `${name}.tsx`) + writeFileSync( + pagePath, + `export default function Page() { return
${name}
}\n`, + ) + return { + pattern: `/${name}`, + pagePath, + routePath: null, + isDynamic: false, + layouts: [], + parallelSlots: [], + } + }) + + const production = await startProdServer({ + port: 0, + host: '127.0.0.1', + outDir: root, + rscEntryPath: rscBundlePath, + serverDir, + noCompression: true, + silent: true, + }) + const productionMaxRequestsPerSocket = production.server.maxRequestsPerSocket + production.server.close() + + // "shipped" keeps the server exactly as Vinext starts it for prerendering; + // "reuse" restores Node's default keep-alive reuse on that same server. + const servers = {} + const warm = {} + for (const name of ['shipped', 'reuse']) { + const prodServer = await startProdServer({ + port: 0, + host: '127.0.0.1', + outDir: root, + rscEntryPath: rscBundlePath, + serverDir, + noCompression: true, + purpose: 'prerender', + silent: true, + }) + warm[name] = { + maxRequestsPerSocket: prodServer.server.maxRequestsPerSocket, + } + if (name === 'reuse') prodServer.server.maxRequestsPerSocket = 0 + const response = await fetch(`http://127.0.0.1:${prodServer.port}/warm`) + await response.text() + warm[name].connection = response.headers.get('connection') + servers[name] = prodServer + } + + // Queue the prerender requests, then block the event loop past the servers' + // idle keep-alive timeout, as a long render does during a build. + const idleTimeout = Math.max( + ...Object.values(servers).map( + ({ server }) => server.keepAliveTimeout + server.keepAliveTimeoutBuffer, + ), + ) + const [shipped, reuse] = await new Promise((resolve) => + setImmediate(() => { + const pending = ['shipped', 'reuse'].map((name) => + prerender( + servers[name], + join(root, name), + serverDir, + rscBundlePath, + routes, + ), + ) + const start = performance.now() + while (performance.now() - start < idleTimeout); + resolve(Promise.all(pending)) + }), + ) + for (const { server } of Object.values(servers)) { + server.closeAllConnections() + server.close() + } + + // "reset" stands in for a prerender server whose connection fails after the + // request is sent: every request gets a TCP reset and no response. + const resetServer = createServer((socket) => + socket.once('data', () => socket.resetAndDestroy()), + ) + await new Promise((resolve) => resetServer.listen(0, '127.0.0.1', resolve)) + const reset = await prerender( + { server: resetServer, port: resetServer.address().port }, + join(root, 'reset'), + serverDir, + rscBundlePath, + routes, + ) + resetServer.close() + + process.stdout.write( + `${JSON.stringify({ + idleTimeout, + productionMaxRequestsPerSocket, + shipped: { ...warm.shipped, ...shipped }, + reuse: { ...warm.reuse, victim: classifyVictim(reuse) }, + reset, + })}\n`, + ) +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/vite-plugin/src/__tests__/vinext-prerender-transport.test.ts b/packages/vite-plugin/src/__tests__/vinext-prerender-transport.test.ts new file mode 100644 index 000000000..e2cd2314b --- /dev/null +++ b/packages/vite-plugin/src/__tests__/vinext-prerender-transport.test.ts @@ -0,0 +1,83 @@ +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +// The landing app's Vinext, with the repository's Bun patch applied. +const landing = join(import.meta.dir, '../../../../apps/landing') +const prerenderEntry = Bun.resolveSync( + 'vinext/internal/build/run-prerender', + landing, +) + +// `vinext build` runs in Node, so the helper does too; its fetch and HTTP +// server are the ones whose keep-alive reuse loses pages. +const child = Bun.spawnSync( + [ + 'node', + join(import.meta.dir, 'vinext-prerender-transport.helper.mjs'), + Bun.resolveSync('vinext/server/prod-server', landing), + join(dirname(prerenderEntry), 'prerender.js'), + ], + { stdout: 'pipe', stderr: 'pipe', timeout: 60000 }, +) +const report: unknown = JSON.parse( + child.stdout.toString().trim().split('\n').at(-1) ?? 'null', +) + +it('runs the published prerender code to completion', () => { + expect({ + exitCode: child.exitCode, + stderr: child.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(report).toMatchObject({ idleTimeout: expect.any(Number) }) +}) + +it('serves each build-time prerender request on its own connection', () => { + expect(report).toMatchObject({ + shipped: { + maxRequestsPerSocket: 1, + connection: 'close', + routes: ['/victim:rendered', '/dynamic:skipped:dynamic', '/404:rendered'], + error: null, + html: true, + flight: true, + written: ['victim.html', 'victim.txt'], + }, + }) +}) + +it('keeps Node keep-alive defaults on servers that are not for prerendering', () => { + expect(report).toMatchObject({ productionMaxRequestsPerSocket: 0 }) +}) + +it('never drops a page silently when the server closes a reused connection', () => { + // Node 24 resets the reused connection here; newer Node versions render it. + expect(report).toMatchObject({ + reuse: { + maxRequestsPerSocket: 1, + connection: 'keep-alive', + victim: expect.stringMatching(/^(rendered|failed-closed)$/), + }, + }) +}) + +it('fails the export naming every route and keeping the original error when a request gets no response', () => { + expect(report).toMatchObject({ + reset: { + routes: null, + error: { + name: 'AggregateError', + message: + '[vinext] Static export failed: 2 prerender requests failed before a response was received.\n' + + ' /dynamic: fetch failed (ECONNRESET)\n' + + ' /victim: fetch failed (ECONNRESET)', + causes: [ + { name: 'TypeError', message: 'fetch failed' }, + { code: 'ECONNRESET' }, + ], + errors: 2, + }, + written: [], + }, + }) +}) diff --git a/patches/vinext@1.2.1.patch b/patches/vinext@1.2.1.patch new file mode 100644 index 000000000..dc5cc0b76 --- /dev/null +++ b/patches/vinext@1.2.1.patch @@ -0,0 +1,223 @@ +diff --git a/dist/build/prerender.js b/dist/build/prerender.js +index 0e1a3d21988690c2c69603d03861bc57406c2eae..8dfc5f75b44fb7efb1e0e022118d99d5416d9e00 100644 +--- a/dist/build/prerender.js ++++ b/dist/build/prerender.js +@@ -64,6 +64,45 @@ async function startOptionalPrerenderServerPool(outDir, poolSize, rscEntryPath, + } + var PrerenderUserFunctionError = class extends Error {}; + var InvalidStaticParamsError = class extends Error {}; ++/** ++* Errors thrown by `fetch()` for a request to a build-time prerender server. ++* Such a request failed before any response arrived, so it says nothing about ++* whether the route is dynamic. ++*/ ++const failedPrerenderRequests = /* @__PURE__ */ new WeakSet(); ++function fetchPrerenderServer(url, init) { ++ return fetch(url, init).catch((error) => { ++ if (typeof error === "object" && error !== null) failedPrerenderRequests.add(error); ++ throw error; ++ }); ++} ++/** ++* Collects failed prerender requests during a static export. The export fails ++* once in-flight renders have settled, naming every route in a stable order and ++* keeping each original error. ++*/ ++function createExportRequestFailures(mode) { ++ const failures = []; ++ return { ++ record(error, route, urlPath) { ++ if (mode !== "export" || !failedPrerenderRequests.has(error)) return false; ++ failures.push({ ++ route: urlPath && urlPath !== route ? `${route} (${urlPath})` : route, ++ error ++ }); ++ return true; ++ }, ++ throwIfAny() { ++ if (failures.length === 0) return; ++ failures.sort((a, b) => a.route < b.route ? -1 : a.route > b.route ? 1 : 0); ++ const lines = failures.map(({ route, error }) => { ++ const code = error.cause?.code; ++ return ` ${route}: ${error.message}${typeof code === "string" ? ` (${code})` : ""}`; ++ }); ++ throw new AggregateError(failures.map(({ error }) => error), `[vinext] Static export failed: ${failures.length} prerender request${failures.length === 1 ? "" : "s"} failed before a response was received.\n${lines.join("\n")}`, { cause: failures[0].error }); ++ } ++ }; ++} + function parsePrerenderEndpointError(text) { + try { + const parsed = JSON.parse(text); +@@ -368,6 +407,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + const skipManifest = options.skipManifest ?? false; + const fileMatcher = createValidFileMatcher(config.pageExtensions); + const results = []; ++ const exportRequestFailures = createExportRequestFailures(mode); + if (!pagesBundlePath && !options._prodServer) throw new Error("[vinext] prerenderPages: either pagesBundlePath or _prodServer must be provided."); + fs.mkdirSync(outDir, { recursive: true }); + for (const apiRoute of apiRoutes) results.push({ +@@ -405,7 +445,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + const port = renderPorts[renderRoundRobin++ % renderPorts.length]; + const routeRequestPath = config.trailingSlash && !urlPath.endsWith("/") ? `${urlPath}/` : urlPath; + const requestPath = config.basePath && routeRequestPath === "/" && !config.trailingSlash ? config.basePath : `${config.basePath ?? ""}${routeRequestPath}`; +- return fetch(`http://127.0.0.1:${port}${requestPath}`, { ++ return fetchPrerenderServer(`http://127.0.0.1:${port}${requestPath}`, { + headers: secretHeaders, + redirect: "manual" + }); +@@ -419,7 +459,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + const search = new URLSearchParams({ pattern: r.pattern }); + if (locales.length > 0) search.set("locales", JSON.stringify(locales)); + if (defaultLocale) search.set("defaultLocale", defaultLocale); +- const res = await fetch(`${baseUrl}/__vinext/prerender/pages-static-paths?${search}`, { headers: secretHeaders }); ++ const res = await fetchPrerenderServer(`${baseUrl}/__vinext/prerender/pages-static-paths?${search}`, { headers: secretHeaders }); + const text = await res.text(); + if (!res.ok) { + if (res.status === 500) throw new PrerenderUserFunctionError(parsePrerenderEndpointError(text)); +@@ -476,6 +516,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + defaultLocale: config.i18n?.defaultLocale ?? "" + }); + } catch (e) { ++ exportRequestFailures.record(e, route.pattern); + results.push({ + route: route.pattern, + status: "error", +@@ -630,6 +671,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + }; + } catch (e) { + renderPool?.recordRenderError(e); ++ exportRequestFailures.record(e, route.pattern, urlPath); + const err = e; + result = { + route: route.pattern, +@@ -646,6 +688,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + return result; + }); + results.push(...pageResults); ++ exportRequestFailures.throwIfAny(); + renderPool?.assertHealthy(); + const hasCustom404 = findFileWithExtensions(path.join(pagesDir, "404"), fileMatcher); + const hasErrorPage = findFileWithExtensions(path.join(pagesDir, "_error"), fileMatcher); +@@ -683,8 +726,10 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + } + } catch (e) { + renderPool?.recordRenderError(e); ++ exportRequestFailures.record(e, "/404", localizePagesPath("/404", locale, config.i18n)); + } + } ++ exportRequestFailures.throwIfAny(); + renderPool?.assertHealthy(); + if (!skipManifest) writePrerenderIndex(results, manifestDir, { + buildId: config.buildId, +@@ -716,7 +761,8 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + * Speculative static rendering: routes classified as 'unknown' (no explicit + * config, non-dynamic URL) are attempted with an empty headers/cookies context. + * If they succeed, they are marked as rendered. If they throw a DynamicUsageError +-* or fail, they are marked as skipped with reason 'dynamic'. ++* or fail, they are marked as skipped with reason 'dynamic'. In a static export, ++* a request that fails before any response arrives fails the export instead. + */ + async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, rscBundlePath, serverDir = path.dirname(rscBundlePath), buildOutDir = path.dirname(serverDir), ...options }) { + const manifestDir = options.manifestDir ?? outDir; +@@ -724,6 +770,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + const onProgress = options.onProgress; + const skipManifest = options.skipManifest ?? false; + const results = []; ++ const exportRequestFailures = createExportRequestFailures(mode); + fs.mkdirSync(outDir, { recursive: true }); + const previousHandler = getCacheHandler(); + setCacheHandler(new NoOpCacheHandler()); +@@ -756,7 +803,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + rscHandler = (req) => { + const parsed = new URL(req.url); + const url = `http://127.0.0.1:${renderPorts[renderRoundRobin++ % renderPorts.length]}${parsed.pathname}${parsed.search}`; +- return fetch(url, { ++ return fetchPrerenderServer(url, { + method: req.method, + headers: { + ...secretHeaders, +@@ -777,7 +824,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + const search = new URLSearchParams({ pattern }); + if (Object.keys(params).length > 0) search.set("parentParams", JSON.stringify(params)); + if (mode === "export") search.set("rejectEmptyResults", "1"); +- const res = await fetch(`${baseUrl}/__vinext/prerender/static-params?${search}`, { headers: secretHeaders }); ++ const res = await fetchPrerenderServer(`${baseUrl}/__vinext/prerender/static-params?${search}`, { headers: secretHeaders }); + const text = await res.text(); + if (!res.ok) { + if (res.status === 500) throw new PrerenderUserFunctionError(parsePrerenderEndpointError(text)); +@@ -925,6 +972,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + } + } + } catch (e) { ++ exportRequestFailures.record(e, route.pattern); + const err = e; + const detail = config.enablePrerenderSourceMaps ? getErrorMessageWithStack(err) : err.message; + results.push({ +@@ -1153,6 +1201,11 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + }; + } catch (e) { + renderPool?.recordRenderError(e); ++ if (exportRequestFailures.record(e, routePattern, urlPath)) return { ++ route: routePattern, ++ status: "error", ++ error: e.message ++ }; + if (isSpeculative) return { + route: routePattern, + status: "skipped", +@@ -1186,6 +1239,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + return result; + }); + results.push(...appResults); ++ exportRequestFailures.throwIfAny(); + renderPool?.assertHealthy(); + const outputFiles = mode === "export" && metadataRoutes.length > 0 ? emitStaticMetadataFiles(metadataRoutes, outDir, config.basePath) : []; + try { +@@ -1204,7 +1258,9 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + } + } catch (e) { + renderPool?.recordRenderError(e); ++ exportRequestFailures.record(e, "/404"); + } ++ exportRequestFailures.throwIfAny(); + renderPool?.assertHealthy(); + if (!skipManifest) writePrerenderIndex(results, manifestDir, { + buildId: config.buildId, +diff --git a/dist/server/prod-server.js b/dist/server/prod-server.js +index 0220df4e8ed9df1a4f7d1a15b907d3153a0eccf4..e9887839e789518813a6b53f969051933b22bc47 100644 +--- a/dist/server/prod-server.js ++++ b/dist/server/prod-server.js +@@ -387,6 +387,17 @@ function logProdServerStarted(host, port, purpose) { + console.log(`[vinext] Production server running at ${url}`); + } + /** ++* Build-time prerender servers serve one request per connection. The build's ++* fetch() pools idle keep-alive connections, and a render that blocks the event ++* loop past `keepAliveTimeout` lets Node close an idle socket the client has ++* already written its next request to, so that request fails with ECONNRESET. ++* With `maxRequestsPerSocket = 1` every response carries `Connection: close`, ++* so no idle connection is ever reused. ++*/ ++function applyPrerenderConnectionPolicy(server, purpose) { ++ if (purpose === "prerender") server.maxRequestsPerSocket = 1; ++} ++/** + * Merge middleware/config headers and an optional status override into a new + * Web Response while preserving the original body stream when allowed. + * +@@ -1282,6 +1293,7 @@ async function startAppRouterServer(options) { + const server = createServer((req, res) => { + runWithServerEntryRequire(rscEntryRequire, () => handleRequest(req, res)); + }); ++ applyPrerenderConnectionPolicy(server, purpose); + await new Promise((resolve) => { + server.listen(port, host, () => { + const addr = server.address(); +@@ -1323,6 +1335,7 @@ async function startPagesRouterServer(options) { + const { port, host, purpose, silent } = options; + const handler = await createPagesRouterRequestHandler(options); + const server = createServer(handler); ++ applyPrerenderConnectionPolicy(server, purpose); + await new Promise((resolve) => { + server.listen(port, host, () => { + const addr = server.address();