diff --git a/package.json b/package.json index 019455173..841be3cd8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "sander": "^0.6.0", "serialize-javascript": "^1.4.0", "url-parse": "^1.2.0", + "wait-port": "^0.2.2", "walk-sync": "^0.3.2", "webpack": "^3.10.0" }, diff --git a/src/cli/utils.ts b/src/cli/utils.ts index 6fd1a1eec..a68778495 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -1,18 +1,24 @@ -import * as net from 'net'; +import waitPort from 'wait-port'; export function wait_for_port(port: number, cb: () => void) { - const socket = net.createConnection({ port }, () => { - cb(); - socket.destroy(); - }); + waitPort({ port }).then(cb); +} - socket.on('error', err => { - setTimeout(() => { - wait_for_port(port, cb); - }, 100); - }); +// import * as net from 'net'; - setTimeout(() => { - socket.destroy(); - }, 100); -} \ No newline at end of file +// 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); +// } \ No newline at end of file diff --git a/src/core/create_template.ts b/src/core/create_template.ts index 84eb5dcd0..3ca5da999 100644 --- a/src/core/create_template.ts +++ b/src/core/create_template.ts @@ -34,59 +34,6 @@ export default function create_templates() { return template.replace(/%sapper\.(\w+)%/g, (match, key) => { return key in data ? data[key] : ''; }); - }, - stream: (req: any, res: any, data: Record>) => { - let i = 0; - - let body = ''; - - function stream_inner(): Promise { - if (i >= template.length) { - return; - } - - const start = template.indexOf('%sapper', i); - - if (start === -1) { - const chunk = template.slice(i); - body += chunk; - res.end(chunk); - - if (process.send) { - process.send({ - __sapper__: true, - url: req.url, - method: req.method, - type: 'text/html', - body - }); - } - - return; - } - - const chunk = template.slice(i, start); - body += chunk; - res.write(chunk); - - 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(chunk => { - body += chunk; - res.write(chunk); - i = end + 1; - return stream_inner(); - }); - } - - return Promise.resolve().then(stream_inner); } }; } \ No newline at end of file diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 53f236048..cd2f14614 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -19,6 +19,7 @@ type Assets = { } type RouteObject = { + id: string; type: 'page' | 'route'; pattern: RegExp; params: (match: RegExpMatchArray) => Record; @@ -93,9 +94,7 @@ export default function middleware({ routes }: { } }, - get_route_handler(client_info.assetsByChunkName, routes, template), - - get_not_found_handler(client_info.assetsByChunkName, routes, template) + get_route_handler(client_info.assetsByChunkName, routes, template) ].filter(Boolean)); return middleware; @@ -119,13 +118,12 @@ function get_asset_handler({ pathname, type, cache, body }: { const resolved = Promise.resolve(); function get_route_handler(chunks: Record, routes: RouteObject[], template: Template) { - function handle_route(route: RouteObject, req: Req, res: ServerResponse, next: () => void) { + function handle_route(route: RouteObject, req: Req, res: ServerResponse) { req.params = route.params(route.pattern.exec(req.pathname)); const mod = route.module; if (route.type === 'page') { - // for page routes, we're going to serve some HTML res.setHeader('Content-Type', 'text/html'); // preload main.js and current route @@ -134,33 +132,44 @@ function get_route_handler(chunks: Record, routes: RouteObject[] const data = { params: req.params, query: req.query }; - if (mod.preload) { - const promise = Promise.resolve(mod.preload(req)).then(preloaded => { - const serialized = try_serialize(preloaded); - Object.assign(data, preloaded); - - return { rendered: mod.render(data), serialized }; - }); + let redirect: { statusCode: number, location: string }; + let error: { statusCode: number, message: Error | string }; + + 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; + } - return template.stream(req, res, { - scripts: promise.then(({ serialized }) => { - const main = ``; + if (error) { + handle_error(req, res, error.statusCode, error.message); + return; + } - if (serialized) { - return `${main}`; - } + 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 }) => `${rendered.head}`), - styles: promise.then(({ rendered }) => (rendered.css && rendered.css.code ? `` : '')) - }); - } else { const { html, head, css } = mod.render(data); + let scripts = ``; + scripts = `${scripts}`; + const page = template.render({ - scripts: ``, + scripts, html, head: `${head}`, styles: (css && css.code ? `` : '') @@ -178,7 +187,7 @@ function get_route_handler(chunks: Record, routes: RouteObject[] body: page }); } - } + }); } else { @@ -219,60 +228,55 @@ function get_route_handler(chunks: Record, routes: RouteObject[] }; } - handler(req, res, next); + handler(req, res, () => { + handle_not_found(req, res, 404, 'Not found'); + }); } else { // no matching handler for method — 404 - next(); + handle_not_found(req, res, 404, 'Not found'); } } } - const error_route = routes.find((route: RouteObject) => route.error === '5xx') + const not_found_route = routes.find((route: RouteObject) => route.error === '4xx'); - return function find_route(req: Req, res: ServerResponse, next: () => void) { - const url = req.pathname; + function handle_not_found(req: Req, res: ServerResponse, statusCode: number, message: Error | string) { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'text/html'); - try { - for (const route of routes) { - if (!route.error && route.pattern.test(url)) return handle_route(route, req, res, next); - } + const error = message instanceof Error ? message : new Error(message); - // no matching route — 404 - next(); - } catch (error) { - console.error(error); + const rendered = not_found_route ? not_found_route.module.render({ + status: 404, + error + }) : { head: '', css: null, html: error.message }; - res.statusCode = 500; - res.setHeader('Content-Type', 'text/html'); + const { head, css, html } = rendered; - const rendered = error_route ? error_route.module.render({ - status: 500, - error - }) : { head: '', css: null, html: 'Not found' }; + res.end(template.render({ + scripts: ``, + html, + head: `${head}`, + styles: (css && css.code ? `` : '') + })); + } - const { head, css, html } = rendered; + const error_route = routes.find((route: RouteObject) => route.error === '5xx'); - res.end(template.render({ - scripts: ``, - html, - head: `${head}`, - styles: (css && css.code ? `` : '') - })); + 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); } - }; -} -function get_not_found_handler(chunks: Record, routes: RouteObject[], template: Template) { - const route = routes.find((route: RouteObject) => route.error === '4xx'); - - return function handle_not_found(req: Req, res: ServerResponse) { - res.statusCode = 404; + res.statusCode = statusCode; res.setHeader('Content-Type', 'text/html'); - const rendered = route ? route.module.render({ - status: 404, - message: 'Not found' - }) : { head: '', css: null, html: 'Not found' }; + 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; @@ -282,6 +286,20 @@ function get_not_found_handler(chunks: Record, routes: RouteObje head: `${head}`, styles: (css && css.code ? `` : '') })); + } + + return function find_route(req: Req, res: ServerResponse, next: () => void) { + const url = req.pathname; + + try { + for (const route of routes) { + if (!route.error && route.pattern.test(url)) return handle_route(route, req, res); + } + + handle_not_found(req, res, 404, 'Not found'); + } catch (error) { + handle_error(req, res, 500, error); + } }; } diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 2db8247ff..92fdd0e44 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -74,17 +74,41 @@ function render(Component: ComponentConstructor, data: any, scroll: ScrollPositi } 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) { - return { Component, data }; + return { Component, data, redirect, error }; } 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) - return { Component, data }; + return { Component, data, redirect }; }); } @@ -110,7 +134,11 @@ function navigate(target: Target, id: number) { 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); }); } diff --git a/test/app/routes/4xx.html b/test/app/routes/4xx.html index 119e9fc32..837db653b 100644 --- a/test/app/routes/4xx.html +++ b/test/app/routes/4xx.html @@ -3,8 +3,8 @@ -

{{status}}

-

{{message}}

+

Not found

+

{{error.message}}

\ No newline at end of file diff --git a/test/app/routes/redirect-to.html b/test/app/routes/redirect-to.html new file mode 100644 index 000000000..eeb0dfc2c --- /dev/null +++ b/test/app/routes/redirect-to.html @@ -0,0 +1 @@ +

redirected

\ No newline at end of file diff --git a/test/common/test.js b/test/common/test.js index 5bf9e2c5f..da6ccfb43 100644 --- a/test/common/test.js +++ b/test/common/test.js @@ -219,9 +219,7 @@ function run(env) { }); }) .then(mouseover_requests => { - assert.deepEqual(mouseover_requests.map(r => r.url), [ - '/api/blog/what-is-sapper' - ]); + assert.ok(mouseover_requests.findIndex(r => r.url === '/api/blog/what-is-sapper') !== -1); return capture(() => { return nightmare @@ -230,7 +228,7 @@ function run(env) { }); }) .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); }); }); @@ -297,6 +295,87 @@ function run(env) { 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', () => {