Skip to content
6 changes: 6 additions & 0 deletions .changeset/late-falcons-sort.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion .github/workflows/shared-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello, just a heads up: you should be able to turn Wireit caching back on now by upgrading your wireit dependency to ^0.14.12. See google/wireit#1297 for more info.

# - uses: google/wireit@setup-github-actions-caching/v2

- name: Disable GitHub Actions Annotations
run: |
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/shared-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router-dev/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down Expand Up @@ -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,
};
Expand Down
1 change: 1 addition & 0 deletions packages/react-router-dev/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type Manifest = {
routes: {
[routeId: string]: ManifestRoute;
};
sri: Record<string, string> | undefined;
hmr?: {
timestamp?: number;
runtime: string;
Expand Down
44 changes: 44 additions & 0 deletions packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
}: {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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),
Expand All @@ -1056,6 +1099,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
),
imports: [],
},
sri,
routes,
};

Expand Down
13 changes: 13 additions & 0 deletions packages/react-router/lib/dom-export/hydrated-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 27 additions & 5 deletions packages/react-router/lib/dom/ssr/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" ? (
<script
rr-importmap=""
type="importmap"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: JSON.stringify({
integrity: sri,
}),
}}
/>
) : null}
{!enableFogOfWar ? (
<link
rel="modulepreload"
href={manifest.url}
crossOrigin={props.crossOrigin}
integrity={sri[manifest.url]}
suppressHydrationWarning
/>
) : null}
<link
rel="modulepreload"
href={manifest.entry.module}
crossOrigin={props.crossOrigin}
integrity={sri[manifest.entry.module]}
suppressHydrationWarning
/>
{dedupe(preloads).map((path) => (
{preloads.map((path) => (
<link
key={path}
rel="modulepreload"
href={path}
crossOrigin={props.crossOrigin}
integrity={sri[path]}
suppressHydrationWarning
/>
))}
{initialScripts}
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/lib/dom/ssr/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface EntryContext extends FrameworkContextObject {
}

export interface FutureConfig {
unstable_subResourceIntegrity: boolean;
unstable_middleware: boolean;
}

Expand All @@ -59,4 +60,5 @@ export interface AssetsManifest {
timestamp?: number;
runtime: string;
};
sri?: Record<string, string> | true;
}
3 changes: 2 additions & 1 deletion packages/react-router/lib/dom/ssr/fog-of-war.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function isFogOfWarEnabled(ssr: boolean) {
}

export function getPartialManifest(
manifest: AssetsManifest,
{ sri, ...manifest }: AssetsManifest,
router: DataRouter
) {
// Start with our matches for this pathname
Expand Down Expand Up @@ -64,6 +64,7 @@ export function getPartialManifest(
return {
...manifest,
routes: initialRoutes,
sri: sri ? true : undefined,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/react-router/lib/dom/ssr/routes-test-stub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export function createRoutesStub(
if (routerRef.current == null) {
remixContextRef.current = {
future: {
unstable_subResourceIntegrity: future?.unstable_subResourceIntegrity === true,
unstable_middleware: future?.unstable_middleware === true,
},
manifest: {
Expand Down
22 changes: 21 additions & 1 deletion playground/framework/app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import {
Link,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

<Meta />
<Links />
</head>
<body>
<ul>
<li>
<Link prefetch="intent" to="/">
Home
</Link>
</li>
<li>
<Link prefetch="intent" to="/products/abc">
Product
</Link>
</li>
</ul>
{children}
<ScrollRestoration />
<Scripts />
Expand Down
6 changes: 5 additions & 1 deletion playground/framework/react-router.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type { Config } from "@react-router/dev/config";

export default {} satisfies Config;
export default {
future: {
unstable_subResourceIntegrity: true,
},
} satisfies Config;