From 9b06a22f31655ca3ff70954ebacef0fc351e7097 Mon Sep 17 00:00:00 2001 From: nkzawa Date: Thu, 6 Oct 2016 08:52:50 +0900 Subject: [PATCH] initial source --- .gitignore | 3 + bin/next | 31 +++++ bin/next-build | 44 +++++++ bin/next-start | 27 ++++ client/eval-script.js | 31 +++++ client/next.js | 18 +++ client/router.js | 259 +++++++++++++++++++++++++++++++++++++++ client/shallow-equals.js | 11 ++ gulpfile.js | 156 +++++++++++++++++++++++ lib/app.js | 71 +++++++++++ lib/document.js | 59 +++++++++ package.json | 52 ++++++++ server/build.js | 93 ++++++++++++++ server/index.js | 109 ++++++++++++++++ server/render.js | 37 ++++++ server/router.js | 35 ++++++ 16 files changed, 1036 insertions(+) create mode 100644 .gitignore create mode 100755 bin/next create mode 100755 bin/next-build create mode 100755 bin/next-start create mode 100644 client/eval-script.js create mode 100644 client/next.js create mode 100644 client/router.js create mode 100644 client/shallow-equals.js create mode 100644 gulpfile.js create mode 100644 lib/app.js create mode 100644 lib/document.js create mode 100644 package.json create mode 100644 server/build.js create mode 100644 server/index.js create mode 100644 server/render.js create mode 100644 server/router.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f2e955cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.log +node_modules +dist diff --git a/bin/next b/bin/next new file mode 100755 index 00000000..81f7d96c --- /dev/null +++ b/bin/next @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { resolve } from 'path' +import parseArgs from 'minimist' +import { spawn } from 'cross-spawn'; + +const defaultCommand = 'dev' +const commands = new Set([ + defaultCommand, + 'build', + 'start' +]) + +let cmd = process.argv[2] +let args + +if (commands.has(cmd)) { + args = process.argv.slice(3) +} else { + cmd = defaultCommand + args = process.argv.slice(2) +} + +const bin = resolve(__dirname, 'next-' + cmd) + +const proc = spawn(bin, args, { stdio: 'inherit', customFds: [0, 1, 2] }) +proc.on('close', (code) => process.exit(code)) +proc.on('error', (err) => { + console.log(err) + process.exit(1) +}) diff --git a/bin/next-build b/bin/next-build new file mode 100755 index 00000000..12c96328 --- /dev/null +++ b/bin/next-build @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import { resolve, dirname } from 'path' +import parseArgs from 'minimist' +import fs from 'mz/fs' +import mkdirp from 'mkdirp-then'; +import glob from 'glob-promise' +import { transpile, bundle } from '../server/build' + +const argv = parseArgs(process.argv.slice(2), { + alias: { + h: 'help', + }, + boolean: ['h'] +}) + +const dir = resolve(argv._[0] || '.') + +Promise.resolve() +.then(async () => { + const paths = await glob('**/*.js', { cwd: dir, ignore: 'node_modules/**' }) + await Promise.all(paths.map(async (p) => { + const code = await transpile(resolve(dir, p)) + const outpath = resolve(dir, '.next', p) + await writeFile(outpath, code) + })) + + const pagePaths = await glob('.next/pages/**/*.js', { cwd: dir }) + await Promise.all(pagePaths.map(async (p) => { + const code = await bundle(resolve(dir, p)) + const outpath = resolve(dir, '.next', p) + await writeFile(outpath, code) + })) +}) +.catch((err) => { + console.error(err) + exit(1) +}) + +async function writeFile (path, data) { + await mkdirp(dirname(path)) + await fs.writeFile(path, data, { encoding: 'utf8', flag: 'w+' }) +} + diff --git a/bin/next-start b/bin/next-start new file mode 100755 index 00000000..e46dcab4 --- /dev/null +++ b/bin/next-start @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import parseArgs from 'minimist' +import Server from '../server' + +const argv = parseArgs(process.argv.slice(2), { + alias: { + h: 'help', + p: 'port' + }, + boolean: ['h'], + default: { + p: 3000 + } +}) + +const dir = argv._[0] || '.' + +const srv = new Server(dir) +srv.start(argv.port) +.then(() => { + console.log('> Ready on http://localhost:%d', argv.port); +}) +.catch((err) => { + console.error(err) + exit(1) +}) diff --git a/client/eval-script.js b/client/eval-script.js new file mode 100644 index 00000000..c32901ef --- /dev/null +++ b/client/eval-script.js @@ -0,0 +1,31 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import App from '../lib/app' + +const modules = new Map([ + ['react', React], + ['react-dom', ReactDOM], + ['next/app', App] +]) + +/** + * IMPORTANT: This module is compiled *without* `use strict` + * so that when we `eval` a dependency below, we don't enforce + * `use strict` implicitly. + * + * Otherwise, modules like `d3` get `eval`d and forced into + * `use strict` where they don't work (at least in current versions) + * + * To see the compilation details, look at `gulpfile.js` and the + * usage of `babel-plugin-transform-remove-strict-mode`. + */ + +export default function evalScript (script) { + const module = { exports: {} } + const require = function (path) { // eslint-disable-line no-unused-vars + return modules.get(path) + } + // don't use Function() here since it changes source locations + eval(script) // eslint-disable-line no-eval + return module.exports +} diff --git a/client/next.js b/client/next.js new file mode 100644 index 00000000..0beca747 --- /dev/null +++ b/client/next.js @@ -0,0 +1,18 @@ +import { createElement } from 'react' +import { render } from 'react-dom' +import evalScript from './eval-script' +import Router from './router' +import DefaultApp from '../lib/app' + +const { + __NEXT_DATA__: { app, component, props } +} = window + +const App = app ? evalScript(app).default : DefaultApp +const Component = evalScript(component).default + +const router = new Router({ Component, props }) +const container = document.getElementById('__next') +const appProps = { Component, props, router: {} } + +render(createElement(App, { ...appProps }), container) diff --git a/client/router.js b/client/router.js new file mode 100644 index 00000000..cad9184b --- /dev/null +++ b/client/router.js @@ -0,0 +1,259 @@ +import { parse } from 'url' +import evalScript from './eval-script' +import shallowEquals from './shallow-equals' + +export default class Router { + constructor (initialData) { + this.subscriptions = [] + + const { Component } = initialData + const { pathname } = location + const route = toRoute(pathname) + + this.currentRoute = route + this.currentComponent = Component.displayName + this.currentComponentData = initialData + + // set up the component cache (by route keys) + this.components = { [route]: initialData } + + // in order for `e.state` to work on the `onpopstate` event + // we have to register the initial route upon initialization + const url = pathname + (location.search || '') + (location.hash || '') + this.replace(Component, url) + + this.onPopState = this.onPopState.bind(this) + window.addEventListener('unload', () => {}) + window.addEventListener('popstate', this.onPopState) + } + + onPopState (e) { + this.abortComponentLoad() + const cur = this.currentComponent + const pathname = location.pathname + const url = pathname + (location.search || '') + (location.hash || '') + const { fromComponent, route } = e.state || {} + if (fromComponent && cur && fromComponent === cur) { + // if the component has not changed due + // to the url change, it means we only + // need to notify the subscriber about + // the URL change + this.set(url) + } else { + this.fetchComponent(route || url, (err, data) => { + if (err) { + // the only way we can appropriately handle + // this failure is deferring to the browser + // since the URL has already changed + location.reload() + } else { + this.currentRoute = route || toRoute(pathname) + this.currentComponent = data.Component.displayName + this.currentComponentData = data + this.set(url) + } + }) + } + } + + update (route, data) { + data.Component = evalScript(data.component).default + delete data.component + this.components[route] = data + if (route === this.currentRoute) { + let cancelled = false + const cancel = () => { cancelled = true } + this.componentLoadCancel = cancel + getInitialProps(data, (err, dataWithProps) => { + if (cancel === this.componentLoadCancel) { + this.componentLoadCancel = false + } + if (cancelled) return + if (err) throw err + this.currentComponentData = dataWithProps + this.notify() + }) + } + } + + goTo (url, fn) { + this.change('pushState', null, url, fn) + } + + back () { + history.back() + } + + push (fromComponent, url, fn) { + this.change('pushState', fromComponent, url, fn) + } + + replace (fromComponent, url, fn) { + this.change('replaceState', fromComponent, url, fn) + } + + change (method, component, url, fn) { + this.abortComponentLoad() + + const set = (name) => { + this.currentComponent = name + const state = name + ? { fromComponent: name, route: this.currentRoute } + : {} + history[method](state, null, url) + this.set(url) + if (fn) fn(null) + } + + const componentName = component && component.displayName + if (component && !componentName) { + throw new Error('Initial component must have a unique `displayName`') + } + + if (this.currentComponent && + componentName !== this.currentComponent) { + this.fetchComponent(url, (err, data) => { + if (!err) { + this.currentRoute = toRoute(url) + this.currentComponentData = data + set(data.Component.displayName) + } + if (fn) fn(err, data) + }) + } else { + set(componentName) + } + } + + set (url) { + const parsed = parse(url, true) + if (this.urlIsNew(parsed)) { + this.pathname = parsed.pathname + this.query = parsed.query + this.notify() + } + } + + urlIsNew ({ pathname, query }) { + return this.pathname !== pathname || !shallowEquals(query, this.query) + } + + fetchComponent (url, fn) { + const pathname = parse(url, true) + const route = toRoute(pathname) + + let cancelled = false + let componentXHR = null + const cancel = () => { + cancelled = true + + if (componentXHR && componentXHR.abort) { + componentXHR.abort() + } + } + + if (this.components[route]) { + const data = this.components[route] + getInitialProps(data, (err, dataWithProps) => { + if (cancel === this.componentLoadCancel) { + this.componentLoadCancel = false + } + if (cancelled) return + fn(err, dataWithProps) + }) + this.componentLoadCancel = cancel + return + } + + const componentUrl = toJSONUrl(route) + + componentXHR = loadComponent(componentUrl, (err, data) => { + if (cancel === this.componentLoadCancel) { + this.componentLoadCancel = false + } + if (err) { + if (!cancelled) fn(err) + } else { + // we update the cache even if cancelled + if (!this.components[route]) { + this.components[route] = data + } + if (!cancelled) fn(null, data) + } + }) + + this.componentLoadCancel = cancel + } + + abortComponentLoad () { + if (this.componentLoadCancel) { + this.componentLoadCancel() + this.componentLoadCancel = null + } + } + + notify () { + this.subscriptions.forEach(fn => fn()) + } + + subscribe (fn) { + this.subscriptions.push(fn) + return () => { + const i = this.subscriptions.indexOf(fn) + if (~i) this.subscriptions.splice(i, 1) + } + } +} + +// every route finishing in `/test/` becomes `/test` + +export function toRoute (path) { + return path.replace(/\/$/, '') || '/' +} + +export function toJSONUrl (route) { + return ('/' === route ? '/index' : route) + '.json' +} + +export function loadComponent (url, fn) { + return loadJSON(url, (err, data) => { + if (err && fn) fn(err) + const { component, props } = data + const Component = evalScript(component).default + getInitialProps({ Component, props }, fn) + }) +} + +function loadJSON (url, fn) { + const xhr = new XMLHttpRequest() + xhr.onload = () => { + let data + + try { + data = JSON.parse(xhr.responseText) + } catch (err) { + fn(new Error('Failed to load JSON for ' + url)) + return + } + + fn(null, data) + } + xhr.onerror = () => { + if (fn) fn(new Error('XHR failed. Status: ' + xhr.status)) + } + xhr.open('GET', url) + xhr.send() + + return xhr +} + +function getInitialProps (data, fn) { + const { Component: { getInitialProps } } = data + if (getInitialProps) { + Promise.resolve(getInitialProps({})) + .then((props) => fn(null, { ...data, props })) + .catch(fn) + } else { + fn(null, data) + } +} diff --git a/client/shallow-equals.js b/client/shallow-equals.js new file mode 100644 index 00000000..a4335c50 --- /dev/null +++ b/client/shallow-equals.js @@ -0,0 +1,11 @@ +export default function shallowEquals (a, b) { + for (const i in a) { + if (b[i] !== a[i]) return false; + } + + for (const i in b) { + if (b[i] !== a[i]) return false; + } + + return true; +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 00000000..bd022d54 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,156 @@ +const gulp = require('gulp') +const babel = require('gulp-babel') +const cache = require('gulp-cached') +const notify_ = require('gulp-notify') +const uglify = require('gulp-uglify') +const webpack = require('webpack-stream') +const del = require('del') + +const babelOptions = { + presets: ['es2015', 'react'], + plugins: [ + 'transform-async-to-generator', + 'transform-object-rest-spread', + 'transform-class-properties', + 'transform-runtime' + ] +} + +gulp.task('compile', [ + 'compile-bin', + 'compile-lib', + 'compile-server', + 'compile-client' +]) + +gulp.task('compile-bin', () => { + return gulp.src('bin/*') + .pipe(cache('bin')) + .pipe(babel(babelOptions)) + .pipe(gulp.dest('dist/bin')) + .pipe(notify('Compiled binaries')) +}) + +gulp.task('compile-lib', () => { + return gulp.src('lib/**/*.js') + .pipe(cache('lib')) + .pipe(babel(babelOptions)) + .pipe(gulp.dest('dist/lib')) + .pipe(notify('Compiled lib files')) +}) + +gulp.task('compile-server', () => { + return gulp.src('server/**/*.js') + .pipe(cache('server')) + .pipe(babel(babelOptions)) + .pipe(gulp.dest('dist/server')) + .pipe(notify('Compiled server files')) +}) + +gulp.task('compile-client', () => { + return gulp.src('client/**/*.js') + .pipe(cache('client')) + .pipe(babel(babelOptions)) + .pipe(gulp.dest('dist/client')) + .pipe(notify('Compiled client files')) +}) + +gulp.task('build', [ + 'build-dev-client', +]) + +gulp.task('build-release', [ + 'build-release-client' +]) + +gulp.task('build-dev-client', ['compile-lib', 'compile-client'], () => { + return gulp + .src('dist/client/next-dev.js') + .pipe(webpack({ + quiet: true, + output: { filename: 'next-dev.bundle.js' } + })) + .pipe(gulp.dest('dist/client')) + .pipe(notify('Built dev client')) +}) + +gulp.task('build-release-client', ['compile-lib', 'compile-client'], () => { + return gulp + .src('dist/client/next.js') + .pipe(webpack({ + quiet: true, + output: { filename: 'next.bundle.js' }, + plugins: [ + new webpack.webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: JSON.stringify('production') + } + }) + ] + })) + .pipe(uglify({ preserveComments: 'license' })) + .pipe(gulp.dest('dist/client')) + .pipe(notify('Built release client')) +}) + +gulp.task('watch', [ + 'watch-bin', + 'watch-lib', + 'watch-server', + 'watch-client' +]) + +gulp.task('watch-bin', function () { + return gulp.watch('bin/*', [ + 'compile-bin' + ]) +}) + +gulp.task('watch-lib', function () { + return gulp.watch('lib/**/*.js', [ + 'compile-lib', + 'build-dev-client' + ]) +}) + +gulp.task('watch-server', function () { + return gulp.watch('server/**/*.js', [ + 'compile-server' + ]) +}) + +gulp.task('watch-client', function () { + return gulp.watch('client/**/*.js', [ + 'compile-client', + 'build-dev-client' + ]) +}) + +gulp.task('clean', () => { + return del(['dist']) +}) + +gulp.task('default', [ + 'compile', + 'build', + 'watch' +]) + +gulp.task('release', [ + 'compile', + 'build-release' +]) + +// avoid logging to the console +// that we created a notification +notify_.logLevel(0) + +// notification helper +function notify (msg) { + return notify_({ + title: '▲ Next', + message: msg, + icon: false, + onLast: true + }) +} diff --git a/lib/app.js b/lib/app.js new file mode 100644 index 00000000..cd38bd55 --- /dev/null +++ b/lib/app.js @@ -0,0 +1,71 @@ +import React, { Component, PropTypes } from 'react' + +export default class App extends Component { + static childContextTypes = { + router: PropTypes.object, + headManager: PropTypes.object + } + + constructor (props) { + super(props) + this.state = propsToState(props) + this.close = null + } + + componentWillReceiveProps (nextProps) { + const state = propsToState(nextProps) + try { + this.setState(state) + } catch (err) { + console.error(err) + } + } + + componentDidMount () { + const { router } = this.props + + this.close = router.subscribe(() => { + const state = propsToState({ + router, + data: router.currentComponentData + }) + + try { + this.setState(state) + } catch (err) { + console.error(err) + } + }) + } + + componentWillUnmount () { + if (this.close) this.close() + } + + getChildContext () { + const { router, headManager } = this.props + return { router, headManager } + } + + render () { + const { Component, props } = this.state + return React.createElement(Component, { ...props }) + } +} + +function propsToState (props) { + const { Component, router } = props + const url = { + query: router.query, + pathname: router.pathname, + back: () => router.back(), + goTo: (url, fn) => router.goTo(url, fn), + push: (url, fn) => router.push(Component, url, fn), + replace: (url, fn) => router.replace(Component, url, fn) + } + + return { + Component, + props: { ...props.props, url } + } +} diff --git a/lib/document.js b/lib/document.js new file mode 100644 index 00000000..3e0789c0 --- /dev/null +++ b/lib/document.js @@ -0,0 +1,59 @@ +import React, { Component, PropTypes } from 'react' +import htmlescape from 'htmlescape' + +export default class Document extends Component { + static childContextTypes = { + _documentProps: PropTypes.any + } + + getChildContext () { + return { + _documentProps: this.props + } + } + + render () { + return + + +
+ + + + + } +} + +export function Head (props, context) { + const { head } = context._documentProps + const h = (head || []) + .map((h, i) => React.cloneElement(h, { key: '_next' + i })) + return {h} +} + +Head.contextTypes = { _documentProps: PropTypes.any } + +export function Main (props, context) { + const { html, data } = context._documentProps; + return
+
+