From 008b607c015b9bd7e06b40f5f1278c490e68ed8b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 4 Jul 2018 10:53:41 -0400 Subject: [PATCH 1/2] generate pages and server routes separately --- src/core/create_routes.ts | 170 +++++++++++++++----------------- test/unit/create_routes.test.js | 144 +++++++++++++-------------- 2 files changed, 152 insertions(+), 162 deletions(-) diff --git a/src/core/create_routes.ts b/src/core/create_routes.ts index 2dc0c10..91249f0 100644 --- a/src/core/create_routes.ts +++ b/src/core/create_routes.ts @@ -3,8 +3,16 @@ import glob from 'glob'; import { locations } from '../config'; import { Route } from '../interfaces'; -export default function create_routes({ files } = { files: glob.sync('**/*.*', { cwd: locations.routes(), dot: true, nodir: true }) }) { - const routes: Route[] = files +export default function create_routes({ + files +} = { + files: glob.sync('**/*.*', { + cwd: locations.routes(), + dot: true, + nodir: true + }) +}) { + const all_routes = files .filter((file: string) => !/(^|\/|\\)(_(?!error\.html)|\.(?!well-known))/.test(file)) .map((file: string) => { if (/]\[/.test(file)) { @@ -22,82 +30,14 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', { if (parts.length > 0) parts[parts.length - 1] += part.slice(5); } - return { - files: [file], - base, - parts - }; - }) - .filter(Boolean) - .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) => { - if (a.parts[0] === '_error') return -1; - if (b.parts[0] === '_error') 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) - ); - } - - // If both parts dynamic, check for regexp patterns - if (a_sub_part.dynamic && b_sub_part.dynamic) { - const regexp_pattern = /\((.*?)\)/; - const a_match = regexp_pattern.exec(a_sub_part.content); - const b_match = regexp_pattern.exec(b_sub_part.content); - - if (!a_match && b_match) { - return 1; // No regexp, so less specific than b - } - if (!b_match && a_match) { - return -1; - } - if (a_match && b_match && a_match[1] !== b_match[1]) { - return b_match[1].length - a_match[1].length; - } - } - } - } - - 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, '_') - ) || '_'; + ) || '_'; const params: string[] = []; - const match_patterns: object = {}; + const match_patterns: Record = {}; const param_pattern = /\[([^\(\]]+)(?:\((.+?)\))?\]/g; + let match; while (match = param_pattern.exec(base)) { params.push(match[1]); @@ -152,20 +92,9 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', { return { id, - 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; - }), + base, + type: file.endsWith('.html') ? 'page' : 'route', + file, pattern, test, exec, @@ -174,7 +103,72 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', { }; }); - return routes; + const pages = all_routes + .filter(r => r.type === 'page') + .sort(comparator); + + const server_routes = all_routes + .filter(r => r.type === 'route') + .sort(comparator); + + return { pages, server_routes }; +} + +function comparator(a, b) { + if (a.parts[0] === '_error') return -1; + if (b.parts[0] === '_error') 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) + ); + } + + // If both parts dynamic, check for regexp patterns + if (a_sub_part.dynamic && b_sub_part.dynamic) { + const regexp_pattern = /\((.*?)\)/; + const a_match = regexp_pattern.exec(a_sub_part.content); + const b_match = regexp_pattern.exec(b_sub_part.content); + + if (!a_match && b_match) { + return 1; // No regexp, so less specific than b + } + if (!b_match && a_match) { + return -1; + } + if (a_match && b_match && a_match[1] !== b_match[1]) { + return b_match[1].length - a_match[1].length; + } + } + } + } + + throw new Error(`The ${a.base} and ${b.base} routes clash`); } function get_sub_parts(part: string) { diff --git a/test/unit/create_routes.test.js b/test/unit/create_routes.test.js index 32f2b86..af9ab35 100644 --- a/test/unit/create_routes.test.js +++ b/test/unit/create_routes.test.js @@ -1,31 +1,9 @@ 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' - } - ] - ] - ) - }); - +describe.only('create_routes', () => { it('encodes characters not allowed in path', () => { - const routes = create_routes({ + const { server_routes } = create_routes({ files: [ '"', '#', @@ -34,7 +12,7 @@ describe('create_routes', () => { }); assert.deepEqual( - routes.map(r => r.pattern), + server_routes.map(r => r.pattern), [ /^\/%22\/?$/, /^\/%23\/?$/, @@ -44,22 +22,22 @@ describe('create_routes', () => { }); it('sorts routes correctly', () => { - const routes = create_routes({ + const { pages, server_routes } = create_routes({ files: [ - 'index.html', - 'about.html', - 'post/f[xx].html', - '[wildcard].html', - 'post/foo.html', - 'post/[id].html', - 'post/bar.html', + 'index.html', + 'about.html', + 'post/f[xx].html', + '[wildcard].html', + 'post/foo.html', + 'post/[id].html', + 'post/bar.html', 'post/[id].json.js', 'post/[id([0-9-a-z]{3,})].html', ] }); assert.deepEqual( - routes.map(r => r.handlers[0].file), + pages.map(r => r.file), [ 'index.html', 'about.html', @@ -67,15 +45,21 @@ describe('create_routes', () => { 'post/foo.html', 'post/f[xx].html', 'post/[id([0-9-a-z]{3,})].html', // RegExp is more specific - 'post/[id].json.js', 'post/[id].html', '[wildcard].html' ] ); + + assert.deepEqual( + server_routes.map(r => r.file), + [ + 'post/[id].json.js' + ] + ); }); it('distinguishes and sorts regexp routes correctly', () => { - const routes = create_routes({ + const { pages } = create_routes({ files: [ '[slug].html', '[slug([a-z]{2})].html', @@ -84,7 +68,7 @@ describe('create_routes', () => { }); assert.deepEqual( - routes.map(r => r.handlers[0].file), + pages.map(r => r.file), [ '[slug([0-9-a-z]{3,})].html', '[slug([a-z]{2})].html', @@ -94,7 +78,7 @@ describe('create_routes', () => { }); it('prefers index page to nested route', () => { - let routes = create_routes({ + let { pages, server_routes } = create_routes({ files: [ 'api/examples/[slug].js', 'api/examples/index.js', @@ -110,14 +94,20 @@ describe('create_routes', () => { }); assert.deepEqual( - routes.map(r => r.handlers[0].file), + pages.map(r => r.file), [ '_error.html', 'index.html', 'guide/index.html', 'blog/index.html', + 'blog/[slug].html' + ] + ); + + assert.deepEqual( + server_routes.map(r => r.file), + [ 'blog/rss.xml.js', - 'blog/[slug].html', 'api/examples/index.js', 'api/examples/[slug].js', 'api/gists/index.js', @@ -125,7 +115,7 @@ describe('create_routes', () => { ] ); - routes = create_routes({ + ({ pages, server_routes } = create_routes({ files: [ '_error.html', 'api/blog/[slug].js', @@ -141,20 +131,26 @@ describe('create_routes', () => { 'index.html', 'repl/index.html' ] - }); + })); assert.deepEqual( - routes.map(r => r.handlers[0].file), + pages.map(r => r.file), [ '_error.html', 'index.html', 'guide/index.html', 'blog/index.html', - 'blog/rss.xml.js', 'blog/[slug].html', + 'repl/index.html' + ] + ); + + assert.deepEqual( + server_routes.map(r => r.file), + [ + 'blog/rss.xml.js', 'gist/create.js', 'gist/[id].js', - 'repl/index.html', 'api/guide/index.js', 'api/guide/contents.js', 'api/blog/index.js', @@ -163,16 +159,16 @@ describe('create_routes', () => { ); // RegExp routes - routes = create_routes({ + ({ pages } = create_routes({ files: [ 'blog/[slug].html', 'blog/index.html', 'blog/[slug([^0-9]+)].html', ] - }); + })); assert.deepEqual( - routes.map(r => r.handlers[0].file), + pages.map(r => r.file), [ 'blog/index.html', 'blog/[slug([^0-9]+)].html', @@ -182,16 +178,16 @@ describe('create_routes', () => { }); it('generates params', () => { - const routes = create_routes({ + const { pages } = create_routes({ files: ['index.html', 'about.html', '[wildcard].html', 'post/[id].html'] }); let file; let params; - for (let i = 0; i < routes.length; i += 1) { - const route = routes[i]; + for (let i = 0; i < pages.length; i += 1) { + const route = pages[i]; if (params = route.exec('/post/123')) { - file = route.handlers[0].file; + file = route.file; break; } } @@ -203,12 +199,12 @@ describe('create_routes', () => { }); it('ignores files and directories with leading underscores', () => { - const routes = create_routes({ + const { pages } = create_routes({ files: ['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html'] }); assert.deepEqual( - routes.map(r => r.handlers[0].file), + pages.map(r => r.file), [ 'index.html', 'e/f/g/h.html' @@ -217,13 +213,13 @@ describe('create_routes', () => { }); it('ignores files and directories with leading dots except .well-known', () => { - const routes = create_routes({ - files: ['.well-known', '.unknown'] + const { server_routes } = create_routes({ + files: ['.well-known/dnt-policy.txt.js', '.unknown/foo.txt.js'] }); assert.deepEqual( - routes.map(r => r.handlers[0].file), - ['.well-known'] + server_routes.map(r => r.file), + ['.well-known/dnt-policy.txt.js'] ); }); @@ -236,12 +232,12 @@ describe('create_routes', () => { }); assert.deepEqual( - a.map(r => r.handlers[0].file), + a.pages.map(r => r.file), ['foo/[bar].html', '[baz]/qux.html'] ); assert.deepEqual( - b.map(r => r.handlers[0].file), + b.pages.map(r => r.file), ['foo/[bar].html', '[baz]/qux.html'] ); }); @@ -262,46 +258,46 @@ describe('create_routes', () => { it('matches nested routes', () => { - const route = create_routes({ + const page = create_routes({ files: ['settings/[submenu].html'] - })[0]; + }).pages[0]; - assert.deepEqual(route.exec('/settings/foo'), { + assert.deepEqual(page.exec('/settings/foo'), { submenu: 'foo' }); - assert.deepEqual(route.exec('/settings'), { + assert.deepEqual(page.exec('/settings'), { submenu: null }); }); it('prefers index routes to nested routes', () => { - const routes = create_routes({ + const { pages } = create_routes({ files: ['settings/[submenu].html', 'settings.html'] }); assert.deepEqual( - routes.map(r => r.handlers[0].file), + pages.map(r => r.file), ['settings.html', 'settings/[submenu].html'] ); }); it('matches deeply nested routes', () => { - const route = create_routes({ + const page = create_routes({ files: ['settings/[a]/[b]/index.html'] - })[0]; + }).pages[0]; - assert.deepEqual(route.exec('/settings/foo/bar'), { + assert.deepEqual(page.exec('/settings/foo/bar'), { a: 'foo', b: 'bar' }); - assert.deepEqual(route.exec('/settings/foo'), { + assert.deepEqual(page.exec('/settings/foo'), { a: 'foo', b: null }); - assert.deepEqual(route.exec('/settings'), { + assert.deepEqual(page.exec('/settings'), { a: null, b: null }); @@ -310,7 +306,7 @@ describe('create_routes', () => { it('matches a dynamic part within a part', () => { const route = create_routes({ files: ['things/[slug].json.js'] - })[0]; + }).server_routes[0]; assert.deepEqual(route.exec('/things/foo.json'), { slug: 'foo' @@ -320,7 +316,7 @@ describe('create_routes', () => { it('matches multiple dynamic parts within a part', () => { const route = create_routes({ files: ['things/[id]_[slug].json.js'] - })[0]; + }).server_routes[0]; assert.deepEqual(route.exec('/things/someid_someslug.json'), { id: 'someid', @@ -363,7 +359,7 @@ describe('create_routes', () => { it('treats foo/index.json.js the same as foo.json.js', () => { const route = create_routes({ files: ['foo/index.json.js'] - })[0]; + }).server_routes[0]; assert.ok(route.test('/foo.json')); }); From 8dc52a04e4bb3e358834442c6125a1989fa2ea4e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Jul 2018 08:14:07 -0400 Subject: [PATCH 2/2] 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: [