Skip to content

Commit 1fc4c11

Browse files
authored
Build links using PSR Link, add extensibility (#1)
1 parent 76f012e commit 1fc4c11

10 files changed

+364
-148
lines changed

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ These assets will be added to the `Link` header before sending the response to t
4545

4646
**Note:** To push an image asset, it must have one of the following extensions: `bmp`, `gif`, `jpg`, `jpeg`, `png`, `tiff` or `svg` and not have `loading="lazy"`
4747

48+
### Advanced usage
49+
50+
If the automatic detection isn't enough for you, you can listen for GenerateEarlyHints events, and manually add new links.
51+
4852
## Testing
4953

5054
``` bash

composer.json

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"require": {
2121
"php" : "^8.0",
2222
"laravel/framework": "^10.0|^11.0",
23+
"fig/link-util": "^1.2",
24+
"psr/link": "^1.1.1 || ^2.0.1",
2325
"symfony/dom-crawler": "^6.0|^7.0",
2426
"symfony/css-selector": "^6.0|^7.0"
2527
},

src/Data/LinkHeaders.php

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
3+
namespace JustBetter\Http3EarlyHints\Data;
4+
5+
use Fig\Link\GenericLinkProvider;
6+
use Fig\Link\Link;
7+
use Illuminate\Support\Arr;
8+
use Psr\Link\EvolvableLinkInterface;
9+
use Psr\Link\EvolvableLinkProviderInterface;
10+
use Psr\Link\LinkInterface;
11+
12+
class LinkHeaders
13+
{
14+
private EvolvableLinkProviderInterface $linkProvider;
15+
16+
public function __construct(?EvolvableLinkProviderInterface $linkProvider = null)
17+
{
18+
$this->linkProvider = $linkProvider ?? new GenericLinkProvider();
19+
}
20+
21+
public function getLinkProvider(): EvolvableLinkProviderInterface
22+
{
23+
return $this->linkProvider;
24+
}
25+
26+
public function setLinkProvider(EvolvableLinkProviderInterface $linkProvider): static
27+
{
28+
$this->linkProvider = $linkProvider;
29+
30+
return $this;
31+
}
32+
33+
public function addLink(EvolvableLinkInterface|string|array $uri, string|array|null $rel = null, null|array $attributes = []): static
34+
{
35+
if (is_array($uri)) {
36+
foreach ($uri as $data) {
37+
$data = Arr::Wrap($data);
38+
$this->addLink(...$data);
39+
}
40+
return $this;
41+
}
42+
43+
if ($uri instanceof EvolvableLinkInterface) {
44+
$this->setLinkProvider($this->getLinkProvider()->withLink($uri));
45+
return $this;
46+
}
47+
48+
if ($rel === null) {
49+
return $this;
50+
}
51+
$link = new Link('', $uri);
52+
53+
if (\is_string($rel)) {
54+
$rel = [$rel];
55+
}
56+
57+
foreach ($rel as $value) {
58+
$link = $link->withRel($value);
59+
}
60+
61+
foreach ($attributes as $key => $value) {
62+
$link = $link->withAttribute($key, $value);
63+
}
64+
65+
$this->setLinkProvider($this->getLinkProvider()->withLink($link));
66+
67+
return $this;
68+
}
69+
70+
public function addFromString(string $link) : static
71+
{
72+
$links = explode(',', trim($link));
73+
foreach ($links as $link) {
74+
$parts = explode('; ', trim($link));
75+
$uri = trim(array_shift($parts), '<>');
76+
$rel = null;
77+
$attributes = [];
78+
foreach($parts as $part) {
79+
preg_match('/(?<key>[^=]+)(?:="?(?<value>.*)"?)?/', trim($part), $matches);
80+
$key = $matches['key'];
81+
$value = $matches['value'] ?? null;
82+
83+
if($key === 'rel') {
84+
$rel = $value;
85+
continue;
86+
}
87+
$attributes[$key] = $value ?? true;
88+
}
89+
90+
$this->addLink($uri, $rel, $attributes);
91+
}
92+
93+
return $this;
94+
}
95+
96+
public function makeUnique()
97+
{
98+
$handledHashes = [];
99+
100+
foreach ($this->getLinkProvider()->getLinks() as $link) {
101+
$hash = md5(serialize($link));
102+
if (!in_array($hash, $handledHashes)) {
103+
$handledHashes[] = $hash;
104+
continue;
105+
}
106+
107+
$this->setLinkProvider($this->getLinkProvider()->withoutLink($link));
108+
}
109+
110+
return $this;
111+
}
112+
113+
public function __toString()
114+
{
115+
return trim(collect($this->getLinkProvider()->getLinks())
116+
->map([static::class, 'linkToString'])
117+
->filter()
118+
->implode(','));
119+
}
120+
121+
public static function linkToString(LinkInterface $link)
122+
{
123+
if ($link->isTemplated()) {
124+
return;
125+
}
126+
127+
$attributes = ['', sprintf('rel="%s"', implode(' ', $link->getRels()))];
128+
129+
foreach ($link->getAttributes() as $key => $value) {
130+
if (\is_array($value)) {
131+
foreach ($value as $v) {
132+
$attributes[] = sprintf('%s="%s"', $key, $v);
133+
}
134+
135+
continue;
136+
}
137+
138+
if (!\is_bool($value)) {
139+
$attributes[] = sprintf('%s="%s"', $key, $value);
140+
141+
continue;
142+
}
143+
144+
if ($value === true) {
145+
$attributes[] = $key;
146+
}
147+
}
148+
149+
return sprintf('<%s>%s', $link->getHref(), implode('; ', $attributes));
150+
}
151+
}

src/Events/GenerateEarlyHints.php

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace JustBetter\Http3EarlyHints\Events;
4+
5+
use Illuminate\Foundation\Events\Dispatchable;
6+
use Illuminate\Http\Request;
7+
use JustBetter\Http3EarlyHints\Data\LinkHeaders;
8+
use Symfony\Component\HttpFoundation\Response;
9+
10+
class GenerateEarlyHints
11+
{
12+
use Dispatchable;
13+
14+
public function __construct(
15+
public LinkHeaders $linkHeaders,
16+
public Request $request,
17+
public Response $response
18+
) {}
19+
}

src/Listeners/AddDefaultHeaders.php

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace JustBetter\Http3EarlyHints\Listeners;
4+
5+
use Illuminate\Support\Facades\Event;
6+
use JustBetter\Http3EarlyHints\Events\GenerateEarlyHints;
7+
8+
class AddDefaultHeaders
9+
{
10+
public function handle(GenerateEarlyHints $event)
11+
{
12+
foreach(config('http3earlyhints.default_headers', []) as $header) {
13+
$event->linkHeaders->addFromString($header);
14+
}
15+
}
16+
17+
public static function register()
18+
{
19+
Event::listen(GenerateEarlyHints::class, static::class);
20+
}
21+
}

src/Listeners/AddFromBody.php

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
namespace JustBetter\Http3EarlyHints\Listeners;
4+
5+
use Fig\Link\Link;
6+
use Illuminate\Support\Collection;
7+
use Illuminate\Support\Facades\Event;
8+
use Illuminate\Support\Str;
9+
use JustBetter\Http3EarlyHints\Events\GenerateEarlyHints;
10+
use Symfony\Component\DomCrawler\Crawler;
11+
use Symfony\Component\HttpFoundation\Response;
12+
13+
class AddFromBody
14+
{
15+
protected ?Crawler $crawler;
16+
17+
public function handle(GenerateEarlyHints $event)
18+
{
19+
$excludeKeywords = array_filter(config('http3earlyhints.exclude_keywords', []));
20+
$headers = $this->fetchLinkableNodes($event->response)
21+
->flatMap(function ($element) {
22+
[$src, $href, $data, $rel, $type] = $element;
23+
$rel = $type === 'module' ? 'modulepreload' : $rel;
24+
25+
return [
26+
$this->buildLinkHeader($src ?? '', $rel ?? null),
27+
$this->buildLinkHeader($href ?? '', $rel ?? null),
28+
$this->buildLinkHeader($data ?? '', $rel ?? null),
29+
];
30+
})
31+
->filter(function (?Link $value) use ($excludeKeywords) {
32+
if (! $value) {
33+
return false;
34+
}
35+
$exclude_keywords = collect($excludeKeywords)->map(function ($keyword) {
36+
return preg_quote($keyword);
37+
});
38+
if ($exclude_keywords->count() <= 0) {
39+
return true;
40+
}
41+
42+
return ! preg_match('%('.$exclude_keywords->implode('|').')%i', $value->getHref());
43+
});
44+
45+
$event->linkHeaders->addLink($headers->toArray());
46+
}
47+
48+
49+
/**
50+
* Get the DomCrawler instance.
51+
*/
52+
protected function getCrawler(Response $response): Crawler
53+
{
54+
return $this->crawler ??= new Crawler($response->getContent());
55+
}
56+
57+
/**
58+
* Get all nodes we are interested in pushing.
59+
*/
60+
protected function fetchLinkableNodes(Response $response): Collection
61+
{
62+
$crawler = $this->getCrawler($response);
63+
64+
return collect($crawler->filter('link:not([rel*="icon"]):not([rel="canonical"]):not([rel="manifest"]):not([rel="alternate"]), script[src], *:not(picture)>img[src]:not([loading="lazy"]), object[data]')->extract(['src', 'href', 'data', 'rel', 'type']));
65+
}
66+
67+
/**
68+
* Build out header string based on asset extension.
69+
*/
70+
private function buildLinkHeader(string $url, ?string $rel = 'preload'): ?Link
71+
{
72+
$linkTypeMap = [
73+
'.CSS' => 'style',
74+
'.JS' => 'script',
75+
'.BMP' => 'image',
76+
'.GIF' => 'image',
77+
'.JPG' => 'image',
78+
'.JPEG' => 'image',
79+
'.PNG' => 'image',
80+
'.SVG' => 'image',
81+
'.TIFF' => 'image',
82+
'.WEBP' => 'image',
83+
'.WOFF' => 'font',
84+
'.WOFF2' => 'font',
85+
];
86+
87+
if (!$url) {
88+
return null;
89+
}
90+
91+
$type = collect($linkTypeMap)->first(function ($type, $extension) use ($url) {
92+
return Str::contains(strtoupper($url), $extension);
93+
});
94+
95+
if (! preg_match('%^(https?:)?//%i', $url)) {
96+
$basePath = config('http3earlyhints.base_path', '/');
97+
$url = rtrim($basePath.ltrim($url, $basePath), '/');
98+
}
99+
100+
if (! in_array($rel, ['preload', 'modulepreload', 'preconnect'])) {
101+
$rel = 'preload';
102+
}
103+
104+
$link = new Link($rel, $url);
105+
106+
if ($rel === 'preconnect' && $url) {
107+
return $link;
108+
}
109+
110+
$link = $link->withAttribute('as', $type ?? 'fetch');
111+
if ($type === 'font') {
112+
$link = $link->withAttribute('crossorigin', true);
113+
}
114+
115+
return $link;
116+
}
117+
118+
public static function register()
119+
{
120+
Event::listen(GenerateEarlyHints::class, static::class);
121+
}
122+
}

0 commit comments

Comments
 (0)