diff --git a/config/bundle.php b/config/bundle.php index 68270aa..d830d3a 100644 --- a/config/bundle.php +++ b/config/bundle.php @@ -19,11 +19,11 @@ |-------------------------------------------------------------------------- | | 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', 800), + 'import_resolution_timeout' => env('BUNDLE_IMPORT_RESOLUTION_TIMEOUT', 200), /* |-------------------------------------------------------------------------- diff --git a/docs/introduction.md b/docs/introduction.md index 3b9f7a7..cdd0a68 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,19 +32,13 @@ 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. +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 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 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/Components/Import.php b/src/Components/Import.php index 3d79c0b..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 (Needed for Alpine support) - 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/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 --}} diff --git a/src/InjectCore.php b/src/InjectCore.php new file mode 100644 index 0000000..028efd4 --- /dev/null +++ b/src/InjectCore.php @@ -0,0 +1,145 @@ +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 it up & wrap in script tag + $script = $this->wrapInScriptTag( + file_get_contents($this->bundle()) + ); + + // Inject into response + $originalContent = $handled->response->original; + + $handled->response->setContent( + $this->injectAssets($html, $script) + ); + + $handled->response->original = $originalContent; + } + + public function bundle(): SplFileInfo + { + return $this->manager()->bundle( + $this->core() + ); + } + + /** 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 + { + $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(); + } + + /** Wrap the contents in a inline script tag */ + 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; + } +} 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); 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/Commands/BuildCommandTest.php b/tests/Feature/Commands/BuildCommandTest.php index b360a9c..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,5 +54,34 @@ // Execute build command $this->artisan('bundle:build'); + expect($manager->buildDisk()->allFiles())->toHaveCount(2); // core + markdown file +}); + +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/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); +}); diff --git a/tests/Fixtures/resources/empty/.gitkeep b/tests/Fixtures/resources/empty/.gitkeep new file mode 100644 index 0000000..e69de29 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(); -}); 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 @@ - - + +