diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e3772..bf64274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # sapper changelog +## 0.14.0 + +* `4xx.html` and `5xx.html` are replaced with `_error.html` ([#209](https://github.com/sveltejs/sapper/issues/209)) +* Treat `foo/index.json.js` and `foo.json.js` as equivalents ([#297](https://github.com/sveltejs/sapper/issues/297)) +* Return a promise from `goto` ([#270](https://github.com/sveltejs/sapper/issues/270)) +* Use store when rendering error pages ([#293](https://github.com/sveltejs/sapper/issues/293)) +* Prevent console errors when visiting an error page ([#279](https://github.com/sveltejs/sapper/issues/279)) + +## 0.13.6 + +* Fix `baseUrl` synthesis ([#296](https://github.com/sveltejs/sapper/issues/296)) + +## 0.13.5 + +* Fix handling of fatal errors ([#289](https://github.com/sveltejs/sapper/issues/289)) + +## 0.13.4 + +* Focus `` after navigation ([#287](https://github.com/sveltejs/sapper/issues/287)) +* Fix timing of hot reload updates +* Emit `fatal` event if server crashes ([#285](https://github.com/sveltejs/sapper/pull/285)) +* Emit `stdout` and `stderr` events on dev watcher ([#285](https://github.com/sveltejs/sapper/pull/285)) +* Always refresh client assets in dev ([#286](https://github.com/sveltejs/sapper/pull/286)) +* Correctly initialise rebuild stats + +## 0.13.3 + +* Make `fatal` events clonable for IPC purposes + +## 0.13.2 + +* Emit a `basepath` event ([#284](https://github.com/sveltejs/sapper/pull/284)) + ## 0.13.1 * Reinstate ten-second interval between dev server heartbeats ([#276](https://github.com/sveltejs/sapper/issues/276)) diff --git a/package.json b/package.json index 4d188c4..d97f372 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sapper", - "version": "0.13.1", + "version": "0.14.0", "description": "Military-grade apps, engineered by Svelte", "main": "dist/middleware.ts.js", "bin": { diff --git a/src/api/dev.ts b/src/api/dev.ts index 524a4f9..712b302 100644 --- a/src/api/dev.ts +++ b/src/api/dev.ts @@ -6,7 +6,6 @@ import * as ports from 'port-authority'; import mkdirp from 'mkdirp'; import rimraf from 'rimraf'; import format_messages from 'webpack-format-messages'; -import prettyMs from 'pretty-ms'; import { locations } from '../config'; import { EventEmitter } from 'events'; import { create_routes, create_main_manifests, create_compilers, create_serviceworker_manifest } from '../core'; @@ -34,6 +33,7 @@ class Watcher extends EventEmitter { server: Deferred; }; + crashed: boolean; restarting: boolean; current_build: { changed: Set; @@ -91,7 +91,7 @@ class Watcher extends EventEmitter { if (this.port) { if (!await ports.check(this.port)) { this.emit('fatal', { - error: new Error(`Port ${this.port} is unavailable`) + message: `Port ${this.port} is unavailable` }); return; } @@ -131,6 +131,18 @@ class Watcher extends EventEmitter { // TODO watch the configs themselves? const compilers = create_compilers({ webpack: this.dirs.webpack }); + let log = ''; + + const emitFatal = () => { + this.emit('fatal', { + message: `Server crashed`, + log + }); + + this.crashed = true; + this.proc = null; + }; + this.watch(compilers.server, { name: 'server', @@ -143,22 +155,35 @@ class Watcher extends EventEmitter { fs.writeFileSync(path.join(dest, 'server_info.json'), JSON.stringify(info, null, ' ')); this.deferreds.client.promise.then(() => { - this.dev_server.send({ - status: 'completed' - }); - const restart = () => { - ports.wait(this.port).then((() => { - this.emit('ready', { - port: this.port, - process: this.proc - }); + log = ''; + this.crashed = false; - this.deferreds.server.fulfil(); - })); + ports.wait(this.port) + .then((() => { + this.emit('ready', { + port: this.port, + process: this.proc + }); + + this.deferreds.server.fulfil(); + + this.dev_server.send({ + status: 'completed' + }); + })) + .catch(err => { + if (this.crashed) return; + + this.emit('fatal', { + message: `Server is not listening on port ${this.port}`, + log + }); + }); }; if (this.proc) { + this.proc.removeListener('exit', emitFatal); this.proc.kill(); this.proc.on('exit', restart); } else { @@ -172,6 +197,26 @@ class Watcher extends EventEmitter { }, process.env), stdio: ['ipc'] }); + + this.proc.stdout.on('data', chunk => { + log += chunk; + this.emit('stdout', chunk); + }); + + this.proc.stderr.on('data', chunk => { + log += chunk; + this.emit('stderr', chunk); + }); + + this.proc.on('message', message => { + if (message.__sapper__ && message.event === 'basepath') { + this.emit('basepath', { + basepath: message.basepath + }); + } + }); + + this.proc.on('exit', emitFatal); }); } }); @@ -243,8 +288,8 @@ class Watcher extends EventEmitter { this.restarting = true; this.current_build = { - changed: new Set(), - rebuilding: new Set(), + changed: new Set([filename]), + rebuilding: new Set([type]), unique_warnings: new Set(), unique_errors: new Set() }; @@ -277,7 +322,7 @@ class Watcher extends EventEmitter { if (err) { this.emit('error', { type: name, - error: err + message: err.message }); } else { const messages = format_messages(stats); diff --git a/src/api/export.ts b/src/api/export.ts index 9e764c4..f0a0a57 100644 --- a/src/api/export.ts +++ b/src/api/export.ts @@ -66,7 +66,7 @@ async function execute(emitter: EventEmitter, { const saved = new Set(); proc.on('message', message => { - if (!message.__sapper__) return; + if (!message.__sapper__ || message.event !== 'file') return; let file = new URL(message.url, origin).pathname.slice(1); let { body } = message; diff --git a/src/api/interfaces.ts b/src/api/interfaces.ts index 623ae63..e1ac627 100644 --- a/src/api/interfaces.ts +++ b/src/api/interfaces.ts @@ -7,11 +7,12 @@ export type ReadyEvent = { export type ErrorEvent = { type: string; - error: Error; + message: string; }; export type FatalEvent = { - error: Error; + message: string; + log?: string; }; export type InvalidEvent = { diff --git a/src/cli/dev.ts b/src/cli/dev.ts index daf1306..cfc3414 100644 --- a/src/cli/dev.ts +++ b/src/cli/dev.ts @@ -36,11 +36,12 @@ export function dev(opts: { port: number, open: boolean }) { watcher.on('error', (event: events.ErrorEvent) => { console.log(`${colors.red(`✗ ${event.type}`)}`); - console.log(`${colors.red(event.error.message)}`); + console.log(`${colors.red(event.message)}`); }); watcher.on('fatal', (event: events.FatalEvent) => { - console.log(`${colors.bold.red(`> ${event.error.message}`)}`); + console.log(`${colors.bold.red(`> ${event.message}`)}`); + if (event.log) console.log(event.log); }); watcher.on('build', (event: events.BuildEvent) => { diff --git a/src/core/create_manifests.ts b/src/core/create_manifests.ts index 1135a8c..58f3075 100644 --- a/src/core/create_manifests.ts +++ b/src/core/create_manifests.ts @@ -52,8 +52,8 @@ function generate_client(routes: Route[], path_to_routes: string, dev_port?: num const file = posixify(`${path_to_routes}/${page.file}`); - if (route.id === '_4xx' || route.id === '_5xx') { - return `{ error: '${route.id.slice(1)}', load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`; + if (route.id === '_error') { + return `{ error: true, load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`; } const params = route.params.length === 0 @@ -107,8 +107,8 @@ function generate_server(routes: Route[], path_to_routes: string) { `{ type: '${type}', module: ${route.id}${index} }`) .join(', '); - if (route.id === '_4xx' || route.id === '_5xx') { - return `{ error: '${route.id.slice(1)}', handlers: [${handlers}] }`; + if (route.id === '_error') { + return `{ error: true, handlers: [${handlers}] }`; } const params = route.params.length === 0 diff --git a/src/core/create_routes.ts b/src/core/create_routes.ts index a0d9b89..e527abc 100644 --- a/src/core/create_routes.ts +++ b/src/core/create_routes.ts @@ -5,17 +5,22 @@ import { Route } from '../interfaces'; export default function create_routes({ files } = { files: glob.sync('**/*.*', { cwd: locations.routes(), dot: true, nodir: true }) }) { const routes: Route[] = files - .filter((file: string) => !/(^|\/|\\)_/.test(file)) + .filter((file: string) => !/(^|\/|\\)(_(?!error\.html)|\.(?!well-known))/.test(file)) .map((file: string) => { - if (/(^|\/|\\)(_|\.(?!well-known))/.test(file)) return; - if (/]\[/.test(file)) { throw new Error(`Invalid route ${file} — parameters must be separated`); } + if (file === '4xx.html' || file === '5xx.html') { + throw new Error('As of Sapper 0.14, 4xx.html and 5xx.html should be replaced with _error.html'); + } + const base = file.replace(/\.[^/.]+$/, ''); const parts = base.split('/'); // glob output is always posix-style - if (parts[parts.length - 1] === 'index') parts.pop(); + if (/^index(\..+)?/.test(parts[parts.length - 1])) { + const part = parts.pop(); + if (parts.length > 0) parts[parts.length - 1] += part.slice(5); + } return { files: [file], @@ -30,8 +35,8 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', { return !found; }) .sort((a, b) => { - if (a.parts[0] === '4xx' || a.parts[0] === '5xx') return -1; - if (b.parts[0] === '4xx' || b.parts[0] === '5xx') return 1; + if (a.parts[0] === '_error') return -1; + if (b.parts[0] === '_error') return 1; const max = Math.max(a.parts.length, b.parts.length); @@ -63,6 +68,7 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', { (a_sub_part.content < b_sub_part.content ? -1 : 1) ); } + // If both parts dynamic, check for regexp patterns if (a_sub_part.dynamic && b_sub_part.dynamic) { const regexp_pattern = /\((.*?)\)/; diff --git a/src/middleware.ts b/src/middleware.ts index bf9d3bd..0ca1a99 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,15 +1,12 @@ import * as fs from 'fs'; import * as path from 'path'; -import { resolve, URL } from 'url'; +import { URL } from 'url'; import { ClientRequest, ServerResponse } from 'http'; import cookie from 'cookie'; -import mkdirp from 'mkdirp'; -import rimraf from 'rimraf'; import devalue from 'devalue'; import fetch from 'node-fetch'; import { lookup } from './middleware/mime'; import { locations, dev } from './config'; -import { Route, Template } from './interfaces'; import sourceMapSupport from 'source-map-support'; sourceMapSupport.install(); @@ -59,16 +56,31 @@ export default function middleware({ App, routes, store }: { const output = locations.dest(); - const client_assets = JSON.parse(fs.readFileSync(path.join(output, 'client_assets.json'), 'utf-8')); + let emitted_basepath = false; const middleware = compose_handlers([ (req: Req, res: ServerResponse, next: () => void) => { if (req.baseUrl === undefined) { - req.baseUrl = req.originalUrl - ? req.originalUrl.slice(0, -req.url.length) + let { originalUrl } = req; + if (req.url === '/' && originalUrl[originalUrl.length - 1] !== '/') { + originalUrl += '/'; + } + + req.baseUrl = originalUrl + ? originalUrl.slice(0, -req.url.length) : ''; } + if (!emitted_basepath && process.send) { + process.send({ + __sapper__: true, + event: 'basepath', + basepath: req.baseUrl + }); + + emitted_basepath = true; + } + if (req.path === undefined) { req.path = req.url.replace(/\?.*/, ''); } @@ -96,7 +108,7 @@ export default function middleware({ App, routes, store }: { cache_control: 'max-age=31536000' }), - get_route_handler(client_assets, App, routes, store) + get_route_handler(App, routes, store) ].filter(Boolean)); return middleware; @@ -139,24 +151,41 @@ function serve({ prefix, pathname, cache_control }: { }; } -const resolved = Promise.resolve(); +function get_route_handler(App: Component, routes: RouteObject[], store_getter: (req: Req) => Store) { + const output = locations.dest(); + + const get_chunks = dev() + ? () => JSON.parse(fs.readFileSync(path.join(output, 'client_assets.json'), 'utf-8')) + : (assets => () => assets)(JSON.parse(fs.readFileSync(path.join(output, 'client_assets.json'), 'utf-8'))); -function get_route_handler(chunks: Record, App: Component, routes: RouteObject[], store_getter: (req: Req) => Store) { const template = dev() ? () => fs.readFileSync(`${locations.app()}/template.html`, 'utf-8') : (str => () => str)(fs.readFileSync(`${locations.dest()}/template.html`, 'utf-8')); - function handle_route(route: RouteObject, req: Req, res: ServerResponse) { - req.params = route.params(route.pattern.exec(req.path)); + const error_route = routes.find((route: RouteObject) => route.error); + + function handle_route(route: RouteObject, req: Req, res: ServerResponse, status = 200, error: Error | string = null) { + req.params = error + ? {} + : route.params(route.pattern.exec(req.path)); const handlers = route.handlers[Symbol.iterator](); function next() { + const chunks: Record = get_chunks(); + try { const { value: handler, done } = handlers.next(); if (done) { - handle_error(req, res, 404, 'Not found'); + if (route.error) { + // there was an error rendering the error page! + res.statusCode = status; + res.end(error instanceof Error ? error.message : error); + } else { + handle_route(error_route, req, res, 404, 'Not found'); + } + return; } @@ -168,7 +197,7 @@ function get_route_handler(chunks: Record, App: Component, route // preload main.js and current route // TODO detect other stuff we can preload? images, CSS, fonts? const link = [] - .concat(chunks.main, chunks[route.id]) + .concat(chunks.main, chunks[route.id] || chunks._error) // TODO this is gross .filter(file => !file.match(/\.map$/)) .map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`) .join(', '); @@ -178,8 +207,13 @@ function get_route_handler(chunks: Record, App: Component, route const store = store_getter ? store_getter(req) : null; const props = { params: req.params, query: req.query, path: req.path }; + if (route.error) { + props.error = error instanceof Error ? error : { message: error }; + props.status = status; + } + let redirect: { statusCode: number, location: string }; - let error: { statusCode: number, message: Error | string }; + let preload_error: { statusCode: number, message: Error | string }; Promise.resolve( mod.preload ? mod.preload.call({ @@ -187,7 +221,7 @@ function get_route_handler(chunks: Record, App: Component, route redirect = { statusCode, location }; }, error: (statusCode: number, message: Error | string) => { - error = { statusCode, message }; + preload_error = { statusCode, message }; }, fetch: (url: string, opts?: any) => { const parsed = new URL(url, `http://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`); @@ -227,7 +261,7 @@ function get_route_handler(chunks: Record, App: Component, route store }, req) : {} ).catch(err => { - error = { statusCode: 500, message: err }; + preload_error = { statusCode: 500, message: err }; }).then(preloaded => { if (redirect) { res.statusCode = redirect.statusCode; @@ -237,8 +271,8 @@ function get_route_handler(chunks: Record, App: Component, route return; } - if (error) { - handle_error(req, res, error.statusCode, error.message); + if (preload_error) { + handle_route(error_route, req, res, preload_error.statusCode, preload_error.message); return; } @@ -276,11 +310,13 @@ function get_route_handler(chunks: Record, App: Component, route .replace('%sapper.head%', `${head}`) .replace('%sapper.styles%', (css && css.code ? `` : '')); + res.statusCode = status; res.end(page); if (process.send) { process.send({ __sapper__: true, + event: 'file', url: req.url, method: req.method, status: 200, @@ -320,6 +356,7 @@ function get_route_handler(chunks: Record, App: Component, route process.send({ __sapper__: true, + event: 'file', url: req.url, method: req.method, status: res.statusCode, @@ -350,84 +387,25 @@ function get_route_handler(chunks: Record, App: Component, route } } } catch (error) { - handle_error(req, res, 500, error); + if (route.error) { + // there was an error rendering the error page! + res.statusCode = status; + res.end(error instanceof Error ? error.message : error); + } else { + handle_route(error_route, req, res, 500, error || 'Internal server error'); + } } } next(); } - const not_found_route = routes.find((route: RouteObject) => route.error === '4xx'); - const error_route = routes.find((route: RouteObject) => route.error === '5xx'); - - function handle_error(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 not_found = statusCode >= 400 && statusCode < 500; - - const route = not_found - ? not_found_route - : error_route; - - function render_page({ head, css, html }) { - const page = template() - .replace('%sapper.base%', ``) - .replace('%sapper.scripts%', ``) - .replace('%sapper.html%', html) - .replace('%sapper.head%', `${head}`) - .replace('%sapper.styles%', (css && css.code ? `` : '')); - - res.end(page); - } - - function handle_notfound() { - const title: string = not_found - ? 'Not found' - : `Internal server error: ${error.message}`; - - render_page({ head: '', css: null, html: title }); - } - - if (route) { - const handlers = route.handlers[Symbol.iterator](); - - function next() { - const { value: handler, done } = handlers.next(); - - if (done) { - handle_notfound(); - } else if (handler.type === 'page') { - render_page(handler.module.render({ - status: statusCode, - error - }, { - store: store_getter && store_getter(req) - })); - } else { - const handle_method = mod[method_export]; - if (handle_method) { - handle_method(req, res, next); - } else { - next(); - } - } - } - - next(); - } else { - handle_notfound(); - } - } - return function find_route(req: Req, res: ServerResponse) { for (const route of routes) { if (!route.error && route.pattern.test(req.path)) return handle_route(route, req, res); } - handle_error(req, res, 404, 'Not found'); + handle_route(error_route, req, res, 404, 'Not found'); }; } @@ -451,10 +429,6 @@ function compose_handlers(handlers: Handler[]) { }; } -function read_json(file: string) { - return JSON.parse(fs.readFileSync(file, 'utf-8')); -} - function try_serialize(data: any) { try { return devalue(data); diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 9575b74..6b202c8 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -8,7 +8,7 @@ export let component: Component; let target: Node; let store: Store; let routes: Route[]; -let errors: { '4xx': Route, '5xx': Route }; +let error_route: Route; const history = typeof window !== 'undefined' ? window.history : { pushState: (state: any, title: string, href: string) => {}, @@ -117,11 +117,7 @@ function prepare_route(Page: ComponentConstructor, props: RouteData) { 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: Page }: { default: ComponentConstructor }) => { + return error_route.load().then(({ default: Page }: { default: ComponentConstructor }) => { const err = error.message instanceof Error ? error.message : new Error(error.message); Object.assign(props, { status: error.statusCode, error: err }); return { Page, props, redirect: null }; @@ -133,7 +129,7 @@ function prepare_route(Page: ComponentConstructor, props: RouteData) { }); } -function navigate(target: Target, id: number) { +function navigate(target: Target, id: number): Promise { if (id) { // popstate or initial navigation cid = id; @@ -161,6 +157,7 @@ function navigate(target: Target, id: number) { } render(Page, props, scroll_history[id], token); + document.activeElement.blur(); }); } @@ -211,7 +208,11 @@ function handle_popstate(event: PopStateEvent) { if (event.state) { const url = new URL(window.location.href); const target = select_route(url); - navigate(target, event.state.id); + if (target) { + navigate(target, event.state.id); + } else { + window.location.href = window.location.href; + } } else { // hashchange cid = ++uid; @@ -261,10 +262,7 @@ export function init(opts: { App: ComponentConstructor, target: Node, routes: Ro App = opts.App; target = opts.target; routes = opts.routes.filter(r => !r.error); - errors = { - '4xx': opts.routes.find(r => r.error === '4xx'), - '5xx': opts.routes.find(r => r.error === '5xx') - }; + error_route = opts.routes.find(r => r.error); if (opts && opts.store) { store = opts.store(manifest.store); @@ -292,19 +290,23 @@ export function init(opts: { App: ComponentConstructor, target: Node, routes: Ro history.replaceState({ id: uid }, '', href); const target = select_route(new URL(window.location.href)); - return navigate(target, uid); + if (target) return navigate(target, uid); }); } export function goto(href: string, opts = { replaceState: false }) { const target = select_route(new URL(href, document.baseURI)); + let promise; if (target) { - navigate(target, null); + promise = navigate(target, null); if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href); } else { window.location.href = href; + promise = new Promise(f => {}); // never resolves } + + return promise; } export function prefetchRoutes(pathnames: string[]) { diff --git a/src/runtime/interfaces.ts b/src/runtime/interfaces.ts index d141039..00f814c 100644 --- a/src/runtime/interfaces.ts +++ b/src/runtime/interfaces.ts @@ -17,7 +17,7 @@ export interface Component { export type Route = { pattern: RegExp; load: () => Promise<{ default: ComponentConstructor }>; - error?: string; + error?: boolean; params?: (match: RegExpExecArray) => Record; ignore?: boolean; }; diff --git a/test/app/routes/5xx.html b/test/app/routes/5xx.html deleted file mode 100644 index 1425e2a..0000000 --- a/test/app/routes/5xx.html +++ /dev/null @@ -1,6 +0,0 @@ - - Internal server error - - -

Internal server error

-

{error.message}

\ No newline at end of file diff --git a/test/app/routes/4xx.html b/test/app/routes/_error.html similarity index 80% rename from test/app/routes/4xx.html rename to test/app/routes/_error.html index c63ca84..9974a17 100644 --- a/test/app/routes/4xx.html +++ b/test/app/routes/_error.html @@ -2,5 +2,5 @@ {status} -

Not found

+

{status}

{error.message}

\ No newline at end of file diff --git a/test/app/routes/about.html b/test/app/routes/about.html index 605e0db..65ebb57 100644 --- a/test/app/routes/about.html +++ b/test/app/routes/about.html @@ -6,15 +6,21 @@

This is the 'about' page. There's not much here.

-