Skip to content

Commit 6be5556

Browse files
committed
feature #236 Allow entrypoints.json to be hosted remotely (Raphaël Louvradoux, Kocal)
This PR was merged into the 2.x branch. Discussion ---------- Allow entrypoints.json to be hosted remotely Fixes #76 See #97, this PR uses the Symfony HttpClient to remotely fetch the entrypoints file, but also to allow mocking in a proper way. It also add/adapt many tests, and improve the documentation. Commits ------- f5b9a1d Use HttpClient instead of file_get_contents(), add tests and documentation 2fe9a88 Allow entrypoints.json to be hosted remotely
2 parents f5c3db0 + f5b9a1d commit 6be5556

File tree

10 files changed

+152
-10
lines changed

10 files changed

+152
-10
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
},
2020
"require-dev": {
2121
"symfony/framework-bundle": "^5.4 || ^6.2 || ^7.0",
22+
"symfony/http-client": "^5.4 || ^6.2 || ^7.0",
2223
"symfony/phpunit-bridge": "^5.4 || ^6.2 || ^7.0",
2324
"symfony/twig-bundle": "^5.4 || ^6.2 || ^7.0",
2425
"symfony/web-link": "^5.4 || ^6.2 || ^7.0"

doc/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ file:
5252
# if you have multiple builds:
5353
# builds:
5454
# frontend: '%kernel.project_dir%/public/frontend/build'
55+
# or if you use a CDN:
56+
# frontend: 'https://cdn.example.com/frontend/build'
5557
5658
# pass the build name" as the 3rd argument to the Twig functions
5759
# {{ encore_entry_script_tags('entry1', null, 'frontend') }}

psalm-baseline.xml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<files psalm-version="5.13.1@086b94371304750d1c673315321a55d15fc59015">
2+
<files psalm-version="5.26.1@d747f6500b38ac4f7dfc5edbcae6e4b637d7add0">
3+
<file src="src/Asset/EntrypointLookup.php">
4+
<InvalidReturnStatement>
5+
<code><![CDATA[$this->entriesData]]></code>
6+
</InvalidReturnStatement>
7+
</file>
38
<file src="src/DependencyInjection/Configuration.php">
49
<UndefinedMethod>
5-
<code>children</code>
10+
<code><![CDATA[children]]></code>
611
</UndefinedMethod>
712
</file>
813
</files>

src/Asset/EntrypointLookup.php

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
namespace Symfony\WebpackEncoreBundle\Asset;
1313

1414
use Psr\Cache\CacheItemPoolInterface;
15+
use Symfony\Component\HttpClient\HttpClient;
16+
use Symfony\Contracts\HttpClient\HttpClientInterface;
1517
use Symfony\WebpackEncoreBundle\Exception\EntrypointNotFoundException;
1618

1719
/**
@@ -35,12 +37,15 @@ class EntrypointLookup implements EntrypointLookupInterface, IntegrityDataProvid
3537

3638
private $strictMode;
3739

38-
public function __construct(string $entrypointJsonPath, ?CacheItemPoolInterface $cache = null, ?string $cacheKey = null, bool $strictMode = true)
40+
private $httpClient;
41+
42+
public function __construct(string $entrypointJsonPath, ?CacheItemPoolInterface $cache = null, ?string $cacheKey = null, bool $strictMode = true, ?HttpClientInterface $httpClient = null)
3943
{
4044
$this->entrypointJsonPath = $entrypointJsonPath;
4145
$this->cache = $cache;
4246
$this->cacheKey = $cacheKey;
4347
$this->strictMode = $strictMode;
48+
$this->httpClient = $httpClient;
4449
}
4550

4651
public function getJavaScriptFiles(string $entryName): array
@@ -119,15 +124,31 @@ private function getEntriesData(): array
119124
}
120125
}
121126

122-
if (!file_exists($this->entrypointJsonPath)) {
127+
if (str_starts_with($this->entrypointJsonPath, 'http')) {
128+
if (null === $this->httpClient && !class_exists(HttpClient::class)) {
129+
throw new \LogicException(\sprintf('You cannot fetch the entrypoints file from URL "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', $this->entrypointJsonPath));
130+
}
131+
$httpClient = $this->httpClient ?? HttpClient::create();
132+
133+
$response = $httpClient->request('GET', $this->entrypointJsonPath);
134+
135+
if (200 !== $response->getStatusCode()) {
136+
if (!$this->strictMode) {
137+
return [];
138+
}
139+
throw new \InvalidArgumentException(\sprintf('Could not find the entrypoints file from URL "%s": the HTTP request failed with status code %d.', $this->entrypointJsonPath, $response->getStatusCode()));
140+
}
141+
142+
$this->entriesData = $response->toArray();
143+
} elseif (!file_exists($this->entrypointJsonPath)) {
123144
if (!$this->strictMode) {
124145
return [];
125146
}
126147
throw new \InvalidArgumentException(\sprintf('Could not find the entrypoints file from Webpack: the file "%s" does not exist.', $this->entrypointJsonPath));
148+
} else {
149+
$this->entriesData = json_decode(file_get_contents($this->entrypointJsonPath), true);
127150
}
128151

129-
$this->entriesData = json_decode(file_get_contents($this->entrypointJsonPath), true);
130-
131152
if (null === $this->entriesData) {
132153
throw new \InvalidArgumentException(\sprintf('There was a problem JSON decoding the "%s" file', $this->entrypointJsonPath));
133154
}

src/CacheWarmer/EntrypointCacheWarmer.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,31 @@
1313

1414
use Symfony\Bundle\FrameworkBundle\CacheWarmer\AbstractPhpFileCacheWarmer;
1515
use Symfony\Component\Cache\Adapter\ArrayAdapter;
16+
use Symfony\Contracts\HttpClient\HttpClientInterface;
1617
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookup;
1718
use Symfony\WebpackEncoreBundle\Exception\EntrypointNotFoundException;
1819

1920
class EntrypointCacheWarmer extends AbstractPhpFileCacheWarmer
2021
{
2122
private $cacheKeys;
23+
private $httpClient;
2224

23-
public function __construct(array $cacheKeys, string $phpArrayFile)
25+
public function __construct(array $cacheKeys, ?HttpClientInterface $httpClient, string $phpArrayFile)
2426
{
2527
$this->cacheKeys = $cacheKeys;
28+
$this->httpClient = $httpClient;
2629
parent::__construct($phpArrayFile);
2730
}
2831

2932
protected function doWarmUp(string $cacheDir, ArrayAdapter $arrayAdapter, ?string $buildDir = null): bool
3033
{
3134
foreach ($this->cacheKeys as $cacheKey => $path) {
3235
// If the file does not exist then just skip past this entry point.
33-
if (!file_exists($path)) {
36+
if (!str_starts_with($path, 'http') && !file_exists($path)) {
3437
continue;
3538
}
3639

37-
$entryPointLookup = new EntrypointLookup($path, $arrayAdapter, $cacheKey);
40+
$entryPointLookup = new EntrypointLookup($path, $arrayAdapter, $cacheKey, httpClient: $this->httpClient);
3841

3942
try {
4043
$entryPointLookup->getJavaScriptFiles('dummy');

src/DependencyInjection/WebpackEncoreExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ private function entrypointFactory(ContainerBuilder $container, string $name, st
9595
$cacheEnabled ? new Reference('webpack_encore.cache') : null,
9696
$name,
9797
$strictMode,
98+
new Reference('http_client', ContainerBuilder::NULL_ON_INVALID_REFERENCE),
9899
];
99100
$definition = new Definition(EntrypointLookup::class, $arguments);
100101
$definition->addTag('kernel.reset', ['method' => 'reset']);

src/Resources/config/services.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
<service id="webpack_encore.entrypoint_lookup.cache_warmer" class="Symfony\WebpackEncoreBundle\CacheWarmer\EntrypointCacheWarmer">
4040
<tag name="kernel.cache_warmer" />
4141
<argument /> <!-- build list of entrypoint paths -->
42+
<argument type="service" id="http_client" on-invalid="null" />
4243
<argument>%kernel.cache_dir%/webpack_encore.cache.php</argument>
4344
</service>
4445

@@ -67,5 +68,7 @@
6768
<tag name="kernel.event_subscriber" />
6869
<argument type="service" id="webpack_encore.entrypoint_lookup_collection" />
6970
</service>
71+
72+
<service id="webpack_encore.http_client" alias="http_client" />
7073
</services>
7174
</container>

tests/IntegrationTest.php

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@
2020
use Symfony\Component\DependencyInjection\Alias;
2121
use Symfony\Component\DependencyInjection\ContainerBuilder;
2222
use Symfony\Component\DependencyInjection\Reference;
23+
use Symfony\Component\HttpClient\Response\MockResponse;
2324
use Symfony\Component\HttpFoundation\Request;
2425
use Symfony\Component\HttpFoundation\Response;
2526
use Symfony\Component\HttpKernel\HttpKernelInterface;
2627
use Symfony\Component\HttpKernel\Kernel;
2728
use Symfony\Component\HttpKernel\Log\Logger;
2829
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
30+
use Symfony\Contracts\HttpClient\ResponseInterface;
2931
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupCollectionInterface;
3032
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
3133
use Symfony\WebpackEncoreBundle\Asset\TagRenderer;
@@ -71,6 +73,26 @@ public function testTwigIntegration()
7173
'<script src="/build/other4.js"></script>',
7274
$html2
7375
);
76+
77+
$html3 = $twig->render('@integration_test/template_remote.twig');
78+
$this->assertStringContainsString(
79+
'<script src="https://cdn.example.com/app.js?v=abcde01" referrerpolicy="origin"></script>',
80+
$html3
81+
);
82+
$this->assertStringContainsString(
83+
'<link rel="stylesheet" href="https://cdn.example.com/app.css?v=abcde02">',
84+
$html3
85+
);
86+
87+
$html4 = $twig->render('@integration_test/manual_template_remote.twig');
88+
$this->assertStringContainsString(
89+
'<script src="https://cdn.example.com/backend.js?v=abcde01"></script>',
90+
$html4
91+
);
92+
$this->assertStringContainsString(
93+
'<link rel="stylesheet" href="https://cdn.example.com/backend.css?v=abcde02" />',
94+
$html4
95+
);
7496
}
7597

7698
public function testEntriesAreNotRepeatedWhenAlreadyOutputIntegration()
@@ -137,7 +159,7 @@ public function testCacheWarmer()
137159
$this->assertFileExists($cachePath);
138160
$data = require $cachePath;
139161
// check for both build keys
140-
$this->assertSame(['_default', 'different_build'], array_keys($data[0] ?? $data));
162+
$this->assertSame(['_default', 'different_build', 'remote_build'], array_keys($data[0] ?? $data));
141163
}
142164

143165
public function testEnabledStrictModeThrowsExceptionIfBuildMissing()
@@ -165,6 +187,31 @@ public function testDisabledStrictModeIgnoresMissingBuild()
165187
self::assertSame('', trim($html));
166188
}
167189

190+
public function testEnabledStrictModeThrowsExceptionIfRemoteBuildMissing()
191+
{
192+
$this->expectException(\Twig\Error\RuntimeError::class);
193+
$this->expectExceptionMessage('Could not find the entrypoints file from URL "https://example.com/missing_build/entrypoints.json": the HTTP request failed with status code 404.');
194+
195+
$kernel = new WebpackEncoreIntegrationTestKernel(true);
196+
$kernel->outputPath = 'remote_build';
197+
$kernel->builds = ['remote_build' => 'https://example.com/missing_build'];
198+
$kernel->boot();
199+
$twig = $this->getTwigEnvironmentFromBootedKernel($kernel);
200+
$twig->render('@integration_test/template_remote.twig');
201+
}
202+
203+
public function testDisabledStrictModeIgnoresMissingRemoteBuild()
204+
{
205+
$kernel = new WebpackEncoreIntegrationTestKernel(true);
206+
$kernel->outputPath = 'remote_build';
207+
$kernel->strictMode = false;
208+
$kernel->builds = ['remote_build' => 'https://example.com/missing_build'];
209+
$kernel->boot();
210+
$twig = $this->getTwigEnvironmentFromBootedKernel($kernel);
211+
$html = $twig->render('@integration_test/template_remote.twig');
212+
self::assertSame('', trim($html));
213+
}
214+
168215
public function testAutowireableInterfaces()
169216
{
170217
$kernel = new WebpackEncoreIntegrationTestKernel(true);
@@ -228,6 +275,7 @@ class WebpackEncoreIntegrationTestKernel extends Kernel
228275
public $outputPath = __DIR__.'/fixtures/build';
229276
public $builds = [
230277
'different_build' => __DIR__.'/fixtures/different_build',
278+
'remote_build' => 'https://example.com/build',
231279
];
232280
public $scriptAttributes = [];
233281

@@ -261,6 +309,9 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa
261309
'enabled' => $this->enableAssets,
262310
],
263311
'test' => true,
312+
'http_client' => [
313+
'mock_response_factory' => WebpackEncoreHttpClientMockCallback::class,
314+
],
264315
];
265316
if (self::VERSION_ID >= 50100) {
266317
$frameworkConfig['router'] = [
@@ -310,6 +361,9 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa
310361
// @legacy for 5.0 and earlier: did not have controller.service_arguments tag
311362
$container->getDefinition('kernel')
312363
->addTag('controller.service_arguments');
364+
365+
$container->register(WebpackEncoreHttpClientMockCallback::class)
366+
->setPublic(true);
313367
}
314368

315369
public function getCacheDir(): string
@@ -365,3 +419,46 @@ public function __construct(EntrypointLookupInterface $entrypointLookup, Entrypo
365419
{
366420
}
367421
}
422+
423+
class WebpackEncoreHttpClientMockCallback
424+
{
425+
/** @var callable|null */
426+
public $callback;
427+
428+
public function __invoke(string $method, string $url, array $options = []): ResponseInterface
429+
{
430+
$callback = $this->callback ?? static function (string $method, string $url) {
431+
if ('GET' === $method && 'https://example.com/build/entrypoints.json' === $url) {
432+
return new MockResponse(json_encode([
433+
'entrypoints' => [
434+
'app' => [
435+
'js' => [
436+
'https://cdn.example.com/app.js?v=abcde01',
437+
],
438+
'css' => [
439+
'https://cdn.example.com/app.css?v=abcde02',
440+
],
441+
],
442+
'backend' => [
443+
'js' => [
444+
'https://cdn.example.com/backend.js?v=abcde01',
445+
],
446+
'css' => [
447+
'https://cdn.example.com/backend.css?v=abcde02',
448+
],
449+
],
450+
],
451+
], flags: \JSON_THROW_ON_ERROR), [
452+
'http_code' => 200,
453+
'response_headers' => ['Content-Type: application/json'],
454+
]);
455+
}
456+
457+
return new MockResponse('Not found.', [
458+
'http_code' => 404,
459+
]);
460+
};
461+
462+
return ($callback)($method, $url, $options);
463+
}
464+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% for jsFile in encore_entry_js_files('backend', 'remote_build') %}
2+
<script src="{{ asset(jsFile) }}"></script>
3+
{% endfor %}
4+
5+
{% for cssFile in encore_entry_css_files('backend', 'remote_build') %}
6+
<link rel="stylesheet" href="{{ asset(cssFile) }}" />
7+
{% endfor %}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{{ encore_entry_script_tags('app', null, 'remote_build') }}
2+
{{ encore_entry_link_tags('app', null, 'remote_build') }}

0 commit comments

Comments
 (0)