Skip to content

Commit 5461461

Browse files
authored
feat: unstable_subResourceIntegrity (#13163)
1 parent d07cefe commit 5461461

File tree

14 files changed

+131
-11
lines changed

14 files changed

+131
-11
lines changed

.changeset/late-falcons-sort.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@react-router/dev": patch
3+
"react-router": patch
4+
---
5+
6+
Introduce `unstable_subResourceIntegrity` future flag that enables generation of an importmap with integrity for the scripts that will be loaded by the browser.

.github/workflows/shared-build.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ jobs:
2222
node-version-file: ".nvmrc"
2323
cache: "pnpm"
2424

25-
- uses: google/wireit@setup-github-actions-caching/v2
25+
# TODO: Track and renable once this has been fixed: https://github.com/google/wireit/issues/1297
26+
# - uses: google/wireit@setup-github-actions-caching/v2
2627

2728
- name: Disable GitHub Actions Annotations
2829
run: |

.github/workflows/shared-integration.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ jobs:
4545
node-version: ${{ matrix.node }}
4646
cache: "pnpm"
4747

48-
- uses: google/wireit@setup-github-actions-caching/v2
48+
# TODO: Track and renable once this has been fixed: https://github.com/google/wireit/issues/1297
49+
# - uses: google/wireit@setup-github-actions-caching/v2
4950

5051
- name: Disable GitHub Actions Annotations
5152
run: |

.github/workflows/test.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ jobs:
4545
cache: pnpm
4646
check-latest: true
4747

48-
- uses: google/wireit@setup-github-actions-caching/v2
48+
# TODO: Track and renable once this has been fixed: https://github.com/google/wireit/issues/1297
49+
# - uses: google/wireit@setup-github-actions-caching/v2
4950

5051
- name: Disable GitHub Actions Annotations
5152
run: |

packages/react-router-dev/config/config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ interface FutureConfig {
9292
* Automatically split route modules into multiple chunks when possible.
9393
*/
9494
unstable_splitRouteModules: boolean | "enforce";
95+
unstable_subResourceIntegrity: boolean;
9596
/**
9697
* Use Vite Environment API (experimental)
9798
*/
@@ -497,6 +498,8 @@ async function resolveConfig({
497498
reactRouterUserConfig.future?.unstable_optimizeDeps ?? false,
498499
unstable_splitRouteModules:
499500
reactRouterUserConfig.future?.unstable_splitRouteModules ?? false,
501+
unstable_subResourceIntegrity:
502+
reactRouterUserConfig.future?.unstable_subResourceIntegrity ?? false,
500503
unstable_viteEnvironmentApi:
501504
reactRouterUserConfig.future?.unstable_viteEnvironmentApi ?? false,
502505
};

packages/react-router-dev/manifest.ts

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type Manifest = {
2828
routes: {
2929
[routeId: string]: ManifestRoute;
3030
};
31+
sri: Record<string, string> | undefined;
3132
hmr?: {
3233
timestamp?: number;
3334
runtime: string;

packages/react-router-dev/vite/plugin.ts

+44
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// context but want to use Vite's ESM build to avoid deprecation warnings
33
import type * as Vite from "vite";
44
import { type BinaryLike, createHash } from "node:crypto";
5+
import * as fs from "node:fs";
56
import * as path from "node:path";
67
import * as url from "node:url";
78
import * as fse from "fs-extra";
@@ -805,6 +806,39 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
805806
return new Set([...cssUrlPaths, ...chunkAssetPaths]);
806807
};
807808

809+
let generateSriManifest = async (ctx: ReactRouterPluginContext) => {
810+
let clientBuildDirectory = getClientBuildDirectory(ctx.reactRouterConfig);
811+
// walk the client build directory and generate SRI hashes for all .js files
812+
let entries = fs.readdirSync(clientBuildDirectory, {
813+
withFileTypes: true,
814+
recursive: true,
815+
});
816+
let sriManifest: ReactRouterManifest["sri"] = {};
817+
for (const entry of entries) {
818+
if (entry.isFile() && entry.name.endsWith(".js")) {
819+
let contents;
820+
try {
821+
contents = await fse.readFile(
822+
path.join(entry.path, entry.name),
823+
"utf-8"
824+
);
825+
} catch (e) {
826+
logger.error(`Failed to read file for SRI generation: ${entry.name}`);
827+
throw e;
828+
}
829+
let hash = createHash("sha384")
830+
.update(contents)
831+
.digest()
832+
.toString("base64");
833+
let filepath = getVite().normalizePath(
834+
path.relative(clientBuildDirectory, path.join(entry.path, entry.name))
835+
);
836+
sriManifest[`${ctx.publicPath}${filepath}`] = `sha384-${hash}`;
837+
}
838+
}
839+
return sriManifest;
840+
};
841+
808842
let generateReactRouterManifestsForBuild = async ({
809843
routeIds,
810844
}: {
@@ -942,6 +976,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
942976
let reactRouterBrowserManifest: ReactRouterManifest = {
943977
...fingerprintedValues,
944978
...nonFingerprintedValues,
979+
sri: undefined,
945980
};
946981

947982
// Write the browser manifest to disk as part of the build process
@@ -952,12 +987,18 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
952987
)};`
953988
);
954989

990+
let sri: ReactRouterManifest["sri"] = undefined;
991+
if (ctx.reactRouterConfig.future.unstable_subResourceIntegrity) {
992+
sri = await generateSriManifest(ctx);
993+
}
994+
955995
// The server manifest is the same as the browser manifest, except for
956996
// server bundle builds which only includes routes for the current bundle,
957997
// otherwise the server and client have the same routes
958998
let reactRouterServerManifest: ReactRouterManifest = {
959999
...reactRouterBrowserManifest,
9601000
routes: serverRoutes,
1001+
sri,
9611002
};
9621003

9631004
return {
@@ -1043,6 +1084,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
10431084
};
10441085
}
10451086

1087+
let sri: ReactRouterManifest["sri"] = undefined;
1088+
10461089
let reactRouterManifestForDev = {
10471090
version: String(Math.random()),
10481091
url: combineURLs(ctx.publicPath, virtual.browserManifest.url),
@@ -1056,6 +1099,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
10561099
),
10571100
imports: [],
10581101
},
1102+
sri,
10591103
routes,
10601104
};
10611105

packages/react-router/lib/dom-export/hydrated-router.tsx

+13
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,19 @@ function initSsrInfo(): void {
5050
window.__reactRouterManifest &&
5151
window.__reactRouterRouteModules
5252
) {
53+
if (window.__reactRouterManifest.sri === true) {
54+
const importMap = document.querySelector("script[rr-importmap]");
55+
if (importMap?.textContent) {
56+
try {
57+
window.__reactRouterManifest.sri = JSON.parse(
58+
importMap.textContent
59+
).integrity;
60+
} catch (err) {
61+
console.error("Failed to parse import map", err);
62+
}
63+
}
64+
}
65+
5366
ssrInfo = {
5467
context: window.__reactRouterContext,
5568
manifest: window.__reactRouterManifest,

packages/react-router/lib/dom/ssr/components.tsx

+27-5
Original file line numberDiff line numberDiff line change
@@ -785,32 +785,54 @@ import(${JSON.stringify(manifest.entry.module)});`;
785785

786786
let preloads = isHydrated
787787
? []
788-
: manifest.entry.imports.concat(
789-
getModuleLinkHrefs(matches, manifest, {
790-
includeHydrateFallback: true,
791-
})
788+
: dedupe(
789+
manifest.entry.imports.concat(
790+
getModuleLinkHrefs(matches, manifest, {
791+
includeHydrateFallback: true,
792+
})
793+
)
792794
);
793795

796+
let sri = typeof manifest.sri === "object" ? manifest.sri : {};
797+
794798
return isHydrated ? null : (
795799
<>
800+
{typeof manifest.sri === "object" ? (
801+
<script
802+
rr-importmap=""
803+
type="importmap"
804+
suppressHydrationWarning
805+
dangerouslySetInnerHTML={{
806+
__html: JSON.stringify({
807+
integrity: sri,
808+
}),
809+
}}
810+
/>
811+
) : null}
796812
{!enableFogOfWar ? (
797813
<link
798814
rel="modulepreload"
799815
href={manifest.url}
800816
crossOrigin={props.crossOrigin}
817+
integrity={sri[manifest.url]}
818+
suppressHydrationWarning
801819
/>
802820
) : null}
803821
<link
804822
rel="modulepreload"
805823
href={manifest.entry.module}
806824
crossOrigin={props.crossOrigin}
825+
integrity={sri[manifest.entry.module]}
826+
suppressHydrationWarning
807827
/>
808-
{dedupe(preloads).map((path) => (
828+
{preloads.map((path) => (
809829
<link
810830
key={path}
811831
rel="modulepreload"
812832
href={path}
813833
crossOrigin={props.crossOrigin}
834+
integrity={sri[path]}
835+
suppressHydrationWarning
814836
/>
815837
))}
816838
{initialScripts}

packages/react-router/lib/dom/ssr/entry.ts

+2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface EntryContext extends FrameworkContextObject {
4242
}
4343

4444
export interface FutureConfig {
45+
unstable_subResourceIntegrity: boolean;
4546
unstable_middleware: boolean;
4647
}
4748

@@ -59,4 +60,5 @@ export interface AssetsManifest {
5960
timestamp?: number;
6061
runtime: string;
6162
};
63+
sri?: Record<string, string> | true;
6264
}

packages/react-router/lib/dom/ssr/fog-of-war.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function isFogOfWarEnabled(ssr: boolean) {
3131
}
3232

3333
export function getPartialManifest(
34-
manifest: AssetsManifest,
34+
{ sri, ...manifest }: AssetsManifest,
3535
router: DataRouter
3636
) {
3737
// Start with our matches for this pathname
@@ -64,6 +64,7 @@ export function getPartialManifest(
6464
return {
6565
...manifest,
6666
routes: initialRoutes,
67+
sri: sri ? true : undefined,
6768
};
6869
}
6970

packages/react-router/lib/dom/ssr/routes-test-stub.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export function createRoutesStub(
100100
if (routerRef.current == null) {
101101
remixContextRef.current = {
102102
future: {
103+
unstable_subResourceIntegrity: future?.unstable_subResourceIntegrity === true,
103104
unstable_middleware: future?.unstable_middleware === true,
104105
},
105106
manifest: {

playground/framework/app/root.tsx

+21-1
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,35 @@
1-
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
1+
import {
2+
Link,
3+
Links,
4+
Meta,
5+
Outlet,
6+
Scripts,
7+
ScrollRestoration,
8+
} from "react-router";
29

310
export function Layout({ children }: { children: React.ReactNode }) {
411
return (
512
<html lang="en">
613
<head>
714
<meta charSet="utf-8" />
815
<meta name="viewport" content="width=device-width, initial-scale=1" />
16+
917
<Meta />
1018
<Links />
1119
</head>
1220
<body>
21+
<ul>
22+
<li>
23+
<Link prefetch="intent" to="/">
24+
Home
25+
</Link>
26+
</li>
27+
<li>
28+
<Link prefetch="intent" to="/products/abc">
29+
Product
30+
</Link>
31+
</li>
32+
</ul>
1333
{children}
1434
<ScrollRestoration />
1535
<Scripts />
+5-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
import type { Config } from "@react-router/dev/config";
22

3-
export default {} satisfies Config;
3+
export default {
4+
future: {
5+
unstable_subResourceIntegrity: true,
6+
},
7+
} satisfies Config;

0 commit comments

Comments
 (0)