From e4936375db8ecfc30fabef26b817b62912cdf5cd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 16 Dec 2017 20:10:49 -0500 Subject: [PATCH] support HMR --- lib/index.js | 191 +++++++++++++++--------- lib/utils/create_app.js | 4 +- lib/utils/create_compiler.js | 257 ++++++++++++++++---------------- package-lock.json | 149 ++++++++++++++++++- package.json | 7 +- runtime/app.js | 275 ++++++++++++++++++----------------- webpack/hmr.js | 1 + 7 files changed, 546 insertions(+), 338 deletions(-) create mode 100644 webpack/hmr.js diff --git a/lib/index.js b/lib/index.js index eb8667b..8fa8552 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,6 +3,7 @@ const path = require('path'); const glob = require('glob'); const rimraf = require('rimraf'); const mkdirp = require('mkdirp'); +const webpack = require('webpack'); const create_routes = require('./utils/create_routes.js'); const templates = require('./templates.js'); const create_app = require('./utils/create_app.js'); @@ -20,15 +21,65 @@ module.exports = function connect(opts) { create_app(src, dest, routes, opts); + const client = webpack( + require(path.resolve('webpack.client.config.js')) + ); + + const server = webpack( + require(path.resolve('webpack.server.config.js')) + ); + const compiler = create_compiler( + client, + server, dest, routes, dev ); - return async function(req, res, next) { - const url = req.url.replace(/\?.+/, ''); + const dev_middleware = dev ? require('webpack-dev-middleware')(client, { + noInfo: true, + logLevel: 'silent', + publicPath: '/client/' + }) : null; + const hot_middleware = dev ? require('webpack-hot-middleware')(client, { + reload: true, + path: '/__webpack_hmr', + heartbeat: 10 * 1000 + }) : null; + + async function handle_webpack_generated_files(url, req, res, next) { + if (dev) { + dev_middleware(req, res, () => { + hot_middleware(req, res, next); + }); + } else { + if (url.startsWith('/client/')) { + await compiler.ready; + res.set({ + 'Content-Type': 'application/javascript', + 'Cache-Control': 'max-age=31536000' + }); + res.end(compiler.asset_cache[url]); + } + } + } + + async function handle_index(url, req, res, next) { + if (url === '/index.html') { + await compiler.ready; + res.set({ + 'Content-Type': 'text/html', + 'Cache-Control': dev ? 'no-cache' : 'max-age=600' + }); + res.end(compiler.shell); + } else { + next(); + } + } + + async function handle_service_worker(url, req, res, next) { if (url === '/service-worker.js') { await compiler.ready; res.set({ @@ -36,94 +87,90 @@ module.exports = function connect(opts) { 'Cache-Control': dev ? 'no-cache' : 'max-age=600' }); res.end(compiler.service_worker); + } else { + next(); } + } - else if (url === '/index.html') { - await compiler.ready; - res.set({ - 'Content-Type': 'text/html', - 'Cache-Control': dev ? 'no-cache' : 'max-age=600' - }); - res.end(compiler.shell); - } + async function handle_route(url, req, res) { + // whatever happens, we're going to serve some HTML + res.set({ + 'Content-Type': 'text/html' + }); - else if (url.startsWith('/client/')) { - await compiler.ready; - res.set({ - 'Content-Type': 'application/javascript', - 'Cache-Control': 'max-age=31536000' - }); - res.end(compiler.asset_cache[url]); - } + try { + for (const route of routes) { + if (route.test(url)) { + await compiler.ready; - else { - // whatever happens, we're going to serve some HTML - res.set({ - 'Content-Type': 'text/html' - }); + req.params = route.exec(url); - try { - for (const route of routes) { - if (route.test(url)) { - await compiler.ready; + const mod = require(compiler.server_routes)[route.id]; - req.params = route.exec(url); + if (route.type === 'page') { + let data = { params: req.params, query: req.query }; + if (mod.preload) data = Object.assign(data, await mod.preload(data)); - const mod = require(compiler.server_routes)[route.id]; + const { html, head, css } = mod.render(data); - if (route.type === 'page') { - let data = { params: req.params, query: req.query }; - if (mod.preload) data = Object.assign(data, await mod.preload(data)); + const page = templates.render(200, { + main: compiler.client_main, + html, + head: `${head}`, + styles: (css && css.code ? `` : '') + }); - const { html, head, css } = mod.render(data); + res.status(200); + res.end(page); + } - const page = templates.render(200, { - main: compiler.client_main, - html, - head: `${head}`, - styles: (css && css.code ? `` : '') - }); + else { + const handler = mod[req.method.toLowerCase()]; + if (handler) { + if (handler.length === 2) { + handler(req, res); + } else { + const data = await handler(req); - res.status(200); - res.end(page); - } - - else { - const handler = mod[req.method.toLowerCase()]; - if (handler) { - if (handler.length === 2) { - handler(req, res); + // TODO headers, error handling + if (typeof data === 'string') { + res.end(data); } else { - const data = await handler(req); - - // TODO headers, error handling - if (typeof data === 'string') { - res.end(data); - } else { - res.end(JSON.stringify(data)); - } + res.end(JSON.stringify(data)); } } } - - return; } - } - res.status(404).end(templates.render(404, { - title: 'Not found', - status: 404, - method: req.method, - url - })); - } catch(err) { - res.status(500).end(templates.render(500, { - title: (err && err.name) || 'Internal server error', - url, - error: escape_html(err && (err.details || err.message || err) || 'Unknown error'), - stack: err && err.stack.split('\n').slice(1).join('\n') - })); + return; + } } + + res.status(404).end(templates.render(404, { + title: 'Not found', + status: 404, + method: req.method, + url + })); + } catch(err) { + res.status(500).end(templates.render(500, { + title: (err && err.name) || 'Internal server error', + url, + error: escape_html(err && (err.details || err.message || err) || 'Unknown error'), + stack: err && err.stack.split('\n').slice(1).join('\n') + })); } + } + + return async function(req, res, next) { + const url = req.url.replace(/\?.+/, ''); + + handle_index(url, req, res, () => { + handle_service_worker(url, req, res, () => { + handle_webpack_generated_files(url, req, res, () => { + handle_route(url, req, res); + }); + }); + }); }; }; \ No newline at end of file diff --git a/lib/utils/create_app.js b/lib/utils/create_app.js index 864b415..e9ff19c 100644 --- a/lib/utils/create_app.js +++ b/lib/utils/create_app.js @@ -25,7 +25,7 @@ module.exports = function create_app(src, dest, routes, options) { // need to fudge the mtime, because webpack is soft in the head const stats = fs.statSync(main_built); - fs.utimesSync(main_built, stats.atimeMs - 9999, stats.mtimeMs - 9999); + fs.utimesSync(main_built, stats.atimeMs - 999999, stats.mtimeMs - 999999); } function create_server_routes() { @@ -42,7 +42,7 @@ module.exports = function create_app(src, dest, routes, options) { fs.writeFileSync(server_routes, `${imports}\n\n${exports}`); const stats = fs.statSync(server_routes); - fs.utimesSync(server_routes, stats.atimeMs - 9999, stats.mtimeMs - 9999); + fs.utimesSync(server_routes, stats.atimeMs - 999999, stats.mtimeMs - 999999); } // TODO in dev mode, watch files diff --git a/lib/utils/create_compiler.js b/lib/utils/create_compiler.js index 0957055..1a4fe33 100644 --- a/lib/utils/create_compiler.js +++ b/lib/utils/create_compiler.js @@ -1,141 +1,148 @@ const fs = require('fs'); const path = require('path'); const glob = require('glob'); -const webpack = require('webpack'); -const hot_middleware = require('webpack-hot-middleware'); +const chalk = require('chalk'); const { dev } = require('../config.js'); const templates = require('../templates.js'); -module.exports = function create_webpack_compiler(dest, routes, dev) { +module.exports = function create_compiler(client, server, dest, routes, dev) { const compiler = {}; - function go() { - const client = webpack( - require(path.resolve('webpack.client.config.js')) - ); + function client_updated(stats) { + console.log(stats.toString({ colors: true })); - const server = webpack( - require(path.resolve('webpack.server.config.js')) - ); + const info = stats.toJson(); - function client_updated(err, stats, reject) { - console.log(stats.toString({ colors: true })); + compiler.client_main = `/client/${info.assetsByChunkName.main}`; + compiler.assets = info.assets.map(asset => `/client/${asset.name}`); - const info = stats.toJson(); - - if (err || stats.hasErrors()) { - reject(err || info.errors[0]); - } - - compiler.client_main = `/client/${info.assetsByChunkName.main}`; - compiler.assets = info.assets.map(asset => `/client/${asset.name}`); - - compiler.asset_cache = {}; - compiler.assets.forEach(file => { - compiler.asset_cache[file] = fs.readFileSync(path.join(dest, file), 'utf-8'); - }); - } - - function server_updated(err, stats, reject) { - console.log(stats.toString({ colors: true })); - - const info = stats.toJson(); - - if (err || stats.hasErrors()) { - reject(err || info.errors[0]); - } - - compiler.server_routes = path.resolve(dest, 'server', info.assetsByChunkName.server_routes); - compiler.chunks = info.assetsByChunkName; - } - - function both_updated() { - const assets = glob.sync('**', { cwd: 'assets', nodir: true }); - - const route_code = `[${ - routes - .filter(route => route.type === 'page') - .map(route => `{ pattern: ${route.pattern} }`) - .join(', ') - }]`; - - compiler.service_worker = fs.readFileSync('templates/service-worker.js', 'utf-8') - .replace('__timestamp__', Date.now()) - .replace('__assets__', JSON.stringify(assets)) - .replace('__shell__', JSON.stringify(compiler.assets.concat('/index.html'))) - .replace('__routes__', route_code); - - compiler.shell = templates.render(200, { - styles: '', - head: '', - html: '', - main: compiler.client_main - }); - - // useful for debugging, but the files are served from memory - fs.writeFileSync(path.resolve(dest, 'service-worker.js'), compiler.service_worker); - fs.writeFileSync(path.resolve(dest, 'index.html'), compiler.shell); - } - - if (dev) { - compiler.hot_middleware = hot_middleware(client); - - let client_is_ready = false; - let server_is_ready = false; - - let fulfil; - let reject; - - const invalidate = () => new Promise((f, r) => { - fulfil = f; - reject = r; - }); - - compiler.ready = invalidate(); - - client.plugin('invalid', () => { - client_is_ready = false; - compiler.ready = invalidate(); - }); - - server.plugin('invalid', () => { - server_is_ready = false; - compiler.ready = invalidate(); - }); - - client.watch({}, (err, stats) => { - client_updated(err, stats, reject); - client_is_ready = true; - if (server_is_ready) fulfil(); - }); - - server.watch({}, (err, stats) => { - server_updated(err, stats, reject); - server_is_ready = true; - if (client_is_ready) fulfil(); - }); - } else { - compiler.ready = Promise.all([ - new Promise((fulfil, reject) => { - client.run((err, stats) => { - client_updated(err, stats, reject); - fulfil(); - }); - }), - - new Promise((fulfil, reject) => { - server.run((err, stats) => { - server_updated(err, stats, reject); - fulfil(); - }); - }) - ]).then(both_updated); - } + const fs = client.outputFileSystem; + compiler.asset_cache = {}; + compiler.assets.forEach(file => { + compiler.asset_cache[file] = fs.readFileSync(path.join(dest, file), 'utf-8'); + }); } - // TODO rerun go when routes are added/renamed - // (or webpack config/templates change?) - go(); + function server_updated(stats) { + console.log(stats.toString({ colors: true })); + + const info = stats.toJson(); + compiler.server_routes = path.resolve(dest, 'server', info.assetsByChunkName.server_routes); + compiler.chunks = info.assetsByChunkName; + } + + function both_updated() { + const assets = glob.sync('**', { cwd: 'assets', nodir: true }); + + const route_code = `[${ + routes + .filter(route => route.type === 'page') + .map(route => `{ pattern: ${route.pattern} }`) + .join(', ') + }]`; + + compiler.service_worker = fs.readFileSync('templates/service-worker.js', 'utf-8') + .replace('__timestamp__', Date.now()) + .replace('__assets__', JSON.stringify(assets)) + .replace('__shell__', JSON.stringify(compiler.assets.concat('/index.html'))) + .replace('__routes__', route_code); + + compiler.shell = templates.render(200, { + styles: '', + head: '', + html: '', + main: compiler.client_main + }); + + // useful for debugging, but the files are served from memory + fs.writeFileSync(path.resolve(dest, 'service-worker.js'), compiler.service_worker); + fs.writeFileSync(path.resolve(dest, 'index.html'), compiler.shell); + } + + if (dev) { + let client_is_ready = false; + let server_is_ready = false; + + let fulfil; + let reject; + + const invalidate = () => new Promise((f, r) => { + fulfil = f; + reject = r; + }); + + compiler.ready = invalidate(); + + client.plugin('invalid', filename => { + console.log(chalk.red(`client bundle invalidated, file changed: ${chalk.bold(filename)}`)); + client_is_ready = false; + compiler.ready = invalidate(); + }); + + client.plugin('done', stats => { + if (stats.hasErrors()) { + reject(stats.toJson().errors[0]); + } else { + client_updated(stats); + } + + client_is_ready = true; + if (server_is_ready) fulfil(); + }); + + client.plugin('failed', reject); + + server.plugin('invalid', filename => { + console.log(chalk.red(`server bundle invalidated, file changed: ${chalk.bold(filename)}`)); + server_is_ready = false; + compiler.ready = invalidate(); + }); + + server.plugin('done', stats => { + if (stats.hasErrors()) { + reject(stats.toJson().errors[0]); + } else { + server_updated(stats); + } + + server_is_ready = true; + if (client_is_ready) fulfil(); + }); + + server.plugin('failed', reject); + + // client is already being watched by the middleware, + // so we only need to start the server compiler + server.watch({}, (err, stats) => { + if (stats.hasErrors()) { + reject(stats.toJson().errors[0]); + } else { + server_updated(stats); + server_is_ready = true; + if (client_is_ready) fulfil(); + } + }); + } else { + compiler.ready = Promise.all([ + new Promise((fulfil, reject) => { + client.run((err, stats) => { + if (stats.hasErrors()) { + reject(stats.toJson().errors[0]); + } else { + client_updated(stats); + } + fulfil(); + }); + }), + + new Promise((fulfil, reject) => { + server.run((err, stats) => { + server_updated(err, stats, reject); + fulfil(); + }); + }) + ]).then(both_updated); + } return compiler; }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 600114c..0be0cfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "sapper", - "version": "0.0.20", + "version": "0.0.21", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -25,9 +25,9 @@ } }, "ajv": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.1.tgz", - "integrity": "sha1-s4u4h22ehr7plJVqBOch6IskjrI=", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", "requires": { "co": "4.6.0", "fast-deep-equal": "1.0.0", @@ -50,11 +50,24 @@ "repeat-string": "1.6.1" } }, + "ansi-html": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz", + "integrity": "sha1-gTWEAhliqenm/QOflA0S9WynhZ4=" + }, "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" }, + "ansi-styles": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.0.tgz", + "integrity": "sha512-NnSOmMEYtVR2JVMIGTzynRkkaxtiq1xnFBcdQD/DnNCYPoEPsVJhM98BDyaoNOQIi7p4okdi3E27eN7GQbsUug==", + "requires": { + "color-convert": "1.9.1" + } + }, "anymatch": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz", @@ -77,6 +90,11 @@ "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==" }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=" + }, "array-unique": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", @@ -271,6 +289,16 @@ "lazy-cache": "1.0.4" } }, + "chalk": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.0.tgz", + "integrity": "sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==", + "requires": { + "ansi-styles": "3.2.0", + "escape-string-regexp": "1.0.5", + "supports-color": "4.5.0" + } + }, "chokidar": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", @@ -316,6 +344,19 @@ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, + "color-convert": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.1.tgz", + "integrity": "sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, "commander": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", @@ -406,6 +447,14 @@ "randomfill": "1.0.3" } }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "requires": { + "array-find-index": "1.0.2" + } + }, "d": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", @@ -581,8 +630,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escope": { "version": "3.6.0", @@ -1606,6 +1654,11 @@ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.5.0.tgz", "integrity": "sha512-pNgbURSuab90KbTqvRPsseaTxOJCZBD0a7t+haSN33piP9cCM4l0CqdzAif2hUqm716UovKB2ROmiabGAKVXyg==" }, + "html-entities": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.2.1.tgz", + "integrity": "sha1-DfKTUfByEWNRXfueVUPl9u7VFi8=" + }, "https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", @@ -1827,11 +1880,38 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" }, + "log-symbols": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.1.0.tgz", + "integrity": "sha512-zLeLrzMA1A2vRF1e/0Mo+LNINzi6jzBylHj5WqvQ/WK/5WCZt8si9SyN4p9llr/HRYvVR1AoXHRHl4WTHyQAzQ==", + "requires": { + "chalk": "2.3.0" + } + }, + "loglevel": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.0.tgz", + "integrity": "sha1-rgyqVhERSYxboTcj1vtjHSQAOTQ=" + }, + "loglevel-plugin-prefix": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/loglevel-plugin-prefix/-/loglevel-plugin-prefix-0.5.3.tgz", + "integrity": "sha512-zRAJw3WYCQAJ6xfEIi04/oqlmR6jkwg3hmBcMW82Zic3iPWyju1gwntcgic0m5NgqYNJ62alCmb0g/div26WjQ==" + }, "longest": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "requires": { + "currently-unhandled": "0.4.1", + "signal-exit": "3.0.2" + } + }, "lru-cache": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.1.tgz", @@ -1907,6 +1987,11 @@ "brorand": "1.1.0" } }, + "mime": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.0.3.tgz", + "integrity": "sha512-TrpAd/vX3xaLPDgVRm6JkZwLR0KHfukMdU2wTEbqMDdCnY6Yo3mE+mjs9YE6oMNw2QRfXVeBEYpmpO94BIqiug==" + }, "mimic-fn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.1.0.tgz", @@ -2288,6 +2373,11 @@ "safe-buffer": "5.1.1" } }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + }, "read-pkg": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", @@ -2559,6 +2649,11 @@ "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.8.tgz", "integrity": "sha1-mTcqXJmb8t8WCvwNdL7U9HlIzSI=" }, + "time-stamp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-2.0.0.tgz", + "integrity": "sha1-lcakRTDhW6jW9KPsuMOj+sRto1c=" + }, "timers-browserify": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.4.tgz", @@ -2632,6 +2727,11 @@ } } }, + "url-join": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-2.0.2.tgz", + "integrity": "sha1-wHJ1aWetJLi1nldBVRyqx49QuLc=" + }, "util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", @@ -2652,6 +2752,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "uuid": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", + "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + }, "validate-npm-package-license": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz", @@ -2686,7 +2791,7 @@ "requires": { "acorn": "5.2.1", "acorn-dynamic-import": "2.0.2", - "ajv": "5.5.1", + "ajv": "5.5.2", "ajv-keywords": "2.1.1", "async": "2.6.0", "enhanced-resolve": "3.4.1", @@ -2708,6 +2813,36 @@ "yargs": "8.0.2" } }, + "webpack-dev-middleware": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-2.0.1.tgz", + "integrity": "sha512-jEQgJK+eblBzE4blKmNuJqNd4cz3t4K3mFmN6uZz4Iq44x2vc1r+CwZBgcX+GzQoSOk5iWSVB3bIN5AYKpFRTw==", + "requires": { + "chalk": "2.3.0", + "log-symbols": "2.1.0", + "loglevel": "1.6.0", + "loglevel-plugin-prefix": "0.5.3", + "loud-rejection": "1.6.0", + "memory-fs": "0.4.1", + "mime": "2.0.3", + "path-is-absolute": "1.0.1", + "range-parser": "1.2.0", + "time-stamp": "2.0.0", + "url-join": "2.0.2", + "uuid": "3.1.0" + } + }, + "webpack-hot-middleware": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.21.0.tgz", + "integrity": "sha512-P6xiOLy10QlSVSO7GanU9PLxN6zLLQ7RG16MPTvmFwf2KUG7jMp6m+fmdgsR7xoaVVLA7OlX3YO6JjoZEKjCuA==", + "requires": { + "ansi-html": "0.0.7", + "html-entities": "1.2.1", + "querystring": "0.2.0", + "strip-ansi": "3.0.1" + } + }, "webpack-sources": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.1.0.tgz", diff --git a/package.json b/package.json index 8dc2d6e..8456e13 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,18 @@ "name": "sapper", "version": "0.0.21", "description": "Combat-ready apps, engineered by Svelte", - "main": "connect.js", + "main": "lib/index.js", "directories": { "test": "test" }, "dependencies": { + "chalk": "^2.3.0", "escape-html": "^1.0.3", "mkdirp": "^0.5.1", "rimraf": "^2.6.2", - "webpack": "^3.10.0" + "webpack": "^3.10.0", + "webpack-dev-middleware": "^2.0.1", + "webpack-hot-middleware": "^2.21.0" }, "devDependencies": { "mocha": "^4.0.1" diff --git a/runtime/app.js b/runtime/app.js index 3e7a876..494dfbf 100644 --- a/runtime/app.js +++ b/runtime/app.js @@ -3,6 +3,8 @@ const detach = node => { }; let component; +let target; +let routes; const scroll_history = {}; let uid = 1; @@ -12,153 +14,166 @@ if ('scrollRestoration' in history) { history.scrollRestoration = 'manual' } -const app = { - init(target, routes) { - function select_route(url) { - if (url.origin !== window.location.origin) return null; +function select_route(url) { + if (url.origin !== window.location.origin) return null; - for (const route of routes) { - const match = route.pattern.exec(url.pathname); - if (match) { - const params = route.params(match); + for (const route of routes) { + const match = route.pattern.exec(url.pathname); + if (match) { + const params = route.params(match); - const query = {}; - for (const [key, value] of url.searchParams) query[key] = value || true; + const query = {}; + for (const [key, value] of url.searchParams) query[key] = value || true; - return { route, data: { params, query } }; - } - } + return { route, data: { params, query } }; } + } +} - function render(Component, data, scroll) { - Promise.resolve( - Component.preload ? Component.preload(data) : {} - ).then(preloaded => { - if (component) { - component.destroy(); - } else { - // first load — remove SSR'd contents - const start = document.querySelector('#sapper-head-start'); - let end = document.querySelector('#sapper-head-end'); +function render(Component, data, scroll) { + Promise.resolve( + Component.preload ? Component.preload(data) : {} + ).then(preloaded => { + if (component) { + component.destroy(); + } else { + // first load — remove SSR'd contents + const start = document.querySelector('#sapper-head-start'); + let end = document.querySelector('#sapper-head-end'); - if (start && end) { - while (start.nextSibling !== end) detach(start.nextSibling); - detach(start); - detach(end); - } - - // preload additional routes - routes.reduce((promise, route) => promise.then(route.load), Promise.resolve()); - } - - component = new Component({ - target, - data: Object.assign(data, preloaded), - hydrate: !!component - }); - - if (scroll) { - window.scrollTo(scroll.x, scroll.y); - } - }); - } - - function navigate(url, id) { - const selected = select_route(url); - if (selected) { - if (id) { - // popstate or initial navigation - cid = id; - } else { - // clicked on a link. preserve scroll state - scroll_history[cid] = scroll_state(); - - id = cid = ++uid; - scroll_history[cid] = { x: 0, y: 0 }; - - history.pushState({ id }, '', url.href); - } - - selected.route.load().then(mod => { - render(mod.default, selected.data, scroll_history[id]); - }); - - cid = id; - return true; - } - } - - function findAnchor(node) { - while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG elements have a lowercase name - return node; - } - - window.addEventListener('click', event => { - // Adapted from https://github.com/visionmedia/page.js - // MIT license https://github.com/visionmedia/page.js#license - if (which(event) !== 1) return; - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - if (event.defaultPrevented) return; - - const a = findAnchor(event.target); - if (!a) return; - - // check if link is inside an svg - // in this case, both href and target are always inside an object - const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString'; - const href = svg ? a.href.baseVal : a.href; - - if (href === window.location.href) { - event.preventDefault(); - return; + if (start && end) { + while (start.nextSibling !== end) detach(start.nextSibling); + detach(start); + detach(end); } - // Ignore if tag has - // 1. 'download' attribute - // 2. rel='external' attribute - if (a.hasAttribute('download') || a.getAttribute('rel') === 'external') return; + // preload additional routes + routes.reduce((promise, route) => promise.then(route.load), Promise.resolve()); + } - // Ignore if has a target - if (svg ? a.target.baseVal : a.target) return; - - const url = new URL(href); - - // Don't handle hash changes - if (url.pathname === window.location.pathname && url.search === window.location.search) return; - - if (navigate(url, null)) { - event.preventDefault(); - } + component = new Component({ + target, + data: Object.assign(data, preloaded), + hydrate: !!component }); - function preload(event) { - const a = findAnchor(event.target); - if (!a || a.rel !== 'prefetch') return; - - const selected = select_route(new URL(a.href)); - - if (selected) { - selected.route.load().then(mod => { - if (mod.default.preload) mod.default.preload(selected.data); - }); - } + if (scroll) { + window.scrollTo(scroll.x, scroll.y); } + }); +} - window.addEventListener('touchstart', preload); - window.addEventListener('mouseover', preload); - - window.addEventListener('popstate', event => { +function navigate(url, id) { + const selected = select_route(url); + if (selected) { + if (id) { + // popstate or initial navigation + cid = id; + } else { + // clicked on a link. preserve scroll state scroll_history[cid] = scroll_state(); - if (event.state) { - navigate(new URL(window.location), event.state.id); - } else { - // hashchange - cid = ++uid; - history.replaceState({ id: cid }, '', window.location.href); - } + id = cid = ++uid; + scroll_history[cid] = { x: 0, y: 0 }; + + history.pushState({ id }, '', url.href); + } + + selected.route.load().then(mod => { + render(mod.default, selected.data, scroll_history[id]); }); + cid = id; + return true; + } +} + +function handle_click(event) { + // Adapted from https://github.com/visionmedia/page.js + // MIT license https://github.com/visionmedia/page.js#license + if (which(event) !== 1) return; + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + if (event.defaultPrevented) return; + + const a = findAnchor(event.target); + if (!a) return; + + // check if link is inside an svg + // in this case, both href and target are always inside an object + const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString'; + const href = svg ? a.href.baseVal : a.href; + + if (href === window.location.href) { + event.preventDefault(); + return; + } + + // Ignore if tag has + // 1. 'download' attribute + // 2. rel='external' attribute + if (a.hasAttribute('download') || a.getAttribute('rel') === 'external') return; + + // Ignore if has a target + if (svg ? a.target.baseVal : a.target) return; + + const url = new URL(href); + + // Don't handle hash changes + if (url.pathname === window.location.pathname && url.search === window.location.search) return; + + if (navigate(url, null)) { + event.preventDefault(); + } +} + +function handle_popstate(event) { + scroll_history[cid] = scroll_state(); + + if (event.state) { + navigate(new URL(window.location), event.state.id); + } else { + // hashchange + cid = ++uid; + history.replaceState({ id: cid }, '', window.location.href); + } +} + +function prefetch(event) { + const a = findAnchor(event.target); + if (!a || a.rel !== 'prefetch') return; + + const selected = select_route(new URL(a.href)); + + if (selected) { + selected.route.load().then(mod => { + if (mod.default.preload) mod.default.preload(selected.data); + }); + } +} + +function findAnchor(node) { + while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG elements have a lowercase name + return node; +} + +let inited; + +const app = { + init(_target, _routes) { + target = _target; + routes = _routes; + + if (!inited) { // this check makes HMR possible + window.addEventListener('click', handle_click); + window.addEventListener('popstate', handle_popstate); + + // prefetch + window.addEventListener('touchstart', prefetch); + window.addEventListener('mouseover', prefetch); + + inited = true; + } + const scroll = scroll_history[uid] = scroll_state(); history.replaceState({ id: uid }, '', window.location.href); diff --git a/webpack/hmr.js b/webpack/hmr.js new file mode 100644 index 0000000..39cb639 --- /dev/null +++ b/webpack/hmr.js @@ -0,0 +1 @@ +import 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000'; \ No newline at end of file