fix: add link rel=preload for exported sites

This commit is contained in:
Nolan Lawson
2019-02-15 23:52:30 -08:00
parent 82e637ea7c
commit 351ab13d29
17 changed files with 358 additions and 30 deletions

48
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "sapper",
"version": "0.26.0-alpha.5",
"version": "0.26.0-alpha.6",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -2366,7 +2366,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@@ -2387,12 +2388,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -2407,17 +2410,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@@ -2534,7 +2540,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@@ -2546,6 +2553,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -2560,6 +2568,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -2567,12 +2576,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@@ -2591,6 +2602,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -2671,7 +2683,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@@ -2683,6 +2696,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@@ -2768,7 +2782,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -2804,6 +2819,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -2823,6 +2839,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -2866,12 +2883,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -3093,6 +3112,11 @@
"uglify-js": "3.4.x"
}
},
"http-link-header": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.0.2.tgz",
"integrity": "sha512-z6YOZ8ZEnejkcCWlGZzYXNa6i+ZaTfiTg3WhlV/YvnNya3W/RbX1bMVUMTuCrg/DrtTCQxaFCkXCz4FtLpcebg=="
},
"https-browserify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",

View File

@@ -19,6 +19,7 @@
},
"dependencies": {
"html-minifier": "^3.5.21",
"http-link-header": "^1.0.2",
"shimport": "0.0.14",
"source-map-support": "^0.5.10",
"sourcemap-codec": "^1.4.4",

View File

@@ -9,6 +9,7 @@ import clean_html from './utils/clean_html';
import minify_html from './utils/minify_html';
import Deferred from './utils/Deferred';
import { noop } from './utils/noop';
import { parse as parseLinkHeader } from 'http-link-header';
type Opts = {
build_dir?: string,
@@ -21,6 +22,12 @@ type Opts = {
onfile?: ({ file, size, status }: { file: string, size: number, status: number }) => void;
};
type Ref = {
uri: string,
rel: string,
as: string
};
function resolve(from: string, to: string) {
return url.parse(url.resolve(from, to));
}
@@ -139,38 +146,49 @@ async function _export({
clearTimeout(the_timeout); // prevent it hanging at the end
let type = r.headers.get('Content-Type');
let body = await r.text();
const range = ~~(r.status / 100);
if (range === 2) {
if (type === 'text/html' && pathname !== '/service-worker-index.html') {
const cleaned = clean_html(body);
if (type === 'text/html') {
// parse link rel=preload headers and embed them in the HTML
let link = parseLinkHeader(r.headers.get('Link') || '');
link.refs.forEach((ref: Ref) => {
if (ref.rel === 'preload') {
body = body.replace('</head>',
`<link rel="preload" as=${JSON.stringify(ref.as)} href=${JSON.stringify(ref.uri)}></head>`)
}
});
if (pathname !== '/service-worker-index.html') {
const cleaned = clean_html(body);
const q = yootils.queue(8);
let promise;
const q = yootils.queue(8);
let promise;
const base_match = /<base ([\s\S]+?)>/m.exec(cleaned);
const base_href = base_match && get_href(base_match[1]);
const base = resolve(url.href, base_href);
const base_match = /<base ([\s\S]+?)>/m.exec(cleaned);
const base_href = base_match && get_href(base_match[1]);
const base = resolve(url.href, base_href);
let match;
let pattern = /<a ([\s\S]+?)>/gm;
let match;
let pattern = /<a ([\s\S]+?)>/gm;
while (match = pattern.exec(cleaned)) {
const attrs = match[1];
const href = get_href(attrs);
while (match = pattern.exec(cleaned)) {
const attrs = match[1];
const href = get_href(attrs);
if (href) {
const url = resolve(base.href, href);
if (href) {
const url = resolve(base.href, href);
if (url.protocol === protocol && url.host === host) {
promise = q.add(() => handle(url));
if (url.protocol === protocol && url.host === host) {
promise = q.add(() => handle(url));
}
}
}
}
await promise;
await promise;
}
}
}

View File

@@ -0,0 +1,9 @@
import * as sapper from '@sapper/app';
window.start = () => sapper.start({
target: document.querySelector('#sapper')
});
window.prefetchRoutes = () => sapper.prefetchRoutes();
window.prefetch = href => sapper.prefetch(href);
window.goto = href => sapper.goto(href);

View File

@@ -0,0 +1,3 @@
<h1>{status}</h1>
<p>{error.message}</p>

View File

@@ -0,0 +1,13 @@
<script context="module">
export function preload({ params }) {
return this.fetch(`blog/${params.slug}.json`).then(r => r.json()).then(post => {
return { post };
});
}
</script>
<script>
export let post;
</script>
<h1>{post.title}</h1>

View File

@@ -0,0 +1,19 @@
import posts from './_posts.js';
export function get(req, res) {
const post = posts.find(post => post.slug === req.params.slug);
if (post) {
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify(post));
} else {
res.writeHead(404, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify({ message: 'not found' }));
}
}

View File

@@ -0,0 +1,5 @@
export default [
{ slug: 'foo', title: 'once upon a foo' },
{ slug: 'bar', title: 'a bar is born' },
{ slug: 'baz', title: 'bazzily ever after' }
];

View File

@@ -0,0 +1,17 @@
<script context="module">
export function preload() {
return this.fetch('blog.json').then(r => r.json()).then(posts => {
return { posts };
});
}
</script>
<script>
export let posts;
</script>
<h1>blog</h1>
{#each posts as post}
<p><a href="blog/{post.slug}">{post.title}</a></p>
{/each}

View File

@@ -0,0 +1,9 @@
import posts from './_posts.js';
export function get(req, res) {
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify(posts));
}

View File

@@ -0,0 +1,4 @@
<h1>Great success!</h1>
<a href="blog">blog</a>
<a href="">empty anchor</a>

View File

@@ -0,0 +1,15 @@
import sirv from 'sirv';
import polka from 'polka';
import * as sapper from '@sapper/server';
const { PORT, NODE_ENV } = process.env;
const dev = NODE_ENV === 'development';
polka()
.use(
sirv('static', { dev }),
sapper.middleware()
)
.listen(PORT, err => {
if (err) console.log('error', err);
});

View File

@@ -0,0 +1,82 @@
import * as sapper from '@sapper/service-worker';
const ASSETS = `cache${sapper.timestamp}`;
// `shell` is an array of all the files generated by webpack,
// `files` is an array of everything in the `static` directory
const to_cache = sapper.shell.concat(sapper.files);
const cached = new Set(to_cache);
self.addEventListener('install', event => {
event.waitUntil(
caches
.open(ASSETS)
.then(cache => cache.addAll(to_cache))
.then(() => {
self.skipWaiting();
})
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(async keys => {
// delete old caches
for (const key of keys) {
if (key !== ASSETS) await caches.delete(key);
}
self.clients.claim();
})
);
});
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// don't try to handle e.g. data: URIs
if (!url.protocol.startsWith('http')) return;
// ignore dev server requests
if (url.hostname === self.location.hostname && url.port !== self.location.port) return;
// always serve assets and webpack-generated files from cache
if (url.host === self.location.host && cached.has(url.pathname)) {
event.respondWith(caches.match(event.request));
return;
}
// for pages, you might want to serve a shell `index.html` file,
// which Sapper has generated for you. It's not right for every
// app, but if it's right for yours then uncomment this section
/*
if (url.origin === self.origin && routes.find(route => route.pattern.test(url.pathname))) {
event.respondWith(caches.match('/index.html'));
return;
}
*/
if (event.request.cache === 'only-if-cached') return;
// for everything else, try the network first, falling back to
// cache if the user is offline. (If the pages never change, you
// might prefer a cache-first approach to a network-first one.)
event.respondWith(
caches
.open(`offline${sapper.timestamp}`)
.then(async cache => {
try {
const response = await fetch(event.request);
cache.put(event.request, response.clone());
return response;
} catch(err) {
const response = await cache.match(event.request);
if (response) return response;
throw err;
}
})
);
});

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset='utf-8'>
%sapper.base%
%sapper.styles%
%sapper.head%
</head>
<body>
<div id='sapper'>%sapper.html%</div>
%sapper.scripts%
</body>
</html>

View File

@@ -0,0 +1,3 @@
body {
font-family: 'Comic Sans MS';
}

View File

@@ -0,0 +1,19 @@
import * as assert from 'assert';
import * as api from '../../../api';
import * as fs from 'fs';
describe('export-webpack', function() {
this.timeout(10000);
// hooks
before(async () => {
await api.build({ cwd: __dirname, bundler: 'webpack' });
await api.export({ cwd: __dirname, bundler: 'webpack' });
});
it('injects <link rel=preload> tags', () => {
const index = fs.readFileSync(`${__dirname}/__sapper__/export/index.html`, 'utf8');
assert.ok(/rel=preload/, index);
});
});

View File

@@ -0,0 +1,73 @@
const webpack = require('webpack');
const config = require('../../../config/webpack.js');
const mode = process.env.NODE_ENV;
const dev = mode === 'development';
module.exports = {
client: {
entry: config.client.entry(),
output: config.client.output(),
resolve: {
extensions: ['.mjs', '.js', '.json', '.html', '.svelte'],
mainFields: ['svelte', 'module', 'browser', 'main']
},
module: {
rules: [
{
test: /\.(html|svelte)$/,
use: {
loader: 'svelte-loader',
options: {
dev,
hydratable: true,
hotReload: true
}
}
}
]
},
mode,
plugins: [
dev && new webpack.HotModuleReplacementPlugin(),
new webpack.DefinePlugin({
'process.browser': true,
'process.env.NODE_ENV': JSON.stringify(mode)
}),
].filter(Boolean),
devtool: dev ? 'inline-source-map' : 'source-map'
},
server: {
entry: config.server.entry(),
output: config.server.output(),
target: 'node',
resolve: {
extensions: ['.mjs', '.js', '.json', '.html', '.svelte'],
mainFields: ['svelte', 'module', 'browser', 'main']
},
module: {
rules: [
{
test: /\.(html|svelte)$/,
use: {
loader: 'svelte-loader',
options: {
css: false,
generate: 'ssr',
dev
}
}
}
]
},
mode: process.env.NODE_ENV
},
serviceworker: {
entry: config.serviceworker.entry(),
output: config.serviceworker.output(),
mode: process.env.NODE_ENV,
devtool: 'sourcemap'
}
};