Skip to content

Commit 27a3819

Browse files
committed
feature #12 [Integrity] Emit Subresource Integrity hashes in entrypoints.json (Kocal)
This PR was merged into the main branch. Discussion ---------- [Integrity] Emit Subresource Integrity hashes in entrypoints.json | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #... <!-- prefix each issue number with "Fix #", no need to open an issue if none exists, explain below instead --> | License | MIT Add an opt-in `integrity: { enabled, algorithms? }` option (default `['sha384']`) that writes an `integrity` map (asset URL -> SRI hash) into entrypoints.json, for Reprise's Symfony bundle (src/RepriseBundle.php, still a stub) to render as integrity="..." on the script/link tags. This change is JS-only. Hashes are computed from the files on disk after each bundler finishes emitting: the Rspack `done` hook and a Vite `writeBundle` hook both read the emitted files back and hash them. In-memory hashing does not work for Vite because it finalizes chunks (e.g. replacing `__VITE_PRELOAD__`) only when writing to disk, so the shipped bytes differ from the bundle. Every referenced file per entry (js/css/preload/dynamic) is covered. Build only -- the dev server serves changing in-memory assets, so no hashes there. Commits ------- 9f16080 [Integrity] Emit Subresource Integrity hashes in entrypoints.json
2 parents 32b824b + 9f16080 commit 27a3819

16 files changed

Lines changed: 393 additions & 15 deletions

AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ unplugin still earns its place for Vite and (upcoming) the Stimulus virtual modu
4848

4949
## The Symfony integration contract (the core of this project)
5050

51-
Encore's real value to Symfony is two JSON files written into `outputPath`, consumed by WebpackEncoreBundle's Twig helpers (`encore_entry_script_tags()`, `encore_entry_link_tags()`, `asset()`). Generating these in Encore-compatible format is the primary work:
51+
Encore's real value to Symfony is two JSON files written into `outputPath`, consumed by Reprise's **own** Symfony bundle (`RepriseBundle`, the PHP side under `src/` — still a stub) via its Twig helpers that render the `<script>`/`<link>`/`asset()` tags. Reprise does **not** use `symfony/webpack-encore-bundle`. Generating these two files in Encore-compatible format is the primary work:
5252

5353
- **`entrypoints.json`** — maps each entry name to its asset URLs grouped by type, in load order (runtime chunks before app chunks). Optional `integrity` section for SRI hashes.
5454
```json
@@ -61,7 +61,7 @@ Encore's real value to Symfony is two JSON files written into `outputPath`, cons
6161
The plugin must behave differently depending on the bundler mode:
6262

6363
- **Build mode** (`vite build`, `rsbuild build`): assets are written to `outputPath` with content hashes; `entrypoints.json`/`manifest.json` point at those files under `publicPath`.
64-
- **Serve/dev mode** (`vite`, `rsbuild dev`): the bundler's own dev server holds modules in memory and serves them over HTTP with native ESM + HMR. Here `entrypoints.json` must instead point at the dev server origin (e.g. `http://127.0.0.1:5173/build/app.js`) and inject the HMR client (`@vite/client`; React additionally needs the refresh preamble), so WebpackEncoreBundle's Twig tags load from the running dev server rather than from disk.
64+
- **Serve/dev mode** (`vite`, `rsbuild dev`): the bundler's own dev server holds modules in memory and serves them over HTTP with native ESM + HMR. Here `entrypoints.json` must instead point at the dev server origin (e.g. `http://127.0.0.1:5173/build/app.js`) and inject the HMR client (`@vite/client`; React additionally needs the refresh preamble), so RepriseBundle's Twig tags load from the running dev server rather than from disk.
6565

6666
The dev server itself is native to Vite/Rsbuild — this plugin does not run one. Its only dev-server responsibility is detecting the mode (unplugin `meta`, or Vite's `configResolved` `command === 'serve'` vs `'build'`; Rsbuild/Rspack expose the same distinction) and emitting the dev-flavored `entrypoints.json` plus client injection. Encore's counterpart is `configureDevServerOptions()` (webpack-dev-server) in the reference `index.ts`, but that whole layer is replaced by the native dev server.
6767

@@ -95,5 +95,6 @@ Read-only clones under `.references/` (git-ignored) show how mature unplugins ar
9595
- ESM only, strict TypeScript, ES2017 target. Use the `node:` prefix for Node builtins.
9696
- New public options go in `assets/src/types.ts` with JSDoc; keep bundler adapters trivial.
9797
- Documentation: any user-facing feature ships with a short section in `doc/index.rst`, and that section shows **both** a Vite and an Rsbuild example (the two supported bundlers) — never document one without the other. Flip the matching `*(planned)*` marker in the feature lists (`doc/index.rst` and `README.md`) when the feature lands. Match the existing sections' natural voice; draft/polish the prose with the `natural-writing-editor` agent.
98-
- Commit messages: Symfony style `[<Scope>] <Short description>` — PascalCase scope, imperative mood, capitalized first word, no trailing period; combine scopes as `[A][B]` when a change spans several. E.g. `[Stimulus] Emit forward-slash local controller paths`, `[Docs] Frame Stimulus usage as the Encore experience`, `[CI] Cancel superseded runs with a concurrency group`. This is the convention used across Symfony UX and WebpackEncoreBundle — **not** Conventional Commits (no `feat:`/`fix:`/`chore:` prefixes).
98+
- Tests: a functional/integration test for one bundler (Vite or Rsbuild) always ships with its equivalent for the other — never cover one bundler without the other, including the negative/off cases.
99+
- Commit messages: Symfony style `[<Scope>] <Short description>` — PascalCase scope, imperative mood, capitalized first word, no trailing period. A feature commit uses the feature's **own name** as the scope (e.g. `[Integrity]`, `[Manifest]`) and does **not** tack on `[Tests]` or `[Docs]` for the tests and docs it naturally includes; `[Tests]`/`[Docs]` are only for changes that are _exclusively_ tests or documentation. Combine scopes as `[A][B]` only when a change genuinely spans several distinct components. E.g. `[Stimulus] Emit forward-slash local controller paths`, `[Docs] Frame Stimulus usage as the Encore experience`, `[CI] Cancel superseded runs with a concurrency group`. This is the convention used across Symfony UX and WebpackEncoreBundle — **not** Conventional Commits (no `feat:`/`fix:`/`chore:` prefixes).
99100
- Releases: the published npm package lives in `assets/` (`@symfony/reprise`); its `prepublishOnly` runs the `tsdown` build before publish.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ Symfony Reprise covers only the Symfony-side glue the bundlers leave out:
3131
- 🔥 **Dev server & HMR**: points Twig at the running Vite/Rsbuild server
3232
- 🧩 **Symfony UX / Stimulus**: registers `controllers.json` and local controllers, eager or lazy
3333
- 🌐 **CDN support**: serve built assets from an absolute `publicPath`
34-
- 🛡️ **Subresource Integrity**: SRI hashes in `entrypoints.json` _(planned)_
34+
- 🛡️ **Subresource Integrity**: SRI hashes in `entrypoints.json`
3535
- 📦 **Shared runtime chunk**: one runtime shared across entries _(planned)_
3636

3737
Vite and Rsbuild already handle **Sass/Less/PostCSS**, **TypeScript**, **JSX/Vue/Svelte**, **code splitting**, **content hashing**, **source maps**, **minification** and **HMR** on their own, so Symfony Reprise does not reimplement any of that.
3838

39-
It generates the Encore-compatible `entrypoints.json` and `manifest.json` that [WebpackEncoreBundle](https://github.com/symfony/webpack-encore-bundle)'s Twig helpers (`encore_entry_script_tags()`, `encore_entry_link_tags()`) read, wires up the native dev server, and turns your Stimulus controllers into a running application.
39+
It generates the Encore-compatible `entrypoints.json` and `manifest.json` that Reprise's own Symfony bundle (`RepriseBundle`, still a stub) reads to render the `<script>` and `<link>` tags, wires up the native dev server, and turns your Stimulus controllers into a running application.
4040

4141
[Read the documentation](doc/index.rst)

assets/src/core/format.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,20 @@ export function buildEntrypoints(graph: NormalizedGraph, ctx: BuildContext): Ent
1414
dynamic: files.dynamic.map((f) => joinUrl(ctx.urlPrefix, f)),
1515
};
1616
}
17-
return { isProd: ctx.isProd, devServer: ctx.devServer, publicPath: ctx.publicPath, entryPoints };
17+
const out: EntrypointsJson = {
18+
isProd: ctx.isProd,
19+
devServer: ctx.devServer,
20+
publicPath: ctx.publicPath,
21+
entryPoints,
22+
};
23+
if (graph.integrity) {
24+
// Re-key the per-file-name hashes by the same URLs that appear in the entry lists,
25+
// so the Symfony side can look each one up by asset URL.
26+
out.integrity = Object.fromEntries(
27+
Object.entries(graph.integrity).map(([fileName, sri]) => [joinUrl(ctx.urlPrefix, fileName), sri])
28+
);
29+
}
30+
return out;
1831
}
1932

2033
export function buildManifest(graph: NormalizedGraph, ctx: BuildContext): ManifestJson {

assets/src/core/integrity.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { EntryFiles } from '../types';
2+
import { createHash } from 'node:crypto';
3+
import { readFileSync } from 'node:fs';
4+
import { join } from 'node:path';
5+
6+
/**
7+
* Build a Subresource Integrity string for `content`: one `<algorithm>-<base64 digest>`
8+
* token per algorithm, joined by spaces (the format browsers expect in an `integrity`
9+
* attribute, and the one Webpack Encore writes into `entrypoints.json`).
10+
*/
11+
export function computeIntegrity(content: string | Uint8Array, algorithms: string[]): string {
12+
return algorithms.map((algo) => `${algo}-${createHash(algo).update(content).digest('base64')}`).join(' ');
13+
}
14+
15+
/**
16+
* The distinct file names referenced by every entry, across all four buckets
17+
* (js/css/preload/dynamic), in first-seen order. This is the set of emitted files
18+
* that get an integrity hash.
19+
*/
20+
export function referencedFileNames(entryPoints: Record<string, EntryFiles>): string[] {
21+
const seen = new Set<string>();
22+
for (const files of Object.values(entryPoints)) {
23+
for (const fileName of [...files.js, ...files.css, ...files.preload, ...files.dynamic]) {
24+
seen.add(fileName);
25+
}
26+
}
27+
return [...seen];
28+
}
29+
30+
/**
31+
* Compute the integrity of each file read from `outputPath` on disk, keyed by file name.
32+
* Used by the Rspack path, whose `done` hook fires after the assets are emitted (like
33+
* Encore, which reads the emitted files back). Bytes are hashed raw, so binary assets work.
34+
*/
35+
export function integrityFromDisk(
36+
fileNames: string[],
37+
outputPath: string,
38+
algorithms: string[]
39+
): Record<string, string> {
40+
const integrity: Record<string, string> = {};
41+
for (const fileName of fileNames) {
42+
integrity[fileName] = computeIntegrity(readFileSync(join(outputPath, fileName)), algorithms);
43+
}
44+
return integrity;
45+
}

assets/src/core/options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { Options, ResolvedOptions, ResolvedStimulusOptions } from '../types';
22
import * as path from 'node:path';
33

4+
function normalizeIntegrity(integrity: Options['integrity']): ResolvedOptions['integrity'] {
5+
if (!integrity?.enabled) return undefined;
6+
return { algorithms: integrity.algorithms?.length ? [...integrity.algorithms] : ['sha384'] };
7+
}
8+
49
function normalizeStimulus(stimulus: Options['stimulus'], cwd: string): ResolvedStimulusOptions | undefined {
510
if (!stimulus) return undefined;
611
const raw = typeof stimulus === 'string' ? { controllersJson: stimulus } : stimulus;
@@ -38,6 +43,7 @@ export function normalizeOptions(options: Options | undefined, cwd: string): Res
3843
manifestKeyPrefix,
3944
devServerOrigin: options?.devServerOrigin,
4045
stimulus: normalizeStimulus(options?.stimulus, cwd),
46+
integrity: normalizeIntegrity(options?.integrity),
4147
};
4248
}
4349

assets/src/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import type { UnpluginFactory } from 'unplugin';
2-
import type { BuildContext, Options } from './types';
2+
import type { BuildContext, NormalizedGraph, Options } from './types';
3+
import { writeFileSync } from 'node:fs';
4+
import { join } from 'node:path';
35
import * as process from 'node:process';
46
import { createUnplugin } from 'unplugin';
57
import { bundleToGraph, configToDevGraph } from './collectors/vite';
68
import { resolveDevOrigin } from './core/dev-server';
79
import { writeSymfonyFiles } from './core/emit';
810
import { buildEntrypoints, buildManifest } from './core/format';
11+
import { integrityFromDisk, referencedFileNames } from './core/integrity';
912
import { normalizeOptions, resolvePublicPath } from './core/options';
1013
import { generateControllersModule, STIMULUS_NOT_ENABLED_MESSAGE, VIRTUAL_CONTROLLERS_ID } from './core/stimulus';
1114

@@ -15,6 +18,8 @@ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_ID}`;
1518
export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _meta) => {
1619
const resolved = normalizeOptions(options, process.cwd());
1720
let isDev = false;
21+
// When SRI is on, entrypoints.json is finished in `writeBundle` (see below); stash what it needs.
22+
let pendingIntegrity: { graph: NormalizedGraph; ctx: BuildContext } | null = null;
1823

1924
return {
2025
name: '@symfony/reprise',
@@ -49,6 +54,25 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
4954
fileName: 'manifest.json',
5055
source: `${JSON.stringify(buildManifest(graph, ctx), null, 2)}\n`,
5156
});
57+
// SRI must hash the bytes that ship: Vite only finalizes chunks (replacing markers like
58+
// `__VITE_PRELOAD__`) when writing to disk, so the in-memory bundle differs from the file.
59+
// Defer to writeBundle (files on disk) and rewrite entrypoints.json with the integrity map.
60+
if (resolved.integrity) pendingIntegrity = { graph, ctx };
61+
},
62+
63+
writeBundle() {
64+
if (!pendingIntegrity || !resolved.integrity) return;
65+
const { graph, ctx } = pendingIntegrity;
66+
pendingIntegrity = null;
67+
graph.integrity = integrityFromDisk(
68+
referencedFileNames(graph.entryPoints),
69+
resolved.outputPath,
70+
resolved.integrity.algorithms
71+
);
72+
writeFileSync(
73+
join(resolved.outputPath, 'entrypoints.json'),
74+
`${JSON.stringify(buildEntrypoints(graph, ctx), null, 2)}\n`
75+
);
5276
},
5377

5478
configResolved(config) {

assets/src/rsbuild.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as process from 'node:process';
66
import { rspack } from '@rsbuild/core';
77
import { statsToGraph } from './collectors/rspack';
88
import { writeSymfonyFiles } from './core/emit';
9+
import { integrityFromDisk, referencedFileNames } from './core/integrity';
910
import { buildEntrypoints, buildManifest } from './core/format';
1011
import { normalizeOptions, resolvePublicPath } from './core/options';
1112
import { generateControllersModule, STIMULUS_NOT_ENABLED_MESSAGE, VIRTUAL_CONTROLLERS_ID } from './core/stimulus';
@@ -131,6 +132,15 @@ export default function symfony(options?: Options): RsbuildPlugin {
131132
manifestKeyPrefix: resolved.manifestKeyPrefix,
132133
};
133134
const graph = statsToGraph(stats.toJson({ assets: true, entrypoints: true }) as RspackStats);
135+
// SRI (build only): `done` fires after emit, so hash the files back off disk
136+
// (the same approach Encore takes). Dev serves changing in-memory assets, no hashes.
137+
if (!isDev && resolved.integrity) {
138+
graph.integrity = integrityFromDisk(
139+
referencedFileNames(graph.entryPoints),
140+
resolved.outputPath,
141+
resolved.integrity.algorithms
142+
);
143+
}
134144
// In dev the manifest is empty: assets are served from the dev server, never looked
135145
// up on disk by hash, so cache-busting is moot. entrypoints.json alone drives loading.
136146
// Matches the Vite dev path (see `configureServer` in index.ts), which also writes `{}`.

assets/src/types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,34 @@ export interface Options {
8484
*/
8585
stimulus?: string | StimulusOptions;
8686

87+
/**
88+
* Emit Subresource Integrity (SRI) hashes for the built assets.
89+
*
90+
* When enabled, `entrypoints.json` gains an `integrity` map (asset URL -> hash),
91+
* which Reprise's Symfony bundle renders as `integrity="..."` on the script/link tags.
92+
* Only applies to build mode; the dev server serves changing in-memory assets, so
93+
* no hashes are emitted there.
94+
*
95+
* ```js
96+
* // enable only for the production build (Vite exposes `command`)
97+
* Symfony({ integrity: { enabled: command === 'build', algorithms: ['sha384'] } })
98+
* ```
99+
*/
100+
integrity?: IntegrityOptions;
101+
87102
// singleRuntimeChunk?: boolean
88103
}
89104

105+
/** Hash algorithm used for Subresource Integrity. */
106+
export type IntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512';
107+
108+
export interface IntegrityOptions {
109+
/** Turn SRI on or off. Off by default. */
110+
enabled: boolean;
111+
/** Algorithms to hash each asset with. Default: `['sha384']`. */
112+
algorithms?: IntegrityAlgorithm[];
113+
}
114+
90115
export interface StimulusOptions {
91116
/** Path to `controllers.json`, e.g. `assets/controllers.json`. */
92117
controllersJson: string;
@@ -112,6 +137,8 @@ export interface ResolvedOptions {
112137
manifestKeyPrefix: string;
113138
devServerOrigin?: string;
114139
stimulus?: ResolvedStimulusOptions;
140+
/** Present (with a non-empty algorithm list) only when SRI is enabled. */
141+
integrity?: { algorithms: string[] };
115142
}
116143

117144
export interface EntryFiles {
@@ -134,6 +161,8 @@ export interface AssetEntry {
134161
export interface NormalizedGraph {
135162
entryPoints: Record<string, EntryFiles>;
136163
assets: AssetEntry[];
164+
/** SRI hash per emitted file name; set by the collectors only when SRI is enabled. */
165+
integrity?: Record<string, string>;
137166
}
138167

139168
export interface BuildContext {
@@ -152,6 +181,8 @@ export interface EntrypointsJson {
152181
devServer: DevServer | null;
153182
publicPath: string;
154183
entryPoints: Record<string, EntryFiles>;
184+
/** SRI hash per asset URL; present only in build mode with SRI enabled. */
185+
integrity?: Record<string, string>;
155186
}
156187

157188
export type ManifestJson = Record<string, string>;

assets/test/core/format.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ describe('buildEntrypoints', () => {
4646
expect(out.entryPoints.app.js).toEqual(['/build/app-a1b2.js']);
4747
});
4848

49+
it('emits a top-level integrity map keyed by URL when the graph carries hashes', () => {
50+
const out = buildEntrypoints(
51+
{ ...graph, integrity: { 'app-a1b2.js': 'sha384-JS', 'app-c3d4.css': 'sha384-CSS' } },
52+
ctx
53+
);
54+
expect(out.integrity).toEqual({
55+
'/build/app-a1b2.js': 'sha384-JS',
56+
'/build/app-c3d4.css': 'sha384-CSS',
57+
});
58+
});
59+
60+
it('omits integrity when the graph carries no hashes', () => {
61+
expect(buildEntrypoints(graph, ctx).integrity).toBeUndefined();
62+
});
63+
4964
it('builds URLs from urlPrefix but emits the original publicPath field', () => {
5065
const devCtx: BuildContext = {
5166
isProd: false,

0 commit comments

Comments
 (0)