Skip to content

Commit 98d73f8

Browse files
committed
[EntrypointsLookup] Read and resolve entrypoints.json in RepriseBundle
First slice of the PHP consumer bundle -- the read foundation: - Value objects for the self-describing entrypoints.json (Entrypoints, Entry, DevServer) with validating fromArray() factories. - EntrypointsLookup (+ interface): resolves an entry's js/css/preload/dynamic URLs, deduplicates a chunk shared by several entries within one request, exposes the integrity map and the build mode, and supports strict / non-strict lookups. - ResetAssetsEventListener clears the per-request dedup state when the main request finishes (long-running workers). - Bundle config (output_path, strict_mode) + service wiring; the interface is autowirable so user code can inject it (e.g. rendering several times / PDF). Also record ADR 0001 (doc/adr/): asset URLs will be resolved through symfony/asset Packages at render time (base_path + base_urls), which the TagRenderer slice builds on. Rendering (Twig tags, the render event, dev-server client injection) and WebLink preload land in the next slices.
1 parent 436561e commit 98d73f8

21 files changed

Lines changed: 1156 additions & 0 deletions
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# 1. Packages-driven asset URL resolution
2+
3+
- Status: Accepted
4+
- Date: 2026-07-11
5+
6+
## Context
7+
8+
The `@symfony/reprise` plugin currently writes `entrypoints.json` with **final** URLs
9+
(content-hashed filenames, prefixed with `publicPath`), and `RepriseBundle` would render the
10+
`<script>`/`<link>` tags from those URLs as-is.
11+
12+
Emitting final URLs and skipping Symfony's asset component loses, at render time:
13+
14+
- **`base_path`** — a build cannot be served under an arbitrary sub-directory
15+
(`https://host/myapp/`); baked `/build/...` URLs 404 under `/myapp`.
16+
- **`base_urls`** — no runtime CDN selection or domain sharding.
17+
- **named packages**`asset(path, 'package')`-style routing.
18+
- **consistency** — entry tags do not share the URL treatment `asset()` gives the app's other
19+
assets (images, etc.).
20+
- **version not in the filename** — Webpack Encore lets the hash live in a query string
21+
(`app.js?v=hash`, see symfony/webpack-encore#1266 and #1340), where only the manifest carries
22+
the version. The general lesson: a URL is opaque, not a filesystem path, and the PHP side must
23+
not assume where the version lives.
24+
25+
## Decision
26+
27+
Adopt Webpack Encore's model: **`Packages` (symfony/asset) drives final URL generation at render
28+
time.** The bundler is responsible only for producing the files (already content-hashed) and the
29+
manifest; `RepriseBundle` turns a reference into a URL via `Packages::getUrl()`
30+
(`base_path` + `base_urls` [+ named package]).
31+
32+
Concretely — the "relative hashed paths" variant, chosen over the pure-Encore "logical keys +
33+
`JsonManifestVersionStrategy`" one because Vite/Rollup shared & dynamic chunks are anonymous
34+
(hashed names only, no natural logical key):
35+
36+
- **`entrypoints.json` (build)** carries **relative, hashed** paths — `build/app-<hash>.js`, no
37+
leading slash and no origin — instead of final URLs.
38+
- **`RepriseBundle`** resolves each reference through a Reprise asset package configured with the
39+
app's `base_path`/`base_urls` but an **empty version strategy** (the filename is already
40+
hashed).
41+
- **Dev / serve mode** keeps **absolute** dev-server-origin URLs
42+
(`http://127.0.0.1:5173/build/app.js`); `Packages` returns absolute URLs unchanged, so
43+
`base_path`/CDN correctly do not apply (the browser hits the dev server directly).
44+
- **SRI**: the `integrity` map must be keyed so the tag renderer can find each hash for the
45+
reference it renders. SRI computation (hashing files on disk) is unaffected; only the map's key
46+
changes.
47+
48+
## Consequences
49+
50+
This is cross-cutting, not a PHP-only addition:
51+
52+
- **JS plugin** (`assets/src/core/format.ts`, collectors, the `cdn`/`build` integration tests):
53+
build `entrypoints.json` changes from final URLs to relative hashed paths; SRI integrity keying
54+
updates; the shipped tests that assert final URLs change.
55+
- **RepriseBundle**: the tag renderer (slice 2) resolves via `Packages`; config points at the
56+
package(s); the integrity lookup is keyed to match. `EntrypointsLookup` (slice 1) is unaffected —
57+
it returns whatever references the file holds.
58+
- **Docs**: the "Using a CDN" section reframes around `framework.assets`
59+
(`base_path`/`base_urls`) instead of an absolute build-time `publicPath`.
60+
61+
## Considered and rejected
62+
63+
- **Keep self-describing final URLs.** Simplest, but loses everything under _Context_.
64+
- **Pure Encore (logical keys + `JsonManifestVersionStrategy`).** Most faithful and would support
65+
query-string versioning, but requires inventing stable manifest keys for Vite's anonymous
66+
shared/dynamic chunks, and query-string versioning is not expressible in Rollup output names
67+
anyway.

src/Asset/DevServer.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Reprise\Asset;
13+
14+
use Symfony\Reprise\Exception\InvalidEntrypointsException;
15+
16+
/**
17+
* The dev-server section of a serve-mode entrypoints.json.
18+
*
19+
* @author Hugo Alliaume <hugo@alliau.me>
20+
*/
21+
final class DevServer
22+
{
23+
public function __construct(
24+
public readonly string $origin,
25+
public readonly ?string $client,
26+
) {
27+
}
28+
29+
/**
30+
* @param array<mixed, mixed> $data the raw, untrusted decoded "devServer" section
31+
*/
32+
public static function fromArray(array $data): self
33+
{
34+
$origin = $data['origin'] ?? null;
35+
if (!\is_string($origin)) {
36+
throw new InvalidEntrypointsException('The dev-server "origin" must be a string.');
37+
}
38+
39+
$client = $data['client'] ?? null;
40+
if (null !== $client && !\is_string($client)) {
41+
throw new InvalidEntrypointsException('The dev-server "client" must be a string or null.');
42+
}
43+
44+
return new self($origin, $client);
45+
}
46+
}

src/Asset/Entry.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Reprise\Asset;
13+
14+
use Symfony\Reprise\Exception\InvalidEntrypointsException;
15+
16+
/**
17+
* One entry's asset URLs, grouped by type, in load order.
18+
*
19+
* @author Hugo Alliaume <hugo@alliau.me>
20+
*
21+
* @internal
22+
*/
23+
final class Entry
24+
{
25+
/**
26+
* @param list<string> $js
27+
* @param list<string> $css
28+
* @param list<string> $preload
29+
* @param list<string> $dynamic
30+
*/
31+
public function __construct(
32+
public readonly array $js,
33+
public readonly array $css,
34+
public readonly array $preload,
35+
public readonly array $dynamic,
36+
) {
37+
}
38+
39+
/**
40+
* @param array<mixed, mixed> $data the raw, untrusted decoded entry section
41+
*/
42+
public static function fromArray(array $data, string $entryName): self
43+
{
44+
return new self(
45+
self::stringList($data['js'] ?? [], $entryName, 'js'),
46+
self::stringList($data['css'] ?? [], $entryName, 'css'),
47+
self::stringList($data['preload'] ?? [], $entryName, 'preload'),
48+
self::stringList($data['dynamic'] ?? [], $entryName, 'dynamic'),
49+
);
50+
}
51+
52+
/**
53+
* @return list<string>
54+
*/
55+
private static function stringList(mixed $value, string $entryName, string $key): array
56+
{
57+
if (!\is_array($value) || !array_is_list($value)) {
58+
throw new InvalidEntrypointsException(\sprintf('The "%s" key of entry "%s" must be a list of strings.', $key, $entryName));
59+
}
60+
61+
$strings = [];
62+
foreach ($value as $item) {
63+
if (!\is_string($item)) {
64+
throw new InvalidEntrypointsException(\sprintf('The "%s" key of entry "%s" must contain only strings.', $key, $entryName));
65+
}
66+
$strings[] = $item;
67+
}
68+
69+
return $strings;
70+
}
71+
}

src/Asset/Entrypoints.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Reprise\Asset;
13+
14+
use Symfony\Reprise\Exception\InvalidEntrypointsException;
15+
16+
/**
17+
* The parsed contents of an entrypoints.json file emitted by the @symfony/reprise plugin.
18+
*
19+
* Unlike Webpack Encore's format, the file is self-describing: it carries the build mode
20+
* (`isProd`/`devServer`), the `publicPath`, per-entry `preload`/`dynamic` chunks and, when
21+
* SRI is enabled, an `integrity` map keyed by asset URL.
22+
*
23+
* @author Hugo Alliaume <hugo@alliau.me>
24+
*
25+
* @internal
26+
*/
27+
final class Entrypoints
28+
{
29+
/**
30+
* @param array<string, Entry> $entryPoints
31+
* @param array<string, string> $integrity
32+
*/
33+
public function __construct(
34+
public readonly bool $isProd,
35+
public readonly ?DevServer $devServer,
36+
public readonly string $publicPath,
37+
public readonly array $entryPoints,
38+
public readonly array $integrity,
39+
) {
40+
}
41+
42+
/**
43+
* Validate and hydrate the raw decoded contents of an entrypoints.json file.
44+
*
45+
* The input is untrusted (whatever `json_decode()` produced), so it is deliberately typed
46+
* loosely -- checking its shape is this method's job. Precision instead lives on the value
47+
* object's properties and on the file-format documented on the class.
48+
*
49+
* @param array<mixed, mixed> $data
50+
*/
51+
public static function fromArray(array $data): self
52+
{
53+
$isProd = $data['isProd'] ?? null;
54+
if (!\is_bool($isProd)) {
55+
throw new InvalidEntrypointsException('The "isProd" key must be a boolean.');
56+
}
57+
58+
$publicPath = $data['publicPath'] ?? null;
59+
if (!\is_string($publicPath)) {
60+
throw new InvalidEntrypointsException('The "publicPath" key must be a string.');
61+
}
62+
63+
$rawEntries = $data['entryPoints'] ?? null;
64+
if (!\is_array($rawEntries)) {
65+
throw new InvalidEntrypointsException('The "entryPoints" key must be an object.');
66+
}
67+
68+
$entryPoints = [];
69+
foreach ($rawEntries as $name => $files) {
70+
if (!\is_array($files)) {
71+
throw new InvalidEntrypointsException(\sprintf('Entry "%s" must be an object.', (string) $name));
72+
}
73+
$entryPoints[(string) $name] = Entry::fromArray($files, (string) $name);
74+
}
75+
76+
$devServer = $data['devServer'] ?? null;
77+
if (null !== $devServer && !\is_array($devServer)) {
78+
throw new InvalidEntrypointsException('The "devServer" key must be an object or null.');
79+
}
80+
81+
$rawIntegrity = $data['integrity'] ?? [];
82+
if (!\is_array($rawIntegrity)) {
83+
throw new InvalidEntrypointsException('The "integrity" key must be an object.');
84+
}
85+
$integrity = [];
86+
foreach ($rawIntegrity as $url => $hash) {
87+
if (!\is_string($url) || !\is_string($hash)) {
88+
throw new InvalidEntrypointsException('The "integrity" map must map asset URLs to hash strings.');
89+
}
90+
$integrity[$url] = $hash;
91+
}
92+
93+
return new self(
94+
$isProd,
95+
null !== $devServer ? DevServer::fromArray($devServer) : null,
96+
$publicPath,
97+
$entryPoints,
98+
$integrity,
99+
);
100+
}
101+
}

0 commit comments

Comments
 (0)