work in progress

This commit is contained in:
Rich Harris
2018-02-16 12:01:55 -05:00
parent 9a760c570f
commit f9828f9fd2
36 changed files with 667 additions and 7791 deletions

View File

@@ -2,14 +2,9 @@ import * as fs from 'fs';
import * as path from 'path';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import { create_compilers, create_app, create_assets } from 'sapper/core.js';
import { create_compilers, create_app, create_routes, create_serviceworker } from 'sapper/core.js';
export default function build({
src,
dest,
dev,
entry
}: {
export default async function build({ src, dest, dev, entry }: {
src: string;
dest: string;
dev: boolean;
@@ -18,11 +13,32 @@ export default function build({
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
// create main.js and server-routes.js
create_app({ dev, entry, src });
const routes = create_routes({ src });
// create app/manifest/client.js and app/manifest/server.js
create_app({ routes, src, dev });
const { client, server, serviceworker } = create_compilers();
const client_stats = await compile(client);
fs.writeFileSync(path.join(dest, 'client_info.json'), JSON.stringify(client_stats.toJson()));
await compile(server);
if (serviceworker) {
create_serviceworker({
routes,
client_files: client_stats.toJson().assets.map((chunk: { name: string }) => `/client/${chunk.name}`),
src
});
await compile(serviceworker);
}
}
function compile(compiler: any) {
return new Promise((fulfil, reject) => {
function handleErrors(err, stats) {
compiler.run((err: Error, stats: any) => {
if (err) {
reject(err);
process.exit(1);
@@ -32,29 +48,10 @@ export default function build({
console.error(stats.toString({ colors: true }));
reject(new Error(`Encountered errors while building app`));
}
}
const { client, server } = create_compilers();
client.run((err, client_stats) => {
handleErrors(err, client_stats);
const client_info = client_stats.toJson();
fs.writeFileSync(
path.join(dest, 'stats.client.json'),
JSON.stringify(client_info, null, ' ')
);
server.run((err, server_stats) => {
handleErrors(err, server_stats);
const server_info = server_stats.toJson();
fs.writeFileSync(
path.join(dest, 'stats.server.json'),
JSON.stringify(server_info, null, ' ')
);
create_assets({ src, dest, dev, client_info, server_info });
fulfil();
});
else {
fulfil(stats);
}
});
});
}
}

View File

@@ -1,3 +1,4 @@
import * as child_process from 'child_process';
import * as path from 'path';
import * as sander from 'sander';
import express from 'express';
@@ -6,30 +7,20 @@ import fetch from 'node-fetch';
import URL from 'url-parse';
import { create_assets } from 'sapper/core.js';
const { PORT = 3000, OUTPUT_DIR = 'dist' } = process.env;
const origin = `http://localhost:${PORT}`;
const { OUTPUT_DIR = 'dist' } = process.env;
const app = express();
function read_json(file) {
function read_json(file: string) {
return JSON.parse(sander.readFileSync(file, { encoding: 'utf-8' }));
}
export default function exporter({ src, dest }) { // TODO dest is a terrible name in this context
export default async function exporter({ src, dest }) { // TODO dest is a terrible name in this context
// Prep output directory
sander.rimrafSync(OUTPUT_DIR);
const { service_worker } = create_assets({
src, dest,
dev: false,
client_info: read_json(path.join(dest, 'stats.client.json')),
server_info: read_json(path.join(dest, 'stats.server.json'))
});
sander.copydirSync('assets').to(OUTPUT_DIR);
sander.copydirSync(dest, 'client').to(OUTPUT_DIR, 'client');
sander.writeFileSync(OUTPUT_DIR, 'service-worker.js', service_worker);
// Intercept server route fetches
function save(res) {
@@ -48,9 +39,13 @@ export default function exporter({ src, dest }) { // TODO dest is a terrible nam
});
}
const port = await require('get-port')(3000);
const origin = `http://localhost:${port}`;
global.fetch = (url, opts) => {
if (url[0] === '/') {
url = `http://localhost:${PORT}${url}`;
url = `http://localhost:${port}${url}`;
return fetch(url, opts)
.then(r => {
@@ -62,9 +57,15 @@ export default function exporter({ src, dest }) { // TODO dest is a terrible nam
return fetch(url, opts);
};
const middleware = require('./middleware')({ dev: false }); // TODO this is filthy
app.use(middleware);
const server = app.listen(PORT);
const proc = child_process.fork(path.resolve(`${dest}/server.js`), [], {
cwd: process.cwd(),
env: {
PORT: port,
NODE_ENV: 'production'
}
});
await require('wait-port')({ port });
const seen = new Set();
@@ -99,5 +100,5 @@ export default function exporter({ src, dest }) { // TODO dest is a terrible nam
}
return handle(new URL(origin)) // TODO all static routes
.then(() => server.close());
.then(() => proc.kill());
}

View File

@@ -1,86 +1,73 @@
import * as fs from 'fs';
import * as path from 'path';
import mkdirp from 'mkdirp';
import create_routes from './create_routes';
import { fudge_mtime, posixify, write } from './utils';
import { Route } from '../interfaces';
function posixify(file: string) {
return file.replace(/[/\\]/g, '/');
}
function fudge_mtime(file: string) {
// need to fudge the mtime so that webpack doesn't go doolally
const { atime, mtime } = fs.statSync(file);
fs.utimesSync(
file,
new Date(atime.getTime() - 999999),
new Date(mtime.getTime() - 999999)
);
}
function create_app({ src, dev, entry }: {
export default function create_app({ routes, src, dev }: {
routes: Route[];
src: string;
dev: boolean;
entry: { client: string; server: string };
}) {
const routes = create_routes({ src });
mkdirp.sync('app/manifest');
function create_client_main() {
const code = `[${routes
.filter(route => route.type === 'page')
.map(route => {
const params =
route.dynamic.length === 0
write('app/manifest/client.js', generate_client(routes, src, dev));
write('app/manifest/server.js', generate_server(routes, src));
}
function generate_client(routes: Route[], src: string, dev: boolean) {
let code = `
// This file is generated by Sapper — do not edit it!\nexport const routes = [
${routes
.filter(route => route.type === 'page')
.map(route => {
const params = route.dynamic.length === 0
? '{}'
: `{ ${route.dynamic
.map((part, i) => `${part}: match[${i + 1}]`)
.join(', ')} }`;
: `{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
const file = posixify(`${src}/${route.file}`);
return `{ pattern: ${
route.pattern
}, params: match => (${params}), load: () => import(/* webpackChunkName: "${
route.id
}" */ '${file}') }`;
})
.join(', ')}]`;
const file = posixify(`../../routes/${route.file}`);
return `{ pattern: ${route.pattern}, params: ${route.dynamic.length > 0 ? `match` : `()`} => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`;
})
.join(',\n\t')}
];`.replace(/^\t\t/gm, '').trim();
let main = fs
.readFileSync('templates/main.js', 'utf-8')
.replace(
/__app__/g,
posixify(path.resolve(__dirname, '../../runtime/app.js'))
)
.replace(/__routes__/g, code)
.replace(/__dev__/g, String(dev));
if (dev) {
const hmr_client = posixify(
require.resolve(`webpack-hot-middleware/client`)
);
if (dev) {
const hmr_client = posixify(
require.resolve(`webpack-hot-middleware/client`)
);
main += `\n\nimport('${hmr_client}?path=/__webpack_hmr&timeout=20000'); if (module.hot) module.hot.accept();`;
}
fs.writeFileSync(entry.client, main);
fudge_mtime(entry.client);
code += `\n\nimport('${hmr_client}?path=/__webpack_hmr&timeout=20000'); if (module.hot) module.hot.accept();`;
}
function create_server_routes() {
const imports = routes
return code;
}
function generate_server(routes: Route[], src: string) {
let code = `
// This file is generated by Sapper — do not edit it!
${routes
.map(route => {
const file = posixify(`${src}/${route.file}`);
const file = posixify(`../../routes/${route.file}`);
return route.type === 'page'
? `import ${route.id} from '${file}';`
: `import * as ${route.id} from '${file}';`;
})
.join('\n');
.join('\n')}
const exports = `export { ${routes.map(route => route.id)} };`;
export const routes = [
${routes
.map(route => {
const params = route.dynamic.length === 0
? '{}'
: `{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
fs.writeFileSync(entry.server, `${imports}\n\n${exports}`);
fudge_mtime(entry.server);
}
const file = posixify(`${src}/${route.file}`);
return `{ id: '${route.id}', type: '${route.type}', pattern: ${route.pattern}, params: ${route.dynamic.length > 0 ? `match` : `()`} => (${params}), module: ${route.id} }`;
})
.join(',\n\t')
}
];`.replace(/^\t\t/gm, '').trim();
create_client_main();
create_server_routes();
}
export default create_app;
return code;
}

View File

@@ -1,98 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import glob from 'glob';
import { create_templates, render } from './templates';
import create_routes from './create_routes';
function ensure_array(thing: any) {
return Array.isArray(thing) ? thing : [thing]; // omg webpack what the HELL are you doing
}
type WebpackInfo = {
assetsByChunkName: Record<string, string>;
assets: Array<{ name: string }>
}
export default function create_assets({ src, dest, dev, client_info, server_info }: {
src: string;
dest: string;
dev: boolean;
client_info: WebpackInfo;
server_info: WebpackInfo;
}) {
create_templates(); // TODO refactor this...
const main_file = `/client/${ensure_array(client_info.assetsByChunkName.main)[0]}`;
const chunk_files = client_info.assets.map(chunk => `/client/${chunk.name}`);
const service_worker = generate_service_worker(chunk_files, src);
const index = generate_index(main_file);
const routes = create_routes({ src });
if (dev) { // TODO move this into calling code
fs.writeFileSync(path.join(dest, 'service-worker.js'), service_worker);
fs.writeFileSync(path.join(dest, 'index.html'), index);
}
return {
client: {
main_file,
chunk_files,
main: read(`${dest}${main_file}`),
chunks: chunk_files.reduce((lookup: Record<string, string>, file) => {
lookup[file] = read(`${dest}${file}`);
return lookup;
}, {}),
// TODO confusing that `routes` refers to an array *and* a lookup
routes: routes.reduce((lookup: Record<string, string>, route) => {
lookup[route.id] = `/client/${ensure_array(client_info.assetsByChunkName[route.id])[0]}`;
return lookup;
}, {}),
index,
service_worker
},
server: {
entry: path.resolve(dest, 'server', server_info.assetsByChunkName.main)
},
service_worker
};
}
function generate_service_worker(chunk_files: string[], src: string) {
const assets = glob.sync('**', { cwd: 'assets', nodir: true });
const routes = create_routes({ src });
const route_code = `[${
routes
.filter(route => route.type === 'page')
.map(route => `{ pattern: ${route.pattern} }`)
.join(', ')
}]`;
return read('templates/service-worker.js')
.replace(/__timestamp__/g, String(Date.now()))
.replace(/__assets__/g, JSON.stringify(assets))
.replace(/__shell__/g, JSON.stringify(chunk_files.concat('/index.html')))
.replace(/__routes__/g, route_code);
}
function generate_index(main_file: string) {
return render(200, {
styles: '',
head: '',
html: '<noscript>Please enable JavaScript!</noscript>',
main: main_file
});
}
function read(file: string) {
return fs.readFileSync(file, 'utf-8');
}

View File

@@ -4,6 +4,8 @@ import relative from 'require-relative';
export default function create_compilers() {
const webpack = relative('webpack', process.cwd());
const serviceworker_config = try_require(path.resolve('webpack/service-worker.config.js'));
return {
client: webpack(
require(path.resolve('webpack/client.config.js'))
@@ -13,13 +15,11 @@ export default function create_compilers() {
require(path.resolve('webpack/server.config.js'))
),
serviceWorker: webpack(
tryRequire(path.resolve('webpack/server.config.js'))
)
serviceworker: serviceworker_config && webpack(serviceworker_config)
};
}
function tryRequire(specifier: string) {
function try_require(specifier: string) {
try {
return require(specifier);
} catch (err) {

View File

@@ -1,16 +1,6 @@
import * as path from 'path';
import glob from 'glob';
type Route = {
id: string;
type: 'page' | 'route';
file: string;
pattern: RegExp;
test: (url: string) => boolean;
exec: (url: string) => Record<string, string>;
parts: string[];
dynamic: string[];
}
import { Route } from '../interfaces';
export default function create_routes({ src, files = glob.sync('**/*.+(html|js|mjs)', { cwd: src }) }: {
src: string;

View File

@@ -0,0 +1,26 @@
import * as fs from 'fs';
import * as path from 'path';
import glob from 'glob';
import create_routes from './create_routes';
import { fudge_mtime, posixify, write } from './utils';
import { Route } from '../interfaces';
export default function create_serviceworker({ routes, client_files, src }: {
routes: Route[];
client_files: string[];
src: string;
}) {
const assets = glob.sync('**', { cwd: 'assets', nodir: true });
let code = `
export const timestamp = ${Date.now()};
export const assets = [\n\t${assets.map((x: string) => `"${x}"`).join(',\n\t')}\n];
export const shell = [\n\t${client_files.map((x: string) => `"${x}"`).join(',\n\t')}\n];
export const routes = [\n\t${routes.filter((r: Route) => r.type === 'page').map((r: Route) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n];
`.replace(/^\t\t/gm, '');
write('app/manifest/service-worker.js', code);
}

View File

@@ -0,0 +1,74 @@
import * as fs from 'fs';
import chalk from 'chalk';
import framer from 'code-frame';
import { locate } from 'locate-character';
function error(e: any) {
if (e.title) console.error(chalk.bold.red(e.title));
if (e.body) console.error(chalk.red(e.body));
if (e.url) console.error(chalk.cyan(e.url));
if (e.frame) console.error(chalk.grey(e.frame));
process.exit(1);
}
export default function create_templates() {
const template = fs.readFileSync(`app/template.html`, 'utf-8');
const index = template.indexOf('%sapper.main%');
if (index !== -1) {
// TODO remove this in a future version
const { line, column } = locate(template, index, { offsetLine: 1 });
const frame = framer(template, line, column);
error({
title: `app/template.html`,
body: `<script src='%sapper.main%'> is unsupported — use %sapper.scripts% (without the <script> tag) instead`,
url: 'https://github.com/sveltejs/sapper/issues/86',
frame
});
}
return {
render: (data: Record<string, string>) => {
return template.replace(/%sapper\.(\w+)%/g, (match, key) => {
return key in data ? data[key] : '';
});
},
stream: (res: any, data: Record<string, string | Promise<string>>) => {
let i = 0;
function stream_inner(): Promise<void> {
if (i >= template.length) {
return;
}
const start = template.indexOf('%sapper', i);
if (start === -1) {
res.end(template.slice(i));
return;
}
res.write(template.slice(i, start));
const end = template.indexOf('%', start + 1);
if (end === -1) {
throw new Error(`Bad template`); // TODO validate ahead of time
}
const tag = template.slice(start + 1, end);
const match = /sapper\.(\w+)/.exec(tag);
if (!match || !(match[1] in data)) throw new Error(`Bad template`); // TODO ditto
return Promise.resolve(data[match[1]]).then(datamatch => {
res.write(datamatch);
i = end + 1;
return stream_inner();
});
}
return Promise.resolve().then(stream_inner);
}
};
}

View File

@@ -1,8 +1,5 @@
import { create_templates, render, stream } from './templates'; // TODO templates is an anomaly... fix post-#91
export { default as create_app } from './create_app';
export { default as create_assets } from './create_assets';
export { default as create_serviceworker } from './create_serviceworker';
export { default as create_compilers } from './create_compilers';
export { default as create_routes } from './create_routes';
export const templates = { create_templates, render, stream };
export { default as create_template } from './create_template';

View File

@@ -1,115 +0,0 @@
import * as fs from 'fs';
import glob from 'glob';
import chalk from 'chalk';
import framer from 'code-frame';
import { locate } from 'locate-character';
let templates;
function error(e) {
if (e.title) console.error(chalk.bold.red(e.title));
if (e.body) console.error(chalk.red(e.body));
if (e.url) console.error(chalk.cyan(e.url));
if (e.frame) console.error(chalk.grey(e.frame));
process.exit(1);
}
export function create_templates() {
templates = glob.sync('*.html', { cwd: 'templates' })
.map(file => {
const template = fs.readFileSync(`templates/${file}`, 'utf-8');
const status = file.replace('.html', '').toLowerCase();
if (!/^[0-9x]{3}$/.test(status)) {
error({
title: `templates/${file}`,
body: `Bad template — should be a valid status code like 404.html, or a wildcard like 2xx.html`
});
}
const index = template.indexOf('%sapper.main%');
if (index !== -1) {
// TODO remove this in a future version
const { line, column } = locate(template, index, { offsetLine: 1 });
const frame = framer(template, line, column);
error({
title: `templates/${file}`,
body: `<script src='%sapper.main%'> is unsupported — use %sapper.scripts% (without the <script> tag) instead`,
url: 'https://github.com/sveltejs/sapper/issues/86',
frame
});
}
const specificity = (
(status[0] === 'x' ? 0 : 4) +
(status[1] === 'x' ? 0 : 2) +
(status[2] === 'x' ? 0 : 1)
);
const pattern = new RegExp(`^${status.split('').map(d => d === 'x' ? '\\d' : d).join('')}$`);
return {
test: status => pattern.test(status),
specificity,
render: data => {
return template.replace(/%sapper\.(\w+)%/g, (match, key) => {
return key in data ? data[key] : '';
});
},
stream: (res, data) => {
let i = 0;
function stream_inner() {
if (i >= template.length) {
return;
}
const start = template.indexOf('%sapper', i);
if (start === -1) {
res.end(template.slice(i));
return;
}
res.write(template.slice(i, start));
const end = template.indexOf('%', start + 1);
if (end === -1) {
throw new Error(`Bad template`); // TODO validate ahead of time
}
const tag = template.slice(start + 1, end);
const match = /sapper\.(\w+)/.exec(tag);
if (!match || !(match[1] in data)) throw new Error(`Bad template`); // TODO ditto
return Promise.resolve(data[match[1]]).then(datamatch => {
res.write(datamatch);
i = end + 1;
return stream_inner();
});
}
return Promise.resolve().then(stream_inner);
}
};
})
.sort((a, b) => b.specificity - a.specificity);
return templates;
}
export function render(status, data) {
const template = templates.find(template => template.test(status));
if (template) return template.render(data);
return `Missing template for status code ${status}`;
}
export function stream(res, status, data) {
const template = templates.find(template => template.test(status));
if (template) return template.stream(res, data);
return `Missing template for status code ${status}`;
}

20
src/core/utils.ts Normal file
View File

@@ -0,0 +1,20 @@
import * as fs from 'fs';
export function write(file: string, code: string) {
fs.writeFileSync(file, code);
fudge_mtime(file);
}
export function posixify(file: string) {
return file.replace(/[/\\]/g, '/');
}
export function fudge_mtime(file: string) {
// need to fudge the mtime so that webpack doesn't go doolally
const { atime, mtime } = fs.statSync(file);
fs.utimesSync(
file,
new Date(atime.getTime() - 999999),
new Date(mtime.getTime() - 999999)
);
}

15
src/interfaces.ts Normal file
View File

@@ -0,0 +1,15 @@
export type Route = {
id: string;
type: 'page' | 'route';
file: string;
pattern: RegExp;
test: (url: string) => boolean;
exec: (url: string) => Record<string, string>;
parts: string[];
dynamic: string[];
};
export type Template = {
render: (data: Record<string, string>) => string;
stream: (res, data: Record<string, string | Promise<string>>) => void;
};

View File

@@ -1,11 +1,17 @@
import * as fs from 'fs';
import * as path from 'path';
import chalk from 'chalk';
import { create_app, create_assets, create_routes, templates } from 'sapper/core.js';
import { create_app, create_serviceworker, create_routes, create_template } from 'sapper/core.js';
import { dest } from '../config.js';
type Deferred = {
promise?: Promise<any>;
fulfil?: (value: any) => void;
reject?: (err: Error) => void;
}
function deferred() {
const d = {};
const d: Deferred = {};
d.promise = new Promise((fulfil, reject) => {
d.fulfil = fulfil;
@@ -15,7 +21,7 @@ function deferred() {
return d;
}
export default function create_watcher({ compilers, dev, entry, src, onroutes }) {
export default function create_watcher({ compilers, dev, entry, src, onroutes, ontemplate }) {
const deferreds = {
client: deferred(),
server: deferred()
@@ -31,27 +37,29 @@ export default function create_watcher({ compilers, dev, entry, src, onroutes })
const server_info = server_stats.toJson();
fs.writeFileSync(path.join(dest, 'stats.server.json'), JSON.stringify(server_info, null, ' '));
return create_assets({
src, dest, dev,
client_info: client_stats.toJson(),
server_info: server_stats.toJson()
const client_files = client_info.assets.map((chunk: { name: string }) => `/client/${chunk.name}`);
return create_serviceworker({
routes: create_routes({ src }),
client_files,
src
});
});
function watch_compiler(type) {
function watch_compiler(type: 'client' | 'server') {
const compiler = compilers[type];
compiler.plugin('invalid', filename => {
compiler.plugin('invalid', (filename: string) => {
console.log(chalk.cyan(`${type} bundle invalidated, file changed: ${chalk.bold(filename)}`));
deferreds[type] = deferred();
watcher.ready = invalidate();
});
compiler.plugin('failed', err => {
compiler.plugin('failed', (err: Error) => {
deferreds[type].reject(err);
});
return compiler.watch({}, (err, stats) => {
return compiler.watch({}, (err: Error, stats: any) => {
if (stats.hasErrors()) {
deferreds[type].reject(stats.toJson().errors[0]);
} else {
@@ -62,7 +70,7 @@ export default function create_watcher({ compilers, dev, entry, src, onroutes })
const chokidar = require('chokidar');
function watch_files(pattern, callback) {
function watch_files(pattern: string, callback: () => void) {
const watcher = chokidar.watch(pattern, {
persistent: false
});
@@ -76,15 +84,13 @@ export default function create_watcher({ compilers, dev, entry, src, onroutes })
const routes = create_routes({ src });
onroutes(routes);
create_app({ dev, entry, src }); // TODO this calls `create_routes` again, we should pass `routes` to `create_app` instead
create_app({ routes, src, dev });
});
watch_files('templates/main.js', () => {
create_app({ dev, entry, src });
});
watch_files('app/template.html', () => {
const template = create_template();
ontemplate(template);
watch_files('templates/**.html', () => {
templates.create_templates();
// TODO reload current page?
});

View File

@@ -4,117 +4,61 @@ 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_assets } from 'sapper/core.js';
import { create_routes, templates, create_compilers, create_template } from 'sapper/core.js';
import { dest, entry, isDev, src } from '../config';
import create_watcher from './create_watcher';
import { Route, Template } from '../interfaces';
const dev = isDev();
function connect_dev() {
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
const compilers = create_compilers();
let routes;
const watcher = create_watcher({
dev, entry, src,
compilers,
onroutes: _ => {
routes = _;
}
});
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, () => routes),
get_not_found_handler(() => asset_cache)
]);
middleware.close = () => {
watcher.close();
// TODO shut down chokidar
};
return middleware;
type Assets = {
index: string;
service_worker: string;
client: Record<string, string>;
}
function connect_prod() {
const asset_cache = create_assets({
src, dest,
dev: false,
client_info: read_json(path.join(dest, 'stats.client.json')),
server_info: read_json(path.join(dest, 'stats.server.json'))
});
export default function middleware({ routes }: {
routes: Route[]
}) {
const client_info = JSON.parse(fs.readFileSync(path.join(dest, 'client_info.json'), 'utf-8'));
const routes = create_routes({ src }); // TODO rename update
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 => pathname === '/index.html',
filter: (pathname: string) => pathname === '/index.html',
type: 'text/html',
cache: 'max-age=600',
fn: () => asset_cache.client.index
fn: () => assets.index
}),
get_asset_handler({
filter: pathname => pathname === '/service-worker.js',
filter: (pathname: string) => pathname === '/service-worker.js',
type: 'application/javascript',
cache: 'max-age=600',
fn: () => asset_cache.client.service_worker
fn: () => assets.service_worker
}),
get_asset_handler({
filter: pathname => pathname.startsWith('/client/'),
filter: (pathname: string) => pathname.startsWith('/client/'),
type: 'application/javascript',
cache: 'max-age=31536000',
fn: pathname => asset_cache.client.chunks[pathname]
fn: (pathname: string) => assets.client[pathname.replace('/client/', '')]
}),
get_route_handler(() => asset_cache, () => routes),
get_route_handler(client_info.assetsByChunkName, () => assets, () => routes, () => template),
get_not_found_handler(() => asset_cache)
get_not_found_handler(client_info.assetsByChunkName, () => routes, () => template)
]);
// here for API consistency between dev, and prod, but
@@ -124,10 +68,6 @@ function connect_prod() {
return middleware;
}
export default function connect({ dev: _dev = dev } = {}) {
return _dev ? connect_dev() : connect_prod();
}
function set_req_pathname(req, res, next) {
req.pathname = req.url.replace(/\?.*/, '');
next();
@@ -146,11 +86,11 @@ function get_asset_handler(opts) {
const resolved = Promise.resolve();
function get_route_handler(get_assets, get_routes) {
function handle_route(route, req, res, next, { client, server }) {
req.params = route.exec(req.pathname);
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 = require(server.entry)[route.id];
const mod = route.module;
if (route.type === 'page') {
// for page routes, we're going to serve some HTML
@@ -158,10 +98,12 @@ function get_route_handler(get_assets, get_routes) {
// preload main.js and current route
// TODO detect other stuff we can preload? images, CSS, fonts?
res.setHeader('Link', `<${client.main_file}>;rel="preload";as="script", <${client.routes[route.id]}>;rel="preload";as="script"`);
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);
@@ -170,9 +112,9 @@ function get_route_handler(get_assets, get_routes) {
return { rendered: mod.render(data), serialized };
});
return templates.stream(res, 200, {
return template.stream(res, {
scripts: promise.then(({ serialized }) => {
const main = `<script src='${client.main_file}'></script>`;
const main = `<script src='/client/${chunks.main}'></script>`;
if (serialized) {
return `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${main}`;
@@ -187,8 +129,8 @@ function get_route_handler(get_assets, get_routes) {
} else {
const { html, head, css } = mod.render(data);
const page = templates.render(200, {
scripts: `<script src='${client.main_file}'></script>`,
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>` : '')
@@ -216,39 +158,55 @@ function get_route_handler(get_assets, get_routes) {
return function find_route(req, res, next) {
const url = req.pathname;
resolved
.then(() => {
const routes = get_routes();
for (const route of routes) {
if (route.test(url)) return handle_route(route, req, res, next, get_assets());
}
const routes = get_routes();
// no matching route — 404
next();
})
.catch(err => {
res.statusCode = 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')
}));
});
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(fn) {
function get_not_found_handler(chunks: Record<string, string>, get_routes: () => Route[], get_template: () => Template) {
return function handle_not_found(req, res) {
const asset_cache = fn();
res.statusCode = 404;
res.end(templates.render(404, {
title: 'Not found',
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,
method: req.method,
scripts: `<script src='${asset_cache.client.main_file}'></script>`,
url: req.url
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>` : '')
}));
};
}
@@ -273,14 +231,22 @@ function compose_handlers(handlers) {
};
}
function read_json(file) {
function read_json(file: string) {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
}
function try_serialize(data) {
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;
}
}

View File

@@ -7,7 +7,7 @@ export default {
entry: () => {
return {
main: [
entry.client,
'./app/client.js',
// workaround for https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/456
'style-loader/lib/addStyles',
'css-loader/lib/css-base'
@@ -28,14 +28,14 @@ export default {
server: {
entry: () => {
return {
main: entry.server
server: './app/server.js'
};
},
output: () => {
return {
path: `${dest}/server`,
filename: '[name].[hash].js',
path: `${dest}`,
filename: '[name].js',
chunkFilename: '[name].[id].[hash].js',
libraryTarget: 'commonjs2'
};