From 6c6d6192aa5732a4177c8cb7d2d6c084a6bf6513 Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Tue, 30 Jan 2024 14:28:12 +0100 Subject: [PATCH 01/13] wip - better defaults --- config/bundle.php | 2 +- src/Components/Import.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/bundle.php b/config/bundle.php index 68270aa..b86b765 100644 --- a/config/bundle.php +++ b/config/bundle.php @@ -23,7 +23,7 @@ | and Alpine support. Here you can tweak it's internal timout in ms. | */ - 'import_resolution_timeout' => env('BUNDLE_IMPORT_RESOLUTION_TIMEOUT', 800), + 'import_resolution_timeout' => env('BUNDLE_IMPORT_RESOLUTION_TIMEOUT', 100), /* |-------------------------------------------------------------------------- diff --git a/src/Components/Import.php b/src/Components/Import.php index 3d79c0b..e23e3d9 100644 --- a/src/Components/Import.php +++ b/src/Components/Import.php @@ -74,7 +74,7 @@ protected function core(): string //-------------------------------------------------------------------------- window._import = async function(alias, exportName = 'default') { - // Wait for module to become available (Needed for Alpine support) + // Wait for module to become available (account for invoking from non-deferred script) const module = await poll( () => window.x_import_modules[alias], {$timeout}, 5, alias From 40a00d58bc15c52f1db093a61af209e70e83e71c Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Tue, 30 Jan 2024 15:05:01 +0100 Subject: [PATCH 02/13] update config comment --- config/bundle.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/bundle.php b/config/bundle.php index b86b765..0a57f26 100644 --- a/config/bundle.php +++ b/config/bundle.php @@ -19,8 +19,8 @@ |-------------------------------------------------------------------------- | | The _import() function uses a built-in non blocking polling mechanism in - | order to account for script tags that are not processed sequentially - | and Alpine support. Here you can tweak it's internal timout in ms. + | order to account for script tags that are not processed sequentially. + | Here you can tweak it's internal timout in ms. | */ 'import_resolution_timeout' => env('BUNDLE_IMPORT_RESOLUTION_TIMEOUT', 100), From 3239b3bd5f75e42fa1d8932d0a0a01916bffaf0e Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Tue, 30 Jan 2024 15:15:26 +0100 Subject: [PATCH 03/13] wip - update playground --- .../resources/views/playground.blade.php | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/workbench/resources/views/playground.blade.php b/workbench/resources/views/playground.blade.php index b4c7bcc..e737672 100644 --- a/workbench/resources/views/playground.blade.php +++ b/workbench/resources/views/playground.blade.php @@ -1,23 +1,14 @@ - - + +
From 14d4782e4924e6d762c8363348e3e8525afc2a09 Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Tue, 30 Jan 2024 15:23:33 +0100 Subject: [PATCH 04/13] don't allow passing extra attributes to x-import --- src/Components/views/script.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/views/script.blade.php b/src/Components/views/script.blade.php index 749c93c..f023049 100644 --- a/src/Components/views/script.blade.php +++ b/src/Components/views/script.blade.php @@ -2,11 +2,11 @@ @once("bundle:$module:$as") - - + @else {{-- @once else clause --}} From 07a3523c5b3d9c0f8a070bed12a2b3dcc4248ae2 Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Tue, 30 Jan 2024 16:40:26 +0100 Subject: [PATCH 05/13] add tests for core injection --- tests/Browser/InjectsCoreTest.php | 19 +++++++++++++++++++ tests/Feature/InjectsCoreTest.php | 31 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 tests/Browser/InjectsCoreTest.php create mode 100644 tests/Feature/InjectsCoreTest.php diff --git a/tests/Browser/InjectsCoreTest.php b/tests/Browser/InjectsCoreTest.php new file mode 100644 index 0000000..0af21b2 --- /dev/null +++ b/tests/Browser/InjectsCoreTest.php @@ -0,0 +1,19 @@ +blade('') + ->assertScript('typeof window._import', 'function') + ->assertScript('typeof window.x_import_modules', 'object'); + } +} diff --git a/tests/Feature/InjectsCoreTest.php b/tests/Feature/InjectsCoreTest.php new file mode 100644 index 0000000..df37bee --- /dev/null +++ b/tests/Feature/InjectsCoreTest.php @@ -0,0 +1,31 @@ + ''); + + $this->get('test-inject-in-response') + ->assertOk() + ->assertSee('data-bundle="core"', false) + ->assertSee('', false); +}); + +/** @test */ +it('injects core into html body when no head tag is present', function () { + Route::get('test-inject-in-response', fn () => ''); + + $this->get('test-inject-in-response') + ->assertOk() + ->assertSee('data-bundle="core"', false) + ->assertSee('', false); +}); + +/** @test */ +it('doesnt inject core into responses without a closing html tag', function () { + Route::get('test-inject-in-response', fn () => 'OK'); + + $this->get('test-inject-in-response') + ->assertOk() + ->assertDontSee('data-bundle="core"', false) + ->assertDontSee('', false); +}); From 2a6c8f3b83151d63e7f131f15d4dce3067cf3066 Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Tue, 30 Jan 2024 16:40:49 +0100 Subject: [PATCH 06/13] boyscouting --- tests/Unit/.gitkeep | 0 tests/Unit/ExampleTest.php | 5 ----- 2 files changed, 5 deletions(-) create mode 100644 tests/Unit/.gitkeep delete mode 100644 tests/Unit/ExampleTest.php diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 61cd84c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); From c399338425257b1bd562242a9d3b86be7da578ac Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Tue, 30 Jan 2024 16:42:57 +0100 Subject: [PATCH 07/13] bundle & inject core separately on full page responses --- src/Components/Import.php | 47 ------------- src/InjectCore.php | 136 ++++++++++++++++++++++++++++++++++++++ src/ServiceProvider.php | 11 +++ 3 files changed, 147 insertions(+), 47 deletions(-) create mode 100644 src/InjectCore.php diff --git a/src/Components/Import.php b/src/Components/Import.php index e23e3d9..7dcc827 100644 --- a/src/Components/Import.php +++ b/src/Components/Import.php @@ -61,37 +61,12 @@ protected function raiseConsoleErrorOrException(BundlingFailedException $e) /** Builds Bundle's core JavaScript */ protected function core(): string { - $timeout = $this->manager()->config()->get('import_resolution_timeout'); - return <<< JS //-------------------------------------------------------------------------- // Expose x_import_modules map //-------------------------------------------------------------------------- if(!window.x_import_modules) window.x_import_modules = {}; - //-------------------------------------------------------------------------- - // Expose _import function (as soon as possible) - //-------------------------------------------------------------------------- - window._import = async function(alias, exportName = 'default') { - - // Wait for module to become available (account for invoking from non-deferred script) - const module = await poll( - () => window.x_import_modules[alias], - {$timeout}, 5, alias - ) - - if(module === undefined) { - console.info('When invoking _import() from a script tag make sure it has type="module"') - throw `BUNDLE ERROR: '\${alias}' not found`; - } - - return module[exportName] !== undefined - // Return export if it exists - ? module[exportName] - // Otherwise the entire module - : module - }; - //-------------------------------------------------------------------------- // Import the module & push to x_import_modules // Invoke IIFE so we can break out of execution when needed @@ -117,28 +92,6 @@ protected function core(): string : import('{$this->module}') })(); - - //-------------------------------------------------------------------------- - // Non-blocking polling mechanism - //-------------------------------------------------------------------------- - async function poll(success, timeout, interval, ref) { - const startTime = new Date().getTime(); - - while (true) { - // If the success callable returns something truthy, return - let result = success() - if (result) return result; - - // Check if timeout has elapsed - const elapsedTime = new Date().getTime() - startTime; - if (elapsedTime >= timeout) { - throw `BUNDLE TIMEOUT: '\${ref}' could not be resolved`; - } - - // Wait for a set interval - await new Promise(resolve => setTimeout(resolve, interval)); - } - }; JS; } } diff --git a/src/InjectCore.php b/src/InjectCore.php new file mode 100644 index 0000000..32d3b30 --- /dev/null +++ b/src/InjectCore.php @@ -0,0 +1,136 @@ +response->getContent(); + + // Skip if request doesn't return a full page + if (! str_contains($html, '')) { + return; + } + + // Skip if core was included before + if (str_contains($html, '')) { + return; + } + + // Bundle up the core JS + $core = $this->manager()->bundle( + $this->core() + ); + + $script = $this->wrapInScriptTag( + file_get_contents($core) + ); + + // Inject into response + $originalContent = $handled->response->original; + + $handled->response->setContent( + $this->injectAssets($html, $script) + ); + + $handled->response->original = $originalContent; + } + + /** Injects Bundle's core into given html string (taken from Livewire's injection mechanism) */ + public function injectAssets(string $html, string $core): string + { + $html = str($html); + + if ($html->test('/<\s*\/\s*head\s*>/i')) { + return $html + ->replaceMatches('/(<\s*\/\s*head\s*>)/i', $core . '$1') + ->toString(); + } + + return $html + ->replaceMatches('/(<\s*html(?:\s[^>])*>)/i', '$1' . $core) + ->toString(); + } + + protected function wrapInScriptTag($contents): string + { + return <<< HTML + + + + HTML; + } + + protected function core(): string + { + $timeout = $this->manager()->config()->get('import_resolution_timeout'); + + return <<< JS + + //-------------------------------------------------------------------------- + // Expose x_import_modules map + //-------------------------------------------------------------------------- + if(!window.x_import_modules) window.x_import_modules = {}; + + + //-------------------------------------------------------------------------- + // Expose _import function + //-------------------------------------------------------------------------- + window._import = async function(alias, exportName = 'default') { + + // Wait for module to become available (account for invoking from non-deferred script) + const module = await poll( + () => window.x_import_modules[alias], + {$timeout}, 5, alias + ) + + if(module === undefined) { + console.info('When invoking _import() from a script tag make sure it has type="module"') + throw `BUNDLE ERROR: '\${alias}' not found`; + } + + return module[exportName] !== undefined + // Return export if it exists + ? module[exportName] + // Otherwise the entire module + : module + }; + + + //-------------------------------------------------------------------------- + // Non-blocking polling mechanism + //-------------------------------------------------------------------------- + async function poll(success, timeout, interval, ref) { + const startTime = new Date().getTime(); + + while (true) { + // If the success callable returns something truthy, return + let result = success() + if (result) return result; + + // Check if timeout has elapsed + const elapsedTime = new Date().getTime() - startTime; + if (elapsedTime >= timeout) { + throw `BUNDLE TIMEOUT: '\${ref}' could not be resolved`; + } + + // Wait for a set interval + await new Promise(resolve => setTimeout(resolve, interval)); + } + }; + + JS; + } + + /** Get an instance of the BundleManager */ + protected function manager(): BundleManagerContract + { + return BundleManager::new(); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index c52d9a0..2e2c5a2 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -8,8 +8,10 @@ use Leuverink\Bundle\Commands\Build; use Leuverink\Bundle\Commands\Clear; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Leuverink\Bundle\Components\Import; +use Illuminate\Foundation\Http\Events\RequestHandled; use Illuminate\Support\ServiceProvider as BaseServiceProvider; use Leuverink\Bundle\Contracts\BundleManager as BundleManagerContract; @@ -21,6 +23,7 @@ public function boot(): void $this->registerComponents(); $this->registerCommands(); + $this->injectCore(); } public function register() @@ -51,6 +54,14 @@ protected function registerComponents() Blade::component('import', Import::class); } + protected function injectCore() + { + Event::listen( + RequestHandled::class, + InjectCore::class, + ); + } + protected function registerCommands() { $this->commands(Build::class); From 31031683258c312adf8e853bbd6208142d8ac1ce Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Wed, 31 Jan 2024 13:16:42 +0100 Subject: [PATCH 08/13] include bundle's core in the build command --- src/Commands/Build.php | 4 +++ src/InjectCore.php | 23 +++++++++++----- tests/Feature/Commands/BuildCommandTest.php | 29 +++++++++++++++++++++ tests/Fixtures/resources/empty/.gitkeep | 0 4 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 tests/Fixtures/resources/empty/.gitkeep diff --git a/src/Commands/Build.php b/src/Commands/Build.php index de68575..689b91c 100644 --- a/src/Commands/Build.php +++ b/src/Commands/Build.php @@ -4,6 +4,7 @@ use Throwable; use Illuminate\Console\Command; +use Leuverink\Bundle\InjectCore; use Symfony\Component\Finder\Finder; use Illuminate\Support\Facades\Blade; use Symfony\Component\Finder\SplFileInfo; @@ -23,6 +24,9 @@ public function handle(Finder $finder): int { $this->callSilent('bundle:clear'); + // Bundle the core + InjectCore::new()->bundle(); + // Find and bundle all components collect(config('bundle.build_paths')) // Find all files matching *.blade.* diff --git a/src/InjectCore.php b/src/InjectCore.php index 32d3b30..4be1b94 100644 --- a/src/InjectCore.php +++ b/src/InjectCore.php @@ -2,11 +2,16 @@ namespace Leuverink\Bundle; +use SplFileInfo; +use Leuverink\Bundle\Traits\Constructable; use Illuminate\Foundation\Http\Events\RequestHandled; use Leuverink\Bundle\Contracts\BundleManager as BundleManagerContract; class InjectCore { + use Constructable; + + /** Injects a inline script tag containing Bundle's core inside every full-page response */ public function __invoke(RequestHandled $handled) { $html = $handled->response->getContent(); @@ -21,13 +26,9 @@ public function __invoke(RequestHandled $handled) return; } - // Bundle up the core JS - $core = $this->manager()->bundle( - $this->core() - ); - + // Bundle it up & wrap in script tag $script = $this->wrapInScriptTag( - file_get_contents($core) + file_get_contents($this->bundle()) ); // Inject into response @@ -40,8 +41,15 @@ public function __invoke(RequestHandled $handled) $handled->response->original = $originalContent; } + public function bundle(): SplFileInfo + { + return $this->manager()->bundle( + $this->core() + ); + } + /** Injects Bundle's core into given html string (taken from Livewire's injection mechanism) */ - public function injectAssets(string $html, string $core): string + protected function injectAssets(string $html, string $core): string { $html = str($html); @@ -56,6 +64,7 @@ public function injectAssets(string $html, string $core): string ->toString(); } + /** Wrap the contents in a inline script tag */ protected function wrapInScriptTag($contents): string { return <<< HTML diff --git a/tests/Feature/Commands/BuildCommandTest.php b/tests/Feature/Commands/BuildCommandTest.php index b360a9c..fe2209d 100644 --- a/tests/Feature/Commands/BuildCommandTest.php +++ b/tests/Feature/Commands/BuildCommandTest.php @@ -56,3 +56,32 @@ $this->artisan('bundle:build'); expect($manager->buildDisk()->allFiles())->toHaveCount(1); }); + +it('includes Bundle core', function () { + $manager = BundleManager::new(); + + // Scan empty dir + config()->set('bundle.build_paths', [ + realpath(getcwd() . '/tests/Fixtures/resources/empty'), + ]); + + // Make sure all cached scripts are cleared + $this->artisan('bundle:clear'); + $manager->buildDisk()->assertDirectoryEmpty(''); + + // Execute build command + $this->artisan('bundle:build'); + + // Expect it to at lease have 1 bundle. This is the core, + // since the scan path contains no other usages of x-import. + expect($manager->buildDisk()->allFiles())->toHaveCount(1); + + // For good measure, make sure it contains the expected code. (kinda flaky) + $file = $manager->buildDisk()->path( + head($manager->buildDisk()->files()) + ); + + expect(file_get_contents($file)) + ->toContain('window.x_import_modules={}') + ->toContain('window._import=async function'); +}); diff --git a/tests/Fixtures/resources/empty/.gitkeep b/tests/Fixtures/resources/empty/.gitkeep new file mode 100644 index 0000000..e69de29 From 87f4979a59acbe2a67463ec5237abc13d7c27422 Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Wed, 31 Jan 2024 13:21:08 +0100 Subject: [PATCH 09/13] update docs --- docs/introduction.md | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/docs/introduction.md b/docs/introduction.md index 3b9f7a7..6b6aa10 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -17,8 +17,7 @@ The component processes your import on the fly and renders a script - + ``` ### A bit more in depth @@ -33,17 +32,11 @@ Bun treats these bundles as being separate builds. This would cause collisions w A script tag with `type="module"` also makes it `defer` by default, so they are loaded in parallel & executed in order. -When you use the `` component Bundle constructs a small JS script that imports the desired module and exposes it on the page, along with the `_import` helper function. It then bundles it up and caches it in the `storage/app/bundle` directory. This is then either served over http or rendered inline. - - +When you use the `` component Bundle constructs a small JS script that imports the desired module and exposes it on the page. It then bundles it up and caches it in the `storage/app/bundle` directory. This is then either served over http or rendered inline. ## The `_import` helper function -After you use `` somewhere in your template a global `_import` function will become available on the window object. +Bundle's core, which containst `_import` helper function and internal import map, is automatically injected on every page. You can use this function to fetch the bundled import by the name you've passed to the `as` argument. From 5f60aa24e123c598852bb1b42c669de6b63a4b2c Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Wed, 31 Jan 2024 13:25:54 +0100 Subject: [PATCH 10/13] fix tests --- config/bundle.php | 2 +- tests/Feature/Commands/BuildCommandTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/bundle.php b/config/bundle.php index 0a57f26..d830d3a 100644 --- a/config/bundle.php +++ b/config/bundle.php @@ -23,7 +23,7 @@ | Here you can tweak it's internal timout in ms. | */ - 'import_resolution_timeout' => env('BUNDLE_IMPORT_RESOLUTION_TIMEOUT', 100), + 'import_resolution_timeout' => env('BUNDLE_IMPORT_RESOLUTION_TIMEOUT', 200), /* |-------------------------------------------------------------------------- diff --git a/tests/Feature/Commands/BuildCommandTest.php b/tests/Feature/Commands/BuildCommandTest.php index fe2209d..33370f9 100644 --- a/tests/Feature/Commands/BuildCommandTest.php +++ b/tests/Feature/Commands/BuildCommandTest.php @@ -18,7 +18,7 @@ $this->artisan('bundle:build'); // Assert expected scripts are present - expect($manager->buildDisk()->allFiles())->toBeGreaterThanOrEqual(1); + expect($manager->buildDisk()->allFiles())->toBeGreaterThanOrEqual(2); // core + import }); it('scans paths recursively', function () { @@ -37,7 +37,7 @@ $this->artisan('bundle:build'); // Assert expected scripts are present - expect($manager->buildDisk()->allFiles())->toBeGreaterThanOrEqual(2); + expect($manager->buildDisk()->allFiles())->toBeGreaterThanOrEqual(3); // core + 2 imports }); it('scans wildcard blade extentions like both php & md', function () { @@ -54,7 +54,7 @@ // Execute build command $this->artisan('bundle:build'); - expect($manager->buildDisk()->allFiles())->toHaveCount(1); + expect($manager->buildDisk()->allFiles())->toHaveCount(2); // core + markdown file }); it('includes Bundle core', function () { From 57ca200785f7d24ff344169fc4f83717f6fb5c52 Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Wed, 31 Jan 2024 13:36:56 +0100 Subject: [PATCH 11/13] update docs --- docs/introduction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/introduction.md b/docs/introduction.md index 6b6aa10..cdd0a68 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -38,7 +38,7 @@ When you use the `` component Bundle constructs a small JS script th Bundle's core, which containst `_import` helper function and internal import map, is automatically injected on every page. -You can use this function to fetch the bundled import by the name you've passed to the `as` argument. +The `_import` function may be used to fetch the bundled import by the name you've passed to the `as` argument. ```js var module = await _import("lodash"); // Resolves the module's default export From f2dbee1a9dd29d254da83f8d8a8dfd16ab3083fa Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Wed, 31 Jan 2024 20:43:05 +0100 Subject: [PATCH 12/13] reorder methods --- src/InjectCore.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/InjectCore.php b/src/InjectCore.php index 4be1b94..028efd4 100644 --- a/src/InjectCore.php +++ b/src/InjectCore.php @@ -48,6 +48,12 @@ public function bundle(): SplFileInfo ); } + /** Get an instance of the BundleManager */ + protected function manager(): BundleManagerContract + { + return BundleManager::new(); + } + /** Injects Bundle's core into given html string (taken from Livewire's injection mechanism) */ protected function injectAssets(string $html, string $core): string { @@ -136,10 +142,4 @@ protected function core(): string JS; } - - /** Get an instance of the BundleManager */ - protected function manager(): BundleManagerContract - { - return BundleManager::new(); - } } From 0265769fd12ffb1b712c2c2edc38b495b21cce03 Mon Sep 17 00:00:00 2001 From: Willem Leuverink Date: Wed, 31 Jan 2024 21:22:47 +0100 Subject: [PATCH 13/13] update roadmap --- docs/roadmap.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 89321c9..75a6d81 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -102,9 +102,11 @@ It would be incredible if this object could be forwarded to Alpine directly like ``` -## Injecting Bundle's core on every page +## ✅ Injecting Bundle's core on every page -This will reduce every import's size slightly. And more importantly; it will remove the need to wrap `_import` calls inside script tags without `type="module"`, making things easier for the developer and greatly decrease the chance of unexpected behaviour caused by race conditions due to slow network speeds when a `DOMContentLoaded` listener was forgotten. +**_Added in [v0.1.3](https://github.com/gwleuverink/bundle/releases/tag/v0.1.3)_** + +This will reduce every import's size slightly. But more importantly; it will greatly decrease the chance of unexpected behaviour caused by race conditions, since the Bundle's core is available on pageload. ## Optionally assigning a import to the window scope