Skip to content

Commit 4212c81

Browse files
committed
feature #51 [Builds] Add support for multiple named builds (Kocal)
This PR was merged into the main branch. Discussion ---------- [Builds] Add support for multiple named builds | Q | A | -------------- | --- | Bug fix? | no | New feature? | yes | Deprecations? | no | Documentation? | yes | License | MIT This is Reprise's port of Webpack Encore's multi-build feature (Encore's `builds` config plus the `entrypointName` argument on tag functions). A single Symfony app can now drive several independent bundles at once: for example, a Vite-built public frontend and an Rsbuild-built admin panel, each with its own plugin config, output directory, and `entrypoints.json`. The default build continues to come from `output_path`; named builds are declared under a new `builds` key (a map of build name -> output directory). Setting `output_path: false` enables a named-builds-only setup, provided at least one build is declared (the bundle throws at container-compile time otherwise). The name `_default` is reserved. Every Twig tag and file function (`reprise_entry_script_tags`, `reprise_entry_link_tags`, `reprise_entry_js_files`, `reprise_entry_css_files`) and `reprise_entry_exists` now accepts a `build` argument to select which build to look up; omitting it falls back to the default. ```twig {{ reprise_entry_script_tags('app') }} {{ reprise_entry_script_tags('dashboard', build='admin') }} ``` A few internals worth flagging for review: there is a new `EntrypointsLookupCollection` (backed by a service locator, behind a public `EntrypointsLookupCollectionInterface`) that resolves the per-build `EntrypointsLookup` service on demand, and a new `UndefinedBuildException` for unknown build names. The `TagRenderer` now resolves its lookup through that collection and tracks injected HMR clients as a set keyed by client URL, so two dev servers running simultaneously (Vite and Rsbuild) each inject their client exactly once. The reset listener was generalized to reset an `iterable<ResetInterface>` covering every build's lookup plus the renderer, and the cache warmer now warms one cache key per build. The `build` argument on Twig functions, previously introduced as a reserved placeholder that threw on use, is now fully functional. No JS or plugin changes are needed: one bundler config already writes one `entrypoints.json` per output directory. The feature is covered by unit tests, a Vite+Rsbuild functional test, and config-validation tests, and documented in `doc/index.rst`. Commits ------- afb5bfa [Builds] Add support for multiple named builds
2 parents 83b7009 + afb5bfa commit 4212c81

20 files changed

Lines changed: 605 additions & 106 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Add a per-call `attributes` argument to the `reprise_entry_script_tags()` and `reprise_entry_link_tags()` Twig functions
66
- Add the `reprise_entry_exists()` Twig function
7+
- Add support for multiple named builds via the `builds` config and a `build` argument on the Twig functions
78

89
## 0.5.0
910

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Symfony Reprise covers only the Symfony-side glue the bundlers leave out:
3030
- 🔖 **Asset versioning**: content-hash cache busting, wired into the manifest
3131
- 📁 **File copy**: copy static files (images, fonts…) into the build, keyed in the manifest
3232
- 🔥 **Dev server & HMR**: points Twig at the running Vite/Rsbuild server
33+
- 🧱 **Multiple builds**: drive several bundles (e.g. a main app and a separately-built embeddable widget) from one Symfony app
3334
- 🏷️ **Twig tag rendering**: `reprise_entry_script_tags`/`reprise_entry_link_tags` render straight from `entrypoints.json`
3435
- 🧩 **Symfony UX / Stimulus**: registers `controllers.json` and local controllers, eager or lazy
3536
- 🌐 **CDN support**: serve built assets from an absolute `publicPath`

config/cache.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@
3131

3232
$services->set('reprise.entrypoints_cache_warmer', EntrypointsCacheWarmer::class)
3333
->args([
34-
param('reprise.entrypoints_path'),
35-
'reprise.entrypoints',
34+
param('reprise.entrypoints_paths'),
3635
service('reprise.cache'),
3736
])
3837
->tag('kernel.cache_warmer')

doc/index.rst

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ reimplement any of that. It covers only the Symfony-side glue the bundlers leave
1717
- **Asset versioning**: content-hash cache busting, wired into the manifest
1818
- **File copy**: copy static files into the build, keyed in the manifest
1919
- **Dev server and HMR**: points Twig at the running Vite/Rsbuild server
20+
- **Multiple builds**: drive several bundles (e.g. a main app and a separately-built embeddable widget) from one Symfony app
2021
- **Twig tag rendering**: ``reprise_entry_script_tags``/``reprise_entry_link_tags`` render straight from
2122
``entrypoints.json``
2223
- **Symfony UX / Stimulus**: registers ``controllers.json`` and local controllers, eager or lazy
@@ -456,6 +457,89 @@ The resulting ``entrypoints.json`` gets an extra ``integrity`` section:
456457
Hashes cover every referenced file in each entry (js, css, and preloaded/dynamic chunks), and since they're computed
457458
from the files actually written to disk, they stay correct through minification and hashing.
458459

460+
Multiple builds
461+
~~~~~~~~~~~~~~~
462+
463+
Several areas of one app (a public part, an admin panel) are usually just separate entry points in one config: the
464+
Multiple entries case, addressed by name without a ``build`` argument. Reach for multiple builds only when a part
465+
needs its own bundler config and output directory, like an embeddable widget built apart from the main app.
466+
467+
Give the widget its own config, pointing the plugin at a separate output directory -> with Vite:
468+
469+
.. code-block:: javascript
470+
471+
// vite.config.widget.ts -- run with `vite build --config vite.config.widget.ts`
472+
import { defineConfig } from 'vite'
473+
import Symfony from '@symfony/reprise/vite'
474+
475+
export default defineConfig({
476+
build: {
477+
rolldownOptions: {
478+
input: {
479+
widget: './assets/widget.js',
480+
},
481+
},
482+
},
483+
plugins: [
484+
Symfony({
485+
outputPath: 'public/widget-build',
486+
publicPath: '/widget-build/',
487+
}),
488+
],
489+
})
490+
491+
or with Rsbuild:
492+
493+
.. code-block:: javascript
494+
495+
// rsbuild.config.widget.ts -- run with `rsbuild build --config rsbuild.config.widget.ts`
496+
import { defineConfig } from '@rsbuild/core'
497+
import Symfony from '@symfony/reprise/rsbuild'
498+
499+
export default defineConfig({
500+
source: {
501+
entry: {
502+
widget: './assets/widget.js',
503+
},
504+
},
505+
plugins: [
506+
Symfony({
507+
outputPath: 'public/widget-build',
508+
publicPath: '/widget-build/',
509+
}),
510+
],
511+
})
512+
513+
These two blocks are alternatives: pick the one that matches your bundler, you don't use both in one project.
514+
515+
Name that directory in ``reprise.yaml``:
516+
517+
.. code-block:: yaml
518+
519+
# config/packages/reprise.yaml
520+
reprise:
521+
# the main app (your existing config), addressed without a build name
522+
output_path: '%kernel.project_dir%/public/build'
523+
builds:
524+
# each extra build: name -> the directory its entrypoints.json lives in
525+
widget: '%kernel.project_dir%/public/widget-build'
526+
527+
In Twig, pass the build name to load from it; omit it for the default build:
528+
529+
.. code-block:: twig
530+
531+
{# the main app (default build) #}
532+
{{ reprise_entry_script_tags('app') }}
533+
{{ reprise_entry_link_tags('app') }}
534+
535+
{# the widget build, selected with the build argument #}
536+
{{ reprise_entry_script_tags('widget', build='widget') }}
537+
{{ reprise_entry_link_tags('widget', build='widget') }}
538+
539+
The ``build`` argument works on every ``reprise_entry_*`` function. Each build has its own dev server, so the app's
540+
and the widget's can run at once on different ports, each injecting its HMR client once. Set ``output_path: false``
541+
for a named-builds-only setup (at least one build is required).
542+
459543
Configuration
460544
-------------
461545

@@ -466,8 +550,12 @@ Reprise exposes a few optional settings under its own configuration, all shown h
466550
# config/packages/reprise.yaml
467551
reprise:
468552
# Directory the @symfony/reprise plugin writes entrypoints.json and manifest.json into.
553+
# Set to false when using only named builds (requires at least one entry under builds).
469554
output_path: '%kernel.project_dir%/public/build'
470555
556+
# Additional named builds: a map of build name -> output directory (see `Multiple builds`_).
557+
builds: {}
558+
471559
# Throw when entrypoints.json or a requested entry is missing, instead of rendering nothing.
472560
strict_mode: true
473561
@@ -488,7 +576,10 @@ Reprise exposes a few optional settings under its own configuration, all shown h
488576
link_attributes: []
489577
490578
- ``output_path``: filesystem directory holding ``entrypoints.json`` and ``manifest.json``. Must match the plugin's
491-
own ``outputPath``.
579+
own ``outputPath``. Accepts ``false`` to disable the default build entirely (requires at least one entry under
580+
``builds``).
581+
- ``builds``: a map of build name -> output directory for additional bundles. Each named build is addressed by passing
582+
``build='<name>'`` to the tag and file functions. See `Multiple builds`_.
492583
- ``strict_mode``: when ``true`` (the default), throws a clear exception on a missing file or an unknown entry; when
493584
``false``, renders nothing instead.
494585
- ``cache``: when ``true``, parse ``entrypoints.json`` once at ``cache:warmup`` and read it from a compiled PHP file
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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\Contracts\Service\ServiceProviderInterface;
15+
use Symfony\Reprise\Exception\UndefinedBuildException;
16+
17+
/**
18+
* @author Hugo Alliaume <hugo@alliau.me>
19+
*
20+
* @internal
21+
*/
22+
final class EntrypointsLookupCollection implements EntrypointsLookupCollectionInterface
23+
{
24+
/**
25+
* @param ServiceProviderInterface<EntrypointsLookupInterface> $lookups a service locator keyed by build name (the default build under "_default")
26+
*/
27+
public function __construct(
28+
private readonly ServiceProviderInterface $lookups,
29+
private readonly ?string $defaultBuildName = null,
30+
) {
31+
}
32+
33+
public function getEntrypointsLookup(?string $build = null): EntrypointsLookupInterface
34+
{
35+
$build ??= $this->defaultBuildName;
36+
37+
if (null === $build) {
38+
throw new UndefinedBuildException('There is no default build configured: set "reprise.output_path", or pass an explicit build name.');
39+
}
40+
41+
if (!$this->lookups->has($build)) {
42+
throw new UndefinedBuildException(\sprintf('The build "%s" is not configured under "reprise.builds".', $build));
43+
}
44+
45+
return $this->lookups->get($build);
46+
}
47+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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\UndefinedBuildException;
15+
16+
/**
17+
* Resolves the EntrypointsLookup for a named build (or the default build when none is given).
18+
*
19+
* @author Hugo Alliaume <hugo@alliau.me>
20+
*/
21+
interface EntrypointsLookupCollectionInterface
22+
{
23+
/**
24+
* @throws UndefinedBuildException when the build is unknown, or when null is given and no default build exists
25+
*/
26+
public function getEntrypointsLookup(?string $build = null): EntrypointsLookupInterface;
27+
}

src/Asset/TagRenderer.php

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,20 @@
2727
*/
2828
final class TagRenderer implements ResetInterface
2929
{
30-
private bool $clientInjected = false;
30+
/**
31+
* HMR client URLs already injected this request, kept as a set so each dev server (there is one per
32+
* build) injects its client exactly once, even when several builds render on the same page.
33+
*
34+
* @var array<string, true>
35+
*/
36+
private array $injectedClients = [];
3137

3238
/**
3339
* @param array<string, bool|string> $scriptAttributes
3440
* @param array<string, bool|string> $linkAttributes
3541
*/
3642
public function __construct(
37-
private readonly EntrypointsLookupInterface $lookup,
43+
private readonly EntrypointsLookupCollectionInterface $collection,
3844
private readonly Packages $packages,
3945
private readonly ?RequestStack $requestStack = null,
4046
private readonly ?string $defaultPackage = null,
@@ -51,28 +57,28 @@ public function __construct(
5157
*/
5258
public function renderScriptTags(string $entryName, ?string $packageName = null, ?string $build = null, array $attributes = []): string
5359
{
54-
$this->assertNoBuild($build);
55-
$integrity = $this->lookup->getIntegrityData();
60+
$lookup = $this->collection->getEntrypointsLookup($build);
61+
$integrity = $lookup->getIntegrityData();
5662
$tags = [];
5763

58-
$devServer = $this->lookup->getDevServer();
59-
if (!$this->clientInjected && null !== $devServer && null !== $devServer->client) {
64+
$devServer = $lookup->getDevServer();
65+
if (null !== $devServer && null !== $devServer->client && !isset($this->injectedClients[$devServer->client])) {
6066
$tags[] = \sprintf('<script type="module" src="%s"></script>', htmlspecialchars($devServer->client, \ENT_QUOTES));
6167
if (null !== $devServer->reactRefresh) {
6268
$tags[] = $this->renderReactRefreshPreamble($devServer->reactRefresh);
6369
}
64-
$this->clientInjected = true;
70+
$this->injectedClients[$devServer->client] = true;
6571
}
6672

67-
foreach ($this->lookup->getPreloadFiles($entryName) as $reference) {
73+
foreach ($lookup->getPreloadFiles($entryName) as $reference) {
6874
$url = $this->url($reference, $packageName);
6975
$tagAttributes = ['rel' => 'modulepreload', 'href' => $url];
7076
$this->applyIntegrity($tagAttributes, $reference, $integrity);
7177
$tags[] = \sprintf('<link %s>', $this->attributes($tagAttributes));
7278
$this->preload($url, 'modulepreload');
7379
}
7480

75-
foreach ($this->lookup->getJavaScriptFiles($entryName) as $reference) {
81+
foreach ($lookup->getJavaScriptFiles($entryName) as $reference) {
7682
$url = $this->url($reference, $packageName);
7783
$tagAttributes = ['src' => $url, 'type' => 'module'] + $attributes + $this->scriptAttributes;
7884
$this->applyIntegrity($tagAttributes, $reference, $integrity);
@@ -89,10 +95,10 @@ public function renderScriptTags(string $entryName, ?string $packageName = null,
8995
*/
9096
public function renderLinkTags(string $entryName, ?string $packageName = null, ?string $build = null, array $attributes = []): string
9197
{
92-
$this->assertNoBuild($build);
93-
$integrity = $this->lookup->getIntegrityData();
98+
$lookup = $this->collection->getEntrypointsLookup($build);
99+
$integrity = $lookup->getIntegrityData();
94100
$tags = [];
95-
foreach ($this->lookup->getCssFiles($entryName) as $reference) {
101+
foreach ($lookup->getCssFiles($entryName) as $reference) {
96102
$url = $this->url($reference, $packageName);
97103
$tagAttributes = ['rel' => 'stylesheet', 'href' => $url] + $attributes + $this->linkAttributes;
98104
$this->applyIntegrity($tagAttributes, $reference, $integrity);
@@ -106,27 +112,27 @@ public function renderLinkTags(string $entryName, ?string $packageName = null, ?
106112
/**
107113
* @return list<string>
108114
*/
109-
public function getJsFiles(string $entryName, ?string $packageName = null): array
115+
public function getJsFiles(string $entryName, ?string $packageName = null, ?string $build = null): array
110116
{
111-
return array_map(fn (string $r) => $this->url($r, $packageName), $this->lookup->getJavaScriptFiles($entryName));
117+
return array_map(fn (string $r) => $this->url($r, $packageName), $this->collection->getEntrypointsLookup($build)->getJavaScriptFiles($entryName));
112118
}
113119

114120
/**
115121
* @return list<string>
116122
*/
117-
public function getCssFiles(string $entryName, ?string $packageName = null): array
123+
public function getCssFiles(string $entryName, ?string $packageName = null, ?string $build = null): array
118124
{
119-
return array_map(fn (string $r) => $this->url($r, $packageName), $this->lookup->getCssFiles($entryName));
125+
return array_map(fn (string $r) => $this->url($r, $packageName), $this->collection->getEntrypointsLookup($build)->getCssFiles($entryName));
120126
}
121127

122-
public function entryExists(string $entryName): bool
128+
public function entryExists(string $entryName, ?string $build = null): bool
123129
{
124-
return $this->lookup->entryExists($entryName);
130+
return $this->collection->getEntrypointsLookup($build)->entryExists($entryName);
125131
}
126132

127133
public function reset(): void
128134
{
129-
$this->clientInjected = false;
135+
$this->injectedClients = [];
130136
}
131137

132138
/**
@@ -150,13 +156,6 @@ private function renderReactRefreshPreamble(string $reactRefreshUrl): string
150156
);
151157
}
152158

153-
private function assertNoBuild(?string $build): void
154-
{
155-
if (null !== $build) {
156-
throw new \InvalidArgumentException(\sprintf('No build named "%s" is configured.', $build));
157-
}
158-
}
159-
160159
private function url(string $reference, ?string $packageName): string
161160
{
162161
return $this->packages->getUrl($reference, $packageName ?? $this->defaultPackage);

src/CacheWarmer/EntrypointsCacheWarmer.php

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
*/
2626
final class EntrypointsCacheWarmer implements CacheWarmerInterface
2727
{
28+
/**
29+
* @param array<string, string> $entrypointsPaths build name => entrypoints.json path
30+
*/
2831
public function __construct(
29-
private readonly string $entrypointsPath,
30-
private readonly string $cacheKey,
32+
private readonly array $entrypointsPaths,
3133
private readonly PhpArrayAdapter $cache,
3234
) {
3335
}
@@ -39,17 +41,24 @@ public function isOptional(): bool
3941

4042
public function warmUp(string $cacheDir, ?string $buildDir = null): array
4143
{
42-
if (!is_file($this->entrypointsPath)) {
43-
return [];
44-
}
44+
$values = [];
45+
foreach ($this->entrypointsPaths as $build => $path) {
46+
if (!is_file($path)) {
47+
continue;
48+
}
4549

46-
try {
47-
$decoded = json_decode((string) file_get_contents($this->entrypointsPath), true, flags: \JSON_THROW_ON_ERROR);
48-
if (\is_array($decoded)) {
49-
$this->cache->warmUp([$this->cacheKey => Entrypoints::fromArray($decoded)]);
50+
try {
51+
$decoded = json_decode((string) file_get_contents($path), true, flags: \JSON_THROW_ON_ERROR);
52+
if (\is_array($decoded)) {
53+
$values['reprise.entrypoints.'.$build] = Entrypoints::fromArray($decoded);
54+
}
55+
} catch (\Throwable) {
56+
// A malformed entrypoints.json at deploy time must not break cache:warmup.
5057
}
51-
} catch (\Throwable) {
52-
// A malformed entrypoints.json at deploy time must not break cache:warmup.
58+
}
59+
60+
if ($values) {
61+
$this->cache->warmUp($values);
5362
}
5463

5564
return [];

0 commit comments

Comments
 (0)