Compare commits

...

30 Commits

Author SHA1 Message Date
Rich Harris
2a5786d7d7 -> v0.0.6 2017-12-13 22:22:03 -05:00
Rich Harris
8b89a5f27e make now-friendly 2017-12-13 22:21:55 -05:00
Rich Harris
d46a270cf0 remove requires 2017-12-13 22:11:28 -05:00
Rich Harris
72efde9515 -> v0.0.5 2017-12-13 22:08:29 -05:00
Rich Harris
7c0789cabf move webpack config out of sapper, into the app 2017-12-13 21:35:56 -05:00
Rich Harris
bffffe0035 detach SSRd <head> contents 2017-12-13 20:24:58 -05:00
Rich Harris
22d3cb2b1e serve 404 pages etc 2017-12-13 14:22:46 -05:00
Rich Harris
e1ed1896e6 easier templates 2017-12-13 14:14:06 -05:00
Rich Harris
6d4c26d15d server-side preloading, and critical CSS rendering 2017-12-13 13:30:27 -05:00
Rich Harris
941867f0a4 add client-side preloading logic, move router into runtime module 2017-12-13 13:29:38 -05:00
Rich Harris
c7e3fc4493 handle errors. (TODO: handle errors *nicely*) 2017-12-13 12:24:12 -05:00
Rich Harris
a8373c1568 always create valid route id 2017-12-13 12:23:49 -05:00
Rich Harris
db4223133e -> v0.0.4 2017-12-12 11:42:35 -05:00
Rich Harris
ef3a3e83e8 install rimraf 2017-12-12 11:42:18 -05:00
Rich Harris
86292d119b create all routes simultaneously, differentiate with type property 2017-12-12 11:42:06 -05:00
Rich Harris
2549477e05 rename create_matchers -> create_routes 2017-12-12 11:41:45 -05:00
Rich Harris
cf0ab4b9c7 bundle server code as well 2017-12-12 11:41:18 -05:00
Rich Harris
58768ae27d make selector customisable 2017-12-12 06:19:53 -05:00
Rich Harris
fa70024a92 dont treat files and dirs with leading _ as routes 2017-12-12 06:19:28 -05:00
Rich Harris
33fcb865c8 fix navigation and ESM stuff 2017-12-11 18:05:07 -05:00
Rich Harris
c6778c961b add devDependencies 2017-12-11 17:23:30 -05:00
Rich Harris
cea14b4b53 tidy up 2017-12-11 17:23:22 -05:00
Rich Harris
12661449c2 fix deps 2017-12-11 17:05:16 -05:00
Rich Harris
2f51435d93 render with params 2017-12-11 17:05:01 -05:00
Rich Harris
642c2904df switch to using [param].html style filenames 2017-12-11 17:04:21 -05:00
Rich Harris
727782aca2 -> v0.0.3 2017-12-11 13:13:00 -05:00
Rich Harris
b6f789e50c use absolute path 2017-12-11 13:12:51 -05:00
Rich Harris
e723f781a8 -> v0.0.2 2017-12-11 13:08:35 -05:00
Rich Harris
a22e28a11f start generating client-side bundle. WIP 2017-12-11 13:08:25 -05:00
Rich Harris
ad8a410ba4 update README 2017-12-11 10:39:17 -05:00
15 changed files with 5290 additions and 96 deletions

View File

@@ -16,6 +16,7 @@ High-level goals:
* 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
* Links are just `<a>` tags, no special `<Link>` components
## Design
@@ -26,8 +27,6 @@ A Sapper app is just an Express app (conventionally, `server.js`) that uses the
const app = require('express')();
const sapper = require('sapper');
const app = express();
app.use(sapper());
const { PORT = 3000 } = process.env;
@@ -45,13 +44,13 @@ Like Next, routes are defined by the project directory structure, but with some
* 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`.
* 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
// routes/api/post/[id].js
export async function get(req, res) {
try {
const data = await getPostFromDatabase(req.params.id);
@@ -72,13 +71,71 @@ export async function get(req, res) {
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) {
// routes/api/post/[id].js
export async function get(req) {
return await getPostFromDatabase(req.params.id);
}
```
## Client-side app
Sapper will create (and in development mode, update) a barebones `main.js` file that dynamically imports individual routes and renders them — something like this:
```js
window.addEventListener('click', event => {
let a = event.target;
while (a && a.nodeName !== 'A') a = a.parentNode;
if (!a) return;
if (navigate(new URL(a.href))) event.preventDefault();
});
const target = document.querySelector('#sapper');
let component;
function navigate(url) {
if (url.origin !== window.location.origin) return;
let match;
let params = {};
const query = {};
function render(mod) {
if (component) {
component.destroy();
} else {
target.innerHTML = '';
}
component = new mod.default({
target,
data: { query, params },
hydrate: !!component
});
}
if (url.pathname === '/about') {
import('/about/index.html').then(render);
} else if (url.pathname === '/') {
import('/index.js').then(render);
} else if (match = /^\/post\/([^\/]+)$/.exec(url.pathname)) {
params.id = match[1];
import('/post/[id].html').then(render);
} else if (match = /^\/([^\/]+)$/.exec(url.pathname)) {
params.wildcard = match[1];
import('/[wildcard].html').then(render);
}
return true;
}
navigate(window.location);
```
We're glossing over a lot of important stuff here — e.g. handling `popstate` — but you get the idea. Knowledge of all the possible routes means we can generate optimal code, much in the same way that statically analysing Svelte templates allows the compiler to generate optimal code.
## Things to figure out
* How to customise the overall page template
@@ -87,4 +144,7 @@ export async function get(req, res) {
* `store` integration
* Route transitions
* Equivalent of `next export`
* A good story for realtime/GraphQL stuff
* Service worker
* Using `Link...rel=preload` headers to push main.js/[route].js plus styles
* ...and lots of other things that haven't occurred to me yet.

View File

@@ -1,67 +1,110 @@
require('svelte/ssr/register');
const esm = require('@std/esm');
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const create_matchers = require('./utils/create_matchers.js');
const rimraf = require('rimraf');
const create_routes = require('./utils/create_routes.js');
const create_templates = require('./utils/create_templates.js');
const create_app = require('./utils/create_app.js');
const create_webpack_compiler = require('./utils/create_webpack_compiler.js');
const { src, dest, dev } = require('./lib/config.js');
require = esm(module, {
esm: 'all'
const esmRequire = esm(module, {
esm: 'js'
});
module.exports = function connect(opts = {}) {
const routes = path.resolve('routes');
const out = path.resolve('.sapper');
module.exports = function connect(opts) {
rimraf.sync(dest);
fs.mkdirSync(dest);
let pages = glob.sync('**/*.html', { cwd: routes });
let page_matchers = create_matchers(pages);
let routes = create_routes(
glob.sync('**/*.+(html|js|mjs)', { cwd: src })
);
let server_routes = glob.sync('**/*.+(js|mjs)', { cwd: routes });
let server_route_matchers = create_matchers(server_routes);
create_app(src, dest, routes, opts);
const webpack_compiler = create_webpack_compiler(
dest,
routes,
dev
);
const templates = create_templates();
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;
}
if (url.startsWith('/client/')) {
res.set({
'Content-Type': 'application/javascript'
});
fs.createReadStream(`${dest}${url}`).pipe(res);
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}`);
// whatever happens, we're going to serve some HTML
res.set({
'Content-Type': 'text/html'
});
const handler = route[req.method.toLowerCase()];
if (handler) {
if (handler.length === 2) {
handler(req, res);
} else {
const data = await handler(req);
try {
for (const route of routes) {
if (route.test(url)) {
req.params = route.exec(url);
// TODO headers, error handling
if (typeof data === 'string') {
res.end(data);
} else {
res.end(JSON.stringify(data));
const chunk = await webpack_compiler.get_chunk(route.id);
const mod = require(chunk);
if (route.type === 'page') {
const main = await webpack_compiler.client_main;
let data = { params: req.params, query: req.query };
if (mod.default.preload) data = Object.assign(data, await mod.default.preload(data));
const { html, head, css } = mod.default.render(data);
const page = templates.render(200, {
main,
html,
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
styles: (css && css.code ? `<style>${css.code}</style>` : '')
});
res.status(200);
res.end(page);
}
else {
const handler = mod[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;
}
}
res.status(404).end(templates.render(404, {
status: 404,
url
}));
} catch(err) {
// TODO nice error pages
res.status(500);
res.end(err.stack);
}
next();
};
};
};

12
lib/config.js Normal file
View File

@@ -0,0 +1,12 @@
const path = require('path');
exports.dev = process.env.NODE_ENV !== 'production';
exports.templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates');
exports.src = path.resolve(process.env.SAPPER_ROUTES || 'routes');
exports.dest = path.resolve(
process.env.NOW ? '/tmp' :
process.env.SAPPER_DEST || '.sapper'
);

16
lib/route_manager.js Normal file
View File

@@ -0,0 +1,16 @@
const glob = require('glob');
const create_routes = require('../utils/create_routes.js');
const { src } = require('./config.js');
const route_manager = {
routes: create_routes(
glob.sync('**/*.+(html|js|mjs)', { cwd: src })
),
onchange(fn) {
// TODO in dev mode, keep this updated, and allow
// webpack compiler etc to hook into it
}
};
module.exports = route_manager;

2763
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,22 @@
{
"name": "sapper",
"version": "0.0.1",
"version": "0.0.6",
"description": "Combat-ready apps, engineered by Svelte",
"main": "connect.js",
"directories": {
"test": "test"
},
"dependencies": {
"@std/esm": "^0.18.0"
"@std/esm": "^0.18.0",
"rimraf": "^2.6.2",
"webpack": "^3.10.0"
},
"devDependencies": {
"mocha": "^4.0.1",
"svelte": "^1.47.0"
"svelte": "^1.47.1"
},
"peerDependencies": {
"svelte": "^1.47.1"
},
"scripts": {
"test": "mocha --opts mocha.opts"

22
runtime/app.js Normal file
View File

@@ -0,0 +1,22 @@
const app = {
init(callback) {
window.addEventListener('click', event => {
let a = event.target;
while (a && a.nodeName !== 'A') a = a.parentNode;
if (!a) return;
if (callback(new URL(a.href))) {
event.preventDefault();
history.pushState({}, '', a.href);
}
});
window.addEventListener('popstate', event => {
callback(window.location);
});
callback(window.location);
}
};
export default app;

47
templates/main.js Normal file
View File

@@ -0,0 +1,47 @@
import app from 'sapper/runtime/app.js';
import { detachNode } from 'svelte/shared.js';
const target = document.querySelector('__selector__');
let component;
app.init(url => {
if (url.origin !== window.location.origin) return;
let match;
let params = {};
const query = {};
function render(mod) {
const route = { query, params };
Promise.resolve(
mod.default.preload ? mod.default.preload(route) : {}
).then(preloaded => {
if (component) {
component.destroy();
} else {
// remove SSR'd <head> contents
const start = document.querySelector('#sapper-head-start');
let end = document.querySelector('#sapper-head-end');
if (start && end) {
while (start.nextSibling !== end) detachNode(start.nextSibling);
detachNode(start);
detachNode(end);
}
target.innerHTML = '';
}
component = new mod.default({
target,
data: Object.assign(route, preloaded),
hydrate: !!component
});
});
}
// ROUTES
return true;
});

41
utils/create_app.js Normal file
View File

@@ -0,0 +1,41 @@
const fs = require('fs');
const path = require('path');
const template = fs.readFileSync(path.resolve(__dirname, '../templates/main.js'), 'utf-8');
module.exports = function create_app(src, dest, routes, options) {
// TODO in dev mode, watch files
const code = routes
.filter(route => route.type === 'page')
.map(route => {
const condition = route.dynamic.length === 0 ?
`url.pathname === '/${route.parts.join('/')}'` :
`match = ${route.pattern}.exec(url.pathname)`;
const lines = [];
route.dynamic.forEach((part, i) => {
lines.push(
`params.${part} = match[${i + 1}];`
);
});
lines.push(
`import('${src}/${route.file}').then(render);`
);
return `
if (${condition}) {
${lines.join(`\n\t\t\t\t\t`)}
}
`.replace(/^\t{3}/gm, '').trim();
})
.join(' else ') + ' else return false;';
const main = template
.replace('__selector__', options.selector || 'main')
.replace('// ROUTES', code);
fs.writeFileSync(path.join(dest, 'main.js'), main);
};

View File

@@ -3,15 +3,19 @@ const path = require('path');
module.exports = function create_matchers(files) {
return files
.map(file => {
if (/(^|\/|\\)_/.test(file)) return;
const parts = file.replace(/\.(html|js|mjs)$/, '').split(path.sep);
if (parts[parts.length - 1] === 'index') parts.pop();
const id = parts.join('_').replace(/[[\]]/g, '$') || '_';
const dynamic = parts
.filter(part => part[0] === '%')
.filter(part => part[0] === '[')
.map(part => part.slice(1, -1));
const pattern = new RegExp(
`^\\/${parts.map(p => p[0] === '%' ? '([^/]+)' : p).join('\\/')}$`
`^\\/${parts.map(p => p[0] === '[' ? '([^/]+)' : p).join('\\/')}$`
);
const test = url => pattern.test(url);
@@ -29,24 +33,21 @@ module.exports = function create_matchers(files) {
};
return {
id,
type: path.extname(file) === '.html' ? 'page' : 'route',
file,
pattern,
test,
exec,
parts,
dynamic
};
})
.filter(Boolean)
.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
}
});
}

View File

@@ -5,21 +5,21 @@ 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']);
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'
'post/[id].html',
'[wildcard].html'
]
);
});
it('generates params', () => {
const matchers = create_matchers(['index.html', 'about.html', '%wildcard%.html', 'post/%id%.html']);
const matchers = create_matchers(['index.html', 'about.html', '[wildcard].html', 'post/[id].html']);
let file;
let params;
@@ -31,9 +31,21 @@ describe('create_matchers', () => {
}
}
assert.equal(file, 'post/%id%.html');
assert.equal(file, 'post/[id].html');
assert.deepEqual(params, {
id: '123'
});
});
it('ignores files and directories with leading underscores', () => {
const matches = create_matchers(['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html']);
assert.deepEqual(
matches.map(m => m.file),
[
'e/f/g/h.html',
'index.html'
]
);
});
});

42
utils/create_templates.js Normal file
View File

@@ -0,0 +1,42 @@
const fs = require('fs');
const glob = require('glob');
module.exports = function create_templates() {
const templates = glob.sync('*.html', { cwd: 'templates' })
.map(file => {
const template = fs.readFileSync(`templates/${file}`, 'utf-8');
const status = file.replace('.html', '').toLowerCase();
if (!/^[0-9x]{3}$/.test(status)) {
throw new Error(`Bad template — should be a valid status code like 404.html, or a wildcard like 2xx.html`);
}
const specificity = (
(status[0] === 'x' ? 0 : 4) +
(status[1] === 'x' ? 0 : 2) +
(status[2] === 'x' ? 0 : 1)
);
const pattern = new RegExp(`^${status.split('').map(d => d === 'x' ? '\\d' : d).join('')}$`);
return {
test: status => pattern.test(status),
specificity,
render(data) {
return template.replace(/%sapper\.(\w+)%/g, (match, key) => {
return key in data ? data[key] : '';
});
}
}
})
.sort((a, b) => b.specificity - a.specificity);
return {
render: (status, data) => {
const template = templates.find(template => template.test(status));
if (template) return template.render(data);
return `Missing template for status code ${status}`;
}
};
};

View File

@@ -0,0 +1,62 @@
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
module.exports = function create_webpack_compiler(out, routes, dev) {
const compiler = {};
const client = webpack(
require(path.resolve('webpack.client.config.js'))
);
const server = webpack(
require(path.resolve('webpack.server.config.js'))
);
if (false) { // TODO watch in dev
// TODO how can we invalidate compiler.client_main when watcher restarts?
compiler.client_main = new Promise((fulfil, reject) => {
client.watch({}, (err, stats) => {
if (err || stats.hasErrors()) {
// TODO handle errors
}
const filename = stats.toJson().assetsByChunkName.main;
fulfil(`/client/${filename}`);
});
});
// TODO server
} else {
compiler.client_main = new Promise((fulfil, reject) => {
client.run((err, stats) => {
if (err || stats.hasErrors()) {
console.log(stats.toString({ colors: true }));
reject(err);
}
const filename = stats.toJson().assetsByChunkName.main;
fulfil(`/client/${filename}`);
});
});
const chunks = new Promise((fulfil, reject) => {
server.run((err, stats) => {
if (err || stats.hasErrors()) {
// TODO deal with hasErrors
console.log(stats.toString({ colors: true }));
reject(err);
}
fulfil(stats.toJson().assetsByChunkName);
});
});
compiler.get_chunk = async id => {
const assetsByChunkName = await chunks;
return path.resolve(out, 'server', assetsByChunkName[id]);
};
}
return compiler;
};

43
webpack/config.js Normal file
View File

@@ -0,0 +1,43 @@
const path = require('path');
const route_manager = require('../lib/route_manager.js');
const { src, dest, dev } = require('../lib/config.js');
module.exports = {
dev,
client: {
entry: () => {
return {
main: `${dest}/main.js`
};
},
output: () => {
return {
path: `${dest}/client`,
filename: '[name].[hash].js',
chunkFilename: '[name].[id].js',
publicPath: '/client/'
};
}
},
server: {
entry: () => {
const entries = {};
route_manager.routes.forEach(route => {
entries[route.id] = path.resolve(src, route.file);
});
return entries;
},
output: () => {
return {
path: `${dest}/server`,
filename: '[name].[hash].js',
chunkFilename: '[name].[id].js',
libraryTarget: 'commonjs2'
};
}
}
};

2085
yarn.lock Normal file

File diff suppressed because it is too large Load Diff