From 917dd60cc374dd2201997f9f0869befce225b917 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sun, 15 Apr 2018 23:11:08 +0900 Subject: [PATCH] Allow to have middleware for the path same with a HTML page HTTP allows to change the type of the content to serve by Accept field in the request. The middleware for the path same with a HTML page will be inserted before the HTML renderer, and can take advantage of this feature, using expressjs's "accepts" method, for example. --- src/core/create_manifests.ts | 32 ++- src/core/create_routes.ts | 112 +++++---- src/interfaces.ts | 6 +- src/middleware.ts | 400 ++++++++++++++++++-------------- test/unit/create_routes.test.js | 46 ++-- 5 files changed, 344 insertions(+), 252 deletions(-) diff --git a/src/core/create_manifests.ts b/src/core/create_manifests.ts index 3614359..2a31220 100644 --- a/src/core/create_manifests.ts +++ b/src/core/create_manifests.ts @@ -45,11 +45,13 @@ function generate_client(routes: Route[], path_to_routes: string, dev_port?: num export const routes = [ ${routes .map(route => { - if (route.type !== 'page') { + const page = route.handlers.find(({ type }) => type === 'page'); + + if (!page) { return `{ pattern: ${route.pattern}, ignore: true }`; } - const file = posixify(`${path_to_routes}/${route.file}`); + 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}') }`; @@ -85,28 +87,36 @@ function generate_server(routes: Route[], path_to_routes: string) { let code = ` // This file is generated by Sapper — do not edit it! ${routes - .map(route => { - const file = posixify(`${path_to_routes}/${route.file}`); - return route.type === 'page' - ? `import ${route.id} from '${file}';` - : `import * as ${route.id} from '${file}';`; - }) + .map(route => + route.handlers + .map(({ type, file }, index) => { + const module = posixify(`${path_to_routes}/${file}`); + + return type === 'page' + ? `import ${route.id}${index} from '${module}';` + : `import * as ${route.id}${index} from '${module}';`; + }) + .join('\n') + ) .join('\n')} export const routes = [ ${routes .map(route => { - const file = posixify(`../../${route.file}`); + const handlers = route.handlers + .map(({ type }, index) => + `{ type: '${type}', module: ${route.id}${index} }`) + .join(', '); if (route.id === '_4xx' || route.id === '_5xx') { - return `{ error: '${route.id.slice(1)}', module: ${route.id} }`; + return `{ error: '${route.id.slice(1)}', handlers: [${handlers}] }`; } const params = route.params.length === 0 ? '{}' : `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; - return `{ id: '${route.id}', type: '${route.type}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), module: ${route.id} }`; + return `{ id: '${route.id}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), handlers: [${handlers}] }`; }) .join(',\n\t') } diff --git a/src/core/create_routes.ts b/src/core/create_routes.ts index 3888b87..60dbe06 100644 --- a/src/core/create_routes.ts +++ b/src/core/create_routes.ts @@ -5,9 +5,8 @@ import { Route } from '../interfaces'; export default function create_routes({ files } = { files: glob.sync('**/*.*', { cwd: locations.routes(), nodir: true }) }) { const routes: Route[] = files + .filter((file: string) => !/(^|\/|\\)_/.test(file)) .map((file: string) => { - if (/(^|\/|\\)_/.test(file)) return; - if (/]\[/.test(file)) { throw new Error(`Invalid route ${file} — parameters must be separated`); } @@ -16,6 +15,59 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', { const parts = base.split('/'); // glob output is always posix-style if (parts[parts.length - 1] === 'index') parts.pop(); + return { + files: [file], + base, + parts + }; + }) + .filter((a, index, array) => { + const found = array.slice(index + 1).find(b => a.base === b.base); + if (found) found.files.push(...a.files); + return !found; + }) + .sort((a, b) => { + const max = Math.max(a.parts.length, b.parts.length); + + if (max === 1) { + if (a.parts[0] === '4xx' || a.parts[0] === '5xx') return -1; + if (b.parts[0] === '4xx' || b.parts[0] === '5xx') return 1; + } + + for (let i = 0; i < max; i += 1) { + const a_part = a.parts[i]; + const b_part = b.parts[i]; + + if (!a_part) return -1; + if (!b_part) return 1; + + const a_sub_parts = get_sub_parts(a_part); + const b_sub_parts = get_sub_parts(b_part); + const max = Math.max(a_sub_parts.length, b_sub_parts.length); + + for (let i = 0; i < max; i += 1) { + const a_sub_part = a_sub_parts[i]; + const b_sub_part = b_sub_parts[i]; + + if (!a_sub_part) return 1; // b is more specific, so goes first + if (!b_sub_part) return -1; + + if (a_sub_part.dynamic !== b_sub_part.dynamic) { + return a_sub_part.dynamic ? 1 : -1; + } + + if (!a_sub_part.dynamic && a_sub_part.content !== b_sub_part.content) { + return ( + (b_sub_part.content.length - a_sub_part.content.length) || + (a_sub_part.content < b_sub_part.content ? -1 : 1) + ); + } + } + } + + throw new Error(`The ${a.base} and ${b.base} routes clash`); + }) + .map(({ files, base, parts }) => { const id = ( parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_') ) || '_'; @@ -63,54 +115,26 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', { return { id, - type: path.extname(file) === '.html' ? 'page' : 'route', - file, + handlers: files.map(file => ({ + type: path.extname(file) === '.html' ? 'page' : 'route', + file + })).sort((a, b) => { + if (a.type === 'page' && b.type === 'route') { + return 1; + } + + if (a.type === 'route' && b.type === 'page') { + return -1; + } + + return 0; + }), pattern, test, exec, parts, params }; - }) - .filter(Boolean) - .sort((a: Route, b: Route) => { - if (a.file === '4xx.html' || a.file === '5xx.html') return -1; - if (b.file === '4xx.html' || b.file === '5xx.html') return 1; - - const max = Math.max(a.parts.length, b.parts.length); - - for (let i = 0; i < max; i += 1) { - const a_part = a.parts[i]; - const b_part = b.parts[i]; - - if (!a_part) return -1; - if (!b_part) return 1; - - const a_sub_parts = get_sub_parts(a_part); - const b_sub_parts = get_sub_parts(b_part); - const max = Math.max(a_sub_parts.length, b_sub_parts.length); - - for (let i = 0; i < max; i += 1) { - const a_sub_part = a_sub_parts[i]; - const b_sub_part = b_sub_parts[i]; - - if (!a_sub_part) return 1; // b is more specific, so goes first - if (!b_sub_part) return -1; - - if (a_sub_part.dynamic !== b_sub_part.dynamic) { - return a_sub_part.dynamic ? 1 : -1; - } - - if (!a_sub_part.dynamic && a_sub_part.content !== b_sub_part.content) { - return ( - (b_sub_part.content.length - a_sub_part.content.length) || - (a_sub_part.content < b_sub_part.content ? -1 : 1) - ); - } - } - } - - throw new Error(`The ${a.file} and ${b.file} routes clash`); }); return routes; diff --git a/src/interfaces.ts b/src/interfaces.ts index ebc006e..82f6b3b 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,7 +1,9 @@ export type Route = { id: string; - type: 'page' | 'route'; - file: string; + handlers: { + type: 'page' | 'route'; + file: string; + }[]; pattern: RegExp; test: (url: string) => boolean; exec: (url: string) => Record; diff --git a/src/middleware.ts b/src/middleware.ts index 57c1d67..5316527 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -143,195 +143,212 @@ function get_route_handler(chunks: Record, routes: RouteObject[] function handle_route(route: RouteObject, req: Req, res: ServerResponse) { req.params = route.params(route.pattern.exec(req.path)); - const mod = route.module; + const handlers = route.handlers[Symbol.iterator](); - if (route.type === 'page') { - res.setHeader('Content-Type', 'text/html'); + function next() { + try { + const { value: handler, done } = handlers.next(); - // preload main.js and current route - // TODO detect other stuff we can preload? images, CSS, fonts? - const link = [] - .concat(chunks.main, chunks[route.id]) - .filter(file => !file.match(/\.map$/)) - .map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`) - .join(', '); + if (done) { + handle_error(req, res, 404, 'Not found'); + return; + } - res.setHeader('Link', link); + const mod = handler.module; - const store = store_getter ? store_getter(req) : null; - const data = { params: req.params, query: req.query }; + if (handler.type === 'page') { + res.setHeader('Content-Type', 'text/html'); - let redirect: { statusCode: number, location: string }; - let error: { statusCode: number, message: Error | string }; + // preload main.js and current route + // TODO detect other stuff we can preload? images, CSS, fonts? + const link = [] + .concat(chunks.main, chunks[route.id]) + .filter(file => !file.match(/\.map$/)) + .map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`) + .join(', '); - Promise.resolve( - mod.preload ? mod.preload.call({ - redirect: (statusCode: number, location: string) => { - redirect = { statusCode, location }; - }, - error: (statusCode: number, message: Error | string) => { - 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 + '/' :''}`); + res.setHeader('Link', link); - if (opts) { - opts = Object.assign({}, opts); + const store = store_getter ? store_getter(req) : null; + const data = { params: req.params, query: req.query }; - const include_cookies = ( - opts.credentials === 'include' || - opts.credentials === 'same-origin' && parsed.origin === `http://127.0.0.1:${process.env.PORT}` - ); + let redirect: { statusCode: number, location: string }; + let error: { statusCode: number, message: Error | string }; - if (include_cookies) { - const cookies: Record = {}; - if (!opts.headers) opts.headers = {}; + Promise.resolve( + mod.preload ? mod.preload.call({ + redirect: (statusCode: number, location: string) => { + redirect = { statusCode, location }; + }, + error: (statusCode: number, message: Error | string) => { + 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 + '/' :''}`); - const str = [] - .concat( - cookie.parse(req.headers.cookie || ''), - cookie.parse(opts.headers.cookie || ''), - cookie.parse(res.getHeader('Set-Cookie') || '') - ) - .map(cookie => { - return Object.keys(cookie) - .map(name => `${name}=${encodeURIComponent(cookie[name])}`) - .join('; '); - }) - .filter(Boolean) - .join(', '); + if (opts) { + opts = Object.assign({}, opts); - opts.headers.cookie = str; - } + const include_cookies = ( + opts.credentials === 'include' || + opts.credentials === 'same-origin' && parsed.origin === `http://127.0.0.1:${process.env.PORT}` + ); + + if (include_cookies) { + const cookies: Record = {}; + if (!opts.headers) opts.headers = {}; + + const str = [] + .concat( + cookie.parse(req.headers.cookie || ''), + cookie.parse(opts.headers.cookie || ''), + cookie.parse(res.getHeader('Set-Cookie') || '') + ) + .map(cookie => { + return Object.keys(cookie) + .map(name => `${name}=${encodeURIComponent(cookie[name])}`) + .join('; '); + }) + .filter(Boolean) + .join(', '); + + opts.headers.cookie = str; + } + } + + return fetch(parsed.href, opts); + }, + store + }, req) : {} + ).catch(err => { + error = { statusCode: 500, message: err }; + }).then(preloaded => { + if (redirect) { + res.statusCode = redirect.statusCode; + res.setHeader('Location', `${req.baseUrl}/${redirect.location}`); + res.end(); + + return; } - return fetch(parsed.href, opts); - }, - store - }, req) : {} - ).catch(err => { - error = { statusCode: 500, message: err }; - }).then(preloaded => { - if (redirect) { - res.statusCode = redirect.statusCode; - res.setHeader('Location', `${req.baseUrl}/${redirect.location}`); - res.end(); + if (error) { + handle_error(req, res, error.statusCode, error.message); + return; + } - return; - } + const serialized = { + preloaded: mod.preload && try_serialize(preloaded), + store: store && try_serialize(store.get()) + }; + Object.assign(data, preloaded); - if (error) { - handle_error(req, res, error.statusCode, error.message); - return; - } + const { html, head, css } = mod.render(data, { + store + }); - const serialized = { - preloaded: mod.preload && try_serialize(preloaded), - store: store && try_serialize(store.get()) - }; - Object.assign(data, preloaded); + let scripts = [] + .concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack + .filter(file => !file.match(/\.map$/)) + .map(file => ``) + .join(''); - const { html, head, css } = mod.render(data, { - store - }); + let inline_script = `__SAPPER__={${[ + `baseUrl: "${req.baseUrl}"`, + serialized.preloaded && `preloaded: ${serialized.preloaded}`, + serialized.store && `store: ${serialized.store}` + ].filter(Boolean).join(',')}};`; - let scripts = [] - .concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack - .filter(file => !file.match(/\.map$/)) - .map(file => ``) - .join(''); + const has_service_worker = fs.existsSync(path.join(locations.dest(), 'service-worker.js')); + if (has_service_worker) { + inline_script += `if ('serviceWorker' in navigator) navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`; + } - let inline_script = `__SAPPER__={${[ - `baseUrl: "${req.baseUrl}"`, - serialized.preloaded && `preloaded: ${serialized.preloaded}`, - serialized.store && `store: ${serialized.store}` - ].filter(Boolean).join(',')}};`; + const page = template() + .replace('%sapper.base%', ``) + .replace('%sapper.scripts%', `${scripts}`) + .replace('%sapper.html%', html) + .replace('%sapper.head%', `${head}`) + .replace('%sapper.styles%', (css && css.code ? `` : '')); - const has_service_worker = fs.existsSync(path.join(locations.dest(), 'service-worker.js')); - if (has_service_worker) { - inline_script += `if ('serviceWorker' in navigator) navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`; - } + res.end(page); - const page = template() - .replace('%sapper.base%', ``) - .replace('%sapper.scripts%', `${scripts}`) - .replace('%sapper.html%', html) - .replace('%sapper.head%', `${head}`) - .replace('%sapper.styles%', (css && css.code ? `` : '')); - - res.end(page); - - if (process.send) { - process.send({ - __sapper__: true, - url: req.url, - method: req.method, - status: 200, - type: 'text/html', - body: page + if (process.send) { + process.send({ + __sapper__: true, + url: req.url, + method: req.method, + status: 200, + type: 'text/html', + body: page + }); + } }); } - }); - } - else { - const method = req.method.toLowerCase(); - // 'delete' cannot be exported from a module because it is a keyword, - // so check for 'del' instead - const method_export = method === 'delete' ? 'del' : method; - const handler = mod[method_export]; - if (handler) { - if (process.env.SAPPER_EXPORT) { - const { write, end, setHeader } = res; - const chunks: any[] = []; - const headers: Record = {}; + else { + const method = req.method.toLowerCase(); + // 'delete' cannot be exported from a module because it is a keyword, + // so check for 'del' instead + const method_export = method === 'delete' ? 'del' : method; + const handle_method = mod[method_export]; + if (handle_method) { + if (process.env.SAPPER_EXPORT) { + const { write, end, setHeader } = res; + const chunks: any[] = []; + const headers: Record = {}; - // intercept data so that it can be exported - res.write = function(chunk: any) { - chunks.push(new Buffer(chunk)); - write.apply(res, arguments); - }; + // 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.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); + 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() - }); - }; - } + process.send({ + __sapper__: true, + url: req.url, + method: req.method, + status: res.statusCode, + type: headers['content-type'], + body: Buffer.concat(chunks).toString() + }); + }; + } - const handle_bad_result = (err?: Error) => { - if (err) { - console.error(err.stack); - res.statusCode = 500; - res.end(err.message); + const handle_bad_result = (err?: Error) => { + if (err) { + console.error(err.stack); + res.statusCode = 500; + res.end(err.message); + } else { + process.nextTick(next); + } + }; + + try { + handle_method(req, res, handle_bad_result); + } catch (err) { + handle_bad_result(err); + } } else { - handle_error(req, res, 404, 'Not found'); + // no matching handler for method + process.nextTick(next); } - }; - - try { - handler(req, res, handle_bad_result); - } catch (err) { - handle_bad_result(err); } - } else { - // no matching handler for method — 404 - handle_error(req, res, 404, 'Not found'); + } catch (error) { + handle_error(req, res, 500, error); } } + + next(); } const not_found_route = routes.find((route: RouteObject) => route.error === '4xx'); @@ -349,39 +366,62 @@ function get_route_handler(chunks: Record, routes: RouteObject[] ? not_found_route : error_route; - const title: string = not_found - ? 'Not found' - : `Internal server error: ${error.message}`; + 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 ? `` : '')); - const rendered = route ? route.module.render({ - status: statusCode, - error - }, { - store: store_getter && store_getter(req) - }) : { head: '', css: null, html: title }; + res.end(page); + } - const { head, css, html } = rendered; + function handle_notfound() { + const title: string = not_found + ? 'Not found' + : `Internal server error: ${error.message}`; - const page = template() - .replace('%sapper.base%', ``) - .replace('%sapper.scripts%', ``) - .replace('%sapper.html%', html) - .replace('%sapper.head%', `${head}`) - .replace('%sapper.styles%', (css && css.code ? `` : '')); + render_page({ head: '', css: null, html: title }); + } - res.end(page); + 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) { - try { - 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'); - } catch (error) { - handle_error(req, res, 500, error); + 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'); }; } diff --git a/test/unit/create_routes.test.js b/test/unit/create_routes.test.js index 5b70ffe..c422413 100644 --- a/test/unit/create_routes.test.js +++ b/test/unit/create_routes.test.js @@ -2,13 +2,35 @@ const assert = require('assert'); const { create_routes } = require('../../dist/core.ts.js'); describe('create_routes', () => { + it('sorts handlers correctly', () => { + const routes = create_routes({ + files: ['foo.html', 'foo.js'] + }); + + assert.deepEqual( + routes.map(r => r.handlers), + [ + [ + { + type: 'route', + file: 'foo.js' + }, + { + type: 'page', + file: 'foo.html' + } + ] + ] + ); + }); + it('sorts routes correctly', () => { const routes = create_routes({ files: ['index.html', 'about.html', 'post/f[xx].html', '[wildcard].html', 'post/foo.html', 'post/[id].html', 'post/bar.html', 'post/[id].json.js'] }); assert.deepEqual( - routes.map(r => r.file), + routes.map(r => r.handlers[0].file), [ 'index.html', 'about.html', @@ -40,7 +62,7 @@ describe('create_routes', () => { }); assert.deepEqual( - routes.map(r => r.file), + routes.map(r => r.handlers[0].file), [ '4xx.html', '5xx.html', @@ -76,7 +98,7 @@ describe('create_routes', () => { }); assert.deepEqual( - routes.map(r => r.file), + routes.map(r => r.handlers[0].file), [ '4xx.html', '5xx.html', @@ -106,7 +128,7 @@ describe('create_routes', () => { for (let i = 0; i < routes.length; i += 1) { const route = routes[i]; if (params = route.exec('/post/123')) { - file = route.file; + file = route.handlers[0].file; break; } } @@ -123,7 +145,7 @@ describe('create_routes', () => { }); assert.deepEqual( - routes.map(r => r.file), + routes.map(r => r.handlers[0].file), [ 'index.html', 'e/f/g/h.html' @@ -140,12 +162,12 @@ describe('create_routes', () => { }); assert.deepEqual( - a.map(r => r.file), + a.map(r => r.handlers[0].file), ['foo/[bar].html', '[baz]/qux.html'] ); assert.deepEqual( - b.map(r => r.file), + b.map(r => r.handlers[0].file), ['foo/[bar].html', '[baz]/qux.html'] ); }); @@ -155,13 +177,7 @@ describe('create_routes', () => { create_routes({ files: ['[foo].html', '[bar]/index.html'] }); - }, /The \[foo\].html and \[bar\]\/index.html routes clash/); - - assert.throws(() => { - create_routes({ - files: ['foo.html', 'foo.js'] - }); - }, /The foo.html and foo.js routes clash/); + }, /The \[foo\] and \[bar\]\/index routes clash/); }); it('matches nested routes', () => { @@ -184,7 +200,7 @@ describe('create_routes', () => { }); assert.deepEqual( - routes.map(r => r.file), + routes.map(r => r.handlers[0].file), ['settings.html', 'settings/[submenu].html'] ); });