diff --git a/package-lock.json b/package-lock.json index 2b2b8cf7d..6c1660426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1986,7 +1986,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -2007,12 +2008,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" @@ -2027,17 +2030,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", @@ -2154,7 +2160,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -2166,6 +2173,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -2180,6 +2188,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -2187,12 +2196,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" @@ -2211,6 +2222,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -2291,7 +2303,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -2303,6 +2316,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -2388,7 +2402,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -2424,6 +2439,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", @@ -2443,6 +2459,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -2486,12 +2503,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 } } }, @@ -2687,6 +2706,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", diff --git a/package.json b/package.json index 59048042f..41b28fb78 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "html-minifier": "^3.5.21", + "http-link-header": "^1.0.2", "shimport": "0.0.14", "sourcemap-codec": "^1.4.4", "string-hash": "^1.1.3" diff --git a/src/api/export.ts b/src/api/export.ts index cfdfe5ef3..793a10211 100644 --- a/src/api/export.ts +++ b/src/api/export.ts @@ -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'; import { rimraf, copy, mkdirp } from './utils/fs_utils'; type Opts = { @@ -22,6 +23,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)); } @@ -137,38 +144,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('', + ``) + } + }); + 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 = //m.exec(cleaned); - const base_href = base_match && get_href(base_match[1]); - const base = resolve(url.href, base_href); + const base_match = //m.exec(cleaned); + const base_href = base_match && get_href(base_match[1]); + const base = resolve(url.href, base_href); - let match; - let pattern = //gm; + let match; + let pattern = //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; + } } } diff --git a/test/apps/export-webpack/src/client.js b/test/apps/export-webpack/src/client.js new file mode 100644 index 000000000..6cce7e658 --- /dev/null +++ b/test/apps/export-webpack/src/client.js @@ -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); \ No newline at end of file diff --git a/test/apps/export-webpack/src/routes/_error.svelte b/test/apps/export-webpack/src/routes/_error.svelte new file mode 100644 index 000000000..4cd55d28d --- /dev/null +++ b/test/apps/export-webpack/src/routes/_error.svelte @@ -0,0 +1,3 @@ +

{status}

+ +

{error.message}

\ No newline at end of file diff --git a/test/apps/export-webpack/src/routes/blog/[slug].html b/test/apps/export-webpack/src/routes/blog/[slug].html new file mode 100644 index 000000000..2febdf20d --- /dev/null +++ b/test/apps/export-webpack/src/routes/blog/[slug].html @@ -0,0 +1,13 @@ + + + + +

{post.title}

\ No newline at end of file diff --git a/test/apps/export-webpack/src/routes/blog/[slug].json.js b/test/apps/export-webpack/src/routes/blog/[slug].json.js new file mode 100644 index 000000000..66781ad28 --- /dev/null +++ b/test/apps/export-webpack/src/routes/blog/[slug].json.js @@ -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' })); + } +} \ No newline at end of file diff --git a/test/apps/export-webpack/src/routes/blog/_posts.js b/test/apps/export-webpack/src/routes/blog/_posts.js new file mode 100644 index 000000000..d283aa6cf --- /dev/null +++ b/test/apps/export-webpack/src/routes/blog/_posts.js @@ -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' } +]; \ No newline at end of file diff --git a/test/apps/export-webpack/src/routes/blog/index.html b/test/apps/export-webpack/src/routes/blog/index.html new file mode 100644 index 000000000..d12e4321d --- /dev/null +++ b/test/apps/export-webpack/src/routes/blog/index.html @@ -0,0 +1,17 @@ + + + + +

blog

+ +{#each posts as post} +

{post.title}

+{/each} \ No newline at end of file diff --git a/test/apps/export-webpack/src/routes/blog/index.json.js b/test/apps/export-webpack/src/routes/blog/index.json.js new file mode 100644 index 000000000..0097f7793 --- /dev/null +++ b/test/apps/export-webpack/src/routes/blog/index.json.js @@ -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)); +} \ No newline at end of file diff --git a/test/apps/export-webpack/src/routes/index.svelte b/test/apps/export-webpack/src/routes/index.svelte new file mode 100644 index 000000000..cdeb5a8e1 --- /dev/null +++ b/test/apps/export-webpack/src/routes/index.svelte @@ -0,0 +1,4 @@ +

Great success!

+ +blog +empty anchor \ No newline at end of file diff --git a/test/apps/export-webpack/src/server.js b/test/apps/export-webpack/src/server.js new file mode 100644 index 000000000..2c6932da6 --- /dev/null +++ b/test/apps/export-webpack/src/server.js @@ -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); + }); diff --git a/test/apps/export-webpack/src/service-worker.js b/test/apps/export-webpack/src/service-worker.js new file mode 100644 index 000000000..8adb97a43 --- /dev/null +++ b/test/apps/export-webpack/src/service-worker.js @@ -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; + } + }) + ); +}); diff --git a/test/apps/export-webpack/src/template.html b/test/apps/export-webpack/src/template.html new file mode 100644 index 000000000..0eb1f3ba4 --- /dev/null +++ b/test/apps/export-webpack/src/template.html @@ -0,0 +1,14 @@ + + + + + + %sapper.base% + %sapper.styles% + %sapper.head% + + +
%sapper.html%
+ %sapper.scripts% + + diff --git a/test/apps/export-webpack/static/global.css b/test/apps/export-webpack/static/global.css new file mode 100644 index 000000000..800f57ad1 --- /dev/null +++ b/test/apps/export-webpack/static/global.css @@ -0,0 +1,3 @@ +body { + font-family: 'Comic Sans MS'; +} \ No newline at end of file diff --git a/test/apps/export-webpack/test.ts b/test/apps/export-webpack/test.ts new file mode 100644 index 000000000..44abb78c6 --- /dev/null +++ b/test/apps/export-webpack/test.ts @@ -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 tags', () => { + const index = fs.readFileSync(`${__dirname}/__sapper__/export/index.html`, 'utf8'); + assert.ok(/rel=preload/.test(index)); + }); + +}); diff --git a/test/apps/export-webpack/webpack.config.js b/test/apps/export-webpack/webpack.config.js new file mode 100644 index 000000000..a8b950fbc --- /dev/null +++ b/test/apps/export-webpack/webpack.config.js @@ -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' + } +};