Files
sapper/src/middleware/index.ts
2018-02-16 12:01:55 -05:00

252 lines
7.0 KiB
TypeScript

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<string, string>;
}
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<string, string>, 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<string, string>, 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', `</client/${chunks.main}>;rel="preload";as="script", </client/${chunks[route.id]}>;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 = `<script src='/client/${chunks.main}'></script>`;
if (serialized) {
return `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${main}`;
}
return main;
}),
html: promise.then(({ rendered }) => rendered.html),
head: promise.then(({ rendered }) => `<noscript id='sapper-head-start'></noscript>${rendered.head}<noscript id='sapper-head-end'></noscript>`),
styles: promise.then(({ rendered }) => (rendered.css && rendered.css.code ? `<style>${rendered.css.code}</style>` : ''))
});
} else {
const { html, head, css } = mod.render(data);
const page = template.render({
scripts: `<script src='/client/${chunks.main}'></script>`,
html,
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
styles: (css && css.code ? `<style>${css.code}</style>` : '')
});
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: `<script src='/client/${chunks.main}'></script>`,
html,
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
styles: (css && css.code ? `<style>${css.code}</style>` : '')
}));
}
};
}
function get_not_found_handler(chunks: Record<string, string>, 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: `<script src='/client/${chunks.main}'></script>`,
html,
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
styles: (css && css.code ? `<style>${css.code}</style>` : '')
}));
};
}
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;
}
}