import * as fs from 'fs'; import * as path from 'path'; import mkdirp from 'mkdirp'; import rimraf from 'rimraf'; import serialize from 'serialize-javascript'; import escape_html from 'escape-html'; import { create_routes, templates, create_compilers, create_template } from 'sapper/core.js'; import { dest, entry, isDev, src } from '../config'; import { Route, Template } from '../interfaces'; const dev = isDev(); type Assets = { index: string; service_worker: string; client: Record; } export default function middleware({ routes }: { routes: Route[] }) { const client_info = JSON.parse(fs.readFileSync(path.join(dest, 'client_info.json'), 'utf-8')); const assets: Assets = { index: try_read(path.join(dest, 'index.html')), service_worker: try_read(path.join(dest, 'service-worker.js')), client: fs.readdirSync(path.join(dest, 'client')).reduce((lookup: Record, file: string) => { lookup[file] = try_read(path.join(dest, 'client', file)); return lookup; }, {}) }; const template = create_template(); const middleware = compose_handlers([ set_req_pathname, get_asset_handler({ filter: (pathname: string) => pathname === '/index.html', type: 'text/html', cache: 'max-age=600', fn: () => assets.index }), get_asset_handler({ filter: (pathname: string) => pathname === '/service-worker.js', type: 'application/javascript', cache: 'max-age=600', fn: () => assets.service_worker }), get_asset_handler({ filter: (pathname: string) => pathname.startsWith('/client/'), type: 'application/javascript', cache: 'max-age=31536000', fn: (pathname: string) => assets.client[pathname.replace('/client/', '')] }), get_route_handler(client_info.assetsByChunkName, () => assets, () => routes, () => template), get_not_found_handler(client_info.assetsByChunkName, () => routes, () => template) ]); // here for API consistency between dev, and prod, but // doesn't actually need to do anything middleware.close = () => {}; return middleware; } 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.setHeader('Content-Type', opts.type); res.setHeader('Cache-Control', opts.cache); res.end(opts.fn(req.pathname)); }; } const resolved = Promise.resolve(); function get_route_handler(chunks: Record, get_assets: () => Assets, get_routes: () => Route[], get_template: () => Template) { function handle_route(route, req, res, next, { client }) { req.params = route.params(route.pattern.exec(req.pathname)); const mod = route.module; if (route.type === 'page') { // for page routes, we're going to serve some HTML res.setHeader('Content-Type', 'text/html'); // preload main.js and current route // TODO detect other stuff we can preload? images, CSS, fonts? res.setHeader('Link', `;rel="preload";as="script", ;rel="preload";as="script"`); const data = { params: req.params, query: req.query }; const template = get_template(); 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 template.stream(res, { 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 = template.render({ 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; const routes = get_routes(); try { for (const route of routes) { if (route.pattern.test(url)) return handle_route(route, req, res, next, get_assets()); } // no matching route — 404 next(); } catch (error) { res.statusCode = 500; res.setHeader('Content-Type', 'text/html'); const route = get_routes().find((route: Route) => route.pattern.test('/5xx')); const rendered = route ? route.module.render({ status: 500, error }) : { head: '', css: '', html: 'Not found' }; const { head, css, html } = rendered; res.end(get_template().render({ scripts: ``, html, head: `${head}`, styles: (css && css.code ? `` : '') })); } }; } function get_not_found_handler(chunks: Record, get_routes: () => Route[], get_template: () => Template) { return function handle_not_found(req, res) { res.statusCode = 404; res.setHeader('Content-Type', 'text/html'); const route = get_routes().find((route: Route) => route.pattern.test('/4xx')); // TODO separate 4xx and 5xx out const rendered = route ? route.module.render({ status: 404, message: 'Not found' }) : { head: '', css: '', html: 'Not found' }; const { head, css, html } = rendered; res.end(get_template().render({ scripts: ``, html, head: `${head}`, styles: (css && css.code ? `` : '') })); }; } 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: string) { return JSON.parse(fs.readFileSync(file, 'utf-8')); } function try_serialize(data: any) { try { return serialize(data); } catch (err) { return null; } } function try_read(file: string) { try { return fs.readFileSync(file, 'utf-8'); } catch (err) { return null; } }