From e01ff8cb6b492538dfd0f30bf18c91ec5dc6cf85 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 11 Dec 2017 08:55:21 -0500 Subject: [PATCH] initial commit --- .gitignore | 2 + README.md | 90 +++++++++++++++ connect.js | 67 +++++++++++ mocha.opts | 2 + package-lock.json | 205 ++++++++++++++++++++++++++++++++++ package.json | 36 ++++++ utils/create_matchers.js | 52 +++++++++ utils/create_matchers.test.js | 39 +++++++ 8 files changed, 493 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 connect.js create mode 100644 mocha.opts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 utils/create_matchers.js create mode 100644 utils/create_matchers.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91dfed8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +node_modules \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..43b1113 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# sapper + +Combat-ready apps, engineered by Svelte. + +## This is not a thing yet + +If you visit this README in a few weeks, hopefully it will have blossomed into the app development framework we deserve. Right now, it's just a set of ideas. + +--- + +[Next.js](https://github.com/zeit/next.js/) introduced a beautiful idea — that you should be able to build your app as universal React components in a special `pages` directory, and the framework should take care of routing and rendering on both client and server. What if we did the same thing for Svelte? + +High-level goals: + +* Extreme ease of development +* Code-splitting and HMR out of the box (probably via webpack) +* Best-in-class performance +* As little magic as possible. Anyone should be able to understand how everything fits together, and e.g. make changes to the webpack config + + +## Design + +A Sapper app is just an Express app (conventionally, `server.js`) that uses the `sapper` middleware: + +```js +const app = require('express')(); +const sapper = require('sapper'); + +const app = express(); + +app.use(sapper()); + +const { PORT = 3000 } = process.env; +app.listen(PORT, () => { + console.log(`listening on port ${PORT}`); +}); +``` + +The middleware serves pages that match files in the `routes` directory, and assets generated by webpack. In development mode, the middleware once activated watches `routes` to keep the app up-to-date. + + +## Routing + +Like Next, routes are defined by the project directory structure, but with some crucial differences: + +* Files with an `.html` extension are treated as Svelte components. The `routes/about.html` (or `routes/about/index.html`) would create the `/about` route. +* Files with a `.js` or `.mjs` extension are more generic route handlers. These files should export functions corresponding to the HTTP methods they support (example below). +* Instead of route masking, we embed parameters in the filename. For example `post/%id%.html` maps to `/post/:id`, and the component will be rendered with the appropriate parameter. +* Nested routes (read [this article](https://joshduff.com/2015-06-why-you-need-a-state-router.md)) can be handled by creating a file that matches the subroute — for example, `routes/app/settings/%submenu%.html` would match `/app/settings/profile` *and* `app/settings`, but in the latter case the `submenu` parameter would be `null`. + +An example of a generic route: + +```js +// routes/api/post/%id%.js +export async function get(req, res) { + try { + const data = await getPostFromDatabase(req.params.id); + const json = JSON.stringify(data); + + res.set({ + 'Content-Type': 'application/json', + 'Content-Length': json.length + }); + + res.send(json); + } catch (err) { + res.status(500).send(err.message); + } +} +``` + +Or, if you omit the `res` argument, it can use the return value: + +```js +// routes/api/post/%id%.js +export async function get(req, res) { + return await getPostFromDatabase(req.params.id); +} +``` + + +## Things to figure out + +* How to customise the overall page template +* An equivalent of `getInitialProps` +* Critical CSS +* `store` integration +* Route transitions +* Equivalent of `next export` +* ...and lots of other things that haven't occurred to me yet. \ No newline at end of file diff --git a/connect.js b/connect.js new file mode 100644 index 0000000..457c3db --- /dev/null +++ b/connect.js @@ -0,0 +1,67 @@ +require('svelte/ssr/register'); +const esm = require('@std/esm'); +const path = require('path'); +const glob = require('glob'); +const create_matchers = require('./utils/create_matchers.js'); + +require = esm(module, { + esm: 'all' +}); + +module.exports = function connect(opts = {}) { + const routes = path.resolve('routes'); + const out = path.resolve('.sapper'); + + let pages = glob.sync('**/*.html', { cwd: routes }); + let page_matchers = create_matchers(pages); + + let server_routes = glob.sync('**/*.+(js|mjs)', { cwd: routes }); + let server_route_matchers = create_matchers(server_routes); + + return async function(req, res, next) { + const url = req.url.replace(/\?.+/, ''); + + for (let i = 0; i < page_matchers.length; i += 1) { + const matcher = page_matchers[i]; + if (matcher.test(url)) { + const params = matcher.exec(url); + const Component = require(`${routes}/${matcher.file}`); + + res.end(Component.render({ + params, + query: req.query + })); + return; + } + } + + for (let i = 0; i < server_route_matchers.length; i += 1) { + const matcher = server_route_matchers[i]; + if (matcher.test(url)) { + req.params = matcher.exec(url); + const route = require(`${routes}/${matcher.file}`); + + const handler = route[req.method.toLowerCase()]; + if (handler) { + if (handler.length === 2) { + handler(req, res); + } else { + const data = await handler(req); + + // TODO headers, error handling + if (typeof data === 'string') { + res.end(data); + } else { + res.end(JSON.stringify(data)); + } + } + + return; + } + } + } + + next(); + }; +}; + diff --git a/mocha.opts b/mocha.opts new file mode 100644 index 0000000..c690038 --- /dev/null +++ b/mocha.opts @@ -0,0 +1,2 @@ +--recursive +utils/**/*.test.js \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5fe55d0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,205 @@ +{ + "name": "sapper", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@std/esm": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@std/esm/-/esm-0.18.0.tgz", + "integrity": "sha512-oeHSSVp/WxC08ngpKgyYR4LcI0+EBwZiJcB58jvIqyJnOGxudSkxTgAQKsVfpNsMXfOoILgu9PWhuzIZ8GQEjw==" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", + "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", + "dev": true, + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-stdout": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", + "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", + "dev": true + }, + "commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "diff": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.3.1.tgz", + "integrity": "sha512-MKPHZDMB0o6yHyDryUOScqZibp914ksXwAMYMTHj6KO8UeKsRYNJD3oNCKjTqZon+V488P7N/HzXF8t7ZR95ww==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "growl": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", + "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==", + "dev": true + }, + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "1.1.8" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-4.0.1.tgz", + "integrity": "sha512-evDmhkoA+cBNiQQQdSKZa2b9+W2mpLoj50367lhy+Klnx9OV8XlCIhigUnn1gaTFLQCa0kdNhEGDr0hCXOQFDw==", + "dev": true, + "requires": { + "browser-stdout": "1.3.0", + "commander": "2.11.0", + "debug": "3.1.0", + "diff": "3.3.1", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.3", + "he": "1.1.1", + "mkdirp": "0.5.1", + "supports-color": "4.4.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1.0.2" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "supports-color": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", + "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", + "dev": true, + "requires": { + "has-flag": "2.0.0" + } + }, + "svelte": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-1.47.0.tgz", + "integrity": "sha512-+RrPJXW3BDbbfSWFd2G2kAtUJs3cre0b4bWWe4mIkqprzL0FqVNBgWoSQNKmDngbCDbeXXvYC6ldkPp8bNXlRA==" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f47cc85 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "sapper", + "version": "0.0.1", + "description": "Combat-ready apps, engineered by Svelte", + "main": "connect.js", + "directories": { + "test": "test" + }, + "dependencies": { + "@std/esm": "^0.18.0" + }, + "devDependencies": { + "mocha": "^4.0.1", + "svelte": "^1.47.0" + }, + "scripts": { + "test": "mocha --opts mocha.opts" + }, + "repository": "https://github.com/sveltejs/sapper", + "keywords": [ + "svelte", + "isomorphic", + "universal", + "template", + "express" + ], + "author": "Rich Harris", + "license": "LIL", + "bugs": { + "url": "https://github.com/sveltejs/sapper/issues" + }, + "homepage": "https://github.com/sveltejs/sapper#readme", + "@std/esm": { + "esm": "js" + } +} diff --git a/utils/create_matchers.js b/utils/create_matchers.js new file mode 100644 index 0000000..a01200f --- /dev/null +++ b/utils/create_matchers.js @@ -0,0 +1,52 @@ +const path = require('path'); + +module.exports = function create_matchers(files) { + return files + .map(file => { + const parts = file.replace(/\.(html|js|mjs)$/, '').split(path.sep); + if (parts[parts.length - 1] === 'index') parts.pop(); + + const dynamic = parts + .filter(part => part[0] === '%') + .map(part => part.slice(1, -1)); + + const pattern = new RegExp( + `^\\/${parts.map(p => p[0] === '%' ? '([^/]+)' : p).join('\\/')}$` + ); + + const test = url => pattern.test(url); + + const exec = url => { + const match = pattern.exec(url); + if (!match) return; + + const params = {}; + dynamic.forEach((param, i) => { + params[param] = match[i + 1]; + }); + + return params; + }; + + return { + file, + test, + exec, + parts, + dynamic + }; + }) + .sort((a, b) => { + return ( + (a.dynamic.length - b.dynamic.length) || // match static paths first + (b.parts.length - a.parts.length) // match longer paths first + ); + }) + .map(matcher => { + return { + file: matcher.file, + test: matcher.test, + exec: matcher.exec + } + }); +} \ No newline at end of file diff --git a/utils/create_matchers.test.js b/utils/create_matchers.test.js new file mode 100644 index 0000000..e74f068 --- /dev/null +++ b/utils/create_matchers.test.js @@ -0,0 +1,39 @@ +const path = require('path'); +const assert = require('assert'); + +const create_matchers = require('./create_matchers.js'); + +describe('create_matchers', () => { + it('sorts routes correctly', () => { + const matchers = create_matchers(['index.html', 'about.html', '%wildcard%.html', 'post/%id%.html']); + + assert.deepEqual( + matchers.map(m => m.file), + [ + 'about.html', + 'index.html', + 'post/%id%.html', + '%wildcard%.html' + ] + ); + }); + + it('generates params', () => { + const matchers = create_matchers(['index.html', 'about.html', '%wildcard%.html', 'post/%id%.html']); + + let file; + let params; + for (let i = 0; i < matchers.length; i += 1) { + const matcher = matchers[i]; + if (params = matcher.exec('/post/123')) { + file = matcher.file; + break; + } + } + + assert.equal(file, 'post/%id%.html'); + assert.deepEqual(params, { + id: '123' + }); + }); +}); \ No newline at end of file