Compare commits

...

3 Commits

Author SHA1 Message Date
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
9 changed files with 5053 additions and 40 deletions

View File

@@ -16,6 +16,7 @@ High-level goals:
* Code-splitting and HMR out of the box (probably via webpack) * Code-splitting and HMR out of the box (probably via webpack)
* Best-in-class performance * 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 * 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 ## Design
@@ -26,8 +27,6 @@ A Sapper app is just an Express app (conventionally, `server.js`) that uses the
const app = require('express')(); const app = require('express')();
const sapper = require('sapper'); const sapper = require('sapper');
const app = express();
app.use(sapper()); app.use(sapper());
const { PORT = 3000 } = process.env; const { PORT = 3000 } = process.env;
@@ -73,12 +72,70 @@ Or, if you omit the `res` argument, it can use the return value:
```js ```js
// routes/api/post/%id%.js // routes/api/post/%id%.js
export async function get(req, res) { export async function get(req) {
return await getPostFromDatabase(req.params.id); 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 ## Things to figure out
* How to customise the overall page template * How to customise the overall page template
@@ -87,4 +144,7 @@ export async function get(req, res) {
* `store` integration * `store` integration
* Route transitions * Route transitions
* Equivalent of `next export` * 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. * ...and lots of other things that haven't occurred to me yet.

View File

@@ -1,14 +1,20 @@
require('svelte/ssr/register'); require('svelte/ssr/register');
const esm = require('@std/esm'); const esm = require('@std/esm');
const fs = require('fs');
const path = require('path'); const path = require('path');
const glob = require('glob'); const glob = require('glob');
const tmp = require('tmp');
const create_matchers = require('./utils/create_matchers.js'); const create_matchers = require('./utils/create_matchers.js');
const create_app = require('./utils/create_app.js');
const create_webpack_compiler = require('./utils/create_webpack_compiler.js');
require = esm(module, { const esmRequire = esm(module, {
esm: 'all' esm: 'all'
}); });
module.exports = function connect(opts = {}) { const dir = tmp.dirSync({ unsafeCleanup: true });
module.exports = function connect(opts) {
const routes = path.resolve('routes'); const routes = path.resolve('routes');
const out = path.resolve('.sapper'); const out = path.resolve('.sapper');
@@ -18,19 +24,40 @@ module.exports = function connect(opts = {}) {
let server_routes = glob.sync('**/*.+(js|mjs)', { cwd: routes }); let server_routes = glob.sync('**/*.+(js|mjs)', { cwd: routes });
let server_route_matchers = create_matchers(server_routes); let server_route_matchers = create_matchers(server_routes);
// create_app(routes, dir.name, page_matchers, opts.dev);
create_app(routes, out, page_matchers, opts.dev);
const webpack_compiler = create_webpack_compiler(
path.join(out, 'main.js'),
path.resolve('.sapper/webpack'),
opts.dev
);
return async function(req, res, next) { return async function(req, res, next) {
const url = req.url.replace(/\?.+/, ''); const url = req.url.replace(/\?.+/, '');
if (url.startsWith('/webpack/')) {
fs.createReadStream(path.resolve('.sapper' + url)).pipe(res);
return;
}
for (let i = 0; i < page_matchers.length; i += 1) { for (let i = 0; i < page_matchers.length; i += 1) {
const matcher = page_matchers[i]; const matcher = page_matchers[i];
if (matcher.test(url)) { if (matcher.test(url)) {
const params = matcher.exec(url); const params = matcher.exec(url);
const Component = require(`${routes}/${matcher.file}`); const Component = require(`${routes}/${matcher.file}`);
res.end(Component.render({ const app = await webpack_compiler.app;
params,
query: req.query const page = opts.template({
})); app,
html: Component.render({
params,
query: req.query
})
});
res.end(page);
return; return;
} }
} }
@@ -39,7 +66,7 @@ module.exports = function connect(opts = {}) {
const matcher = server_route_matchers[i]; const matcher = server_route_matchers[i];
if (matcher.test(url)) { if (matcher.test(url)) {
req.params = matcher.exec(url); req.params = matcher.exec(url);
const route = require(`${routes}/${matcher.file}`); const route = esmRequire(`${routes}/${matcher.file}`);
const handler = route[req.method.toLowerCase()]; const handler = route[req.method.toLowerCase()];
if (handler) { if (handler) {

2738
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,20 @@
{ {
"name": "sapper", "name": "sapper",
"version": "0.0.1", "version": "0.0.2",
"description": "Combat-ready apps, engineered by Svelte", "description": "Combat-ready apps, engineered by Svelte",
"main": "connect.js", "main": "connect.js",
"directories": { "directories": {
"test": "test" "test": "test"
}, },
"dependencies": { "dependencies": {
"@std/esm": "^0.18.0" "@std/esm": "^0.18.0",
"extract-text-webpack-plugin": "^3.0.2",
"tmp": "0.0.33",
"webpack": "^3.10.0"
}, },
"devDependencies": { "devDependencies": {
"mocha": "^4.0.1", "mocha": "^4.0.1",
"svelte": "^1.47.0" "svelte": "^1.47.1"
}, },
"scripts": { "scripts": {
"test": "mocha --opts mocha.opts" "test": "mocha --opts mocha.opts"

38
templates/main.js Normal file
View File

@@ -0,0 +1,38 @@
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('main');
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
});
}
// ROUTES
return true;
}
navigate(window.location);

27
utils/create_app.js Normal file
View File

@@ -0,0 +1,27 @@
const fs = require('fs');
const path = require('path');
const tmp = require('tmp');
const template = fs.readFileSync(path.resolve(__dirname, '../templates/main.js'), 'utf-8');
module.exports = function create_app(routes, dest, matchers, dev) {
// TODO in dev mode, watch files
const code = matchers
.map(matcher => {
const condition = matcher.dynamic.length === 0 ?
`url.pathname === '/${matcher.parts.join('/')}'` :
`${matcher.pattern}.test(url.pathname)`;
return `
if (${condition}) {
import('../routes/${matcher.file}').then(render);
}
`.replace(/^\t{3}/gm, '').trim();
})
.join(' else ');
const main = template.replace('// ROUTES', code);
fs.writeFileSync(path.join(dest, 'main.js'), main);
};

View File

@@ -30,6 +30,7 @@ module.exports = function create_matchers(files) {
return { return {
file, file,
pattern,
test, test,
exec, exec,
parts, parts,
@@ -41,12 +42,5 @@ module.exports = function create_matchers(files) {
(a.dynamic.length - b.dynamic.length) || // match static paths first (a.dynamic.length - b.dynamic.length) || // match static paths first
(b.parts.length - a.parts.length) // match longer 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

@@ -0,0 +1,81 @@
const fs = require('fs');
const webpack = require('webpack');
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
// TODO make the webpack config, err, configurable
module.exports = function create_webpack_compiler(main, dest, dev) {
const compiler = {};
const _ = webpack({
entry: {
main
},
resolve: {
extensions: ['.js', '.html']
},
output: {
path: dest,
filename: '[name].[hash].js',
chunkFilename: '[name].[id].js',
publicPath: '/webpack/'
},
module: {
rules: [
{
test: /\.html$/,
exclude: /node_modules/,
use: {
loader: 'svelte-loader',
options: {
emitCss: true,
cascade: false,
store: true
}
}
},
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [{ loader: 'css-loader', options: { sourceMap: dev } }]
})
}
]
},
plugins: [
new ExtractTextPlugin('main.css'),
!dev && new webpack.optimize.ModuleConcatenationPlugin(),
!dev && new UglifyJSPlugin()
].filter(Boolean),
devtool: dev ? 'inline-source-map' : false
});
if (false) { // TODO watch in dev
// TODO how can we invalidate compiler.app when watcher restarts?
compiler.app = new Promise((fulfil, reject) => {
_.watch({}, (err, stats) => {
if (err || stats.hasErrors()) {
// TODO handle errors
}
const filename = stats.toJson().assetsByChunkName.main;
fulfil(`/webpack/${filename}`);
});
});
} else {
compiler.app = new Promise((fulfil, reject) => {
_.run((err, stats) => {
if (err || stats.hasErrors()) {
// TODO handle errors
}
const filename = stats.toJson().assetsByChunkName.main;
fulfil(`/webpack/${filename}`);
});
});
}
return compiler;
};

2085
yarn.lock Normal file

File diff suppressed because it is too large Load Diff