const fs = require('fs'); const path = require('path'); const serialize = require('serialize-javascript'); const route_manager = require('./route_manager.js'); const templates = require('./templates.js'); const create_app = require('./utils/create_app.js'); const create_watcher = require('./utils/create_watcher.js'); const compilers = require('./utils/compilers.js'); const generate_asset_cache = require('./utils/generate_asset_cache.js'); const escape_html = require('escape-html'); const { dest, dev } = require('./config.js'); function connect_dev() { create_app(); const watcher = create_watcher(); let asset_cache; const middleware = compose_handlers([ require('webpack-hot-middleware')(compilers.client, { reload: true, path: '/__webpack_hmr', heartbeat: 10 * 1000 }), (req, res, next) => { watcher.ready.then(cache => { asset_cache = cache; next(); }); }, set_req_pathname, get_asset_handler({ filter: pathname => pathname === '/index.html', type: 'text/html', cache: 'max-age=600', fn: () => asset_cache.client.index }), get_asset_handler({ filter: pathname => pathname === '/service-worker.js', type: 'application/javascript', cache: 'max-age=600', fn: () => asset_cache.client.service_worker }), get_asset_handler({ filter: pathname => pathname.startsWith('/client/'), type: 'application/javascript', cache: 'max-age=31536000', fn: pathname => asset_cache.client.chunks[pathname] }), get_route_handler(() => asset_cache), get_not_found_handler(() => asset_cache) ]); middleware.close = () => { watcher.close(); // TODO shut down chokidar }; return middleware; } function connect_prod() { const asset_cache = generate_asset_cache( read_json(path.join(dest, 'stats.client.json')), read_json(path.join(dest, 'stats.server.json')) ); const middleware = compose_handlers([ set_req_pathname, get_asset_handler({ filter: pathname => pathname === '/index.html', type: 'text/html', cache: 'max-age=600', fn: () => asset_cache.client.index }), get_asset_handler({ filter: pathname => pathname === '/service-worker.js', type: 'application/javascript', cache: 'max-age=600', fn: () => asset_cache.client.service_worker }), get_asset_handler({ filter: pathname => pathname.startsWith('/client/'), type: 'application/javascript', cache: 'max-age=31536000', fn: pathname => asset_cache.client.chunks[pathname] }), get_route_handler(() => asset_cache), get_not_found_handler(() => asset_cache) ]); // here for API consistency between dev, and prod, but // doesn't actually need to do anything middleware.close = () => {}; return middleware; } module.exports = dev ? connect_dev : connect_prod; function set_req_pathname(req, res, next) { req.pathname = req.url.replace(/\?.+/, ''); next(); } function get_asset_handler(opts) { return (req, res, next) => { if (!opts.filter(req.pathname)) return next(); res.set({ 'Content-Type': opts.type, 'Cache-Control': opts.cache }); res.end(opts.fn(req.pathname)); }; } const resolved = Promise.resolve(); function get_route_handler(fn) { function handle_route(route, req, res, next, { client, server }) { req.params = route.exec(req.pathname); const mod = require(server.entry)[route.id]; if (route.type === 'page') { // preload main.js and current route // TODO detect other stuff we can preload? images, CSS, fonts? res.set('Link', `<${client.main_file}>;rel="preload";as="script", <${client.routes[route.id]}>;rel="preload";as="script"`); const data = { params: req.params, query: req.query }; if (mod.preload) { const promise = Promise.resolve(mod.preload(req)).then(preloaded => { const serialized = try_serialize(preloaded); Object.assign(data, preloaded); return { rendered: mod.render(data), serialized }; }); return templates.stream(res, 200, { scripts: promise.then(({ serialized }) => { const main = ``; if (serialized) { return `${main}`; } return main; }), html: promise.then(({ rendered }) => rendered.html), head: promise.then(({ rendered }) => `${rendered.head}`), styles: promise.then(({ rendered }) => (rendered.css && rendered.css.code ? `` : '')) }); } else { const { html, head, css } = mod.render(data); const page = templates.render(200, { scripts: ``, html, head: `${head}`, styles: (css && css.code ? `` : '') }); res.end(page); } } else { const method = req.method.toLowerCase(); // 'delete' cannot be exported from a module because it is a keyword, // so check for 'del' instead const method_export = method === 'delete' ? 'del' : method; const handler = mod[method_export]; if (handler) { handler(req, res, next); } else { // no matching handler for method — 404 next(); } } } return function find_route(req, res, next) { const url = req.pathname; // whatever happens, we're going to serve some HTML res.set({ 'Content-Type': 'text/html' }); resolved .then(() => { for (const route of route_manager.routes) { if (route.test(url)) return handle_route(route, req, res, next, fn()); } // no matching route — 404 next(); }) .catch(err => { res.status(500); res.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') })); }); }; } function get_not_found_handler(fn) { return function handle_not_found(req, res) { const asset_cache = fn(); res.status(404); res.end(templates.render(404, { title: 'Not found', status: 404, method: req.method, scripts: ``, url: req.url })); }; } function compose_handlers(handlers) { return (req, res, next) => { let i = 0; function go() { const handler = handlers[i]; if (handler) { handler(req, res, () => { i += 1; go(); }); } else { next(); } } go(); }; } function read_json(file) { return JSON.parse(fs.readFileSync(file, 'utf-8')); } function try_serialize(data) { try { return serialize(data); } catch (err) { return null; } }