Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ unplugin still earns its place for Vite and (upcoming) the Stimulus virtual modu

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

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:
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:

- **`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.
```json
Expand All @@ -61,7 +61,7 @@ Encore's real value to Symfony is two JSON files written into `outputPath`, cons
The plugin must behave differently depending on the bundler mode:

- **Build mode** (`vite build`, `rsbuild build`): assets are written to `outputPath` with content hashes; `entrypoints.json`/`manifest.json` point at those files under `publicPath`.
- **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.
- **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.

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.

Expand Down Expand Up @@ -95,5 +95,6 @@ Read-only clones under `.references/` (git-ignored) show how mature unplugins ar
- ESM only, strict TypeScript, ES2017 target. Use the `node:` prefix for Node builtins.
- New public options go in `assets/src/types.ts` with JSDoc; keep bundler adapters trivial.
- 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.
- 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).
- 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.
- 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).
- Releases: the published npm package lives in `assets/` (`@symfony/reprise`); its `prepublishOnly` runs the `tsdown` build before publish.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ Symfony Reprise covers only the Symfony-side glue the bundlers leave out:
- 🔥 **Dev server & HMR**: points Twig at the running Vite/Rsbuild server
- 🧩 **Symfony UX / Stimulus**: registers `controllers.json` and local controllers, eager or lazy
- 🌐 **CDN support**: serve built assets from an absolute `publicPath`
- 🛡️ **Subresource Integrity**: SRI hashes in `entrypoints.json` _(planned)_
- 🛡️ **Subresource Integrity**: SRI hashes in `entrypoints.json`
- 📦 **Shared runtime chunk**: one runtime shared across entries _(planned)_

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.

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.
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.

[Read the documentation](doc/index.rst)
15 changes: 14 additions & 1 deletion assets/src/core/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,20 @@ export function buildEntrypoints(graph: NormalizedGraph, ctx: BuildContext): Ent
dynamic: files.dynamic.map((f) => joinUrl(ctx.urlPrefix, f)),
};
}
return { isProd: ctx.isProd, devServer: ctx.devServer, publicPath: ctx.publicPath, entryPoints };
const out: EntrypointsJson = {
isProd: ctx.isProd,
devServer: ctx.devServer,
publicPath: ctx.publicPath,
entryPoints,
};
if (graph.integrity) {
// Re-key the per-file-name hashes by the same URLs that appear in the entry lists,
// so the Symfony side can look each one up by asset URL.
out.integrity = Object.fromEntries(
Object.entries(graph.integrity).map(([fileName, sri]) => [joinUrl(ctx.urlPrefix, fileName), sri])
);
}
return out;
}

export function buildManifest(graph: NormalizedGraph, ctx: BuildContext): ManifestJson {
Expand Down
45 changes: 45 additions & 0 deletions assets/src/core/integrity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { EntryFiles } from '../types';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

/**
* Build a Subresource Integrity string for `content`: one `<algorithm>-<base64 digest>`
* token per algorithm, joined by spaces (the format browsers expect in an `integrity`
* attribute, and the one Webpack Encore writes into `entrypoints.json`).
*/
export function computeIntegrity(content: string | Uint8Array, algorithms: string[]): string {
return algorithms.map((algo) => `${algo}-${createHash(algo).update(content).digest('base64')}`).join(' ');
}

/**
* The distinct file names referenced by every entry, across all four buckets
* (js/css/preload/dynamic), in first-seen order. This is the set of emitted files
* that get an integrity hash.
*/
export function referencedFileNames(entryPoints: Record<string, EntryFiles>): string[] {
const seen = new Set<string>();
for (const files of Object.values(entryPoints)) {
for (const fileName of [...files.js, ...files.css, ...files.preload, ...files.dynamic]) {
seen.add(fileName);
}
}
return [...seen];
}

/**
* Compute the integrity of each file read from `outputPath` on disk, keyed by file name.
* Used by the Rspack path, whose `done` hook fires after the assets are emitted (like
* Encore, which reads the emitted files back). Bytes are hashed raw, so binary assets work.
*/
export function integrityFromDisk(
fileNames: string[],
outputPath: string,
algorithms: string[]
): Record<string, string> {
const integrity: Record<string, string> = {};
for (const fileName of fileNames) {
integrity[fileName] = computeIntegrity(readFileSync(join(outputPath, fileName)), algorithms);
}
return integrity;
}
6 changes: 6 additions & 0 deletions assets/src/core/options.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { Options, ResolvedOptions, ResolvedStimulusOptions } from '../types';
import * as path from 'node:path';

function normalizeIntegrity(integrity: Options['integrity']): ResolvedOptions['integrity'] {
if (!integrity?.enabled) return undefined;
return { algorithms: integrity.algorithms?.length ? [...integrity.algorithms] : ['sha384'] };
}

function normalizeStimulus(stimulus: Options['stimulus'], cwd: string): ResolvedStimulusOptions | undefined {
if (!stimulus) return undefined;
const raw = typeof stimulus === 'string' ? { controllersJson: stimulus } : stimulus;
Expand Down Expand Up @@ -38,6 +43,7 @@ export function normalizeOptions(options: Options | undefined, cwd: string): Res
manifestKeyPrefix,
devServerOrigin: options?.devServerOrigin,
stimulus: normalizeStimulus(options?.stimulus, cwd),
integrity: normalizeIntegrity(options?.integrity),
};
}

Expand Down
26 changes: 25 additions & 1 deletion assets/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { UnpluginFactory } from 'unplugin';
import type { BuildContext, Options } from './types';
import type { BuildContext, NormalizedGraph, Options } from './types';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import * as process from 'node:process';
import { createUnplugin } from 'unplugin';
import { bundleToGraph, configToDevGraph } from './collectors/vite';
import { resolveDevOrigin } from './core/dev-server';
import { writeSymfonyFiles } from './core/emit';
import { buildEntrypoints, buildManifest } from './core/format';
import { integrityFromDisk, referencedFileNames } from './core/integrity';
import { normalizeOptions, resolvePublicPath } from './core/options';
import { generateControllersModule, STIMULUS_NOT_ENABLED_MESSAGE, VIRTUAL_CONTROLLERS_ID } from './core/stimulus';

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

return {
name: '@symfony/reprise',
Expand Down Expand Up @@ -49,6 +54,25 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
fileName: 'manifest.json',
source: `${JSON.stringify(buildManifest(graph, ctx), null, 2)}\n`,
});
// SRI must hash the bytes that ship: Vite only finalizes chunks (replacing markers like
// `__VITE_PRELOAD__`) when writing to disk, so the in-memory bundle differs from the file.
// Defer to writeBundle (files on disk) and rewrite entrypoints.json with the integrity map.
if (resolved.integrity) pendingIntegrity = { graph, ctx };
},

writeBundle() {
if (!pendingIntegrity || !resolved.integrity) return;
const { graph, ctx } = pendingIntegrity;
pendingIntegrity = null;
graph.integrity = integrityFromDisk(
referencedFileNames(graph.entryPoints),
resolved.outputPath,
resolved.integrity.algorithms
);
writeFileSync(
join(resolved.outputPath, 'entrypoints.json'),
`${JSON.stringify(buildEntrypoints(graph, ctx), null, 2)}\n`
);
},

configResolved(config) {
Expand Down
10 changes: 10 additions & 0 deletions assets/src/rsbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as process from 'node:process';
import { rspack } from '@rsbuild/core';
import { statsToGraph } from './collectors/rspack';
import { writeSymfonyFiles } from './core/emit';
import { integrityFromDisk, referencedFileNames } from './core/integrity';
import { buildEntrypoints, buildManifest } from './core/format';
import { normalizeOptions, resolvePublicPath } from './core/options';
import { generateControllersModule, STIMULUS_NOT_ENABLED_MESSAGE, VIRTUAL_CONTROLLERS_ID } from './core/stimulus';
Expand Down Expand Up @@ -131,6 +132,15 @@ export default function symfony(options?: Options): RsbuildPlugin {
manifestKeyPrefix: resolved.manifestKeyPrefix,
};
const graph = statsToGraph(stats.toJson({ assets: true, entrypoints: true }) as RspackStats);
// SRI (build only): `done` fires after emit, so hash the files back off disk
// (the same approach Encore takes). Dev serves changing in-memory assets, no hashes.
if (!isDev && resolved.integrity) {
graph.integrity = integrityFromDisk(
referencedFileNames(graph.entryPoints),
resolved.outputPath,
resolved.integrity.algorithms
);
}
// In dev the manifest is empty: assets are served from the dev server, never looked
// up on disk by hash, so cache-busting is moot. entrypoints.json alone drives loading.
// Matches the Vite dev path (see `configureServer` in index.ts), which also writes `{}`.
Expand Down
31 changes: 31 additions & 0 deletions assets/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,34 @@ export interface Options {
*/
stimulus?: string | StimulusOptions;

/**
* Emit Subresource Integrity (SRI) hashes for the built assets.
*
* When enabled, `entrypoints.json` gains an `integrity` map (asset URL -> hash),
* which Reprise's Symfony bundle renders as `integrity="..."` on the script/link tags.
* Only applies to build mode; the dev server serves changing in-memory assets, so
* no hashes are emitted there.
*
* ```js
* // enable only for the production build (Vite exposes `command`)
* Symfony({ integrity: { enabled: command === 'build', algorithms: ['sha384'] } })
* ```
*/
integrity?: IntegrityOptions;

// singleRuntimeChunk?: boolean
}

/** Hash algorithm used for Subresource Integrity. */
export type IntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512';

export interface IntegrityOptions {
/** Turn SRI on or off. Off by default. */
enabled: boolean;
/** Algorithms to hash each asset with. Default: `['sha384']`. */
algorithms?: IntegrityAlgorithm[];
}

export interface StimulusOptions {
/** Path to `controllers.json`, e.g. `assets/controllers.json`. */
controllersJson: string;
Expand All @@ -112,6 +137,8 @@ export interface ResolvedOptions {
manifestKeyPrefix: string;
devServerOrigin?: string;
stimulus?: ResolvedStimulusOptions;
/** Present (with a non-empty algorithm list) only when SRI is enabled. */
integrity?: { algorithms: string[] };
}

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

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

export type ManifestJson = Record<string, string>;
15 changes: 15 additions & 0 deletions assets/test/core/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ describe('buildEntrypoints', () => {
expect(out.entryPoints.app.js).toEqual(['/build/app-a1b2.js']);
});

it('emits a top-level integrity map keyed by URL when the graph carries hashes', () => {
const out = buildEntrypoints(
{ ...graph, integrity: { 'app-a1b2.js': 'sha384-JS', 'app-c3d4.css': 'sha384-CSS' } },
ctx
);
expect(out.integrity).toEqual({
'/build/app-a1b2.js': 'sha384-JS',
'/build/app-c3d4.css': 'sha384-CSS',
});
});

it('omits integrity when the graph carries no hashes', () => {
expect(buildEntrypoints(graph, ctx).integrity).toBeUndefined();
});

it('builds URLs from urlPrefix but emits the original publicPath field', () => {
const devCtx: BuildContext = {
isProd: false,
Expand Down
Loading
Loading