Support being mounted on a path — fixes #180

This commit is contained in:
Rich Harris
2018-03-17 11:55:02 -04:00
committed by GitHub
parent a95ddee48d
commit b94481b716
21 changed files with 240 additions and 461 deletions

7
.gitignore vendored
View File

@@ -7,8 +7,7 @@ test/app/.sapper
test/app/app/manifest test/app/app/manifest
test/app/export test/app/export
test/app/build test/app/build
*.js sapper
*.js.map runtime.js
*.ts.js dist
*.ts.js.map
!rollup.config.js !rollup.config.js

View File

@@ -46,6 +46,7 @@
"compression": "^1.7.1", "compression": "^1.7.1",
"eslint": "^4.13.1", "eslint": "^4.13.1",
"eslint-plugin-import": "^2.8.0", "eslint-plugin-import": "^2.8.0",
"express": "^4.16.3",
"get-port": "^3.2.0", "get-port": "^3.2.0",
"mocha": "^5.0.4", "mocha": "^5.0.4",
"nightmare": "^3.0.0", "nightmare": "^3.0.0",

View File

@@ -1,271 +0,0 @@
function detach(node) {
node.parentNode.removeChild(node);
}
function findAnchor(node) {
while (node && node.nodeName.toUpperCase() !== 'A')
node = node.parentNode; // SVG <a> elements have a lowercase name
return node;
}
function which(event) {
return event.which === null ? event.button : event.which;
}
function scroll_state() {
return {
x: window.scrollX,
y: window.scrollY
};
}
var component;
var target;
var routes;
var errors;
var history = typeof window !== 'undefined' ? window.history : {
pushState: function (state, title, href) { },
replaceState: function (state, title, href) { },
scrollRestoration: ''
};
var scroll_history = {};
var uid = 1;
var cid;
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
function select_route(url) {
if (url.origin !== window.location.origin)
return null;
var _loop_1 = function (route) {
var match = route.pattern.exec(url.pathname);
if (match) {
if (route.ignore)
return { value: null };
var params = route.params(match);
var query_1 = {};
if (url.search.length > 0) {
url.search.slice(1).split('&').forEach(function (searchParam) {
var _a = /([^=]+)=(.*)/.exec(searchParam), key = _a[1], value = _a[2];
query_1[key] = value || true;
});
}
return { value: { url: url, route: route, data: { params: params, query: query_1 } } };
}
};
for (var _i = 0, routes_1 = routes; _i < routes_1.length; _i++) {
var route = routes_1[_i];
var state_1 = _loop_1(route);
if (typeof state_1 === "object")
return state_1.value;
}
}
var current_token;
function render(Component, data, scroll, token) {
if (current_token !== token)
return;
if (component) {
component.destroy();
}
else {
// first load — remove SSR'd <head> contents
var start = document.querySelector('#sapper-head-start');
var end = document.querySelector('#sapper-head-end');
if (start && end) {
while (start.nextSibling !== end)
detach(start.nextSibling);
detach(start);
detach(end);
}
}
component = new Component({
target: target,
data: data,
hydrate: !component
});
if (scroll) {
window.scrollTo(scroll.x, scroll.y);
}
}
function prepare_route(Component, data) {
var redirect = null;
var error = null;
if (!Component.preload) {
return { Component: Component, data: data, redirect: redirect, error: error };
}
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) {
return { Component: Component, data: Object.assign(data, window.__SAPPER__.preloaded), redirect: redirect, error: error };
}
return Promise.resolve(Component.preload.call({
redirect: function (statusCode, location) {
redirect = { statusCode: statusCode, location: location };
},
error: function (statusCode, message) {
error = { statusCode: statusCode, message: message };
}
}, data))["catch"](function (err) {
error = { statusCode: 500, message: err };
}).then(function (preloaded) {
if (error) {
var route = error.statusCode >= 400 && error.statusCode < 500
? errors['4xx']
: errors['5xx'];
return route.load().then(function (_a) {
var Component = _a["default"];
var err = error.message instanceof Error ? error.message : new Error(error.message);
Object.assign(data, { status: error.statusCode, error: err });
return { Component: Component, data: data, redirect: null };
});
}
Object.assign(data, preloaded);
return { Component: Component, data: data, redirect: redirect };
});
}
function navigate(target, id) {
if (id) {
// popstate or initial navigation
cid = id;
}
else {
// clicked on a link. preserve scroll state
scroll_history[cid] = scroll_state();
id = cid = ++uid;
scroll_history[cid] = { x: 0, y: 0 };
}
cid = id;
var loaded = prefetching && prefetching.href === target.url.href ?
prefetching.promise :
target.route.load().then(function (mod) { return prepare_route(mod["default"], target.data); });
prefetching = null;
var token = current_token = {};
return loaded.then(function (_a) {
var Component = _a.Component, data = _a.data, redirect = _a.redirect;
if (redirect) {
return goto(redirect.location, { replaceState: true });
}
render(Component, data, scroll_history[id], token);
});
}
function handle_click(event) {
// Adapted from https://github.com/visionmedia/page.js
// MIT license https://github.com/visionmedia/page.js#license
if (which(event) !== 1)
return;
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
if (event.defaultPrevented)
return;
var a = findAnchor(event.target);
if (!a)
return;
// check if link is inside an svg
// in this case, both href and target are always inside an object
var svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString';
var href = String(svg ? a.href.baseVal : a.href);
if (href === window.location.href) {
event.preventDefault();
return;
}
// Ignore if tag has
// 1. 'download' attribute
// 2. rel='external' attribute
if (a.hasAttribute('download') || a.getAttribute('rel') === 'external')
return;
// Ignore if <a> has a target
if (svg ? a.target.baseVal : a.target)
return;
var url = new URL(href);
// Don't handle hash changes
if (url.pathname === window.location.pathname && url.search === window.location.search)
return;
var target = select_route(url);
if (target) {
navigate(target, null);
event.preventDefault();
history.pushState({ id: cid }, '', url.href);
}
}
function handle_popstate(event) {
scroll_history[cid] = scroll_state();
if (event.state) {
var url = new URL(window.location.href);
var target_1 = select_route(url);
navigate(target_1, event.state.id);
}
else {
// hashchange
cid = ++uid;
history.replaceState({ id: cid }, '', window.location.href);
}
}
var prefetching = null;
function prefetch(href) {
var selected = select_route(new URL(href));
if (selected) {
prefetching = {
href: href,
promise: selected.route.load().then(function (mod) { return prepare_route(mod["default"], selected.data); })
};
}
}
function handle_touchstart_mouseover(event) {
var a = findAnchor(event.target);
if (!a || a.rel !== 'prefetch')
return;
prefetch(a.href);
}
var inited;
function init(_target, _routes) {
target = _target;
routes = _routes.filter(function (r) { return !r.error; });
errors = {
'4xx': _routes.find(function (r) { return r.error === '4xx'; }),
'5xx': _routes.find(function (r) { return r.error === '5xx'; })
};
if (!inited) {
window.addEventListener('click', handle_click);
window.addEventListener('popstate', handle_popstate);
// prefetch
window.addEventListener('touchstart', handle_touchstart_mouseover);
window.addEventListener('mouseover', handle_touchstart_mouseover);
inited = true;
}
return Promise.resolve().then(function () {
var _a = window.location, hash = _a.hash, href = _a.href;
var deep_linked = hash && document.getElementById(hash.slice(1));
scroll_history[uid] = deep_linked ?
{ x: 0, y: deep_linked.getBoundingClientRect().top } :
scroll_state();
history.replaceState({ id: uid }, '', href);
var target = select_route(new URL(window.location.href));
return navigate(target, uid);
});
}
function goto(href, opts) {
if (opts === void 0) { opts = { replaceState: false }; }
var target = select_route(new URL(href, window.location.href));
if (target) {
navigate(target, null);
if (history)
history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);
}
else {
window.location.href = href;
}
}
function prefetchRoutes(pathnames) {
if (!routes)
throw new Error("You must call init() first");
return routes
.filter(function (route) {
if (!pathnames)
return true;
return pathnames.some(function (pathname) {
return route.error
? route.error === pathname
: route.pattern.test(pathname);
});
})
.reduce(function (promise, route) {
return promise.then(route.load);
}, Promise.resolve());
}
export { component, prefetch, init, goto, prefetchRoutes, prefetchRoutes as preloadRoutes };

View File

@@ -49,7 +49,8 @@ prog.command('start [dir]')
prog.command('export [dest]') prog.command('export [dest]')
.describe('Export your app as static files (if possible)') .describe('Export your app as static files (if possible)')
.action(async (dest = 'export') => { .option('--basepath', 'Specify a base path')
.action(async (dest = 'export', opts: { basepath?: string }) => {
console.log(`> Building...`); console.log(`> Building...`);
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
@@ -63,7 +64,7 @@ prog.command('export [dest]')
console.error(`\n> Built in ${elapsed(start)}. Crawling site...`); console.error(`\n> Built in ${elapsed(start)}. Crawling site...`);
const { exporter } = await import('./cli/export'); const { exporter } = await import('./cli/export');
await exporter(dest); await exporter(dest, opts);
console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(`npx serve ${dest}`)} to run the app.`); console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(`npx serve ${dest}`)} to run the app.`);
} 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');

View File

@@ -34,7 +34,7 @@ export async function build() {
if (serviceworker) { if (serviceworker) {
create_serviceworker_manifest({ create_serviceworker_manifest({
routes, routes,
client_files: client_stats.toJson().assets.map((chunk: { name: string }) => `/client/${chunk.name}`) client_files: client_stats.toJson().assets.map((chunk: { name: string }) => `client/${chunk.name}`)
}); });
serviceworker_stats = await compile(serviceworker); serviceworker_stats = await compile(serviceworker);

View File

@@ -259,7 +259,7 @@ export async function dev(opts: { port: number, open: boolean }) {
fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify(info, null, ' ')); fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify(info, null, ' '));
deferreds.client.fulfil(); deferreds.client.fulfil();
const client_files = info.assets.map((chunk: { name: string }) => `/client/${chunk.name}`); const client_files = info.assets.map((chunk: { name: string }) => `client/${chunk.name}`);
deferreds.server.promise.then(() => { deferreds.server.promise.then(() => {
hot_update_server.send({ hot_update_server.send({

View File

@@ -10,9 +10,11 @@ import prettyBytes from 'pretty-bytes';
import { minify_html } from './utils/minify_html'; import { minify_html } from './utils/minify_html';
import { locations } from '../config'; import { locations } from '../config';
export async function exporter(export_dir: string) { export async function exporter(export_dir: string, { basepath = '' }) {
const build_dir = locations.dest(); const build_dir = locations.dest();
export_dir = path.join(export_dir, basepath);
// Prep output directory // Prep output directory
sander.rimrafSync(export_dir); sander.rimrafSync(export_dir);
@@ -58,38 +60,43 @@ export async function exporter(export_dir: string) {
console.log(`${clorox.bold.cyan(file)} ${clorox.gray(`(${prettyBytes(body.length)})`)}`); console.log(`${clorox.bold.cyan(file)} ${clorox.gray(`(${prettyBytes(body.length)})`)}`);
sander.writeFileSync(`${export_dir}/${file}`, body); sander.writeFileSync(export_dir, file, body);
}); });
function handle(url: URL) { async function handle(url: URL) {
if (url.origin !== origin) return; const r = await fetch(url.href);
const range = ~~(r.status / 100);
if (seen.has(url.pathname)) return; if (range >= 4) {
seen.add(url.pathname); console.log(`${clorox.red(`> Received ${r.status} response when fetching ${url.pathname}`)}`);
return;
}
return fetch(url.href) if (range === 2) {
.then(r => {
if (r.headers.get('Content-Type') === 'text/html') { if (r.headers.get('Content-Type') === 'text/html') {
return r.text().then((body: string) => { const body = await r.text();
const $ = cheerio.load(body); const $ = cheerio.load(body);
const hrefs: string[] = []; const urls: URL[] = [];
const base = new URL($('base').attr('href') || '/', url.href);
$('a[href]').each((i: number, $a) => { $('a[href]').each((i: number, $a) => {
hrefs.push($a.attribs.href); const url = new URL($a.attribs.href, base.href);
if (url.origin === origin && !seen.has(url.pathname)) {
seen.add(url.pathname);
urls.push(url);
}
}); });
return hrefs.reduce((promise, href) => { for (const url of urls) {
return promise.then(() => handle(new URL(href, url.href))); await handle(url);
}, Promise.resolve()); }
}); }
} }
})
.catch((err: Error) => {
console.log(`${clorox.red(`> Error rendering ${url.pathname}: ${err.message}`)}`);
});
} }
return ports.wait(port) return ports.wait(port)
.then(() => handle(new URL(origin))) // TODO all static routes .then(() => handle(new URL(`/${basepath}`, origin))) // TODO all static routes
.then(() => proc.kill()); .then(() => proc.kill());
} }

View File

@@ -1,5 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { resolve } from 'url';
import { ClientRequest, ServerResponse } from 'http'; import { ClientRequest, ServerResponse } from 'http';
import mkdirp from 'mkdirp'; import mkdirp from 'mkdirp';
import rimraf from 'rimraf'; import rimraf from 'rimraf';
@@ -46,7 +47,16 @@ export default function middleware({ routes }: {
const middleware = compose_handlers([ const middleware = compose_handlers([
(req: Req, res: ServerResponse, next: () => void) => { (req: Req, res: ServerResponse, next: () => void) => {
req.pathname = req.url.replace(/\?.*/, ''); if (req.baseUrl === undefined) {
req.baseUrl = req.originalUrl
? req.originalUrl.slice(0, -req.url.length)
: '';
}
if (req.path === undefined) {
req.path = req.url.replace(/\?.*/, '');
}
next(); next();
}, },
@@ -77,8 +87,8 @@ function serve({ prefix, pathname, cache_control }: {
cache_control: string cache_control: string
}) { }) {
const filter = pathname const filter = pathname
? (req: Req) => req.pathname === pathname ? (req: Req) => req.path === pathname
: (req: Req) => req.pathname.startsWith(prefix); : (req: Req) => req.path.startsWith(prefix);
const output = locations.dest(); const output = locations.dest();
@@ -90,10 +100,10 @@ function serve({ prefix, pathname, cache_control }: {
return (req: Req, res: ServerResponse, next: () => void) => { return (req: Req, res: ServerResponse, next: () => void) => {
if (filter(req)) { if (filter(req)) {
const type = lookup(req.pathname); const type = lookup(req.path);
try { try {
const data = read(req.pathname.slice(1)); const data = read(req.path.slice(1));
res.setHeader('Content-Type', type); res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', cache_control); res.setHeader('Cache-Control', cache_control);
@@ -116,7 +126,7 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
: (str => () => str)(fs.readFileSync(`${locations.dest()}/template.html`, 'utf-8')); : (str => () => str)(fs.readFileSync(`${locations.dest()}/template.html`, 'utf-8'));
function handle_route(route: RouteObject, req: Req, res: ServerResponse) { function handle_route(route: RouteObject, req: Req, res: ServerResponse) {
req.params = route.params(route.pattern.exec(req.pathname)); req.params = route.params(route.pattern.exec(req.path));
const mod = route.module; const mod = route.module;
@@ -127,7 +137,7 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
// TODO detect other stuff we can preload? images, CSS, fonts? // TODO detect other stuff we can preload? images, CSS, fonts?
const link = [] const link = []
.concat(chunks.main, chunks[route.id]) .concat(chunks.main, chunks[route.id])
.map(file => `</client/${file}>;rel="preload";as="script"`) .map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`)
.join(', '); .join(', ');
res.setHeader('Link', link); res.setHeader('Link', link);
@@ -151,7 +161,7 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
}).then(preloaded => { }).then(preloaded => {
if (redirect) { if (redirect) {
res.statusCode = redirect.statusCode; res.statusCode = redirect.statusCode;
res.setHeader('Location', redirect.location); res.setHeader('Location', `${req.baseUrl}/${redirect.location}`);
res.end(); res.end();
return; return;
@@ -169,13 +179,22 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
let scripts = [] let scripts = []
.concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack .concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack
.map(file => `<script src='/client/${file}'></script>`) .map(file => `<script src='${req.baseUrl}/client/${file}'></script>`)
.join(''); .join('');
scripts = `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${scripts}`; let inline_script = `__SAPPER__={${[
`baseUrl: "${req.baseUrl}"`,
mod.preload && serialized && `preloaded: ${serialized}`,
].filter(Boolean).join(',')}}`
const has_service_worker = fs.existsSync(path.join(locations.dest(), 'service-worker.js'));
if (has_service_worker) {
`if ('serviceWorker' in navigator) navigator.serviceWorker.register('${req.baseUrl}/service-worker.js')`
}
const page = template() const page = template()
.replace('%sapper.scripts%', scripts) .replace('%sapper.base%', `<base href="${req.baseUrl}/">`)
.replace('%sapper.scripts%', `<script>${inline_script}</script>${scripts}`)
.replace('%sapper.html%', html) .replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`) .replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : '')); .replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
@@ -282,7 +301,8 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
const { head, css, html } = rendered; const { head, css, html } = rendered;
const page = template() const page = template()
.replace('%sapper.scripts%', `<script src='/client/${chunks.main}'></script>`) .replace('%sapper.base%', `<base href="${req.baseUrl}/">`)
.replace('%sapper.scripts%', `<script>__SAPPER__={baseUrl: "${req.baseUrl}"}</script><script src='${req.baseUrl}/client/${chunks.main}'></script>`)
.replace('%sapper.html%', html) .replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`) .replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : '')); .replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
@@ -291,11 +311,9 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
} }
return function find_route(req: Req, res: ServerResponse) { return function find_route(req: Req, res: ServerResponse) {
const url = req.pathname;
try { try {
for (const route of routes) { for (const route of routes) {
if (!route.error && route.pattern.test(url)) return handle_route(route, req, res); if (!route.error && route.pattern.test(req.path)) return handle_route(route, req, res);
} }
handle_error(req, res, 404, 'Not found'); handle_error(req, res, 404, 'Not found');

View File

@@ -1,6 +1,8 @@
import { detach, findAnchor, scroll_state, which } from './utils'; import { detach, findAnchor, scroll_state, which } from './utils';
import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition, Target } from './interfaces'; import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition, Target } from './interfaces';
const manifest = typeof window !== 'undefined' && window.__SAPPER__;
export let component: Component; export let component: Component;
let target: Node; let target: Node;
let routes: Route[]; let routes: Route[];
@@ -22,9 +24,12 @@ if ('scrollRestoration' in history) {
function select_route(url: URL): Target { function select_route(url: URL): Target {
if (url.origin !== window.location.origin) return null; if (url.origin !== window.location.origin) return null;
if (!url.pathname.startsWith(manifest.baseUrl)) return null;
const pathname = url.pathname.slice(manifest.baseUrl.length);
for (const route of routes) { for (const route of routes) {
const match = route.pattern.exec(url.pathname); const match = route.pattern.exec(pathname);
if (match) { if (match) {
if (route.ignore) return null; if (route.ignore) return null;
@@ -80,8 +85,8 @@ function prepare_route(Component: ComponentConstructor, data: RouteData) {
return { Component, data, redirect, error }; return { Component, data, redirect, error };
} }
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) { if (!component && manifest.preloaded) {
return { Component, data: Object.assign(data, window.__SAPPER__.preloaded), redirect, error }; return { Component, data: Object.assign(data, manifest.preloaded), redirect, error };
} }
return Promise.resolve(Component.preload.call({ return Promise.resolve(Component.preload.call({
@@ -203,7 +208,7 @@ let prefetching: {
} = null; } = null;
export function prefetch(href: string) { export function prefetch(href: string) {
const selected = select_route(new URL(href)); const selected = select_route(new URL(href, document.baseURI));
if (selected) { if (selected) {
prefetching = { prefetching = {
@@ -257,7 +262,8 @@ export function init(_target: Node, _routes: Route[]) {
} }
export function goto(href: string, opts = { replaceState: false }) { export function goto(href: string, opts = { replaceState: false }) {
const target = select_route(new URL(href, window.location.href)); const target = select_route(new URL(href, document.baseURI));
if (target) { if (target) {
navigate(target, null); navigate(target, null);
if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href); if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);

View File

@@ -15,7 +15,7 @@ export default {
path: `${locations.dest()}/client`, path: `${locations.dest()}/client`,
filename: '[hash]/[name].js', filename: '[hash]/[name].js',
chunkFilename: '[hash]/[name].[id].js', chunkFilename: '[hash]/[name].[id].js',
publicPath: '/client/' publicPath: `client/`
}; };
} }
}, },

View File

@@ -1,6 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import polka from 'polka'; import { resolve } from 'url';
import compression from 'compression'; import express from 'express';
import serve from 'serve-static'; import serve from 'serve-static';
import sapper from '../../../dist/middleware.ts.js'; import sapper from '../../../dist/middleware.ts.js';
import { routes } from './manifest/server.js'; import { routes } from './manifest/server.js';
@@ -28,10 +28,24 @@ process.on('message', message => {
} }
}); });
const app = polka(); const app = express();
app.use((req, res, next) => { const { PORT = 3000, BASEPATH = '' } = process.env;
if (pending) pending.add(req.url); const base = `http://localhost:${PORT}${BASEPATH}/`;
// this allows us to do e.g. `fetch('/api/blog')` on the server
const fetch = require('node-fetch');
global.fetch = (url, opts) => {
return fetch(resolve(base, url), opts);
};
const middlewares = [
serve('assets'),
(req, res, next) => {
if (!pending) return next();
pending.add(req.url);
const { write, end } = res; const { write, end } = res;
const chunks = []; const chunks = [];
@@ -61,25 +75,15 @@ app.use((req, res, next) => {
}; };
next(); next();
}); },
const { PORT = 3000 } = process.env; sapper({ routes })
];
// this allows us to do e.g. `fetch('/api/blog')` on the server if (BASEPATH) {
const fetch = require('node-fetch'); app.use(BASEPATH, ...middlewares);
global.fetch = (url, opts) => { } else {
if (url[0] === '/') url = `http://localhost:${PORT}${url}`; app.use(...middlewares);
return fetch(url, opts); }
};
app.use(compression({ threshold: 0 })); app.listen(PORT);
app.use(serve('assets'));
app.use(sapper({
routes
}));
app.listen(PORT, () => {
console.log(`listening on port ${PORT}`);
});

View File

@@ -5,15 +5,11 @@
<meta name='viewport' content='width=device-width'> <meta name='viewport' content='width=device-width'>
<meta name='theme-color' content='#aa1e1e'> <meta name='theme-color' content='#aa1e1e'>
<link rel='stylesheet' href='/global.css'> %sapper.base%
<link rel='manifest' href='/manifest.json'>
<link rel='icon' type='image/png' href='/favicon.png'>
<script> <link rel='stylesheet' href='global.css'>
// if ('serviceWorker' in navigator) { <link rel='manifest' href='manifest.json'>
// navigator.serviceWorker.register('/service-worker.js'); <link rel='icon' type='image/png' href='favicon.png'>
// }
</script>
<!-- Sapper generates a <style> tag containing critical CSS <!-- Sapper generates a <style> tag containing critical CSS
for the current page. CSS for the rest of the app is for the current page. CSS for the rest of the app is

View File

@@ -1,12 +1,12 @@
<nav> <nav>
<ul> <ul>
<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='redirect-from'>redirect</a></li>
<li><a href='/blog/nope'>broken link</a></li> <li><a href='blog/nope'>broken link</a></li>
<li><a href='/blog/throw-an-error'>error 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

@@ -7,8 +7,8 @@
<p>This is the 'about' page. There's not much here.</p> <p>This is the 'about' page. There's not much here.</p>
<button class='goto' on:click='goto("/blog/what-is-sapper")'>What is Sapper?</button> <button class='goto' on:click='goto("blog/what-is-sapper")'>What is Sapper?</button>
<button class='prefetch' on:click='goto("/blog/why-the-name")'>Why the name?</button> <button class='prefetch' on:click='prefetch("blog/why-the-name")'>Why the name?</button>
</Layout> </Layout>
<script> <script>

View File

@@ -63,7 +63,7 @@
return this.error(500, 'something went wrong'); return this.error(500, 'something went wrong');
} }
return fetch(`/blog/${slug}.json`).then(r => { return fetch(`blog/${slug}.json`).then(r => {
if (r.status === 200) { if (r.status === 200) {
return r.json().then(post => ({ post })); return r.json().then(post => ({ post }));
this.error(r.status, '') this.error(r.status, '')

View File

@@ -14,7 +14,7 @@ const posts = [
html: ` html: `
<p>First, you have to know what <a href='https://svelte.technology'>Svelte</a> is. Svelte is a UI framework with a bold new idea: rather than providing a library that you write code with (like React or Vue, for example), it's a compiler that turns your components into highly optimized vanilla JavaScript. If you haven't already read the <a href='https://svelte.technology/blog/frameworks-without-the-framework'>introductory blog post</a>, you should!</p> <p>First, you have to know what <a href='https://svelte.technology'>Svelte</a> is. Svelte is a UI framework with a bold new idea: rather than providing a library that you write code with (like React or Vue, for example), it's a compiler that turns your components into highly optimized vanilla JavaScript. If you haven't already read the <a href='https://svelte.technology/blog/frameworks-without-the-framework'>introductory blog post</a>, you should!</p>
<p>Sapper is a Next.js-style framework (<a href='/blog/how-is-sapper-different-from-next'>more on that here</a>) built around Svelte. It makes it embarrassingly easy to create extremely high performance web apps. Out of the box, you get:</p> <p>Sapper is a Next.js-style framework (<a href='blog/how-is-sapper-different-from-next'>more on that here</a>) built around Svelte. It makes it embarrassingly easy to create extremely high performance web apps. Out of the box, you get:</p>
<ul> <ul>
<li>Code-splitting, dynamic imports and hot module replacement, powered by webpack</li> <li>Code-splitting, dynamic imports and hot module replacement, powered by webpack</li>
@@ -70,8 +70,8 @@ const posts = [
<ul> <ul>
<li>It's powered by <a href='https://svelte.technology'>Svelte</a> instead of React, so it's faster and your apps are smaller</li> <li>It's powered by <a href='https://svelte.technology'>Svelte</a> instead of React, so it's faster and your apps are smaller</li>
<li>Instead of route masking, we encode route parameters in filenames. For example, the page you're looking at right now is <code>routes/blog/[slug].html</code></li> <li>Instead of route masking, we encode route parameters in filenames. For example, the page you're looking at right now is <code>routes/blog/[slug].html</code></li>
<li>As well as pages (Svelte components, which render on server or client), you can create <em>server routes</em> in your <code>routes</code> directory. These are just <code>.js</code> files that export functions corresponding to HTTP methods, and receive Express <code>request</code> and <code>response</code> objects as arguments. This makes it very easy to, for example, add a JSON API such as the one <a href='/blog/how-is-sapper-different-from-next.json'>powering this very page</a></li> <li>As well as pages (Svelte components, which render on server or client), you can create <em>server routes</em> in your <code>routes</code> directory. These are just <code>.js</code> files that export functions corresponding to HTTP methods, and receive Express <code>request</code> and <code>response</code> objects as arguments. This makes it very easy to, for example, add a JSON API such as the one <a href='blog/how-is-sapper-different-from-next.json'>powering this very page</a></li>
<li>Links are just <code>&lt;a&gt;</code> elements, rather than framework-specific <code>&lt;Link&gt;</code> components. That means, for example, that <a href='/blog/how-can-i-get-involved'>this link right here</a>, despite being inside a blob of HTML, works with the router as you'd expect.</li> <li>Links are just <code>&lt;a&gt;</code> elements, rather than framework-specific <code>&lt;Link&gt;</code> components. That means, for example, that <a href='blog/how-can-i-get-involved'>this link right here</a>, despite being inside a blob of HTML, works with the router as you'd expect.</li>
</ul> </ul>
` `
}, },

View File

@@ -11,7 +11,7 @@
tell Sapper to load the data for the page as soon as tell Sapper to load the data for the page as soon as
the user hovers over the link or taps it, instead of the user hovers over the link or taps it, instead of
waiting for the 'click' event --> waiting for the 'click' event -->
<li><a rel='prefetch' href='/blog/{{post.slug}}'>{{post.title}}</a></li> <li><a rel='prefetch' href='blog/{{post.slug}}'>{{post.title}}</a></li>
{{/each}} {{/each}}
</ul> </ul>
</Layout> </Layout>
@@ -32,7 +32,7 @@
}, },
preload({ params, query }) { preload({ params, query }) {
return fetch(`/blog.json`).then(r => r.json()).then(posts => { return fetch(`blog.json`).then(r => r.json()).then(posts => {
return { posts }; return { posts };
}); });
} }

View File

@@ -4,7 +4,7 @@
export default { export default {
methods: { methods: {
del() { del() {
fetch(`/api/delete/42`, { method: 'DELETE' }) fetch(`api/delete/42`, { method: 'DELETE' })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
window.deleted = data; window.deleted = data;

View File

@@ -6,7 +6,7 @@
<h1>Great success!</h1> <h1>Great success!</h1>
<figure> <figure>
<img alt='borat' src='/great-success.png'> <img alt='borat' src='great-success.png'>
<figcaption>HIGH FIVE!</figcaption> <figcaption>HIGH FIVE!</figcaption>
</figure> </figure>

View File

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

View File

@@ -12,6 +12,10 @@ Nightmare.action('page', {
this.evaluate_now(() => document.querySelector('h1').textContent, done); this.evaluate_now(() => document.querySelector('h1').textContent, done);
}, },
html(done) {
this.evaluate_now(() => document.documentElement.innerHTML, done);
},
text(done) { text(done) {
this.evaluate_now(() => document.body.textContent, done); this.evaluate_now(() => document.body.textContent, done);
} }
@@ -35,11 +39,21 @@ describe('sapper', function() {
rimraf.sync('build'); rimraf.sync('build');
rimraf.sync('.sapper'); rimraf.sync('.sapper');
this.timeout(30000); this.timeout(process.env.CI ? 30000 : 5000);
// TODO reinstate dev tests // TODO reinstate dev tests
// run('development'); // run({
run('production'); // mode: 'development'
// });
run({
mode: 'production'
});
run({
mode: 'production',
basepath: '/custom-basepath'
});
describe('export', () => { describe('export', () => {
before(() => { before(() => {
@@ -111,16 +125,29 @@ describe('sapper', function() {
}); });
}); });
function run(env) { function run({ mode, basepath = '' }) {
describe(`env=${env}`, function () { describe(`mode=${mode}`, function () {
let proc; let proc;
let nightmare;
let capture; let capture;
let base; let base;
const 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);
}
});
before(() => { before(() => {
const promise = env === 'production' const promise = mode === 'production'
? exec(`node ${cli} build`).then(() => ports.find(3000)) ? exec(`node ${cli} build`).then(() => ports.find(3000))
: ports.find(3000).then(port => { : ports.find(3000).then(port => {
exec(`node ${cli} dev`); exec(`node ${cli} dev`);
@@ -129,13 +156,15 @@ function run(env) {
return promise.then(port => { return promise.then(port => {
base = `http://localhost:${port}`; base = `http://localhost:${port}`;
if (basepath) base += basepath;
const dir = env === 'production' ? 'build' : '.sapper'; const dir = mode === 'production' ? 'build' : '.sapper';
proc = require('child_process').fork(`${dir}/server.js`, { proc = require('child_process').fork(`${dir}/server.js`, {
cwd: process.cwd(), cwd: process.cwd(),
env: { env: {
NODE_ENV: env, NODE_ENV: mode,
BASEPATH: basepath,
SAPPER_DEST: dir, SAPPER_DEST: dir,
PORT: port PORT: port
} }
@@ -183,30 +212,13 @@ function run(env) {
after(() => { after(() => {
// give a chance to clean up // give a chance to clean up
return new Promise(fulfil => { return Promise.all([
nightmare.end(),
new Promise(fulfil => {
proc.on('exit', fulfil); proc.on('exit', fulfil);
proc.kill(); proc.kill();
}); })
}); ]);
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', () => {
@@ -235,16 +247,16 @@ function run(env) {
}); });
it('navigates to a new page without reloading', () => { it('navigates to a new page without reloading', () => {
return capture(() => nightmare.goto(base).init().prefetchRoutes()) return nightmare.goto(base).init().prefetchRoutes()
.then(() => { .then(() => {
return capture(() => nightmare.click('a[href="/about"]')); return capture(() => nightmare.click('a[href="about"]'));
}) })
.then(requests => { .then(requests => {
assert.deepEqual(requests.map(r => r.url), []); assert.deepEqual(requests.map(r => r.url), []);
return nightmare.path(); return nightmare.path();
}) })
.then(path => { .then(path => {
assert.equal(path, '/about'); assert.equal(path, `${basepath}/about`);
return nightmare.title(); return nightmare.title();
}) })
.then(title => { .then(title => {
@@ -257,7 +269,7 @@ function run(env) {
.goto(`${base}/about`) .goto(`${base}/about`)
.init() .init()
.click('.goto') .click('.goto')
.wait(() => window.location.pathname === '/blog/what-is-sapper') .wait(url => window.location.pathname === url, `${basepath}/blog/what-is-sapper`)
.wait(100) .wait(100)
.title() .title()
.then(title => { .then(title => {
@@ -266,9 +278,7 @@ function run(env) {
}); });
it('prefetches programmatically', () => { it('prefetches programmatically', () => {
return nightmare return capture(() => nightmare.goto(`${base}/about`).init())
.goto(`${base}/about`)
.init()
.then(() => { .then(() => {
return capture(() => { return capture(() => {
return nightmare return nightmare
@@ -277,7 +287,7 @@ function run(env) {
}); });
}) })
.then(requests => { .then(requests => {
assert.ok(!!requests.find(r => r.url === '/blog/why-the-name.json')); assert.ok(!!requests.find(r => r.url === `/blog/why-the-name.json`));
}); });
}); });
@@ -298,21 +308,21 @@ function run(env) {
.then(() => { .then(() => {
return capture(() => { return capture(() => {
return nightmare return nightmare
.mouseover('[href="/blog/what-is-sapper"]') .mouseover('[href="blog/what-is-sapper"]')
.wait(200); .wait(200);
}); });
}) })
.then(mouseover_requests => { .then(mouseover_requests => {
assert.ok(mouseover_requests.findIndex(r => r.url === '/blog/what-is-sapper.json') !== -1); assert.ok(mouseover_requests.findIndex(r => r.url === `/blog/what-is-sapper.json`) !== -1);
return capture(() => { return capture(() => {
return nightmare return nightmare
.click('[href="/blog/what-is-sapper"]') .click('[href="blog/what-is-sapper"]')
.wait(200); .wait(200);
}); });
}) })
.then(click_requests => { .then(click_requests => {
assert.ok(click_requests.findIndex(r => r.url === '/blog/what-is-sapper.json') === -1); assert.ok(click_requests.findIndex(r => r.url === `/blog/what-is-sapper.json`) === -1);
}); });
}); });
@@ -320,13 +330,13 @@ function run(env) {
return nightmare return nightmare
.goto(base) .goto(base)
.init() .init()
.click('a[href="/slow-preload"]') .click('a[href="slow-preload"]')
.wait(100) .wait(100)
.click('a[href="/about"]') .click('a[href="about"]')
.wait(100) .wait(100)
.then(() => nightmare.path()) .then(() => nightmare.path())
.then(path => { .then(path => {
assert.equal(path, '/about'); assert.equal(path, `${basepath}/about`);
return nightmare.title(); return nightmare.title();
}) })
.then(title => { .then(title => {
@@ -335,7 +345,7 @@ function run(env) {
}) })
.then(() => nightmare.path()) .then(() => nightmare.path())
.then(path => { .then(path => {
assert.equal(path, '/about'); assert.equal(path, `${basepath}/about`);
return nightmare.title(); return nightmare.title();
}) })
.then(title => { .then(title => {
@@ -348,7 +358,7 @@ function run(env) {
.goto(`${base}/show-url`) .goto(`${base}/show-url`)
.init() .init()
.evaluate(() => document.querySelector('p').innerHTML) .evaluate(() => document.querySelector('p').innerHTML)
.end().then(html => { .then(html => {
assert.equal(html, `URL is /show-url`); assert.equal(html, `URL is /show-url`);
}); });
}); });
@@ -384,7 +394,7 @@ function run(env) {
return nightmare.goto(`${base}/redirect-from`) return nightmare.goto(`${base}/redirect-from`)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/redirect-to'); assert.equal(path, `${basepath}/redirect-to`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -394,12 +404,12 @@ function run(env) {
it('redirects in client', () => { it('redirects in client', () => {
return nightmare.goto(base) return nightmare.goto(base)
.wait('[href="/redirect-from"]') .wait('[href="redirect-from"]')
.click('[href="/redirect-from"]') .click('[href="redirect-from"]')
.wait(200) .wait(200)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/redirect-to'); assert.equal(path, `${basepath}/redirect-to`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -411,7 +421,7 @@ function run(env) {
return nightmare.goto(`${base}/blog/nope`) return nightmare.goto(`${base}/blog/nope`)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/blog/nope'); assert.equal(path, `${basepath}/blog/nope`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -422,11 +432,11 @@ function run(env) {
it('handles 4xx error in client', () => { it('handles 4xx error in client', () => {
return nightmare.goto(base) return nightmare.goto(base)
.init() .init()
.click('[href="/blog/nope"]') .click('[href="blog/nope"]')
.wait(200) .wait(200)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/blog/nope'); assert.equal(path, `${basepath}/blog/nope`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -438,7 +448,7 @@ function run(env) {
return nightmare.goto(`${base}/blog/throw-an-error`) return nightmare.goto(`${base}/blog/throw-an-error`)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/blog/throw-an-error'); assert.equal(path, `${basepath}/blog/throw-an-error`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -449,11 +459,11 @@ function run(env) {
it('handles non-4xx error in client', () => { it('handles non-4xx error in client', () => {
return nightmare.goto(base) return nightmare.goto(base)
.init() .init()
.click('[href="/blog/throw-an-error"]') .click('[href="blog/throw-an-error"]')
.wait(200) .wait(200)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/blog/throw-an-error'); assert.equal(path, `${basepath}/blog/throw-an-error`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -464,7 +474,7 @@ function run(env) {
it('does not attempt client-side navigation to server routes', () => { it('does not attempt client-side navigation to server routes', () => {
return nightmare.goto(`${base}/blog/how-is-sapper-different-from-next`) return nightmare.goto(`${base}/blog/how-is-sapper-different-from-next`)
.init() .init()
.click(`[href="/blog/how-is-sapper-different-from-next.json"]`) .click(`[href="blog/how-is-sapper-different-from-next.json"]`)
.wait(200) .wait(200)
.page.text() .page.text()
.then(text => { .then(text => {
@@ -515,7 +525,7 @@ function run(env) {
describe('headers', () => { describe('headers', () => {
it('sets Content-Type and Link...preload headers', () => { it('sets Content-Type and Link...preload headers', () => {
return capture(() => nightmare.goto(base).end()).then(requests => { return capture(() => nightmare.goto(base)).then(requests => {
const { headers } = requests[0]; const { headers } = requests[0];
assert.equal( assert.equal(
@@ -523,8 +533,16 @@ function run(env) {
'text/html' 'text/html'
); );
const str = ['main', '_\\.\\d+']
.map(file => {
return `<${basepath}/client/[^/]+/${file}\\.js>;rel="preload";as="script"`;
})
.join(', ');
const regex = new RegExp(str);
assert.ok( assert.ok(
/<\/client\/[^/]+\/main\.js>;rel="preload";as="script", <\/client\/[^/]+\/_\.\d+\.js>;rel="preload";as="script"/.test(headers['link']), regex.test(headers['link']),
headers['link'] headers['link']
); );
}); });
@@ -535,7 +553,7 @@ function run(env) {
function exec(cmd) { function exec(cmd) {
return new Promise((fulfil, reject) => { return new Promise((fulfil, reject) => {
const parts = cmd.split(' '); const parts = cmd.trim().split(' ');
const proc = require('child_process').spawn(parts.shift(), parts); const proc = require('child_process').spawn(parts.shift(), parts);
proc.stdout.on('data', data => { proc.stdout.on('data', data => {