diff --git a/.changeset/late-falcons-sort.md b/.changeset/late-falcons-sort.md new file mode 100644 index 0000000000..da5c5c09c5 --- /dev/null +++ b/.changeset/late-falcons-sort.md @@ -0,0 +1,6 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +Introduce `unstable_subResourceIntegrity` future flag that enables generation of an importmap with integrity for the scripts that will be loaded by the browser. diff --git a/.github/workflows/shared-build.yml b/.github/workflows/shared-build.yml index 50bec16d99..6de9e0c61f 100644 --- a/.github/workflows/shared-build.yml +++ b/.github/workflows/shared-build.yml @@ -22,7 +22,8 @@ jobs: node-version-file: ".nvmrc" cache: "pnpm" - - uses: google/wireit@setup-github-actions-caching/v2 + # TODO: Track and renable once this has been fixed: https://github.com/google/wireit/issues/1297 + # - uses: google/wireit@setup-github-actions-caching/v2 - name: Disable GitHub Actions Annotations run: | diff --git a/.github/workflows/shared-integration.yml b/.github/workflows/shared-integration.yml index ee0d86a340..2438029450 100644 --- a/.github/workflows/shared-integration.yml +++ b/.github/workflows/shared-integration.yml @@ -45,7 +45,8 @@ jobs: node-version: ${{ matrix.node }} cache: "pnpm" - - uses: google/wireit@setup-github-actions-caching/v2 + # TODO: Track and renable once this has been fixed: https://github.com/google/wireit/issues/1297 + # - uses: google/wireit@setup-github-actions-caching/v2 - name: Disable GitHub Actions Annotations run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a364dd55c..af709fdaaa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,7 +45,8 @@ jobs: cache: pnpm check-latest: true - - uses: google/wireit@setup-github-actions-caching/v2 + # TODO: Track and renable once this has been fixed: https://github.com/google/wireit/issues/1297 + # - uses: google/wireit@setup-github-actions-caching/v2 - name: Disable GitHub Actions Annotations run: | diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 86b6833cd0..999e19eec3 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -92,6 +92,7 @@ interface FutureConfig { * Automatically split route modules into multiple chunks when possible. */ unstable_splitRouteModules: boolean | "enforce"; + unstable_subResourceIntegrity: boolean; /** * Use Vite Environment API (experimental) */ @@ -497,6 +498,8 @@ async function resolveConfig({ reactRouterUserConfig.future?.unstable_optimizeDeps ?? false, unstable_splitRouteModules: reactRouterUserConfig.future?.unstable_splitRouteModules ?? false, + unstable_subResourceIntegrity: + reactRouterUserConfig.future?.unstable_subResourceIntegrity ?? false, unstable_viteEnvironmentApi: reactRouterUserConfig.future?.unstable_viteEnvironmentApi ?? false, }; diff --git a/packages/react-router-dev/manifest.ts b/packages/react-router-dev/manifest.ts index 98e7f994b9..a4024095c0 100644 --- a/packages/react-router-dev/manifest.ts +++ b/packages/react-router-dev/manifest.ts @@ -28,6 +28,7 @@ export type Manifest = { routes: { [routeId: string]: ManifestRoute; }; + sri: Record | undefined; hmr?: { timestamp?: number; runtime: string; diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index e4a6f29204..96b51e1711 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -2,6 +2,7 @@ // context but want to use Vite's ESM build to avoid deprecation warnings import type * as Vite from "vite"; import { type BinaryLike, createHash } from "node:crypto"; +import * as fs from "node:fs"; import * as path from "node:path"; import * as url from "node:url"; import * as fse from "fs-extra"; @@ -805,6 +806,39 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { return new Set([...cssUrlPaths, ...chunkAssetPaths]); }; + let generateSriManifest = async (ctx: ReactRouterPluginContext) => { + let clientBuildDirectory = getClientBuildDirectory(ctx.reactRouterConfig); + // walk the client build directory and generate SRI hashes for all .js files + let entries = fs.readdirSync(clientBuildDirectory, { + withFileTypes: true, + recursive: true, + }); + let sriManifest: ReactRouterManifest["sri"] = {}; + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".js")) { + let contents; + try { + contents = await fse.readFile( + path.join(entry.path, entry.name), + "utf-8" + ); + } catch (e) { + logger.error(`Failed to read file for SRI generation: ${entry.name}`); + throw e; + } + let hash = createHash("sha384") + .update(contents) + .digest() + .toString("base64"); + let filepath = getVite().normalizePath( + path.relative(clientBuildDirectory, path.join(entry.path, entry.name)) + ); + sriManifest[`${ctx.publicPath}${filepath}`] = `sha384-${hash}`; + } + } + return sriManifest; + }; + let generateReactRouterManifestsForBuild = async ({ routeIds, }: { @@ -942,6 +976,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { let reactRouterBrowserManifest: ReactRouterManifest = { ...fingerprintedValues, ...nonFingerprintedValues, + sri: undefined, }; // Write the browser manifest to disk as part of the build process @@ -952,12 +987,18 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { )};` ); + let sri: ReactRouterManifest["sri"] = undefined; + if (ctx.reactRouterConfig.future.unstable_subResourceIntegrity) { + sri = await generateSriManifest(ctx); + } + // The server manifest is the same as the browser manifest, except for // server bundle builds which only includes routes for the current bundle, // otherwise the server and client have the same routes let reactRouterServerManifest: ReactRouterManifest = { ...reactRouterBrowserManifest, routes: serverRoutes, + sri, }; return { @@ -1043,6 +1084,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { }; } + let sri: ReactRouterManifest["sri"] = undefined; + let reactRouterManifestForDev = { version: String(Math.random()), url: combineURLs(ctx.publicPath, virtual.browserManifest.url), @@ -1056,6 +1099,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { ), imports: [], }, + sri, routes, }; diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index df0d1bca29..388820ccd7 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -50,6 +50,19 @@ function initSsrInfo(): void { window.__reactRouterManifest && window.__reactRouterRouteModules ) { + if (window.__reactRouterManifest.sri === true) { + const importMap = document.querySelector("script[rr-importmap]"); + if (importMap?.textContent) { + try { + window.__reactRouterManifest.sri = JSON.parse( + importMap.textContent + ).integrity; + } catch (err) { + console.error("Failed to parse import map", err); + } + } + } + ssrInfo = { context: window.__reactRouterContext, manifest: window.__reactRouterManifest, diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 95b07b3546..0f4e39c7e9 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -785,32 +785,54 @@ import(${JSON.stringify(manifest.entry.module)});`; let preloads = isHydrated ? [] - : manifest.entry.imports.concat( - getModuleLinkHrefs(matches, manifest, { - includeHydrateFallback: true, - }) + : dedupe( + manifest.entry.imports.concat( + getModuleLinkHrefs(matches, manifest, { + includeHydrateFallback: true, + }) + ) ); + let sri = typeof manifest.sri === "object" ? manifest.sri : {}; + return isHydrated ? null : ( <> + {typeof manifest.sri === "object" ? ( +