From 8dc52a04e4bb3e358834442c6125a1989fa2ea4e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Jul 2018 08:14:07 -0400 Subject: [PATCH] split pages and server routes into separate arrays --- src/core/create_manifests.ts | 99 +++---- src/core/create_routes.ts | 6 +- src/middleware.ts | 442 ++++++++++++++++---------------- src/runtime/index.ts | 29 +-- test/unit/create_routes.test.js | 2 +- 5 files changed, 287 insertions(+), 291 deletions(-) diff --git a/src/core/create_manifests.ts b/src/core/create_manifests.ts index 58f3075..471667b 100644 --- a/src/core/create_manifests.ts +++ b/src/core/create_manifests.ts @@ -32,38 +32,42 @@ export function create_serviceworker_manifest({ routes, client_files }: { export const shell = [\n\t${client_files.map((x: string) => `"${x}"`).join(',\n\t')}\n]; - export const routes = [\n\t${routes.filter((r: Route) => r.type === 'page' && !/^_[45]xx$/.test(r.id)).map((r: Route) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n]; + export const routes = [\n\t${routes.pages.filter(r => r.id !== '_error').map((r: Route) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n]; `.replace(/^\t\t/gm, '').trim(); write_if_changed(`${locations.app()}/manifest/service-worker.js`, code); } function generate_client(routes: Route[], path_to_routes: string, dev_port?: number) { + const page_ids = new Set(routes.pages.map(page => page.id)); + const server_routes_to_ignore = routes.server_routes.filter(route => !page_ids.has(route.id)); + + const pages = routes.pages.filter(page => page.id !== '_error'); + const error_route = routes.pages.find(page => page.id === '_error'); + let code = ` // This file is generated by Sapper — do not edit it! - export const routes = [ - ${routes - .map(route => { - const page = route.handlers.find(({ type }) => type === 'page'); - - if (!page) { - return `{ pattern: ${route.pattern}, ignore: true }`; - } + export const routes = { + ignore: [${server_routes_to_ignore.map(route => route.pattern).join(', ')}], + pages: [ + ${pages.map(page => { const file = posixify(`${path_to_routes}/${page.file}`); - if (route.id === '_error') { - return `{ error: true, load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`; + if (page.id === '_error') { + return `{ error: true, load: () => import(/* webpackChunkName: "${page.id}" */ '${file}') }`; } - const params = route.params.length === 0 + const params = page.params.length === 0 ? '{}' - : `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; + : `{ ${page.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; - return `{ pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`; - }) - .join(',\n\t')} - ];`.replace(/^\t\t/gm, '').trim(); + return `{ pattern: ${page.pattern}, params: ${page.params.length > 0 ? `match` : `()`} => (${params}), load: () => import(/* webpackChunkName: "${page.id}" */ '${file}') }`; + }).join(',\n\t\t\t\t')} + ], + + error: () => import(/* webpackChunkName: '_error' */ '${posixify(`${path_to_routes}/${error_route.file}`)}') + };`.replace(/^\t\t/gm, '').trim(); if (dev()) { const sapper_dev_client = posixify( @@ -83,43 +87,46 @@ function generate_client(routes: Route[], path_to_routes: string, dev_port?: num } function generate_server(routes: Route[], path_to_routes: string) { + const error_route = routes.pages.find(page => page.id === '_error'); + + const imports = [].concat( + routes.server_routes.map(route => + `import * as route_${route.id} from '${posixify(`${path_to_routes}/${route.file}`)}';`), + routes.pages.map(page => + `import page_${page.id} from '${posixify(`${path_to_routes}/${page.file}`)}';`), + `import error from '${posixify(`${path_to_routes}/${error_route.file}`)}';` + ); + let code = ` // This file is generated by Sapper — do not edit it! - ${routes - .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 handlers = route.handlers - .map(({ type }, index) => - `{ type: '${type}', module: ${route.id}${index} }`) - .join(', '); - - if (route.id === '_error') { - return `{ error: true, handlers: [${handlers}] }`; - } + ${imports.join('\n')} + export const routes = { + server_routes: [ + ${routes.server_routes.map(route => { const params = route.params.length === 0 ? '{}' : `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; - return `{ id: '${route.id}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), handlers: [${handlers}] }`; - }) - .join(',\n\t') + return `{ id: '${route.id}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), handlers: route_${route.id} }`; + }).join(',\n\t\t\t\t')} + ], + + pages: [ + ${routes.pages.map(page => { + const params = page.params.length === 0 + ? '{}' + : `{ ${page.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; + + return `{ id: '${page.id}', pattern: ${page.pattern}, params: ${page.params.length > 0 ? `match` : `()`} => (${params}), handler: page_${page.id} }`; + }).join(',\n\t\t\t\t')} + ], + + error: { + error: true, + handler: error } - ];`.replace(/^\t\t/gm, '').trim(); + };`.replace(/^\t\t/gm, '').trim(); return code; } \ No newline at end of file diff --git a/src/core/create_routes.ts b/src/core/create_routes.ts index 91249f0..556bc28 100644 --- a/src/core/create_routes.ts +++ b/src/core/create_routes.ts @@ -34,6 +34,8 @@ export default function create_routes({ parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_') ) || '_'; + const type = file.endsWith('.html') ? 'page' : 'route'; + const params: string[] = []; const match_patterns: Record = {}; const param_pattern = /\[([^\(\]]+)(?:\((.+?)\))?\]/g; @@ -67,7 +69,7 @@ export default function create_routes({ const key_pattern = new RegExp('\\[' + k + '(?:\\((.+?)\\))?\\]'); matcher = matcher.replace(key_pattern, match_patterns[k] || `([^/]+?)`); }) - pattern_string = nested ? `(?:\\/${matcher}${pattern_string})?` : `\\/${matcher}${pattern_string}`; + pattern_string = (nested && type === 'page') ? `(?:\\/${matcher}${pattern_string})?` : `\\/${matcher}${pattern_string}`; } else { nested = false; pattern_string = `\\/${part}${pattern_string}`; @@ -93,7 +95,7 @@ export default function create_routes({ return { id, base, - type: file.endsWith('.html') ? 'page' : 'route', + type, file, pattern, test, diff --git a/src/middleware.ts b/src/middleware.ts index 0ca1a99..44c2b50 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -108,7 +108,8 @@ export default function middleware({ App, routes, store }: { cache_control: 'max-age=31536000' }), - get_route_handler(App, routes, store) + get_server_route_handler(routes.server_routes), + get_page_handler(App, routes, store) ].filter(Boolean)); return middleware; @@ -151,7 +152,82 @@ function serve({ prefix, pathname, cache_control }: { }; } -function get_route_handler(App: Component, routes: RouteObject[], store_getter: (req: Req) => Store) { +function get_server_route_handler(routes: RouteObject[]) { + function handle_route(route, req, res, next) { + req.params = route.params(route.pattern.exec(req.path)); + + 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 = route.handlers[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); + }; + + res.setHeader = function(name: string, value: string) { + headers[name.toLowerCase()] = value; + setHeader.apply(res, arguments); + }; + + res.end = function(chunk?: any) { + if (chunk) chunks.push(new Buffer(chunk)); + end.apply(res, arguments); + + process.send({ + __sapper__: true, + event: 'file', + url: req.url, + method: req.method, + status: res.statusCode, + type: headers['content-type'], + body: Buffer.concat(chunks).toString() + }); + }; + } + + const handle_next = (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_next); + } catch (err) { + handle_next(err); + } + } else { + // no matching handler for method + process.nextTick(next); + } + } + + return function find_route(req: Req, res: ServerResponse, next) { + for (const route of routes) { + if (route.pattern.test(req.path)) { + handle_route(route, req, res, next); + return; + } + } + + next(); + }; +} + +function get_page_handler(App: Component, routes: RouteObject[], store_getter: (req: Req) => Store) { const output = locations.dest(); const get_chunks = dev() @@ -162,247 +238,159 @@ function get_route_handler(App: Component, routes: RouteObject[], store_getter: ? () => fs.readFileSync(`${locations.app()}/template.html`, 'utf-8') : (str => () => str)(fs.readFileSync(`${locations.dest()}/template.html`, 'utf-8')); - const error_route = routes.find((route: RouteObject) => route.error); + const { server_routes, pages } = routes; + const error_route = routes.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](); + const chunks: Record = get_chunks(); - function next() { - const chunks: Record = get_chunks(); + res.setHeader('Content-Type', 'text/html'); - 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] || chunks._error) // TODO this is gross + .filter(file => !file.match(/\.map$/)) + .map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`) + .join(', '); - if (done) { - 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'); - } + res.setHeader('Link', link); - return; - } + const store = store_getter ? store_getter(req) : null; + const props = { params: req.params, query: req.query, path: req.path }; - const mod = handler.module; - - if (handler.type === 'page') { - res.setHeader('Content-Type', 'text/html'); - - // preload main.js and current route - // TODO detect other stuff we can preload? images, CSS, fonts? - const link = [] - .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(', '); - - res.setHeader('Link', link); - - 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 preload_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) => { - 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 + '/' :''}`); - - if (opts) { - opts = Object.assign({}, opts); - - 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 => { - preload_error = { statusCode: 500, message: err }; - }).then(preloaded => { - if (redirect) { - res.statusCode = redirect.statusCode; - res.setHeader('Location', `${req.baseUrl}/${redirect.location}`); - res.end(); - - return; - } - - if (preload_error) { - handle_route(error_route, req, res, preload_error.statusCode, preload_error.message); - return; - } - - const serialized = { - preloaded: mod.preload && try_serialize(preloaded), - store: store && try_serialize(store.get()) - }; - Object.assign(props, preloaded); - - const { html, head, css } = App.render({ Page: mod, props }, { - store - }); - - let scripts = [] - .concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack - .filter(file => !file.match(/\.map$/)) - .map(file => ``) - .join(''); - - let inline_script = `__SAPPER__={${[ - `baseUrl: "${req.baseUrl}"`, - serialized.preloaded && `preloaded: ${serialized.preloaded}`, - serialized.store && `store: ${serialized.store}` - ].filter(Boolean).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');`; - } - - 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.statusCode = status; - res.end(page); - - if (process.send) { - process.send({ - __sapper__: true, - event: 'file', - 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 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); - }; - - res.setHeader = function(name: string, value: string) { - headers[name.toLowerCase()] = value; - setHeader.apply(res, arguments); - }; - - res.end = function(chunk?: any) { - if (chunk) chunks.push(new Buffer(chunk)); - end.apply(res, arguments); - - process.send({ - __sapper__: true, - event: 'file', - 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); - } else { - process.nextTick(next); - } - }; - - try { - handle_method(req, res, handle_bad_result); - } catch (err) { - handle_bad_result(err); - } - } else { - // no matching handler for method - process.nextTick(next); - } - } - } catch (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'); - } - } + if (route.error) { + props.error = error instanceof Error ? error : { message: error }; + props.status = status; } - next(); + let redirect: { statusCode: number, location: string }; + let preload_error: { statusCode: number, message: Error | string }; + + Promise.resolve( + route.handler.preload ? route.handler.preload.call({ + redirect: (statusCode: number, location: string) => { + redirect = { statusCode, location }; + }, + error: (statusCode: number, message: Error | string) => { + 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 + '/' :''}`); + + if (opts) { + opts = Object.assign({}, opts); + + 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 => { + preload_error = { statusCode: 500, message: err }; + }).then(preloaded => { + if (redirect) { + res.statusCode = redirect.statusCode; + res.setHeader('Location', `${req.baseUrl}/${redirect.location}`); + res.end(); + + return; + } + + if (preload_error) { + handle_route(error_route, req, res, preload_error.statusCode, preload_error.message); + return; + } + + const serialized = { + preloaded: route.handler.preload && try_serialize(preloaded), + store: store && try_serialize(store.get()) + }; + Object.assign(props, preloaded); + + const { html, head, css } = App.render({ Page: route.handler, props }, { + store + }); + + let scripts = [] + .concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack + .filter(file => !file.match(/\.map$/)) + .map(file => ``) + .join(''); + + let inline_script = `__SAPPER__={${[ + `baseUrl: "${req.baseUrl}"`, + serialized.preloaded && `preloaded: ${serialized.preloaded}`, + serialized.store && `store: ${serialized.store}` + ].filter(Boolean).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');`; + } + + 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.statusCode = status; + res.end(page); + + if (process.send) { + process.send({ + __sapper__: true, + event: 'file', + url: req.url, + method: req.method, + status: 200, + type: 'text/html', + body: page + }); + } + }); } 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); + if (!server_routes.some(route => route.pattern.test(req.path))) { + for (const page of pages) { + if (page.pattern.test(req.path)) { + handle_route(page, req, res); + return; + } + } } handle_route(error_route, req, res, 404, 'Not found'); diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 6b202c8..e149c47 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -30,12 +30,15 @@ function select_route(url: URL): Target { const path = url.pathname.slice(manifest.baseUrl.length); - for (const route of routes) { - const match = route.pattern.exec(path); - if (match) { - if (route.ignore) return null; + // avoid accidental clashes between server routes and pages + if (routes.ignore.some(pattern => pattern.test(path))) return; - const params = route.params(match); + for (let i = 0; i < routes.pages.length; i += 1) { + const page = routes.pages[i]; + + const match = page.pattern.exec(path); + if (match) { + const params = page.params(match); const query: Record = {}; if (url.search.length > 0) { @@ -44,7 +47,7 @@ function select_route(url: URL): Target { query[key] = value || true; }) } - return { url, route, props: { params, query, path } }; + return { url, route: page, props: { params, query, path } }; } } } @@ -117,7 +120,7 @@ function prepare_route(Page: ComponentConstructor, props: RouteData) { error = { statusCode: 500, message: err }; }).then(preloaded => { if (error) { - return error_route.load().then(({ default: Page }: { default: ComponentConstructor }) => { + return error_route().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 }; @@ -261,8 +264,8 @@ export function init(opts: { App: ComponentConstructor, target: Node, routes: Ro App = opts.App; target = opts.target; - routes = opts.routes.filter(r => !r.error); - error_route = opts.routes.find(r => r.error); + routes = opts.routes; + error_route = opts.routes.error; if (opts && opts.store) { store = opts.store(manifest.store); @@ -312,14 +315,10 @@ export function goto(href: string, opts = { replaceState: false }) { export function prefetchRoutes(pathnames: string[]) { if (!routes) throw new Error(`You must call init() first`); - return routes + return routes.pages .filter(route => { if (!pathnames) return true; - return pathnames.some(pathname => { - return route.error - ? route.error === pathname - : route.pattern.test(pathname) - }); + return pathnames.some(pathname => route.pattern.test(pathname)); }) .reduce((promise: Promise, route) => { return promise.then(route.load); diff --git a/test/unit/create_routes.test.js b/test/unit/create_routes.test.js index af9ab35..535de03 100644 --- a/test/unit/create_routes.test.js +++ b/test/unit/create_routes.test.js @@ -1,7 +1,7 @@ const assert = require('assert'); const { create_routes } = require('../../dist/core.ts.js'); -describe.only('create_routes', () => { +describe('create_routes', () => { it('encodes characters not allowed in path', () => { const { server_routes } = create_routes({ files: [