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)
})