Skip to content

Commit

Permalink
Merge pull request #8 from gwleuverink/feature/inject-core
Browse files Browse the repository at this point in the history
Feature / Separate core & inject in full-page responses
  • Loading branch information
gwleuverink authored Jan 31, 2024
2 parents 5d29542 + 0265769 commit 314c97a
Show file tree
Hide file tree
Showing 15 changed files with 260 additions and 87 deletions.
6 changes: 3 additions & 3 deletions config/bundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),

/*
|--------------------------------------------------------------------------
Expand Down
15 changes: 4 additions & 11 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ The <x-import /> component processes your import on the fly and renders a script

<!-- yields the following script -->

<script src="/x-import/e52def31336c.min.js" type="module" data-module="apexcharts" data-alias="ApexCharts"
></script>
<script src="/x-import/e52def31336c.min.js" type="module" data-module="apexcharts" data-alias="ApexCharts"></script>
```

### A bit more in depth
Expand All @@ -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 `<x-import />` 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.

<!--
{: .note }
> You may pass any attributes a script tag would accept, like `defer` or `async`. Note that scripts with `type="module"` are deferred by default.
<br />
-->
When you use the `<x-import />` 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 `<x-import />` 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
Expand Down
6 changes: 4 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,11 @@ It would be incredible if this object could be forwarded to Alpine directly like
</div>
```

## 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

Expand Down
4 changes: 4 additions & 0 deletions src/Commands/Build.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.*
Expand Down
47 changes: 0 additions & 47 deletions src/Components/Import.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
}
4 changes: 2 additions & 2 deletions src/Components/views/script.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
@once("bundle:$module:$as")
<!--[BUNDLE: {{ $as }} from '{{ $module }}']-->
<?php if($inline) { ?>
<script data-module="{{ $module }}" data-alias="{{ $as }}" type="module" {{ $attributes }}>
<script data-module="{{ $module }}" data-alias="{{ $as }}" type="module">
{!! file_get_contents($bundle) !!}
</script>
<?php } else { ?>
<script src="{{ route('bundle:import', $bundle->getFilename(), false) }}" data-module="{{ $module }}" data-alias="{{ $as }}" type="module" {{ $attributes }}></script>
<script src="{{ route('bundle:import', $bundle->getFilename(), false) }}" data-module="{{ $module }}" data-alias="{{ $as }}" type="module"></script>
<?php } ?>
<!--[ENDBUNDLE]>-->
@else {{-- @once else clause --}}
Expand Down
145 changes: 145 additions & 0 deletions src/InjectCore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

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();

// Skip if request doesn't return a full page
if (! str_contains($html, '</html>')) {
return;
}

// Skip if core was included before
if (str_contains($html, '<!--[BUNDLE-CORE]-->')) {
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
<!--[BUNDLE-CORE]-->
<script type="module" data-bundle="core">
{$contents}
</script>
<!--[ENDBUNDLE]>-->
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;
}
}
11 changes: 11 additions & 0 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -21,6 +23,7 @@ public function boot(): void

$this->registerComponents();
$this->registerCommands();
$this->injectCore();
}

public function register()
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions tests/Browser/InjectsCoreTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Leuverink\Bundle\Tests\Browser;

use Leuverink\Bundle\Tests\DuskTestCase;

// Pest & Workbench Dusk don't play nicely together
// We need to fall back to PHPUnit syntax.

class InjectsCoreTest extends DuskTestCase
{
/** @test */
public function it_injects_import_and_import_function_on_the_window_object_without_using_the_import_component()
{
$this->blade('')
->assertScript('typeof window._import', 'function')
->assertScript('typeof window.x_import_modules', 'object');
}
}
Loading

0 comments on commit 314c97a

Please sign in to comment.