diff --git a/bin/next b/bin/next index 7e2a46e6..79ab5e9d 100755 --- a/bin/next +++ b/bin/next @@ -26,6 +26,6 @@ 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) + console.error(err) process.exit(1) }) diff --git a/bin/next-dev b/bin/next-dev index 6e9dc8f5..6b7b72d5 100755 --- a/bin/next-dev +++ b/bin/next-dev @@ -3,7 +3,8 @@ import { exec } from 'child_process' import { resolve, join } from 'path' import parseArgs from 'minimist' import Server from '../server' -import build from '../server/build' +import HotReloader from '../server/hot-reloader' +import webpack from '../server/build/webpack' import { exists } from 'mz/fs' const argv = parseArgs(process.argv.slice(2), { @@ -25,9 +26,10 @@ const open = url => { const dir = resolve(argv._[0] || '.') -build(dir) -.then(async () => { - const srv = new Server({ dir, dev: true }) +webpack(dir, { hotReload: true }) +.then(async (compiler) => { + const hotReloader = new HotReloader(compiler) + const srv = new Server({ dir, dev: true, hotReloader }) await srv.start(argv.port) console.log('> Ready on http://localhost:%d', argv.port) diff --git a/client/next-dev.js b/client/next-dev.js index b1516d2a..041e7a20 100644 --- a/client/next-dev.js +++ b/client/next-dev.js @@ -1 +1,7 @@ -import './next' +import 'react-hot-loader/patch' +import 'webpack-dev-server/client?http://localhost:3030' +import * as next from './next' + +module.exports = next + +window.next = next diff --git a/client/next.js b/client/next.js index 7e1c9d1d..1f0b3e4f 100644 --- a/client/next.js +++ b/client/next.js @@ -13,10 +13,11 @@ const { const App = app ? evalScript(app).default : DefaultApp const Component = evalScript(component).default -const router = new Router(window.location.href, { Component }) +export const router = new Router(window.location.href, { Component }) + const headManager = new HeadManager() const container = document.getElementById('__next') const appProps = { Component, props, router, headManager } StyleSheet.rehydrate(classNames) -render(createElement(App, { ...appProps }), container) +render(createElement(App, appProps), container) diff --git a/gulpfile.js b/gulpfile.js index 1b8c55a0..224e0702 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -67,6 +67,16 @@ gulp.task('compile-test', () => { .pipe(notify('Compiled test files')) }) +gulp.task('copy', [ + 'copy-pages', + 'copy-test-fixtures' +]) + +gulp.task('copy-pages', () => { + return gulp.src('pages/**/*.js') + .pipe(gulp.dest('dist/pages')) +}) + gulp.task('copy-test-fixtures', () => { return gulp.src('test/fixtures/**/*') .pipe(gulp.dest('dist/test/fixtures')) @@ -98,7 +108,21 @@ gulp.task('build-dev-client', ['compile-lib', 'compile-client'], () => { .src('dist/client/next-dev.js') .pipe(webpack({ quiet: true, - output: { filename: 'next-dev.bundle.js' } + output: { filename: 'next-dev.bundle.js' }, + module: { + loaders: [ + { + test: /eval-script\.js$/, + exclude: /node_modules/, + loader: 'babel', + query: { + plugins: [ + 'babel-plugin-transform-remove-strict-mode' + ] + } + } + ] + } })) .pipe(gulp.dest('dist/client')) .pipe(notify('Built dev client')) @@ -117,7 +141,21 @@ gulp.task('build-release-client', ['compile-lib', 'compile-client'], () => { } }), new webpack.webpack.optimize.UglifyJsPlugin() - ] + ], + module: { + loaders: [ + { + test: /eval-script\.js$/, + exclude: /node_modules/, + loader: 'babel', + query: { + plugins: [ + 'babel-plugin-transform-remove-strict-mode' + ] + } + } + ] + } })) .pipe(gulp.dest('dist/client')) .pipe(notify('Built release client')) @@ -179,6 +217,7 @@ gulp.task('clean-test', () => { gulp.task('default', [ 'compile', 'build', + 'copy', 'test', 'watch' ]) @@ -187,6 +226,7 @@ gulp.task('release', (cb) => { sequence('clean', [ 'compile', 'build-release', + 'copy', 'test' ], 'clean-test', cb) }) diff --git a/lib/app.js b/lib/app.js index 52696b8c..fac1a37f 100644 --- a/lib/app.js +++ b/lib/app.js @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from 'react' +import { AppContainer } from 'react-hot-loader' export default class App extends Component { static childContextTypes = { @@ -51,7 +52,15 @@ export default class App extends Component { render () { const { Component, props } = this.state - return React.createElement(Component, { ...props }) + + if (typeof window === 'undefined') { + // workaround for https://github.com/gaearon/react-hot-loader/issues/283 + return + } + + return + + } } diff --git a/package.json b/package.json index 837dfa1c..840dffc9 100644 --- a/package.json +++ b/package.json @@ -33,20 +33,25 @@ "glob-promise": "1.0.6", "gulp-benchmark": "^1.1.1", "htmlescape": "1.1.1", + "loader-utils": "0.2.16", "minimist": "1.2.0", "mkdirp-then": "1.2.0", "mz": "2.4.0", "path-match": "1.2.4", "react": "15.3.2", "react-dom": "15.3.2", + "react-hot-loader": "3.0.0-beta.6", "resolve": "1.1.7", "run-sequence": "1.2.2", "send": "0.14.1", "url": "0.11.0", - "webpack": "1.13.2" + "webpack": "1.13.2", + "webpack-dev-server": "1.16.2", + "write-file-webpack-plugin": "3.3.0" }, "devDependencies": { "babel-eslint": "^7.0.0", + "babel-plugin-transform-remove-strict-mode": "0.0.2", "del": "2.2.2", "gulp": "3.9.1", "gulp-ava": "0.14.1", diff --git a/lib/pages/_error.js b/pages/_error.js similarity index 100% rename from lib/pages/_error.js rename to pages/_error.js diff --git a/server/build/bundle.js b/server/build/bundle.js deleted file mode 100644 index b8816f37..00000000 --- a/server/build/bundle.js +++ /dev/null @@ -1,53 +0,0 @@ -import { resolve, dirname, basename } from 'path' -import webpack from 'webpack' - -export default function bundle (src, dst) { - const compiler = webpack({ - entry: src, - output: { - path: dirname(dst), - filename: basename(dst), - libraryTarget: 'commonjs2' - }, - externals: [ - 'react', - 'react-dom', - { - [require.resolve('react')]: 'react', - [require.resolve('../../lib/link')]: 'next/link', - [require.resolve('../../lib/css')]: 'next/css', - [require.resolve('../../lib/head')]: 'next/head' - } - ], - resolveLoader: { - root: resolve(__dirname, '..', '..', 'node_modules') - }, - plugins: [ - new webpack.optimize.UglifyJsPlugin({ - compress: { warnings: false }, - sourceMap: false - }) - ], - module: { - preLoaders: [ - { test: /\.json$/, loader: 'json-loader' } - ] - } - }) - - return new Promise((resolve, reject) => { - compiler.run((err, stats) => { - if (err) return reject(err) - - const jsonStats = stats.toJson() - if (jsonStats.errors.length > 0) { - const error = new Error(jsonStats.errors[0]) - error.errors = jsonStats.errors - error.warnings = jsonStats.warnings - return reject(error) - } - - resolve() - }) - }) -} diff --git a/server/build/index.js b/server/build/index.js index 1b29f9d0..f8aff5a5 100644 --- a/server/build/index.js +++ b/server/build/index.js @@ -1,26 +1,21 @@ -import { resolve } from 'path' -import glob from 'glob-promise' -import transpile from './transpile' -import bundle from './bundle' +import webpack from './webpack' export default async function build (dir) { - const dstDir = resolve(dir, '.next') - const templateDir = resolve(__dirname, '..', '..', 'lib', 'pages') + const compiler = await webpack(dir) - // create `.next/pages/_error.js` - // which may be overwriten by the user script, `pages/_error.js` - const templatPaths = await glob('**/*.js', { cwd: templateDir }) - await Promise.all(templatPaths.map(async (p) => { - await transpile(resolve(templateDir, p), resolve(dstDir, 'pages', p)) - })) + return new Promise((resolve, reject) => { + compiler.run((err, stats) => { + if (err) return reject(err) - const paths = await glob('**/*.js', { cwd: dir, ignore: 'node_modules/**' }) - await Promise.all(paths.map(async (p) => { - await transpile(resolve(dir, p), resolve(dstDir, p)) - })) + const jsonStats = stats.toJson() + if (jsonStats.errors.length > 0) { + const error = new Error(jsonStats.errors[0]) + error.errors = jsonStats.errors + error.warnings = jsonStats.warnings + return reject(error) + } - const pagePaths = await glob('pages/**/*.js', { cwd: dstDir }) - await Promise.all(pagePaths.map(async (p) => { - await bundle(resolve(dstDir, p), resolve(dstDir, '_bundles', p)) - })) + resolve() + }) + }) } diff --git a/server/build/loaders/emit-file-loader.js b/server/build/loaders/emit-file-loader.js new file mode 100644 index 00000000..01ccf39d --- /dev/null +++ b/server/build/loaders/emit-file-loader.js @@ -0,0 +1,16 @@ +import loaderUrils from 'loader-utils' + +module.exports = function (content) { + this.cacheable() + + const query = loaderUrils.parseQuery(this.query) + const name = query.name || '[hash].[ext]' + const context = query.context || this.options.context + const regExp = query.regExp + const opts = { context, content, regExp } + const interpolatedName = loaderUrils.interpolateName(this, name, opts) + + this.emitFile(interpolatedName, content) + + return content +} diff --git a/server/build/loaders/hot-self-accept-loader.js b/server/build/loaders/hot-self-accept-loader.js new file mode 100644 index 00000000..d7c63e91 --- /dev/null +++ b/server/build/loaders/hot-self-accept-loader.js @@ -0,0 +1,14 @@ + +module.exports = function (content) { + this.cacheable() + + return content + ` + if (module.hot) { + module.hot.accept() + if ('idle' !== module.hot.status()) { + const Component = module.exports.default || module.exports + next.router.notify({ Component }) + } + } + ` +} diff --git a/server/build/transpile.js b/server/build/transpile.js deleted file mode 100644 index 949692ed..00000000 --- a/server/build/transpile.js +++ /dev/null @@ -1,51 +0,0 @@ -import { dirname } from 'path' -import fs from 'mz/fs' -import mkdirp from 'mkdirp-then' -import { transformFile } from 'babel-core' -import preset2015 from 'babel-preset-es2015' -import presetReact from 'babel-preset-react' -import transformAsyncToGenerator from 'babel-plugin-transform-async-to-generator' -import transformClassProperties from 'babel-plugin-transform-class-properties' -import transformObjectRestSpread from 'babel-plugin-transform-object-rest-spread' -import transformRuntime from 'babel-plugin-transform-runtime' -import moduleAlias from 'babel-plugin-module-alias' - -const babelRuntimePath = require.resolve('babel-runtime/package') -.replace(/[\\\/]package\.json$/, '') - -const babelOptions = { - presets: [preset2015, presetReact], - plugins: [ - transformAsyncToGenerator, - transformClassProperties, - transformObjectRestSpread, - transformRuntime, - [ - moduleAlias, - [ - { src: `npm:${babelRuntimePath}`, expose: 'babel-runtime' }, - { src: `npm:${require.resolve('react')}`, expose: 'react' }, - { src: `npm:${require.resolve('../../lib/link')}`, expose: 'next/link' }, - { src: `npm:${require.resolve('../../lib/css')}`, expose: 'next/css' }, - { src: `npm:${require.resolve('../../lib/head')}`, expose: 'next/head' } - ] - ] - ], - ast: false -} - -export default async function transpile (src, dst) { - const code = await new Promise((resolve, reject) => { - transformFile(src, babelOptions, (err, result) => { - if (err) return reject(err) - resolve(result.code) - }) - }) - - await writeFile(dst, code) -} - -async function writeFile (path, data) { - await mkdirp(dirname(path)) - await fs.writeFile(path, data, { encoding: 'utf8', flag: 'w+' }) -} diff --git a/server/build/webpack.js b/server/build/webpack.js new file mode 100644 index 00000000..8b15b8d0 --- /dev/null +++ b/server/build/webpack.js @@ -0,0 +1,126 @@ +import { resolve, join } from 'path' +import webpack from 'webpack' +import glob from 'glob-promise' +import WriteFilePlugin from 'write-file-webpack-plugin' + +export default async function createCompiler (dir, { hotReload = false } = {}) { + dir = resolve(dir) + + const pages = await glob('pages/**/*.js', { cwd: dir }) + + const entry = {} + const defaultEntries = hotReload ? ['webpack/hot/only-dev-server'] : [] + for (const p of pages) { + entry[join('_bundles', p)] = defaultEntries.concat(['./' + p]) + } + + const errEntry = join('_bundles', 'pages', '_error.js') + const defaultErrorPath = resolve(__dirname, '..', '..', 'pages', '_error.js') + if (!entry[errEntry]) entry[errEntry] = defaultErrorPath + + const nodeModulesDir = resolve(__dirname, '..', '..', '..', 'node_modules') + + const plugins = [ + hotReload + ? new webpack.HotModuleReplacementPlugin() + : new webpack.optimize.UglifyJsPlugin({ + compress: { warnings: false }, + sourceMap: false + }), + new WriteFilePlugin({ log: false }) + ] + + const babelRuntimePath = require.resolve('babel-runtime/package') + .replace(/[\\\/]package\.json$/, '') + + const loaders = [{ + test: /\.js$/, + loader: 'emit-file-loader', + include: [ + dir, + resolve(__dirname, '..', '..', 'pages') + ], + exclude: /node_modules/, + query: { + name: '[path][name].[ext]' + } + }, { + test: /\.js$/, + loader: 'babel', + include: [ + dir, + resolve(__dirname, '..', '..', 'pages') + ], + exclude: /node_modules/, + query: { + presets: ['es2015', 'react'], + plugins: [ + 'transform-async-to-generator', + 'transform-object-rest-spread', + 'transform-class-properties', + 'transform-runtime', + [ + 'module-alias', + [ + { src: `npm:${babelRuntimePath}`, expose: 'babel-runtime' }, + { src: `npm:${require.resolve('react')}`, expose: 'react' }, + { src: `npm:${require.resolve('../../lib/link')}`, expose: 'next/link' }, + { src: `npm:${require.resolve('../../lib/css')}`, expose: 'next/css' }, + { src: `npm:${require.resolve('../../lib/head')}`, expose: 'next/head' } + ] + ] + ] + } + }] + .concat(hotReload ? [{ + test: /\.js$/, + loader: 'hot-self-accept-loader', + include: resolve(dir, 'pages') + }] : []) + + return webpack({ + context: dir, + entry, + output: { + path: resolve(dir, '.next'), + filename: '[name]', + libraryTarget: 'commonjs2', + publicPath: hotReload ? 'http://localhost:3030/' : null + }, + externals: [ + 'react', + 'react-dom', + { + [require.resolve('react')]: 'react', + [require.resolve('../../lib/link')]: 'next/link', + [require.resolve('../../lib/css')]: 'next/css', + [require.resolve('../../lib/head')]: 'next/head' + } + ], + resolve: { + root: [ + nodeModulesDir, + resolve(dir, 'node_modules') + ] + }, + resolveLoader: { + root: [ + nodeModulesDir, + resolve(__dirname, 'loaders') + ] + }, + plugins, + module: { + preLoaders: [ + { test: /\.json$/, loader: 'json-loader' } + ], + loaders + }, + customInterpolateName: function (url, name, opts) { + if (defaultErrorPath === this.resourcePath) { + return 'pages/_error.js' + } + return url + } + }) +} diff --git a/server/hot-reloader.js b/server/hot-reloader.js new file mode 100644 index 00000000..a697e0dd --- /dev/null +++ b/server/hot-reloader.js @@ -0,0 +1,56 @@ +import WebpackDevServer from 'webpack-dev-server' +import read from './read' + +export default class HotReloader { + constructor (compiler) { + this.server = new WebpackDevServer(compiler, { + publicPath: '/', + hot: true, + noInfo: true, + clientLogLevel: 'warning' + }) + + compiler.plugin('after-emit', (compilation, callback) => { + const { assets } = compilation + for (const f of Object.keys(assets)) { + const source = assets[f] + // delete updated file caches + delete require.cache[source.existsAt] + delete read.cache[source.existsAt] + } + callback() + }) + } + + async start () { + await this.waitBuild() + await this.listen() + } + + async waitBuild () { + const stats = await new Promise((resolve) => { + this.server.middleware.waitUntilValid(resolve) + }) + + const jsonStats = stats.toJson() + if (jsonStats.errors.length > 0) { + const err = new Error(jsonStats.errors[0]) + err.errors = jsonStats.errors + err.warnings = jsonStats.warnings + throw err + } + } + + listen () { + return new Promise((resolve, reject) => { + this.server.listen(3030, (err) => { + if (err) return reject(err) + resolve() + }) + }) + } + + get fileSystem () { + return this.server.middleware.fileSystem + } +} diff --git a/server/index.js b/server/index.js index 70d441a4..f7f67c0e 100644 --- a/server/index.js +++ b/server/index.js @@ -5,9 +5,10 @@ import Router from './router' import { render, renderJSON } from './render' export default class Server { - constructor ({ dir = '.', dev = false }) { + constructor ({ dir = '.', dev = false, hotReloader }) { this.dir = resolve(dir) this.dev = dev + this.hotReloader = hotReloader this.router = new Router() this.http = http.createServer((req, res) => { @@ -39,6 +40,10 @@ export default class Server { await this.render(req, res) }) + if (this.hotReloader) { + await this.hotReloader.start() + } + await new Promise((resolve, reject) => { this.http.listen(port, (err) => { if (err) return reject(err) diff --git a/server/read.js b/server/read.js index 747207cc..d39d1fdd 100644 --- a/server/read.js +++ b/server/read.js @@ -1,8 +1,6 @@ import fs from 'mz/fs' import resolve from './resolve' -const cache = {} - /** * resolve a file like `require.resolve`, * and read and cache the file content @@ -10,13 +8,16 @@ const cache = {} async function read (path) { const f = await resolve(path) - let promise = cache[f] - if (!promise) { - promise = cache[f] = fs.readFile(f, 'utf8') + if (cache.hasOwnProperty(f)) { + return cache[f] } - return promise + + const data = fs.readFile(f, 'utf8') + cache[f] = data + return data } -module.exports = read +export default read +export const cache = {} -exports.cache = cache +read.cache = cache diff --git a/server/render.js b/server/render.js index 8bdd7752..7e14fe7b 100644 --- a/server/render.js +++ b/server/render.js @@ -2,7 +2,7 @@ import { resolve } from 'path' import { parse } from 'url' import { createElement } from 'react' import { renderToString, renderToStaticMarkup } from 'react-dom/server' -import requireResolve from './resolve' +import requireModule from './require' import read from './read' import Router from '../lib/router' import Document from '../lib/document' @@ -16,8 +16,7 @@ export async function render (url, ctx = {}, { staticMarkup = false } = {}) { const path = getPath(url) - const p = await requireResolve(resolve(dir, '.next', 'pages', path)) - const mod = require(p) + const mod = await requireModule(resolve(dir, '.next', 'pages', path)) const Component = mod.default || mod const props = await (Component.getInitialProps ? Component.getInitialProps(ctx) : {}) diff --git a/server/require.js b/server/require.js new file mode 100644 index 00000000..3ca13f32 --- /dev/null +++ b/server/require.js @@ -0,0 +1,6 @@ +import resolve from './resolve' + +export default async function requireModule (path) { + const f = await resolve(path) + return require(f) +} diff --git a/server/resolve.js b/server/resolve.js index 6b56fed0..03ad5d7d 100644 --- a/server/resolve.js +++ b/server/resolve.js @@ -4,9 +4,8 @@ export default function resolve (id, opts) { return new Promise((resolve, reject) => { _resolve(id, opts, (err, path) => { if (err) { - const e = new Error(err) - e.code = 'ENOENT' - return reject(e) + err.code = 'ENOENT' + return reject(err) } resolve(path) })