Compare commits

...

42 Commits

Author SHA1 Message Date
Rich Harris
2752c73ebb -> v0.7.2 2018-02-18 17:24:12 -05:00
Rich Harris
2547db39ac -> v0.7.1 2018-02-18 17:09:55 -05:00
Rich Harris
1285739cc5 -> v0.7.1 2018-02-18 17:08:35 -05:00
Rich Harris
14d64e854a -> v0.7.0 2018-02-18 13:00:36 -05:00
Rich Harris
c419c73550 fix tests 2018-02-18 12:55:56 -05:00
Rich Harris
835b94175d workaround weird webpack(?) bug by not deleting old files 2018-02-18 12:49:37 -05:00
Rich Harris
25bdcf9957 Merge branch 'gh-83' 2018-02-18 12:12:45 -05:00
Rich Harris
792ccf5c6a robustify dev server port selection 2018-02-18 12:12:25 -05:00
Rich Harris
4ca8195037 Merge pull request #127 from sveltejs/gh-83
implement this.redirect in preload
2018-02-18 10:36:05 -05:00
Rich Harris
cb12231053 implement this.error 2018-02-18 09:48:32 -05:00
Rich Harris
d55401d45b see if this fixes windows tests 2018-02-18 08:27:07 -05:00
Rich Harris
99d4eafb0b argh these flaky tests are killing me 2018-02-18 08:18:14 -05:00
Rich Harris
bff6f550be implement this.redirect in preload (#83) 2018-02-17 22:56:47 -05:00
Rich Harris
f8ea9ebda1 Merge pull request #126 from sveltejs/gh-91
[WIP] restructure everything
2018-02-17 19:49:12 -05:00
Rich Harris
181d7b4a61 need to come up with a more robust approach to this... 2018-02-17 19:27:34 -05:00
Rich Harris
beb415c65d argh 2018-02-17 19:22:05 -05:00
Rich Harris
5bbd7ead17 and again 2018-02-17 19:16:06 -05:00
Rich Harris
e11405d555 try increasing timeout 2018-02-17 19:12:47 -05:00
Rich Harris
9fe0ca2c22 remove 4xx and 5xx pages when initing 2018-02-17 19:00:06 -05:00
Rich Harris
f2eb95d546 various minor fixes 2018-02-17 18:40:20 -05:00
Rich Harris
ab1ca60363 basic sapper dev task, with HMR 2018-02-17 17:44:57 -05:00
Rich Harris
d95f52f8e9 fix tests, add WIP dev task 2018-02-17 11:15:26 -05:00
Rich Harris
b02183af53 fix exporting 2018-02-16 14:25:53 -05:00
Rich Harris
f9828f9fd2 work in progress 2018-02-16 12:01:55 -05:00
Rich Harris
9a760c570f move webpack configs in test app 2018-02-14 12:25:40 -05:00
Rich Harris
0f390920a8 fix some typescript stuff 2018-02-14 12:25:24 -05:00
Rich Harris
9adb6ca7e6 Merge pull request #120 from sveltejs/gh-92
implement sapper upgrade
2018-02-14 10:28:01 -05:00
Rich Harris
24980651c0 that was a weird typo 2018-02-13 22:08:29 -05:00
Rich Harris
7c6436a99c implement sapper upgrade 2018-02-13 18:46:53 -05:00
Rich Harris
f6b26f1b07 Merge pull request #119 from sveltejs/better-cli
Better CLI
2018-02-13 17:30:08 -05:00
Rich Harris
55b60369f9 add --help to CLI 2018-02-13 17:12:55 -05:00
Rich Harris
2be9dd1883 Move build.ts and export.ts into src/cli - fixes #115 2018-02-13 15:57:02 -05:00
Rich Harris
b29700f725 update electron to squelch vuln warning 2018-02-13 15:50:04 -05:00
Rich Harris
7188ce0d0d -> v0.6.4 2018-02-06 10:51:24 -05:00
Rich Harris
4f8ce19fe1 Merge pull request #114 from lukeed/fix/prod-hmr
Fix: Remove HMR attempts in Prod~!
2018-02-06 10:39:19 -05:00
Rich Harris
a85f2921e8 Merge pull request #113 from lukeed/feat/map-config
Consolidate Rollup config file
2018-02-06 10:37:34 -05:00
Luke Edwards
7a2ed16884 fix HMR attempts in production 2018-02-05 14:09:24 -08:00
Luke Edwards
08e575fee0 shrink repetitive rollup.config file 2018-02-05 13:43:10 -08:00
Rich Harris
7dbcab74d3 remove logging 2018-02-03 16:35:10 -05:00
Rich Harris
9b1b545194 -> v0.6.3 2018-02-03 16:26:53 -05:00
Rich Harris
7b01242f3e always build in prod mode 2018-02-03 16:26:47 -05:00
Rich Harris
15b1fbf8a6 ignore non-html responses when crawling, and close middleware when done 2018-02-03 16:17:28 -05:00
49 changed files with 1427 additions and 8127 deletions

View File

@@ -1,38 +0,0 @@
{
"root": true,
"rules": {
"semi": [ 2, "always" ],
"space-before-blocks": [ 2, "always" ],
"no-mixed-spaces-and-tabs": [ 2, "smart-tabs" ],
"no-cond-assign": 0,
"no-unused-vars": 2,
"object-shorthand": [ 2, "always" ],
"no-const-assign": 2,
"no-class-assign": 2,
"no-this-before-super": 2,
"no-var": 2,
"no-unreachable": 2,
"valid-typeof": 2,
"quote-props": [ 2, "as-needed" ],
"one-var": [ 2, "never" ],
"prefer-arrow-callback": 2,
"prefer-const": [ 2, { "destructuring": "all" } ],
"arrow-spacing": 2,
"no-inner-declarations": 0
},
"env": {
"es6": true,
"browser": true,
"node": true,
"mocha": true
},
"extends": [
"eslint:recommended",
"plugin:import/errors",
"plugin:import/warnings"
],
"parserOptions": {
"ecmaVersion": 8,
"sourceType": "module"
}
}

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ yarn.lock
node_modules node_modules
cypress/screenshots cypress/screenshots
test/app/.sapper test/app/.sapper
test/app/app/manifest
runtime.js runtime.js
runtime.js.map runtime.js.map
cli.js cli.js

View File

@@ -1,5 +1,30 @@
# sapper changelog # sapper changelog
## 0.7.2
* Add `hmr-client.js` to package
* Wait until first successful client build before creating service-worker.js
## 0.7.1
* Add missing `tslib` dependency
## 0.7.0
* Restructure app layout (see [migration guide](https://sapper.svelte.technology/guide#0-6-to-0-7)) ([#126](https://github.com/sveltejs/sapper/pull/126))
* Support `this.redirect(status, location)` and `this.error(status, error)` in `preload` functions ([#127](https://github.com/sveltejs/sapper/pull/127))
* Add `sapper dev` command
* Add `sapper --help` command
## 0.6.4
* Prevent phantom HMR requests in production mode ([#114](https://github.com/sveltejs/sapper/pull/114))
## 0.6.3
* Ignore non-HTML responses when crawling during `export`
* Build in prod mode for `export`
## 0.6.2 ## 0.6.2
* Handle unspecified type in `sapper export` * Handle unspecified type in `sapper export`

28
hmr-client.js Normal file
View File

@@ -0,0 +1,28 @@
let source;
function check() {
if (module.hot.status() === 'idle') {
module.hot.check(true).then(modules => {
console.log(`HMR updated`);
});
}
}
export function connect(port) {
if (source || !window.EventSource) return;
source = new EventSource(`http://localhost:${port}/hmr`);
source.onopen = function(event) {
console.log(`HMR connected`);
};
source.onmessage = function(event) {
const data = JSON.parse(event.data);
if (!data) return; // just a heartbeat
if (data.status === 'completed') {
check();
}
};
}

6979
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "sapper", "name": "sapper",
"version": "0.6.2", "version": "0.7.2",
"description": "Military-grade apps, engineered by Svelte", "description": "Military-grade apps, engineered by Svelte",
"main": "middleware.js", "main": "middleware.js",
"bin": { "bin": {
@@ -12,6 +12,7 @@
"middleware.js", "middleware.js",
"runtime", "runtime",
"runtime.js", "runtime.js",
"hmr-client.js",
"webpack" "webpack"
], ],
"directories": { "directories": {
@@ -24,42 +25,47 @@
"code-frame": "^5.0.0", "code-frame": "^5.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"express": "^4.16.2", "express": "^4.16.2",
"get-port": "^3.2.0",
"glob": "^7.1.2", "glob": "^7.1.2",
"locate-character": "^2.0.5", "locate-character": "^2.0.5",
"mime": "^2.2.0",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"mri": "^1.1.0",
"node-fetch": "^1.7.3", "node-fetch": "^1.7.3",
"relative": "^3.0.2", "relative": "^3.0.2",
"require-relative": "^0.8.7", "require-relative": "^0.8.7",
"rimraf": "^2.6.2", "rimraf": "^2.6.2",
"sander": "^0.6.0", "sander": "^0.6.0",
"serialize-javascript": "^1.4.0", "serialize-javascript": "^1.4.0",
"tslib": "^1.8.1",
"url-parse": "^1.2.0", "url-parse": "^1.2.0",
"wait-port": "^0.2.2",
"walk-sync": "^0.3.2", "walk-sync": "^0.3.2",
"webpack": "^3.10.0", "webpack": "^3.10.0"
"webpack-hot-middleware": "^2.21.0"
}, },
"devDependencies": { "devDependencies": {
"@std/esm": "^0.19.7", "@std/esm": "^0.19.7",
"@types/glob": "^5.0.34", "@types/glob": "^5.0.34",
"@types/mkdirp": "^0.5.2", "@types/mkdirp": "^0.5.2",
"@types/rimraf": "^2.0.2", "@types/rimraf": "^2.0.2",
"compression": "^1.7.1",
"css-loader": "^0.28.7", "css-loader": "^0.28.7",
"electron": "^1.8.2",
"eslint": "^4.13.1", "eslint": "^4.13.1",
"eslint-plugin-import": "^2.8.0", "eslint-plugin-import": "^2.8.0",
"get-port": "^3.2.0",
"mocha": "^4.0.1", "mocha": "^4.0.1",
"nightmare": "^2.10.0", "nightmare": "^2.10.0",
"npm-run-all": "^4.1.2", "npm-run-all": "^4.1.2",
"rollup": "^0.53.0", "rollup": "^0.53.0",
"rollup-plugin-json": "^2.3.0",
"rollup-plugin-string": "^2.0.2",
"rollup-plugin-typescript": "^0.8.1", "rollup-plugin-typescript": "^0.8.1",
"source-map-support": "^0.5.2", "source-map-support": "^0.5.2",
"style-loader": "^0.19.1", "style-loader": "^0.19.1",
"svelte": "^1.49.1", "svelte": "^1.49.1",
"svelte-loader": "^2.3.2", "svelte-loader": "^2.3.2",
"ts-node": "^4.1.0", "ts-node": "^4.1.0",
"tslib": "^1.8.1", "typescript": "^2.6.2"
"typescript": "^2.6.2",
"wait-on": "^2.0.2"
}, },
"scripts": { "scripts": {
"cy:open": "cypress open", "cy:open": "cypress open",

View File

@@ -1,4 +1,6 @@
import typescript from 'rollup-plugin-typescript'; import typescript from 'rollup-plugin-typescript';
import string from 'rollup-plugin-string';
import json from 'rollup-plugin-json';
import pkg from './package.json'; import pkg from './package.json';
const external = [].concat( const external = [].concat(
@@ -11,91 +13,31 @@ const paths = {
'sapper/core.js': './core.js' 'sapper/core.js': './core.js'
}; };
const plugins = [
string({
include: '**/*.md'
}),
json(),
typescript({
typescript: require('typescript')
})
];
export default [ export default [
// cli.js { name: 'cli', banner: true },
{ { name: 'core' },
input: 'src/cli/index.ts', { name: 'middleware' },
output: { { name: 'runtime', format: 'es' },
file: 'cli.js', { name: 'webpack', file: 'webpack/config' }
format: 'cjs', ].map(obj => ({
banner: '#!/usr/bin/env node', input: `src/${obj.name}/index.ts`,
paths, output: {
sourcemap: true file: `${obj.file || obj.name}.js`,
}, format: obj.format || 'cjs',
external, banner: obj.banner && '#!/usr/bin/env node',
plugins: [ paths,
typescript({ sourcemap: true
typescript: require('typescript')
})
]
}, },
external,
// core.js plugins
{ }));
input: 'src/core/index.ts',
output: {
file: 'core.js',
format: 'cjs',
banner: '#!/usr/bin/env node',
paths,
sourcemap: true
},
external,
plugins: [
typescript({
typescript: require('typescript')
})
]
},
// middleware.js
{
input: 'src/middleware/index.ts',
output: {
file: 'middleware.js',
format: 'cjs',
paths,
sourcemap: true
},
external,
plugins: [
typescript({
typescript: require('typescript')
})
]
},
// runtime.js
{
input: 'src/runtime/index.ts',
output: {
file: 'runtime.js',
format: 'es',
paths,
sourcemap: true
},
external,
plugins: [
typescript({
typescript: require('typescript')
})
]
},
// webpack/config.js
{
input: 'src/webpack/index.ts',
output: {
file: 'webpack/config.js',
format: 'cjs',
paths,
sourcemap: true
},
external,
plugins: [
typescript({
typescript: require('typescript')
})
]
}
];

57
src/cli/build.ts Normal file
View File

@@ -0,0 +1,57 @@
import * as fs from 'fs';
import * as path from 'path';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import { create_compilers, create_app, create_routes, create_serviceworker } from 'sapper/core.js';
export default async function build({ src, dest, dev, entry }: {
src: string;
dest: string;
dev: boolean;
entry: { client: string, server: string }
}) {
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
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) => {
compiler.run((err: Error, stats: any) => {
if (err) {
reject(err);
process.exit(1);
}
if (stats.hasErrors()) {
console.error(stats.toString({ colors: true }));
reject(new Error(`Encountered errors while building app`));
}
else {
fulfil(stats);
}
});
});
}

222
src/cli/dev.ts Normal file
View File

@@ -0,0 +1,222 @@
import * as fs from 'fs';
import * as path from 'path';
import * as net from 'net';
import * as chalk from 'chalk';
import * as child_process from 'child_process';
import * as http from 'http';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import { wait_for_port } from './utils';
import { create_compilers, create_app, create_routes, create_serviceworker, create_template } from 'sapper/core.js';
type Deferred = {
promise?: Promise<any>;
fulfil?: (value?: any) => void;
reject?: (err: Error) => void;
}
function deferred() {
const d: Deferred = {};
d.promise = new Promise((fulfil, reject) => {
d.fulfil = fulfil;
d.reject = reject;
});
return d;
}
function create_hot_update_server(port: number, interval = 10000) {
const clients = new Set();
const server = http.createServer((req, res) => {
if (req.url !== '/hmr') return;
req.socket.setKeepAlive(true);
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text/event-stream;charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
// While behind nginx, event stream should not be buffered:
// http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering
'X-Accel-Buffering': 'no'
});
res.write('\n');
clients.add(res);
req.on('close', () => {
clients.delete(res);
});
});
server.listen(port);
function send(data: any) {
clients.forEach(client => {
client.write(`data: ${JSON.stringify(data)}\n\n`);
});
}
setInterval(() => {
send(null)
}, interval);
return { send };
}
export default async function dev(src: string, dir: string) {
rimraf.sync(dir);
mkdirp.sync(dir);
const chokidar = require('chokidar');
// initial build
const dev_port = await require('get-port')(10000);
const routes = create_routes({ src });
create_app({ routes, src, dev: true, dev_port });
const hot_update_server = create_hot_update_server(dev_port);
// TODO watch the configs themselves?
const compilers = create_compilers();
function watch_files(pattern: string, callback: () => void) {
const watcher = chokidar.watch(pattern, {
persistent: false
});
watcher.on('add', callback);
watcher.on('change', callback);
watcher.on('unlink', callback);
}
watch_files('routes/**/*.+(html|js|mjs)', () => {
const routes = create_routes({ src });
create_app({ routes, src, dev: true, dev_port });
});
watch_files('app/template.html', () => {
const template = create_template();
// TODO reload current page?
});
let proc: child_process.ChildProcess;
const deferreds = {
server: deferred(),
client: deferred()
};
const times = {
client_start: Date.now(),
server_start: Date.now(),
serviceworker_start: Date.now()
};
compilers.server.plugin('invalid', () => {
times.server_start = Date.now();
// TODO print message
deferreds.server = deferred();
});
compilers.server.watch({}, (err: Error, stats: any) => {
if (err) {
console.error(chalk.red(err.message));
} else if (stats.hasErrors()) {
// print errors. TODO notify client
stats.toJson().errors.forEach((error: Error) => {
console.error(error); // TODO make this look nice
});
} else {
console.log(`built server in ${Date.now() - times.server_start}ms`); // TODO prettify
const server_info = stats.toJson();
fs.writeFileSync(path.join(dir, 'server_info.json'), JSON.stringify(server_info, null, ' '));
deferreds.client.promise.then(() => {
if (proc) proc.kill();
proc = child_process.fork(`${dir}/server.js`, [], {
cwd: process.cwd(),
env: Object.assign({}, process.env)
});
wait_for_port(3000, deferreds.server.fulfil); // TODO control port
});
}
});
compilers.client.plugin('invalid', (filename: string) => {
times.client_start = Date.now();
deferreds.client = deferred();
// TODO we should delete old assets. due to a webpack bug
// i don't even begin to comprehend, this is apparently
// quite difficult
});
compilers.client.watch({}, (err: Error, stats: any) => {
if (err) {
console.error(chalk.red(err.message));
} else if (stats.hasErrors()) {
// print errors. TODO notify client
stats.toJson().errors.forEach((error: Error) => {
console.error(error); // TODO make this look nice
});
} else {
console.log(`built client in ${Date.now() - times.client_start}ms`); // TODO prettify
const client_info = stats.toJson();
fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify(client_info, null, ' '));
deferreds.client.fulfil();
const client_files = client_info.assets.map((chunk: { name: string }) => `/client/${chunk.name}`);
deferreds.server.promise.then(() => {
hot_update_server.send({
status: 'completed'
});
});
create_serviceworker({
routes: create_routes({ src }),
client_files,
src
});
watch_serviceworker();
}
});
let watch_serviceworker = compilers.serviceworker
? function() {
watch_serviceworker = noop;
compilers.serviceworker.plugin('invalid', (filename: string) => {
times.serviceworker_start = Date.now();
});
compilers.serviceworker.watch({}, (err: Error, stats: any) => {
if (err) {
// TODO notify client
} else if (stats.hasErrors()) {
// print errors. TODO notify client
stats.toJson().errors.forEach((error: Error) => {
console.error(error); // TODO make this look nice
});
} else {
console.log(`built service worker in ${Date.now() - times.serviceworker_start}ms`); // TODO prettify
const serviceworker_info = stats.toJson();
fs.writeFileSync(path.join(dir, 'serviceworker_info.json'), JSON.stringify(serviceworker_info, null, ' '));
}
});
}
: noop;
}
function noop() {}

91
src/cli/export.ts Normal file
View File

@@ -0,0 +1,91 @@
import * as child_process from 'child_process';
import * as path from 'path';
import * as sander from 'sander';
import express from 'express';
import cheerio from 'cheerio';
import URL from 'url-parse';
import fetch from 'node-fetch';
import { wait_for_port } from './utils';
const { OUTPUT_DIR = 'dist' } = process.env;
const app = express();
function read_json(file: string) {
return JSON.parse(sander.readFileSync(file, { encoding: 'utf-8' }));
}
export default async function exporter(dir: string) { // dir === '.sapper'
// Prep output directory
sander.rimrafSync(OUTPUT_DIR);
sander.copydirSync('assets').to(OUTPUT_DIR);
sander.copydirSync(dir, 'client').to(OUTPUT_DIR, 'client');
sander.copyFileSync(dir, 'service-worker.js').to(OUTPUT_DIR, 'service-worker.js');
const port = await require('get-port')(3000);
const origin = `http://localhost:${port}`;
const proc = child_process.fork(path.resolve(`${dir}/server.js`), [], {
cwd: process.cwd(),
env: {
PORT: port,
NODE_ENV: 'production',
SAPPER_EXPORT: 'true'
}
});
const seen = new Set();
const saved = new Set();
proc.on('message', message => {
if (!message.__sapper__) return;
const url = new URL(message.url, origin);
if (saved.has(url.pathname)) return;
saved.add(url.pathname);
if (message.type === 'text/html') {
const dest = `${OUTPUT_DIR}/${url.pathname}/index.html`;
sander.writeFileSync(dest, message.body);
} else {
const dest = `${OUTPUT_DIR}/${url.pathname}`;
sander.writeFileSync(dest, message.body);
}
});
function handle(url: URL) {
if (url.origin !== origin) return;
if (seen.has(url.pathname)) return;
seen.add(url.pathname);
return fetch(url.href)
.then(r => {
if (r.headers.get('Content-Type') === 'text/html') {
return r.text().then((body: string) => {
const $ = cheerio.load(body);
const hrefs: string[] = [];
$('a[href]').each((i: number, $a) => {
hrefs.push($a.attribs.href);
});
return hrefs.reduce((promise, href) => {
return promise.then(() => handle(new URL(href, url.href)));
}, Promise.resolve());
});
}
})
.catch((err: Error) => {
console.error(`Error rendering ${url.pathname}: ${err.message}`);
});
}
wait_for_port(port, () => {
handle(new URL(origin)) // TODO all static routes
.then(() => proc.kill())
});
}

20
src/cli/help.md Normal file
View File

@@ -0,0 +1,20 @@
# sapper v<@version@>
https://sapper.svelte.technology
> sapper dev
Start a development server
> sapper build
Creates a production-ready version of your app
> sapper export
If possible, exports your app as static files, suitable for hosting on
services like Netlify or Surge
> sapper --help
Shows this message

View File

@@ -1,11 +1,36 @@
import { build, export as exporter } from 'sapper/core.js'; import mri from 'mri';
import { dest, dev, entry, src } from '../config'; import chalk from 'chalk';
import help from './help.md';
import build from './build';
import exporter from './export';
import dev from './dev';
import upgrade from './upgrade';
import { dest, entry, src } from '../config';
import * as pkg from '../../package.json';
const opts = mri(process.argv.slice(2), {
alias: {
h: 'help'
}
});
if (opts.help) {
const rendered = help
.replace('<@version@>', pkg.version)
.replace(/^(.+)/gm, (m: string, $1: string) => /[#>]/.test(m) ? $1 : ` ${$1}`)
.replace(/^# (.+)/gm, (m: string, $1: string) => chalk.bold.underline($1))
.replace(/^> (.+)/gm, (m: string, $1: string) => chalk.cyan($1));
console.log(`\n${rendered}\n`);
process.exit(0);
}
const [cmd] = opts._;
const cmd = process.argv[2];
const start = Date.now(); const start = Date.now();
if (cmd === 'build') { if (cmd === 'build') {
build({ dest, dev, entry, src }) build({ dest, dev: false, entry, src })
.then(() => { .then(() => {
const elapsed = Date.now() - start; const elapsed = Date.now() - start;
console.error(`built in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds' console.error(`built in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds'
@@ -14,10 +39,8 @@ if (cmd === 'build') {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error'); console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
}); });
} else if (cmd === 'export') { } else if (cmd === 'export') {
const start = Date.now(); build({ dest, dev: false, entry, src })
.then(() => exporter(dest))
build({ dest, dev, entry, src })
.then(() => exporter({ src, dest }))
.then(() => { .then(() => {
const elapsed = Date.now() - start; const elapsed = Date.now() - start;
console.error(`extracted in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds' console.error(`extracted in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds'
@@ -25,4 +48,10 @@ if (cmd === 'build') {
.catch(err => { .catch(err => {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error'); console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
}); });
} else if (cmd === 'dev') {
dev(src, dest);
} else if (cmd === 'upgrade') {
upgrade();
} else {
console.log(`unrecognized command ${cmd} — try \`sapper --help\` for more information`);
} }

53
src/cli/upgrade.ts Normal file
View File

@@ -0,0 +1,53 @@
import * as fs from 'fs';
import chalk from 'chalk';
export default async function upgrade() {
const upgraded = [
await upgrade_sapper_main()
].filter(Boolean);
if (upgraded.length === 0) {
console.log(`No changes!`);
}
}
async function upgrade_sapper_main() {
const _2xx = read('templates/2xx.html');
const _4xx = read('templates/4xx.html');
const _5xx = read('templates/5xx.html');
const pattern = /<script src='\%sapper\.main\%'><\/script>/;
let replaced = false;
['2xx', '4xx', '5xx'].forEach(code => {
const file = `templates/${code}.html`
const template = read(file);
if (!template) return;
if (/\%sapper\.main\%/.test(template)) {
if (!pattern.test(template)) {
console.log(chalk.red(`Could not replace %sapper.main% in ${file}`));
} else {
write(file, template.replace(pattern, `%sapper.scripts%`));
console.log(chalk.green(`Replaced %sapper.main% in ${file}`));
replaced = true;
}
}
});
return replaced;
}
function read(file: string) {
try {
return fs.readFileSync(file, 'utf-8');
} catch (err) {
console.error(err);
return null;
}
}
function write(file: string, data: string) {
fs.writeFileSync(file, data);
}

24
src/cli/utils.ts Normal file
View File

@@ -0,0 +1,24 @@
import waitPort from 'wait-port';
export function wait_for_port(port: number, cb: () => void) {
waitPort({ port }).then(cb);
}
// import * as net from 'net';
// export function wait_for_port(port: number, cb: () => void) {
// const socket = net.createConnection(port, 'localhost', () => {
// cb();
// socket.destroy();
// });
// socket.on('error', err => {
// setTimeout(() => {
// wait_for_port(port, cb);
// }, 100);
// });
// setTimeout(() => {
// socket.destroy();
// }, 100);
// }

View File

@@ -1,6 +1,6 @@
import * as path from 'path'; import * as path from 'path';
export const dev = process.env.NODE_ENV !== 'production'; export const isDev = () => process.env.NODE_ENV !== 'production';
export const templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates'); export const templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates');
export const src = path.resolve(process.env.SAPPER_ROUTES || 'routes'); export const src = path.resolve(process.env.SAPPER_ROUTES || 'routes');
@@ -9,4 +9,4 @@ export const dest = path.resolve(process.env.SAPPER_DEST || '.sapper');
export const entry = { export const entry = {
client: path.resolve(templates, '.main.rendered.js'), client: path.resolve(templates, '.main.rendered.js'),
server: path.resolve(dest, 'server-entry.js') server: path.resolve(dest, 'server-entry.js')
}; };

View File

@@ -1,62 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import create_compilers from './create_compilers.js';
import create_app from './create_app.js';
import create_assets from './create_assets.js';
export default function build({
src,
dest,
dev,
entry
}: {
src: string;
dest: string;
dev: boolean;
entry: { client: string, server: string }
}) {
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
// create main.js and server-routes.js
create_app({ dev, entry, src });
return new Promise((fulfil, reject) => {
function handleErrors(err, stats) {
if (err) {
reject(err);
process.exit(1);
}
if (stats.hasErrors()) {
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();
});
});
});
}

View File

@@ -1,90 +1,91 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import mkdirp from 'mkdirp';
import create_routes from './create_routes'; import create_routes from './create_routes';
import { fudge_mtime, posixify, write } from './utils';
import { Route } from '../interfaces';
function posixify(file: string) { export default function create_app({ routes, src, dev, dev_port }: {
return file.replace(/[/\\]/g, '/'); routes: Route[];
}
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
}: {
src: string; src: string;
dev: boolean; dev: boolean;
entry: { client: string; server: string }; dev_port: number;
}) { }) {
const routes = create_routes({ src }); mkdirp.sync('app/manifest');
function create_client_main() { write('app/manifest/client.js', generate_client(routes, src, dev, dev_port));
const code = `[${routes write('app/manifest/server.js', generate_server(routes, src));
.filter(route => route.type === 'page') }
.map(route => {
const params = function generate_client(routes: Route[], src: string, dev: boolean, dev_port?: number) {
route.dynamic.length === 0 let code = `
// This file is generated by Sapper — do not edit it!
export const routes = [
${routes
.filter(route => route.type === 'page')
.map(route => {
const file = posixify(`../../routes/${route.file}`);
if (route.id === '_4xx' || route.id === '_5xx') {
return `{ error: '${route.id.slice(1)}', load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`;
}
const params = route.dynamic.length === 0
? '{}' ? '{}'
: `{ ${route.dynamic : `{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
.map((part, i) => `${part}: match[${i + 1}]`)
.join(', ')} }`;
const file = posixify(`${src}/${route.file}`); return `{ pattern: ${route.pattern}, params: ${route.dynamic.length > 0 ? `match` : `()`} => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`;
return `{ pattern: ${ })
route.pattern .join(',\n\t')}
}, params: match => (${params}), load: () => import(/* webpackChunkName: "${ ];`.replace(/^\t\t/gm, '').trim();
route.id
}" */ '${file}') }`;
})
.join(', ')}]`;
let main = fs if (dev) {
.readFileSync('templates/main.js', 'utf-8') const hmr_client = posixify(
.replace( path.resolve(__dirname, 'hmr-client.js')
/__app__/g, );
posixify(path.resolve(__dirname, '../../runtime/app.js'))
)
.replace(/__routes__/g, code)
.replace(/__dev__/g, String(dev));
if (dev) { code += `
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); if (module.hot) {
fudge_mtime(entry.client); import('${hmr_client}').then(client => {
client.connect(${dev_port});
});
}`.replace(/^\t{3}/gm, '');
} }
function create_server_routes() { return code;
const imports = routes }
function generate_server(routes: Route[], src: string) {
let code = `
// This file is generated by Sapper — do not edit it!
${routes
.map(route => { .map(route => {
const file = posixify(`${src}/${route.file}`); const file = posixify(`../../routes/${route.file}`);
return route.type === 'page' return route.type === 'page'
? `import ${route.id} from '${file}';` ? `import ${route.id} from '${file}';`
: `import * as ${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 file = posixify(`${src}/${route.file}`);
fs.writeFileSync(entry.server, `${imports}\n\n${exports}`); if (route.id === '_4xx' || route.id === '_5xx') {
fudge_mtime(entry.server); return `{ error: '${route.id.slice(1)}', module: ${route.id} }`;
} }
create_client_main(); const params = route.dynamic.length === 0
create_server_routes(); ? '{}'
} : `{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
export default create_app; 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();
return code;
}

View File

@@ -1,87 +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) {
return Array.isArray(thing) ? thing : [thing]; // omg webpack what the HELL are you doing
}
export default function create_assets({ src, dest, dev, client_info, server_info }) {
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, file) => {
lookup[file] = read(`${dest}${file}`);
return lookup;
}, {}),
// TODO confusing that `routes` refers to an array *and* a lookup
routes: routes.reduce((lookup, 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, src }) {
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, 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) {
return render(200, {
styles: '',
head: '',
html: '<noscript>Please enable JavaScript!</noscript>',
main: main_file
});
}
function read(file) {
return fs.readFileSync(file, 'utf-8');
}

View File

@@ -4,13 +4,26 @@ import relative from 'require-relative';
export default function create_compilers() { export default function create_compilers() {
const webpack = relative('webpack', process.cwd()); const webpack = relative('webpack', process.cwd());
const serviceworker_config = try_require(path.resolve('webpack/service-worker.config.js'));
return { return {
client: webpack( client: webpack(
require(path.resolve('webpack.client.config.js')) require(path.resolve('webpack/client.config.js'))
), ),
server: webpack( server: webpack(
require(path.resolve('webpack.server.config.js')) require(path.resolve('webpack/server.config.js'))
) ),
serviceworker: serviceworker_config && webpack(serviceworker_config)
}; };
}
function try_require(specifier: string) {
try {
return require(specifier);
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') return null;
throw err;
}
} }

View File

@@ -1,9 +1,13 @@
import * as path from 'path'; import * as path from 'path';
import glob from 'glob'; import glob from 'glob';
import { Route } from '../interfaces';
export default function create_routes({ src, files = glob.sync('**/*.+(html|js|mjs)', { cwd: src }) }) { export default function create_routes({ src, files = glob.sync('**/*.+(html|js|mjs)', { cwd: src }) }: {
const routes = files src: string;
.map(file => { files?: string[];
}) {
const routes: Route[] = files
.map((file: string) => {
if (/(^|\/|\\)_/.test(file)) return; if (/(^|\/|\\)_/.test(file)) return;
const parts = file.replace(/\.(html|js|mjs)$/, '').split('/'); // glob output is always posix-style const parts = file.replace(/\.(html|js|mjs)$/, '').split('/'); // glob output is always posix-style
@@ -34,13 +38,13 @@ export default function create_routes({ src, files = glob.sync('**/*.+(html|js|m
const pattern = new RegExp(`^${pattern_string}\\/?$`); const pattern = new RegExp(`^${pattern_string}\\/?$`);
const test = url => pattern.test(url); const test = (url: string) => pattern.test(url);
const exec = url => { const exec = (url: string) => {
const match = pattern.exec(url); const match = pattern.exec(url);
if (!match) return; if (!match) return;
const params = {}; const params: Record<string, string> = {};
dynamic.forEach((param, i) => { dynamic.forEach((param, i) => {
params[param] = match[i + 1]; params[param] = match[i + 1];
}); });
@@ -60,7 +64,7 @@ export default function create_routes({ src, files = glob.sync('**/*.+(html|js|m
}; };
}) })
.filter(Boolean) .filter(Boolean)
.sort((a, b) => { .sort((a: Route, b: Route) => {
let same = true; let same = true;
for (let i = 0; true; i += 1) { for (let i = 0; true; i += 1) {

View File

@@ -0,0 +1,27 @@
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 = `
// This file is generated by Sapper — do not edit it!
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' && !/^_[45]xx$/.test(r.id)).map((r: Route) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n];
`.replace(/^\t\t/gm, '').trim();
write('app/manifest/service-worker.js', code);
}

View File

@@ -0,0 +1,39 @@
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] : '';
});
}
};
}

View File

@@ -1,101 +0,0 @@
import * as path from 'path';
import * as sander from 'sander';
import express from 'express';
import cheerio from 'cheerio';
import fetch from 'node-fetch';
import URL from 'url-parse';
import create_assets from './create_assets.js';
// import middleware from '../middleware/index.js';
const { PORT = 3000, OUTPUT_DIR = 'dist' } = process.env;
const origin = `http://localhost:${PORT}`;
const app = express();
function read_json(file) {
return JSON.parse(sander.readFileSync(file, { encoding: 'utf-8' }));
}
export default 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) {
res = res.clone();
return res.text().then(body => {
const { pathname } = new URL(res.url);
let dest = OUTPUT_DIR + pathname;
const type = res.headers.get('Content-Type');
if (type && type.startsWith('text/html')) dest += '/index.html';
sander.writeFileSync(dest, body);
return body;
});
}
global.fetch = (url, opts) => {
if (url[0] === '/') {
url = `http://localhost:${PORT}${url}`;
return fetch(url, opts)
.then(r => {
save(r);
return r;
});
}
return fetch(url, opts);
};
app.use(require('./middleware')()); // TODO this is filthy
const server = app.listen(PORT);
const seen = new Set();
function handle(url) {
if (url.origin !== origin) return;
if (seen.has(url.pathname)) return;
seen.add(url.pathname);
return fetch(url.href)
.then(r => {
save(r);
return r.text();
})
.then(body => {
const $ = cheerio.load(body);
const hrefs = [];
$('a[href]').each((i, $a) => {
hrefs.push($a.attribs.href);
});
return hrefs.reduce((promise, href) => {
return promise.then(() => handle(new URL(href, url.href)));
}, Promise.resolve());
})
.catch(err => {
console.error(`Error rendering ${url.pathname}: ${err.message}`);
});
}
return handle(new URL(origin)) // TODO all static routes
.then(() => server.close());
}

View File

@@ -1,11 +1,5 @@
import { create_templates, render, stream } from './templates'; // TODO templates is an anomaly... fix post-#91
export { default as build } from './build';
export { default as export } from './export.js';
export { default as create_app } from './create_app'; 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_compilers } from './create_compilers';
export { default as create_routes } from './create_routes'; export { default as create_routes } from './create_routes';
export { default as create_template } from './create_template';
export const templates = { create_templates, render, stream };

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: (req, res, data: Record<string, string | Promise<string>>) => void;
};

View File

@@ -1,103 +0,0 @@
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 { dest } from '../config.js';
function deferred() {
const d = {};
d.promise = new Promise((fulfil, reject) => {
d.fulfil = fulfil;
d.reject = reject;
});
return d;
}
export default function create_watcher({ compilers, dev, entry, src, onroutes }) {
const deferreds = {
client: deferred(),
server: deferred()
};
const invalidate = () => Promise.all([
deferreds.client.promise,
deferreds.server.promise
]).then(([client_stats, server_stats]) => {
const client_info = client_stats.toJson();
fs.writeFileSync(path.join(dest, 'stats.client.json'), JSON.stringify(client_info, null, ' '));
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()
});
});
function watch_compiler(type) {
const compiler = compilers[type];
compiler.plugin('invalid', filename => {
console.log(chalk.cyan(`${type} bundle invalidated, file changed: ${chalk.bold(filename)}`));
deferreds[type] = deferred();
watcher.ready = invalidate();
});
compiler.plugin('failed', err => {
deferreds[type].reject(err);
});
return compiler.watch({}, (err, stats) => {
if (stats.hasErrors()) {
deferreds[type].reject(stats.toJson().errors[0]);
} else {
deferreds[type].fulfil(stats);
}
});
}
const chokidar = require('chokidar');
function watch_files(pattern, callback) {
const watcher = chokidar.watch(pattern, {
persistent: false
});
watcher.on('add', callback);
watcher.on('change', callback);
watcher.on('unlink', callback);
}
watch_files('routes/**/*.+(html|js|mjs)', () => {
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
});
watch_files('templates/main.js', () => {
create_app({ dev, entry, src });
});
watch_files('templates/**.html', () => {
templates.create_templates();
// TODO reload current page?
});
const watcher = {
ready: invalidate(),
client: watch_compiler('client'),
server: watch_compiler('server'),
close: () => {
watcher.client.close();
watcher.server.close();
}
};
return watcher;
}

View File

@@ -1,197 +1,187 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ClientRequest, ServerResponse } from 'http';
// import * as mime from 'mime';
import mkdirp from 'mkdirp'; import mkdirp from 'mkdirp';
import rimraf from 'rimraf'; import rimraf from 'rimraf';
import serialize from 'serialize-javascript'; import serialize from 'serialize-javascript';
import escape_html from 'escape-html'; 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 create_watcher from './create_watcher'; import { dest, entry, isDev, src } from '../config';
import { dest, dev, entry, src } from '../config'; import { Route, Template } from '../interfaces';
function connect_dev() { const dev = isDev();
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
const compilers = create_compilers(); type RouteObject = {
id: string;
type: 'page' | 'route';
pattern: RegExp;
params: (match: RegExpMatchArray) => Record<string, string>;
module: {
render: (data: any) => {
head: string;
css: { code: string, map: any };
html: string
},
preload: (data: any) => any | Promise<any>
};
error?: string;
}
let routes; type Handler = (req: Req, res: ServerResponse, next: () => void) => void;
const watcher = create_watcher({ interface Req extends ClientRequest {
dev, entry, src, url: string;
compilers, method: string;
onroutes: _ => { pathname: string;
routes = _; params: Record<string, string>;
} }
});
let asset_cache; export default function middleware({ routes }: {
routes: RouteObject[]
}) {
const client_info = JSON.parse(fs.readFileSync(path.join(dest, 'client_info.json'), 'utf-8'));
const template = create_template();
const shell = try_read(path.join(dest, 'index.html'));
const serviceworker = try_read(path.join(dest, 'service-worker.js'));
const middleware = compose_handlers([ const middleware = compose_handlers([
require('webpack-hot-middleware')(compilers.client, { (req: Req, res: ServerResponse, next: () => void) => {
reload: true, req.pathname = req.url.replace(/\?.*/, '');
path: '/__webpack_hmr', next();
heartbeat: 10 * 1000
}),
(req, res, next) => {
watcher.ready.then(cache => {
asset_cache = cache;
next();
});
}, },
set_req_pathname, shell && get_asset_handler({
pathname: '/index.html',
get_asset_handler({
filter: pathname => pathname === '/index.html',
type: 'text/html', type: 'text/html',
cache: 'max-age=600', cache: 'max-age=600',
fn: () => asset_cache.client.index body: shell
}), }),
get_asset_handler({ serviceworker && get_asset_handler({
filter: pathname => pathname === '/service-worker.js', pathname: '/service-worker.js',
type: 'application/javascript', type: 'application/javascript',
cache: 'max-age=600', cache: 'max-age=600',
fn: () => asset_cache.client.service_worker body: serviceworker
}), }),
get_asset_handler({ (req: Req, res: ServerResponse, next: () => void) => {
filter: pathname => pathname.startsWith('/client/'), if (req.pathname.startsWith('/client/')) {
type: 'application/javascript', // const type = mime.getType(req.pathname);
cache: 'max-age=31536000', const type = 'application/javascript'; // TODO might not be, if using e.g. CSS plugin
fn: pathname => asset_cache.client.chunks[pathname]
}),
get_route_handler(() => asset_cache, () => routes), // TODO cache?
const rs = fs.createReadStream(path.join(dest, req.pathname.slice(1)));
get_not_found_handler(() => asset_cache) rs.on('error', error => {
]); res.statusCode = 404;
res.end('not found');
});
middleware.close = () => { res.setHeader('Content-Type', type);
watcher.close(); res.setHeader('Cache-Control', 'max-age=31536000');
// TODO shut down chokidar rs.pipe(res);
}; } else {
next();
}
},
get_route_handler(client_info.assetsByChunkName, routes, template)
].filter(Boolean));
return middleware; return middleware;
} }
function connect_prod() { function get_asset_handler({ pathname, type, cache, body }: {
const asset_cache = create_assets({ pathname: string;
src, dest, type: string;
dev: false, cache: string;
client_info: read_json(path.join(dest, 'stats.client.json')), body: string;
server_info: read_json(path.join(dest, 'stats.server.json')) }) {
}); return (req: Req, res: ServerResponse, next: () => void) => {
if (req.pathname !== pathname) return next();
const routes = create_routes({ src }); // TODO rename update res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', cache);
const middleware = compose_handlers([ res.end(body);
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)
]);
// here for API consistency between dev, and prod, but
// doesn't actually need to do anything
middleware.close = () => {};
return middleware;
}
export default 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.setHeader('Content-Type', opts.type);
res.setHeader('Cache-Control', opts.cache);
res.end(opts.fn(req.pathname));
}; };
} }
const resolved = Promise.resolve(); const resolved = Promise.resolve();
function get_route_handler(get_assets, get_routes) { function get_route_handler(chunks: Record<string, string>, routes: RouteObject[], template: Template) {
function handle_route(route, req, res, next, { client, server }) { function handle_route(route: RouteObject, req: Req, res: ServerResponse) {
req.params = route.exec(req.pathname); req.params = route.params(route.pattern.exec(req.pathname));
const mod = require(server.entry)[route.id]; const mod = route.module;
if (route.type === 'page') { if (route.type === 'page') {
// for page routes, we're going to serve some HTML
res.setHeader('Content-Type', 'text/html'); res.setHeader('Content-Type', 'text/html');
// preload main.js and current route // preload main.js and current route
// TODO detect other stuff we can preload? images, CSS, fonts? // 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 data = { params: req.params, query: req.query };
if (mod.preload) { let redirect: { statusCode: number, location: string };
const promise = Promise.resolve(mod.preload(req)).then(preloaded => { let error: { statusCode: number, message: Error | string };
const serialized = try_serialize(preloaded);
Object.assign(data, preloaded);
return { rendered: mod.render(data), serialized }; Promise.resolve(
}); mod.preload ? mod.preload.call({
redirect: (statusCode: number, location: string) => {
redirect = { statusCode, location };
},
error: (statusCode: number, message: Error | string) => {
error = { statusCode, message };
}
}, req) : {}
).catch(err => {
error = { statusCode: 500, message: err };
}).then(preloaded => {
if (redirect) {
res.statusCode = redirect.statusCode;
res.setHeader('Location', redirect.location);
res.end();
return templates.stream(res, 200, { return;
scripts: promise.then(({ serialized }) => { }
const main = `<script src='${client.main_file}'></script>`;
if (serialized) { if (error) {
return `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${main}`; handle_error(req, res, error.statusCode, error.message);
} return;
}
const serialized = try_serialize(preloaded); // TODO bail on non-POJOs
Object.assign(data, preloaded);
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 { html, head, css } = mod.render(data);
const page = templates.render(200, { let scripts = `<script src='/client/${chunks.main}'></script>`;
scripts: `<script src='${client.main_file}'></script>`, scripts = `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${scripts}`;
const page = template.render({
scripts,
html, html,
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`, head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
styles: (css && css.code ? `<style>${css.code}</style>` : '') styles: (css && css.code ? `<style>${css.code}</style>` : '')
}); });
res.end(page); res.end(page);
}
if (process.send) {
process.send({
__sapper__: true,
url: req.url,
method: req.method,
status: 200,
type: 'text/html',
body: page
});
}
});
} }
else { else {
@@ -201,56 +191,114 @@ function get_route_handler(get_assets, get_routes) {
const method_export = method === 'delete' ? 'del' : method; const method_export = method === 'delete' ? 'del' : method;
const handler = mod[method_export]; const handler = mod[method_export];
if (handler) { if (handler) {
handler(req, res, next); if (process.env.SAPPER_EXPORT) {
const { write, end, setHeader } = res;
const chunks: any[] = [];
const headers: Record<string, string> = {};
// intercept data so that it can be exported
res.write = function(chunk: any) {
chunks.push(new Buffer(chunk));
write.apply(res, arguments);
};
res.setHeader = function(name: string, value: string) {
headers[name.toLowerCase()] = value;
setHeader.apply(res, arguments);
};
res.end = function(chunk?: any) {
if (chunk) chunks.push(new Buffer(chunk));
end.apply(res, arguments);
process.send({
__sapper__: true,
url: req.url,
method: req.method,
status: res.statusCode,
type: headers['content-type'],
body: Buffer.concat(chunks).toString()
});
};
}
handler(req, res, () => {
handle_not_found(req, res, 404, 'Not found');
});
} else { } else {
// no matching handler for method — 404 // no matching handler for method — 404
next(); handle_not_found(req, res, 404, 'Not found');
} }
} }
} }
return function find_route(req, res, next) { const not_found_route = routes.find((route: RouteObject) => route.error === '4xx');
function handle_not_found(req: Req, res: ServerResponse, statusCode: number, message: Error | string) {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/html');
const error = message instanceof Error ? message : new Error(message);
const rendered = not_found_route ? not_found_route.module.render({
status: 404,
error
}) : { head: '', css: null, html: error.message };
const { head, css, html } = rendered;
res.end(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>` : '')
}));
}
const error_route = routes.find((route: RouteObject) => route.error === '5xx');
function handle_error(req: Req, res: ServerResponse, statusCode: number, message: Error | string) {
if (statusCode >= 400 && statusCode < 500) {
return handle_not_found(req, res, statusCode, message);
}
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/html');
const error = message instanceof Error ? message : new Error(message);
const rendered = error_route ? error_route.module.render({
status: 500,
error
}) : { head: '', css: null, html: `Internal server error: ${error.message}` };
const { head, css, html } = rendered;
res.end(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>` : '')
}));
}
return function find_route(req: Req, res: ServerResponse, next: () => void) {
const url = req.pathname; const url = req.pathname;
resolved try {
.then(() => { for (const route of routes) {
const routes = get_routes(); if (!route.error && route.pattern.test(url)) return handle_route(route, req, res);
for (const route of routes) { }
if (route.test(url)) return handle_route(route, req, res, next, get_assets());
}
// no matching route — 404 handle_not_found(req, res, 404, 'Not found');
next(); } catch (error) {
}) handle_error(req, res, 500, error);
.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')
}));
});
}; };
} }
function get_not_found_handler(fn) { function compose_handlers(handlers: Handler[]) {
return function handle_not_found(req, res) { return (req: Req, res: ServerResponse, next: () => void) => {
const asset_cache = fn();
res.statusCode = 404;
res.end(templates.render(404, {
title: 'Not found',
status: 404,
method: req.method,
scripts: `<script src='${asset_cache.client.main_file}'></script>`,
url: req.url
}));
};
}
function compose_handlers(handlers) {
return (req, res, next) => {
let i = 0; let i = 0;
function go() { function go() {
const handler = handlers[i]; const handler = handlers[i];
@@ -269,14 +317,22 @@ function compose_handlers(handlers) {
}; };
} }
function read_json(file) { function read_json(file: string) {
return JSON.parse(fs.readFileSync(file, 'utf-8')); return JSON.parse(fs.readFileSync(file, 'utf-8'));
} }
function try_serialize(data) { function try_serialize(data: any) {
try { try {
return serialize(data); return serialize(data);
} catch (err) { } catch (err) {
return null; return null;
} }
} }
function try_read(file: string) {
try {
return fs.readFileSync(file, 'utf-8');
} catch (err) {
return null;
}
}

View File

@@ -4,6 +4,7 @@ import { Component, ComponentConstructor, Params, Query, Route, RouteData, Scrol
export let component: Component; export let component: Component;
let target: Node; let target: Node;
let routes: Route[]; let routes: Route[];
let errors: { '4xx': Route, '5xx': Route };
const history = typeof window !== 'undefined' ? window.history : { const history = typeof window !== 'undefined' ? window.history : {
pushState: (state: any, title: string, href: string) => {}, pushState: (state: any, title: string, href: string) => {},
@@ -29,10 +30,10 @@ function select_route(url: URL): Target {
const query: Record<string, string | true> = {}; const query: Record<string, string | true> = {};
if (url.search.length > 0) { if (url.search.length > 0) {
url.search.slice(1).split('&').forEach(searchParam => { url.search.slice(1).split('&').forEach(searchParam => {
const [, key, value] = /([^=]+)=(.*)/.exec(searchParam); const [, key, value] = /([^=]+)=(.*)/.exec(searchParam);
query[key] = value || true; query[key] = value || true;
}) })
} }
return { url, route, data: { params, query } }; return { url, route, data: { params, query } };
} }
@@ -73,17 +74,41 @@ function render(Component: ComponentConstructor, data: any, scroll: ScrollPositi
} }
function prepare_route(Component: ComponentConstructor, data: RouteData) { function prepare_route(Component: ComponentConstructor, data: RouteData) {
let redirect: { statusCode: number, location: string } = null;
let error: { statusCode: number, message: Error | string } = null;
if (!Component.preload) { if (!Component.preload) {
return { Component, data }; return { Component, data, redirect, error };
} }
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) { if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) {
return { Component, data: Object.assign(data, window.__SAPPER__.preloaded) }; return { Component, data: Object.assign(data, window.__SAPPER__.preloaded), redirect, error };
} }
return Promise.resolve(Component.preload(data)).then(preloaded => { return Promise.resolve(Component.preload.call({
redirect: (statusCode: number, location: string) => {
redirect = { statusCode, location };
},
error: (statusCode: number, message: Error | string) => {
error = { statusCode, message };
}
}, data)).catch(err => {
error = { statusCode: 500, message: err };
}).then(preloaded => {
if (error) {
const route = error.statusCode >= 400 && error.statusCode < 500
? errors['4xx']
: errors['5xx'];
return route.load().then(({ default: Component }: { default: ComponentConstructor }) => {
const err = error.message instanceof Error ? error.message : new Error(error.message);
Object.assign(data, { status: error.statusCode, error: err });
return { Component, data, redirect: null };
});
}
Object.assign(data, preloaded) Object.assign(data, preloaded)
return { Component, data }; return { Component, data, redirect };
}); });
} }
@@ -109,7 +134,11 @@ function navigate(target: Target, id: number) {
const token = current_token = {}; const token = current_token = {};
return loaded.then(({ Component, data }) => { return loaded.then(({ Component, data, redirect }) => {
if (redirect) {
return goto(redirect.location, { replaceState: true });
}
render(Component, data, scroll_history[id], token); render(Component, data, scroll_history[id], token);
}); });
} }
@@ -196,7 +225,11 @@ let inited: boolean;
export function init(_target: Node, _routes: Route[]) { export function init(_target: Node, _routes: Route[]) {
target = _target; target = _target;
routes = _routes; routes = _routes.filter(r => !r.error);
errors = {
'4xx': _routes.find(r => r.error === '4xx'),
'5xx': _routes.find(r => r.error === '5xx')
};
if (!inited) { // this check makes HMR possible if (!inited) { // this check makes HMR possible
window.addEventListener('click', handle_click); window.addEventListener('click', handle_click);

View File

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

6
test/app/app/client.js Normal file
View File

@@ -0,0 +1,6 @@
import { init } from '../../../runtime.js';
import { routes } from './manifest/client.js';
window.init = () => {
return init(document.querySelector('#sapper'), routes);
};

78
test/app/app/server.js Normal file
View File

@@ -0,0 +1,78 @@
import fs from 'fs';
import express from 'express';
import compression from 'compression';
import serve from 'serve-static';
import sapper from '../../../middleware';
import { routes } from './manifest/server.js';
let count;
let ended;
process.on('message', message => {
if (message.action === 'start') {
count = 0;
ended = false;
process.send({ type: 'ready' });
}
if (message.action === 'end') {
ended = true;
if (count === 0) process.send({ type: 'done' });
}
});
const app = express();
app.use((req, res, next) => {
count += 1;
const { write, end } = res;
const chunks = [];
res.write = function(chunk) {
chunks.push(new Buffer(chunk));
write.apply(res, arguments);
};
res.end = function(chunk) {
if (chunk) chunks.push(new Buffer(chunk));
end.apply(res, arguments);
count -= 1;
process.send({
method: req.method,
url: req.url,
status: res.statusCode,
headers: res._headers,
body: Buffer.concat(chunks).toString()
});
if (count === 0 && ended) {
process.send({ type: 'done' });
}
};
next();
});
const { PORT = 3000 } = process.env;
// this allows us to do e.g. `fetch('/api/blog')` on the server
const fetch = require('node-fetch');
global.fetch = (url, opts) => {
if (url[0] === '/') url = `http://localhost:${PORT}${url}`;
return fetch(url, opts);
};
app.use(compression({ threshold: 0 }));
app.use(serve('assets'));
app.use(sapper({
routes
}));
app.listen(PORT, () => {
console.log(`listening on port ${PORT}`);
});

View File

@@ -1,14 +1,12 @@
const ASSETS = `cache__timestamp__`; import { assets, shell, timestamp, routes } from './manifest/service-worker.js';
const ASSETS = `cachetimestamp`;
// `shell` is an array of all the files generated by webpack, // `shell` is an array of all the files generated by webpack,
// `assets` is an array of everything in the `assets` directory // `assets` is an array of everything in the `assets` directory
const to_cache = __shell__.concat(__assets__); const to_cache = shell.concat(assets);
const cached = new Set(to_cache); const cached = new Set(to_cache);
// `routes` is an array of `{ pattern: RegExp }` objects that
// match the pages in your app
const routes = __routes__;
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches caches

18
test/app/routes/4xx.html Normal file
View File

@@ -0,0 +1,18 @@
<:Head>
<title>{{status}}</title>
</:Head>
<Layout page='home'>
<h1>Not found</h1>
<p>{{error.message}}</p>
</Layout>
<script>
import Layout from './_components/Layout.html';
export default {
components: {
Layout
}
};
</script>

18
test/app/routes/5xx.html Normal file
View File

@@ -0,0 +1,18 @@
<:Head>
<title>Internal server error</title>
</:Head>
<Layout page='home'>
<h1>Internal server error</h1>
<p>{{error.message}}</p>
</Layout>
<script>
import Layout from './_components/Layout.html';
export default {
components: {
Layout
}
};
</script>

View File

@@ -3,6 +3,9 @@
<li><a href='/'>home</a></li> <li><a href='/'>home</a></li>
<li><a href='/about'>about</a></li> <li><a href='/about'>about</a></li>
<li><a href='/slow-preload'>slow preload</a></li> <li><a href='/slow-preload'>slow preload</a></li>
<li><a href='/redirect-from'>redirect</a></li>
<li><a href='/blog/nope'>broken link</a></li>
<li><a href='/blog/throw-an-error'>error link</a></li>
<li><a rel=prefetch class='{{page === "blog" ? "selected" : ""}}' href='/blog'>blog</a></li> <li><a rel=prefetch class='{{page === "blog" ? "selected" : ""}}' href='/blog'>blog</a></li>
</ul> </ul>
</nav> </nav>

View File

@@ -59,8 +59,21 @@
// is called [slug].html // is called [slug].html
const { slug } = params; const { slug } = params;
return fetch(`/api/blog/${slug}`).then(r => r.json()).then(post => { if (slug === 'throw-an-error') {
return { post }; return this.error(500, 'something went wrong');
}
return fetch(`/api/blog/${slug}`).then(r => {
if (r.status === 200) {
return r.json().then(post => ({ post }));
this.error(r.status, '')
}
if (r.status === 404) {
this.error(404, 'Not found');
} else {
throw new Error('Something went wrong');
}
}); });
} }
}; };

View File

@@ -0,0 +1,7 @@
<script>
export default {
preload() {
this.redirect(301, '/redirect-to');
}
};
</script>

View File

@@ -0,0 +1 @@
<h1>redirected</h1>

View File

@@ -1,24 +0,0 @@
const fs = require('fs');
const app = require('express')();
const compression = require('compression');
const sapper = require('sapper');
const static = require('serve-static');
const { PORT = 3000 } = process.env;
// this allows us to do e.g. `fetch('/api/blog')` on the server
const fetch = require('node-fetch');
global.fetch = (url, opts) => {
if (url[0] === '/') url = `http://localhost:${PORT}${url}`;
return fetch(url, opts);
};
app.use(compression({ threshold: 0 }));
app.use(static('assets'));
app.use(sapper());
app.listen(PORT, () => {
console.log(`listening on port ${PORT}`);
});

View File

@@ -1,44 +0,0 @@
<!doctype>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width'>
<meta name='theme-color' content='#aa1e1e'>
<link rel='manifest' href='/manifest.json'>
<link rel='icon' type='image/png' href='/favicon.png'>
<!-- %sapper.status% is the HTTP status code, e.g. 404 -->
<title>%sapper.status%</title>
<style>
body {
max-width: 800px;
padding: 1em;
margin: 0 auto;
font-family: Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
background-color: #f4f4f4;
box-sizing: border-box;
}
h1 {
color: rgb(170,30,30);
border-bottom: 1px solid #aaa;
padding: 0 0 0.5em 0;
margin: 1em 0;
}
pre {
font-family: Menlo, monospace;
font-size: 14px;
line-height: 1.2;
overflow-x: auto;
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>%sapper.title%</h1>
<p>Could not %sapper.method% %sapper.url%</p>
</body>
</html>

View File

@@ -1,47 +0,0 @@
<!doctype>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width'>
<meta name='theme-color' content='#aa1e1e'>
<link rel='manifest' href='/manifest.json'>
<link rel='icon' type='image/png' href='/favicon.png'>
<title>%sapper.status%</title>
<style>
body {
max-width: 800px;
padding: 1em;
margin: 0 auto;
box-sizing: border-box;
}
h1 {
color: rgb(170,30,30);
border-bottom: 1px solid #aaa;
padding: 0 0 0.5em 0;
margin: 1em 0;
}
pre {
font-family: Menlo, monospace;
font-size: 14px;
line-height: 1.2;
overflow-x: auto;
white-space: pre-wrap;
}
.stack {
font-size: 12px;
color: #999;
}
</style>
</head>
<body>
<h1>%sapper.title%</h1>
<pre>%sapper.error%</pre>
<pre class='stack'>%sapper.stack%</pre>
</body>
</html>

View File

@@ -1,5 +0,0 @@
import { init } from '../../../runtime.js';
window.init = () => {
return init(document.querySelector('#sapper'), __routes__);
};

View File

@@ -1,8 +1,8 @@
const config = require('../../webpack/config.js'); const config = require('../../../webpack/config.js');
const webpack = require('webpack'); const webpack = require('webpack');
module.exports = { module.exports = {
entry: config.client.entry(), entry: './app/client.js',
output: config.client.output(), output: config.client.output(),
resolve: { resolve: {
extensions: ['.js', '.html'] extensions: ['.js', '.html']

View File

@@ -1,12 +1,17 @@
const config = require('../../webpack/config.js'); const config = require('../../../webpack/config.js');
const pkg = require('../package.json');
const sapper_pkg = require('../../../package.json');
module.exports = { module.exports = {
entry: config.server.entry(), entry: {
'server': './app/server.js'
},
output: config.server.output(), output: config.server.output(),
target: 'node', target: 'node',
resolve: { resolve: {
extensions: ['.js', '.html'] extensions: ['.js', '.html']
}, },
externals: Object.keys(pkg.dependencies).concat(Object.keys(sapper_pkg.dependencies)),
module: { module: {
rules: [ rules: [
{ {

View File

@@ -0,0 +1,17 @@
const path = require('path');
const config = require('../../../webpack/config.js');
const webpack = require('webpack');
module.exports = {
entry: {
'service-worker': './app/service-worker.js'
},
output: {
path: path.resolve(`.sapper`),
filename: '[name].js',
chunkFilename: '[name].[id].[hash].js'
},
plugins: [
!config.dev && new webpack.optimize.ModuleConcatenationPlugin()
].filter(Boolean)
};

View File

@@ -15,70 +15,25 @@ Nightmare.action('page', {
} }
}); });
Nightmare.action('init', function(done) {
this.evaluate_now(() => window.init(), done);
});
function run(env) { function run(env) {
describe(`env=${env}`, function () { describe(`env=${env}`, function () {
this.timeout(20000); this.timeout(20000);
let PORT; let PORT;
let server; let proc;
let nightmare; let nightmare;
let middleware;
let capture; let capture;
let base; let base;
function get(url) {
return new Promise(fulfil => {
const req = {
url,
method: 'GET'
};
const result = {
headers: {},
body: ''
};
const res = {
setHeader(header, value) {
result.headers[header] = value;
},
set(headers, value) {
if (typeof headers === 'string') {
return res.set({ [headers]: value });
}
Object.assign(result.headers, headers);
},
status(code) {
result.status = code;
},
write(data) {
result.body += data;
},
end(data) {
result.body += data;
fulfil(result);
}
};
middleware(req, res, () => {
fulfil(result);
});
});
}
before(() => { before(() => {
process.chdir(path.resolve(__dirname, '../app')); process.chdir(path.resolve(__dirname, '../app'));
process.env.NODE_ENV = env;
let exec_promise = Promise.resolve(); let exec_promise = Promise.resolve();
let sapper;
if (env === 'production') { if (env === 'production') {
const cli = path.resolve(__dirname, '../../cli.js'); const cli = path.resolve(__dirname, '../../cli.js');
@@ -90,81 +45,86 @@ function run(env) {
delete require.cache[resolved]; delete require.cache[resolved];
delete require.cache[require.resolve('../../core.js')]; // TODO remove this delete require.cache[require.resolve('../../core.js')]; // TODO remove this
sapper = require(resolved);
return require('get-port')(); return require('get-port')();
}).then(port => { }).then(port => {
PORT = port; base = `http://localhost:${port}`;
base = `http://localhost:${PORT}`;
Nightmare.action('init', function(done) { proc = require('child_process').fork('.sapper/server.js', {
this.evaluate_now(() => window.init(), done); cwd: process.cwd(),
env: {
NODE_ENV: env,
PORT: port
}
}); });
global.fetch = (url, opts) => { let handler;
if (url[0] === '/') url = `${base}${url}`;
return fetch(url, opts); proc.on('message', message => {
}; if (message.__sapper__) return;
if (handler) handler(message);
});
let captured;
capture = fn => { capture = fn => {
const result = captured = []; return new Promise((fulfil, reject) => {
return fn().then(() => { const captured = [];
captured = null;
return result; let start = Date.now();
handler = message => {
if (message.type === 'ready') {
fn().then(() => {
proc.send({
action: 'end'
});
}, reject);
}
else if (message.type === 'done') {
fulfil(captured);
handler = null;
}
else {
captured.push(message);
}
};
proc.send({
action: 'start'
});
}); });
}; };
const app = express();
app.use(serve('assets'));
app.use((req, res, next) => {
if (captured) captured.push(req);
next();
});
middleware = sapper();
app.use(middleware);
return new Promise((fulfil, reject) => {
server = app.listen(PORT, err => {
if (err) reject(err);
else fulfil();
});
});
}); });
}); });
after(() => { after(() => {
server.close(); proc.kill();
middleware.close();
// give a chance to clean up // give a chance to clean up
return new Promise(fulfil => setTimeout(fulfil, 500)); return new Promise(fulfil => setTimeout(fulfil, 500));
}); });
beforeEach(() => {
nightmare = new Nightmare();
nightmare.on('console', (type, ...args) => {
console[type](...args);
});
nightmare.on('page', (type, ...args) => {
if (type === 'error') {
console.error(args[1]);
} else {
console.warn(type, args);
}
});
});
afterEach(() => {
return nightmare.end();
});
describe('basic functionality', () => { describe('basic functionality', () => {
beforeEach(() => {
nightmare = new Nightmare();
nightmare.on('console', (type, ...args) => {
console[type](...args);
});
nightmare.on('page', (type, ...args) => {
if (type === 'error') {
console.error(args[1]);
} else {
console.warn(type, args);
}
});
});
afterEach(() => {
return nightmare.end();
});
it('serves /', () => { it('serves /', () => {
return nightmare.goto(base).page.title().then(title => { return nightmare.goto(base).page.title().then(title => {
assert.equal(title, 'Great success!'); assert.equal(title, 'Great success!');
@@ -190,7 +150,7 @@ function run(env) {
}); });
it('navigates to a new page without reloading', () => { it('navigates to a new page without reloading', () => {
return nightmare.goto(base).init().wait(100) return capture(() => nightmare.goto(base).init().wait(400))
.then(() => { .then(() => {
return capture(() => nightmare.click('a[href="/about"]')); return capture(() => nightmare.click('a[href="/about"]'));
}) })
@@ -224,11 +184,12 @@ function run(env) {
return nightmare return nightmare
.goto(`${base}/about`) .goto(`${base}/about`)
.init() .init()
.wait(200)
.then(() => { .then(() => {
return capture(() => { return capture(() => {
return nightmare return nightmare
.click('.prefetch') .click('.prefetch')
.wait(100); .wait(200);
}); });
}) })
.then(requests => { .then(requests => {
@@ -249,7 +210,7 @@ function run(env) {
it('reuses prefetch promise', () => { it('reuses prefetch promise', () => {
return nightmare return nightmare
.goto(`${base}/blog`) .goto(`${base}/blog`)
.init().wait(100) .init().wait(300)
.then(() => { .then(() => {
return capture(() => { return capture(() => {
return nightmare return nightmare
@@ -258,9 +219,7 @@ function run(env) {
}); });
}) })
.then(mouseover_requests => { .then(mouseover_requests => {
assert.deepEqual(mouseover_requests.map(r => r.url), [ assert.ok(mouseover_requests.findIndex(r => r.url === '/api/blog/what-is-sapper') !== -1);
'/api/blog/what-is-sapper'
]);
return capture(() => { return capture(() => {
return nightmare return nightmare
@@ -269,7 +228,7 @@ function run(env) {
}); });
}) })
.then(click_requests => { .then(click_requests => {
assert.deepEqual(click_requests.map(r => r.url), []); assert.ok(click_requests.findIndex(r => r.url === '/api/blog/what-is-sapper') === -1);
}); });
}); });
@@ -336,19 +295,102 @@ function run(env) {
assert.ok(matches); assert.ok(matches);
}); });
}); });
it('redirects on server', () => {
return nightmare.goto(`${base}/redirect-from`)
.path()
.then(path => {
assert.equal(path, '/redirect-to');
})
.then(() => nightmare.page.title())
.then(title => {
assert.equal(title, 'redirected');
});
});
it('redirects in client', () => {
return nightmare.goto(base)
.wait('[href="/redirect-from"]')
.click('[href="/redirect-from"]')
.wait(200)
.path()
.then(path => {
assert.equal(path, '/redirect-to');
})
.then(() => nightmare.page.title())
.then(title => {
assert.equal(title, 'redirected');
});
});
it('handles 4xx error on server', () => {
return nightmare.goto(`${base}/blog/nope`)
.path()
.then(path => {
assert.equal(path, '/blog/nope');
})
.then(() => nightmare.page.title())
.then(title => {
assert.equal(title, 'Not found')
});
});
it('handles 4xx error in client', () => {
return nightmare.goto(base)
.init()
.click('[href="/blog/nope"]')
.wait(200)
.path()
.then(path => {
assert.equal(path, '/blog/nope');
})
.then(() => nightmare.page.title())
.then(title => {
assert.equal(title, 'Not found');
});
});
it('handles non-4xx error on server', () => {
return nightmare.goto(`${base}/blog/throw-an-error`)
.path()
.then(path => {
assert.equal(path, '/blog/throw-an-error');
})
.then(() => nightmare.page.title())
.then(title => {
assert.equal(title, 'Internal server error')
});
});
it('handles non-4xx error in client', () => {
return nightmare.goto(base)
.init()
.click('[href="/blog/throw-an-error"]')
.wait(200)
.path()
.then(path => {
assert.equal(path, '/blog/throw-an-error');
})
.then(() => nightmare.page.title())
.then(title => {
assert.equal(title, 'Internal server error');
});
});
}); });
describe('headers', () => { describe('headers', () => {
it('sets Content-Type and Link...preload headers', () => { it('sets Content-Type and Link...preload headers', () => {
return get('/').then(({ headers }) => { return capture(() => nightmare.goto(base).end()).then(requests => {
const { headers } = requests[0];
assert.equal( assert.equal(
headers['Content-Type'], headers['content-type'],
'text/html' 'text/html'
); );
assert.ok( assert.ok(
/<\/client\/main.\w+\.js>;rel="preload";as="script", <\/client\/_.\d+.\w+.js>;rel="preload";as="script"/.test(headers['Link']), /<\/client\/[^/]+\/main\.js>;rel="preload";as="script", <\/client\/[^/]+\/_\.0\.js>;rel="preload";as="script"/.test(headers['link']),
headers['Link'] headers['link']
); );
}); });
}); });
@@ -391,20 +433,20 @@ function run(env) {
]; ];
// Client scripts that should show up in the extraction directory. // Client scripts that should show up in the extraction directory.
const expectedClientRegexes = [ const expectedClientRegexes = [
/client\/_\..*?\.js/, /client\/[^/]+\/_(\.\d+)?\.js/,
/client\/about\..*?\.js/, /client\/[^/]+\/about(\.\d+)?\.js/,
/client\/blog_\$slug\$\..*?\.js/, /client\/[^/]+\/blog_\$slug\$(\.\d+)?\.js/,
/client\/blog\..*?\.js/, /client\/[^/]+\/blog(\.\d+)?\.js/,
/client\/main\..*?\.js/, /client\/[^/]+\/main(\.\d+)?\.js/,
/client\/show_url\..*?\.js/, /client\/[^/]+\/show_url(\.\d+)?\.js/,
/client\/slow_preload\..*?\.js/, /client\/[^/]+\/slow_preload(\.\d+)?\.js/,
]; ];
const allPages = walkSync(dest); const allPages = walkSync(dest);
expectedPages.forEach((expectedPage) => { expectedPages.forEach((expectedPage) => {
assert.ok(allPages.includes(expectedPage), assert.ok(allPages.includes(expectedPage),`Could not find page matching ${expectedPage}`);
`Could not find page matching ${expectedPage}`);
}); });
expectedClientRegexes.forEach((expectedRegex) => { expectedClientRegexes.forEach((expectedRegex) => {
// Ensure each client page regular expression matches at least one // Ensure each client page regular expression matches at least one
// generated page. // generated page.
@@ -415,8 +457,7 @@ function run(env) {
break; break;
} }
} }
assert.ok(matched, assert.ok(matched, `Could not find client page matching ${expectedRegex}`);
`Could not find client page matching ${expectedRegex}`);
}); });
}); });
}); });