提交 dec85fe6 编写于 作者: A Arunoda Susiripala 提交者: Guillermo Rauch

Add CDN support with assetPrefix (#1700)

* Introduce script tag based page loading system.

* Call ensurePage only in the dev mode.

* Implement router using the page-loader.

* Fix a typo and remove unwanted code.

* Fix some issues related to rendering.

* Fix production tests.

* Fix ondemand test cases.

* Fix unit tests.

* Get rid of eval completely.

* Remove all the inline code.

* Remove the json-pages plugin.

* Rename NEXT_PAGE_LOADER into __NEXT_PAGE_LOADER__

* Rename NEXT_LOADED_PAGES into __NEXT_LOADED_PAGES__

* Remove some unwanted code.

* Load everything async.

* Remove lib/eval-script.js
We no longer need it.

* Move webpack idle wait code to the page-loader.
Because that's the place to do it.

* Remove pageNotFound key from the error.

* Remove unused error field 'buildError'

* Add much better logic to normalize routes.

* Get rid of mitt.

* Introduce a better way to register pages.

* Came back to the mitt() based page-loader.

* Add link rel=preload support.

* Add assetPrefix support to add support for CDNs.

* Add assetPrefix support for preload links.

* Update readme.md
上级 bdc30bc0
......@@ -4,9 +4,9 @@ import mitt from 'mitt'
import HeadManager from './head-manager'
import { createRouter } from '../lib/router'
import App from '../lib/app'
import evalScript from '../lib/eval-script'
import { loadGetInitialProps, getURL } from '../lib/utils'
import ErrorDebugComponent from '../lib/error-debug'
import PageLoader from '../lib/page-loader'
// Polyfill Promise globally
// This is needed because Webpack2's dynamic loading(common chunks) code
......@@ -19,31 +19,50 @@ if (!window.Promise) {
const {
__NEXT_DATA__: {
component,
errorComponent,
props,
err,
pathname,
query
query,
buildId,
assetPrefix
},
location
} = window
const Component = evalScript(component).default
const ErrorComponent = evalScript(errorComponent).default
let lastAppProps
export const router = createRouter(pathname, query, getURL(), {
Component,
ErrorComponent,
err
const pageLoader = new PageLoader(buildId, assetPrefix)
window.__NEXT_LOADED_PAGES__.forEach(({ route, fn }) => {
pageLoader.registerPage(route, fn)
})
delete window.__NEXT_LOADED_PAGES__
window.__NEXT_REGISTER_PAGE = pageLoader.registerPage.bind(pageLoader)
const headManager = new HeadManager()
const appContainer = document.getElementById('__next')
const errorContainer = document.getElementById('__next-error')
export default () => {
let lastAppProps
export let router
export let ErrorComponent
let Component
export default async () => {
ErrorComponent = await pageLoader.loadPage('/_error')
try {
Component = await pageLoader.loadPage(pathname)
} catch (err) {
console.error(`${err.message}\n${err.stack}`)
Component = ErrorComponent
}
router = createRouter(pathname, query, getURL(), {
pageLoader,
Component,
ErrorComponent,
err
})
const emitter = mitt()
router.subscribe(({ Component, props, hash, err }) => {
......@@ -57,7 +76,10 @@ export default () => {
}
export async function render (props) {
if (props.err) {
// There are some errors we should ignore.
// Next.js rendering logic knows how to handle them.
// These are specially 404 errors
if (props.err && !props.err.ignore) {
await renderError(props.err)
return
}
......@@ -103,7 +125,7 @@ async function doRender ({ Component, props, hash, err, emitter }) {
}
if (emitter) {
emitter.emit('before-reactdom-render', { Component })
emitter.emit('before-reactdom-render', { Component, ErrorComponent })
}
Component = Component || lastAppProps.Component
......@@ -118,6 +140,6 @@ async function doRender ({ Component, props, hash, err, emitter }) {
ReactDOM.render(createElement(App, appProps), appContainer)
if (emitter) {
emitter.emit('after-reactdom-render', { Component })
emitter.emit('after-reactdom-render', { Component, ErrorComponent })
}
}
import evalScript from '../lib/eval-script'
import 'react-hot-loader/patch'
import ReactReconciler from 'react-dom/lib/ReactReconciler'
const { __NEXT_DATA__: { errorComponent } } = window
const ErrorComponent = evalScript(errorComponent).default
require('react-hot-loader/patch')
import initOnDemandEntries from './on-demand-entries-client'
import initWebpackHMR from './webpack-hot-middleware-client'
const next = window.next = require('./')
const emitter = next.default()
next.default()
.then((emitter) => {
initOnDemandEntries()
initWebpackHMR()
let lastScroll
emitter.on('before-reactdom-render', ({ Component, ErrorComponent }) => {
// Remember scroll when ErrorComponent is being rendered to later restore it
if (!lastScroll && Component === ErrorComponent) {
const { pageXOffset, pageYOffset } = window
lastScroll = {
x: pageXOffset,
y: pageYOffset
}
}
})
emitter.on('after-reactdom-render', ({ Component, ErrorComponent }) => {
if (lastScroll && Component !== ErrorComponent) {
// Restore scroll after ErrorComponent was replaced with a page component by HMR
const { x, y } = lastScroll
window.scroll(x, y)
lastScroll = null
}
})
})
.catch((err) => {
console.error(`${err.message}\n${err.stack}`)
})
// This is a patch to catch most of the errors throw inside React components.
const originalMountComponent = ReactReconciler.mountComponent
......@@ -21,25 +47,3 @@ ReactReconciler.mountComponent = function (...args) {
throw err
}
}
let lastScroll
emitter.on('before-reactdom-render', ({ Component }) => {
// Remember scroll when ErrorComponent is being rendered to later restore it
if (!lastScroll && Component === ErrorComponent) {
const { pageXOffset, pageYOffset } = window
lastScroll = {
x: pageXOffset,
y: pageYOffset
}
}
})
emitter.on('after-reactdom-render', ({ Component }) => {
if (lastScroll && Component !== ErrorComponent) {
// Restore scroll after ErrorComponent was replaced with a page component by HMR
const { x, y } = lastScroll
window.scroll(x, y)
lastScroll = null
}
})
import next from './'
next()
.catch((err) => {
console.error(`${err.message}\n${err.stack}`)
})
......@@ -3,31 +3,33 @@
import Router from '../lib/router'
import fetch from 'unfetch'
Router.ready(() => {
Router.router.events.on('routeChangeComplete', ping)
})
export default () => {
Router.ready(() => {
Router.router.events.on('routeChangeComplete', ping)
})
async function ping () {
try {
const url = `/_next/on-demand-entries-ping?page=${Router.pathname}`
const res = await fetch(url)
const payload = await res.json()
if (payload.invalid) {
location.reload()
async function ping () {
try {
const url = `/_next/on-demand-entries-ping?page=${Router.pathname}`
const res = await fetch(url)
const payload = await res.json()
if (payload.invalid) {
location.reload()
}
} catch (err) {
console.error(`Error with on-demand-entries-ping: ${err.message}`)
}
} catch (err) {
console.error(`Error with on-demand-entries-ping: ${err.message}`)
}
}
async function runPinger () {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 5000))
await ping()
async function runPinger () {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 5000))
await ping()
}
}
}
runPinger()
.catch((err) => {
console.error(err)
})
runPinger()
.catch((err) => {
console.error(err)
})
}
import webpackHotMiddlewareClient from 'webpack-hot-middleware/client?overlay=false&reload=true&path=/_next/webpack-hmr'
import Router from '../lib/router'
const handlers = {
reload (route) {
if (route === '/_error') {
for (const r of Object.keys(Router.components)) {
const { err } = Router.components[r]
if (err) {
// reload all error routes
// which are expected to be errors of '/_error' routes
Router.reload(r)
export default () => {
const handlers = {
reload (route) {
if (route === '/_error') {
for (const r of Object.keys(Router.components)) {
const { err } = Router.components[r]
if (err) {
// reload all error routes
// which are expected to be errors of '/_error' routes
Router.reload(r)
}
}
return
}
return
}
if (route === '/_document') {
window.location.reload()
return
}
if (route === '/_document') {
window.location.reload()
return
}
Router.reload(route)
},
Router.reload(route)
},
change (route) {
if (route === '/_document') {
window.location.reload()
return
}
change (route) {
if (route === '/_document') {
window.location.reload()
return
}
const { err } = Router.components[route] || {}
if (err) {
// reload to recover from runtime errors
Router.reload(route)
const { err } = Router.components[route] || {}
if (err) {
// reload to recover from runtime errors
Router.reload(route)
}
}
}
}
webpackHotMiddlewareClient.subscribe((obj) => {
const fn = handlers[obj.action]
if (fn) {
const data = obj.data || []
fn(...data)
} else {
throw new Error('Unexpected action ' + obj.action)
}
})
webpackHotMiddlewareClient.subscribe((obj) => {
const fn = handlers[obj.action]
if (fn) {
const data = obj.data || []
fn(...data)
} else {
throw new Error('Unexpected action ' + obj.action)
}
})
}
......@@ -3,8 +3,8 @@ import HTTPStatus from 'http-status'
import Head from './head'
export default class Error extends React.Component {
static getInitialProps ({ res, jsonPageRes }) {
const statusCode = res ? res.statusCode : (jsonPageRes ? jsonPageRes.status : null)
static getInitialProps ({ res, err }) {
const statusCode = res ? res.statusCode : (err ? err.statusCode : null)
return { statusCode }
}
......
/**
* 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 `flyfile.js` and the
* usage of `babel-plugin-transform-remove-strict-mode`.
*/
export default function evalScript (script) {
const module = { exports: {} }
eval(script) // eslint-disable-line no-eval
return module.exports
}
/* global window, document */
import mitt from 'mitt'
const webpackModule = module
export default class PageLoader {
constructor (buildId, assetPrefix) {
this.buildId = buildId
this.assetPrefix = assetPrefix
this.pageCache = {}
this.pageLoadedHandlers = {}
this.registerEvents = mitt()
this.loadingRoutes = {}
}
normalizeRoute (route) {
if (route[0] !== '/') {
throw new Error('Route name should start with a "/"')
}
return route.replace(/index$/, '')
}
loadPage (route) {
route = this.normalizeRoute(route)
const cachedPage = this.pageCache[route]
if (cachedPage) {
return new Promise((resolve, reject) => {
if (cachedPage.error) return reject(cachedPage.error)
return resolve(cachedPage.page)
})
}
return new Promise((resolve, reject) => {
const fire = ({ error, page }) => {
this.registerEvents.off(route, fire)
if (error) {
reject(error)
} else {
resolve(page)
}
}
this.registerEvents.on(route, fire)
// Load the script if not asked to load yet.
if (!this.loadingRoutes[route]) {
this.loadScript(route)
this.loadingRoutes[route] = true
}
})
}
loadScript (route) {
route = this.normalizeRoute(route)
const script = document.createElement('script')
const url = `${this.assetPrefix}/_next/${encodeURIComponent(this.buildId)}/page${route}`
script.src = url
script.type = 'text/javascript'
script.onerror = () => {
const error = new Error(`Error when loading route: ${route}`)
this.registerEvents.emit(route, { error })
}
document.body.appendChild(script)
}
// This method if called by the route code.
registerPage (route, regFn) {
const register = () => {
const { error, page } = regFn()
this.pageCache[route] = { error, page }
this.registerEvents.emit(route, { error, page })
}
// Wait for webpack to became idle if it's not.
// More info: https://github.com/zeit/next.js/pull/1511
if (webpackModule && webpackModule.hot && webpackModule.hot.status() !== 'idle') {
console.log(`Waiting webpack to became "idle" to initialize the page: "${route}"`)
const check = (status) => {
if (status === 'idle') {
webpackModule.hot.removeStatusHandler(check)
register()
}
}
webpackModule.hot.status(check)
} else {
register()
}
}
clearCache (route) {
route = this.normalizeRoute(route)
delete this.pageCache[route]
delete this.loadingRoutes[route]
}
}
import { parse, format } from 'url'
import mitt from 'mitt'
import fetch from 'unfetch'
import evalScript from '../eval-script'
import shallowEquals from '../shallow-equals'
import PQueue from '../p-queue'
import { loadGetInitialProps, getURL } from '../utils'
import { _notifyBuildIdMismatch } from './'
const webpackModule = module
export default class Router {
constructor (pathname, query, as, { Component, ErrorComponent, err } = {}) {
constructor (pathname, query, as, { pageLoader, Component, ErrorComponent, err } = {}) {
// represents the current component key
this.route = toRoute(pathname)
// set up the component cache (by route keys)
this.components = { [this.route]: { Component, err } }
// contain a map of promise of fetch routes
this.fetchingRoutes = {}
this.components = {}
// We should not keep the cache, if there's an error
// Otherwise, this cause issues when when going back and
// come again to the errored page.
if (Component !== ErrorComponent) {
this.components[this.route] = { Component, err }
}
// Handling Router Events
this.events = mitt()
this.pageLoader = pageLoader
this.prefetchQueue = new PQueue({ concurrency: 2 })
this.ErrorComponent = ErrorComponent
this.pathname = pathname
......@@ -77,7 +77,7 @@ export default class Router {
async reload (route) {
delete this.components[route]
delete this.fetchingRoutes[route]
this.pageLoader.clearCache(route)
if (route !== this.route) return
......@@ -186,11 +186,11 @@ export default class Router {
try {
routeInfo = this.components[route]
if (!routeInfo) {
routeInfo = await this.fetchComponent(route, as)
routeInfo = { Component: await this.fetchComponent(route, as) }
}
const { Component, err, jsonPageRes } = routeInfo
const ctx = { err, pathname, query, jsonPageRes }
const { Component } = routeInfo
const ctx = { pathname, query }
routeInfo.props = await this.getInitialProps(Component, ctx)
this.components[route] = routeInfo
......@@ -199,13 +199,27 @@ export default class Router {
return { error: err }
}
if (err.buildIdMismatched) {
// Now we need to reload the page or do the action asked by the user
_notifyBuildIdMismatch(as)
// We also need to cancel this current route change.
// We do it like this.
err.cancelled = true
return { error: err }
}
if (err.statusCode === 404) {
// Indicate main error display logic to
// ignore rendering this error as a runtime error.
err.ignore = true
}
const Component = this.ErrorComponent
routeInfo = { Component, err }
const ctx = { err, pathname, query }
routeInfo.props = await this.getInitialProps(Component, ctx)
routeInfo.error = err
console.error(err)
}
return routeInfo
......@@ -268,28 +282,7 @@ export default class Router {
cancelled = true
}
const jsonPageRes = await this.fetchRoute(route)
let jsonData
// We can call .json() only once for a response.
// That's why we need to keep a copy of data if we already parsed it.
if (jsonPageRes.data) {
jsonData = jsonPageRes.data
} else {
jsonData = jsonPageRes.data = await jsonPageRes.json()
}
if (jsonData.buildIdMismatch) {
_notifyBuildIdMismatch(as)
const error = Error('Abort due to BUILD_ID mismatch')
error.cancelled = true
throw error
}
const newData = {
...await loadComponent(jsonData),
jsonPageRes
}
const Component = await this.fetchRoute(route)
if (cancelled) {
const error = new Error(`Abort fetching component for route: "${route}"`)
......@@ -301,7 +294,7 @@ export default class Router {
this.componentLoadCancel = null
}
return newData
return Component
}
async getInitialProps (Component, ctx) {
......@@ -324,24 +317,8 @@ export default class Router {
return props
}
fetchRoute (route) {
let promise = this.fetchingRoutes[route]
if (!promise) {
promise = this.fetchingRoutes[route] = this.doFetchRoute(route)
}
return promise
}
doFetchRoute (route) {
const { buildId } = window.__NEXT_DATA__
const url = `/_next/${encodeURIComponent(buildId)}/pages${route}`
return fetch(url, {
method: 'GET',
credentials: 'same-origin',
headers: { 'Accept': 'application/json' }
})
async fetchRoute (route) {
return await this.pageLoader.loadPage(route)
}
abortComponentLoad (as) {
......@@ -365,22 +342,3 @@ export default class Router {
function toRoute (path) {
return path.replace(/\/$/, '') || '/'
}
async function loadComponent (jsonData) {
if (webpackModule && webpackModule.hot && webpackModule.hot.status() !== 'idle') {
await new Promise((resolve) => {
const check = (status) => {
if (status === 'idle') {
webpackModule.hot.removeStatusHandler(check)
resolve()
}
}
webpackModule.hot.status(check)
})
}
const module = evalScript(jsonData.component)
const Component = module.default || module
return { Component, err: jsonData.err }
}
......@@ -34,6 +34,7 @@ Next.js is a minimalistic framework for server-rendered React applications.
- [Custom configuration](#custom-configuration)
- [Customizing webpack config](#customizing-webpack-config)
- [Customizing babel config](#customizing-babel-config)
- [CDN support with Asset Prefix](#cdn-support-with-asset-prefix)
- [Production deployment](#production-deployment)
- [FAQ](#faq)
- [Contributing](#contributing)
......@@ -704,6 +705,20 @@ Here's an example `.babelrc` file:
}
```
### CDN support with Asset Prefix
To set up a CDN, you can set up the `assetPrefix` setting and configure your CDN's origin to resolve to the domain that Next.js is hosted on.
```js
const isProd = process.NODE_ENV === 'production'
module.exports = {
// You may only need to add assetPrefix in the production.
assetPrefix: isProd ? 'https://cdn.mydomain.com' : ''
}
```
Note: Next.js will automatically use that prefix the scripts it loads, but this has no effect whatsoever on `/static`. If you want to serve those assets over the CDN, you'll have to introduce the prefix yourself. One way of introducing a prefix that works inside your components and varies by environment is documented [in this example](https://github.com/zeit/next.js/tree/master/examples/with-universal-configuration).
## Production deployment
To deploy, instead of running `next`, you want to build for production usage ahead of time. Therefore, building and starting are separate commands:
......
export default class JsonPagesPlugin {
apply (compiler) {
compiler.plugin('after-compile', (compilation, callback) => {
const pages = Object
.keys(compilation.assets)
.filter((filename) => /^bundles[/\\]pages.*\.js$/.test(filename))
pages.forEach((pageName) => {
const page = compilation.assets[pageName]
delete compilation.assets[pageName]
const content = page.source()
const newContent = JSON.stringify({ component: content })
compilation.assets[`${pageName}on`] = {
source: () => newContent,
size: () => newContent.length
}
})
callback()
})
}
}
export default class PagesPlugin {
apply (compiler) {
const isBundledPage = /^bundles[/\\]pages.*\.js$/
const matchRouteName = /^bundles[/\\]pages[/\\](.*)\.js$/
compiler.plugin('after-compile', (compilation, callback) => {
const pages = Object
.keys(compilation.namedChunks)
.map(key => compilation.namedChunks[key])
.filter(chunk => isBundledPage.test(chunk.name))
pages.forEach((chunk) => {
const page = compilation.assets[chunk.name]
const pageName = matchRouteName.exec(chunk.name)[1]
const routeName = `/${pageName.replace(/[/\\]?index$/, '')}`
const content = page.source()
const newContent = `
window.__NEXT_REGISTER_PAGE('${routeName}', function() {
var comp = ${content}
return { page: comp.default }
})
`
// Replace the exisiting chunk with the new content
compilation.assets[chunk.name] = {
source: () => newContent,
size: () => newContent.length
}
})
callback()
})
}
}
......@@ -6,7 +6,7 @@ import WriteFilePlugin from 'write-file-webpack-plugin'
import FriendlyErrorsWebpackPlugin from 'friendly-errors-webpack-plugin'
import CaseSensitivePathPlugin from 'case-sensitive-paths-webpack-plugin'
import UnlinkFilePlugin from './plugins/unlink-file-plugin'
import JsonPagesPlugin from './plugins/json-pages-plugin'
import PagesPlugin from './plugins/pages-plugin'
import CombineAssetsPlugin from './plugins/combine-assets-plugin'
import getConfig from '../config'
import * as babelCore from 'babel-core'
......@@ -114,7 +114,7 @@ export default async function createCompiler (dir, { dev = false, quiet = false,
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production')
}),
new JsonPagesPlugin(),
new PagesPlugin(),
new CaseSensitivePathPlugin()
]
......
......@@ -6,7 +6,8 @@ const cache = new Map()
const defaultConfig = {
webpack: null,
poweredByHeader: true,
distDir: '.next'
distDir: '.next',
assetPrefix: ''
}
export default function getConfig (dir) {
......
......@@ -34,9 +34,45 @@ export class Head extends Component {
_documentProps: PropTypes.any
}
getChunkPreloadLink (filename) {
const { __NEXT_DATA__ } = this.context._documentProps
let { buildStats, assetPrefix } = __NEXT_DATA__
const hash = buildStats ? buildStats[filename].hash : '-'
return (
<link
key={filename}
rel='preload'
href={`${assetPrefix}/_next/${hash}/${filename}`}
as='script'
/>
)
}
getPreloadMainLinks () {
const { dev } = this.context._documentProps
if (dev) {
return [
this.getChunkPreloadLink('manifest.js'),
this.getChunkPreloadLink('commons.js'),
this.getChunkPreloadLink('main.js')
]
}
// In the production mode, we have a single asset with all the JS content.
return [
this.getChunkPreloadLink('app.js')
]
}
render () {
const { head, styles } = this.context._documentProps
const { head, styles, __NEXT_DATA__ } = this.context._documentProps
const { pathname, buildId, assetPrefix } = __NEXT_DATA__
return <head>
<link rel='preload' href={`${assetPrefix}/_next/${buildId}/page${pathname}`} as='script' />
<link rel='preload' href={`${assetPrefix}/_next/${buildId}/page/_error`} as='script' />
{this.getPreloadMainLinks()}
{(head || []).map((h, i) => React.cloneElement(h, { key: i }))}
{styles || null}
{this.props.children}
......@@ -67,13 +103,13 @@ export class NextScript extends Component {
getChunkScript (filename, additionalProps = {}) {
const { __NEXT_DATA__ } = this.context._documentProps
let { buildStats } = __NEXT_DATA__
let { buildStats, assetPrefix } = __NEXT_DATA__
const hash = buildStats ? buildStats[filename].hash : '-'
return (
<script
type='text/javascript'
src={`/_next/${hash}/${filename}`}
src={`${assetPrefix}/_next/${hash}/${filename}`}
{...additionalProps}
/>
)
......@@ -98,11 +134,22 @@ export class NextScript extends Component {
render () {
const { staticMarkup, __NEXT_DATA__ } = this.context._documentProps
const { pathname, buildId, assetPrefix } = __NEXT_DATA__
return <div>
{staticMarkup ? null : <script dangerouslySetInnerHTML={{
__html: `__NEXT_DATA__ = ${htmlescape(__NEXT_DATA__)}; module={};`
__html: `
__NEXT_DATA__ = ${htmlescape(__NEXT_DATA__)}
module={}
__NEXT_LOADED_PAGES__ = []
__NEXT_REGISTER_PAGE = function (route, fn) {
__NEXT_LOADED_PAGES__.push({ route: route, fn: fn })
}
`
}} />}
<script async type='text/javascript' src={`${assetPrefix}/_next/${buildId}/page${pathname}`} />
<script async type='text/javascript' src={`${assetPrefix}/_next/${buildId}/page/_error`} />
{staticMarkup ? null : this.getScripts()}
</div>
}
......
......@@ -5,7 +5,6 @@ import onDemandEntryHandler from './on-demand-entry-handler'
import isWindowsBash from 'is-windows-bash'
import webpack from './build/webpack'
import clean from './build/clean'
import readPage from './read-page'
import getConfig from './config'
export default class HotReloader {
......@@ -205,7 +204,6 @@ export default class HotReloader {
function deleteCache (path) {
delete require.cache[path]
delete readPage.cache[path]
}
function diff (a, b) {
......
......@@ -6,10 +6,10 @@ import http, { STATUS_CODES } from 'http'
import {
renderToHTML,
renderErrorToHTML,
renderJSON,
renderErrorJSON,
sendHTML,
serveStatic
serveStatic,
renderScript,
renderScriptError
} from './render'
import Router from './router'
import HotReloader from './hot-reloader'
......@@ -41,7 +41,8 @@ export default class Server {
dir: this.dir,
hotReloader: this.hotReloader,
buildStats: this.buildStats,
buildId: this.buildId
buildId: this.buildId,
assetPrefix: this.config.assetPrefix.replace(/\/$/, '')
}
this.defineRoutes()
......@@ -122,17 +123,44 @@ export default class Server {
await this.serveStatic(req, res, p)
},
'/_next/:buildId/pages/:path*': async (req, res, params) => {
'/_next/:buildId/page/_error': async (req, res, params) => {
if (!this.handleBuildId(params.buildId, res)) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ buildIdMismatch: true }))
return
const error = new Error('INVALID_BUILD_ID')
const customFields = { buildIdMismatched: true }
return await renderScriptError(req, res, '/_error', error, customFields, this.renderOpts)
}
const p = join(this.dir, '.next/bundles/pages/_error.js')
await this.serveStatic(req, res, p)
},
'/_next/:buildId/page/:path*': async (req, res, params) => {
const paths = params.path || ['']
const page = `/${paths.join('/')}`
if (!this.handleBuildId(params.buildId, res)) {
const error = new Error('INVALID_BUILD_ID')
const customFields = { buildIdMismatched: true }
return await renderScriptError(req, res, page, error, customFields, this.renderOpts)
}
const paths = params.path || ['index']
const pathname = `/${paths.join('/')}`
if (this.dev) {
try {
await this.hotReloader.ensurePage(page)
} catch (error) {
return await renderScriptError(req, res, page, error, {}, this.renderOpts)
}
const compilationErr = this.getCompilationError(page)
if (compilationErr) {
const customFields = { statusCode: 500 }
return await renderScriptError(req, res, page, compilationErr, customFields, this.renderOpts)
}
}
await this.renderJSON(req, res, pathname)
await renderScript(req, res, page, this.renderOpts)
},
'/_next/:path+': async (req, res, params) => {
......@@ -256,40 +284,6 @@ export default class Server {
return this.renderError(null, req, res, pathname, query)
}
async renderJSON (req, res, page) {
if (this.dev) {
const compilationErr = this.getCompilationError(page)
if (compilationErr) {
return this.renderErrorJSON(compilationErr, req, res)
}
}
try {
return await renderJSON(req, res, page, this.renderOpts)
} catch (err) {
if (err.code === 'ENOENT') {
res.statusCode = 404
return this.renderErrorJSON(null, req, res)
} else {
if (!this.quiet) console.error(err)
res.statusCode = 500
return this.renderErrorJSON(err, req, res)
}
}
}
async renderErrorJSON (err, req, res) {
if (this.dev) {
const compilationErr = this.getCompilationError('/_error')
if (compilationErr) {
res.statusCode = 500
return renderErrorJSON(compilationErr, req, res, this.renderOpts)
}
}
return renderErrorJSON(err, req, res, this.renderOpts)
}
async serveStatic (req, res, path) {
try {
return await serveStatic(req, res, path)
......
import fs from 'mz/fs'
import resolve from './resolve'
/**
* resolve a JSON page like `require.resolve`,
* and read and cache the file content
*/
async function readPage (path) {
const f = await resolve(path)
if (cache.hasOwnProperty(f)) {
return cache[f]
}
const source = await fs.readFile(f, 'utf8')
const { component } = JSON.parse(source)
cache[f] = component
return component
}
export default readPage
export const cache = {}
readPage.cache = cache
......@@ -5,7 +5,6 @@ import send from 'send'
import requireModule from './require'
import getConfig from './config'
import resolvePath from './resolve'
import readPage from './read-page'
import { Router } from '../lib/router'
import { loadGetInitialProps } from '../lib/utils'
import Head, { defaultHead } from '../lib/head'
......@@ -36,6 +35,7 @@ async function doRender (req, res, pathname, query, {
buildId,
buildStats,
hotReloader,
assetPrefix,
dir = process.cwd(),
dev = false,
staticMarkup = false
......@@ -53,16 +53,7 @@ async function doRender (req, res, pathname, query, {
Component = Component.default || Component
Document = Document.default || Document
const ctx = { err, req, res, pathname, query }
const [
props,
component,
errorComponent
] = await Promise.all([
loadGetInitialProps(Component, ctx),
readPage(join(dir, dist, 'bundles', 'pages', page)),
readPage(join(dir, dist, 'bundles', 'pages', '_error'))
])
const props = await loadGetInitialProps(Component, ctx)
// the response might be finshed on the getinitialprops call
if (res.finished) return
......@@ -98,13 +89,12 @@ async function doRender (req, res, pathname, query, {
const doc = createElement(Document, {
__NEXT_DATA__: {
component,
errorComponent,
props,
pathname,
query,
buildId,
buildStats,
assetPrefix,
err: (err && dev) ? errorToJSON(err) : null
},
dev,
......@@ -115,21 +105,47 @@ async function doRender (req, res, pathname, query, {
return '<!DOCTYPE html>' + renderToStaticMarkup(doc)
}
export async function renderJSON (req, res, page, { dir = process.cwd(), hotReloader } = {}) {
const dist = getConfig(dir).distDir
await ensurePage(page, { dir, hotReloader })
const pagePath = await resolvePath(join(dir, dist, 'bundles', 'pages', page))
return serveStatic(req, res, pagePath)
export async function renderScript (req, res, page, opts) {
try {
const path = join(opts.dir, '.next', 'bundles', 'pages', page)
const realPath = await resolvePath(path)
await serveStatic(req, res, realPath)
} catch (err) {
if (err.code === 'ENOENT') {
renderScriptError(req, res, page, err, {}, opts)
return
}
throw err
}
}
export async function renderErrorJSON (err, req, res, { dir = process.cwd(), dev = false } = {}) {
const dist = getConfig(dir).distDir
const component = await readPage(join(dir, dist, 'bundles', 'pages', '_error'))
export async function renderScriptError (req, res, page, error, customFields, opts) {
if (error.code === 'ENOENT') {
res.setHeader('Content-Type', 'text/javascript')
res.end(`
window.__NEXT_REGISTER_PAGE('${page}', function() {
var error = new Error('Page not exists: ${page}')
error.statusCode = 404
return { error: error }
})
`)
return
}
sendJSON(res, {
component,
err: err && dev ? errorToJSON(err) : null
}, req.method)
res.setHeader('Content-Type', 'text/javascript')
const errorJson = {
...errorToJSON(error),
...customFields
}
res.end(`
window.__NEXT_REGISTER_PAGE('${page}', function() {
var error = ${JSON.stringify(errorJson)}
return { error: error }
})
`)
}
export function sendHTML (res, html, method) {
......
......@@ -31,13 +31,13 @@ describe('On Demand Entries', () => {
})
it('should compile pages for JSON page requests', async () => {
const pageContent = await renderViaHTTP(context.appPort, '/_next/-/pages/about')
const pageContent = await renderViaHTTP(context.appPort, '/_next/-/page/about')
expect(pageContent.includes('About Page')).toBeTruthy()
})
it('should dispose inactive pages', async () => {
await renderViaHTTP(context.appPort, '/_next/-/pages/about')
const aboutPagePath = resolve(__dirname, '../.next/bundles/pages/about.json')
const aboutPagePath = resolve(__dirname, '../.next/bundles/pages/about.js')
expect(existsSync(aboutPagePath)).toBeTruthy()
// Wait maximum of jasmine.DEFAULT_TIMEOUT_INTERVAL checking
......
export default () => (
<div className='about-page'>About Page</div>
)
import Link from 'next/link'
export default () => (
<div>Hello World</div>
<div>
<Link href='/about'><a>About Page</a></Link>
<p>Hello World</p>
</div>
)
/* global jasmine, describe, it, expect, beforeAll, afterAll */
import fetch from 'node-fetch'
import { join } from 'path'
import {
nextServer,
......@@ -9,6 +8,7 @@ import {
stopApp,
renderViaHTTP
} from 'next-test-utils'
import webdriver from 'next-webdriver'
const appDir = join(__dirname, '../')
let appPort
......@@ -37,32 +37,16 @@ describe('Production Usage', () => {
})
})
describe('JSON pages', () => {
describe('when asked for a normal page', () => {
it('should serve the normal page', async () => {
const url = `http://localhost:${appPort}/_next/${app.renderOpts.buildId}/pages`
const res = await fetch(url, { compress: false })
expect(res.headers.get('Content-Encoding')).toBeNull()
describe('With navigation', () => {
it('should navigate via client side', async () => {
const browser = await webdriver(appPort, '/')
const text = await browser
.elementByCss('a').click()
.waitForElementByCss('.about-page')
.elementByCss('div').text()
const page = await res.json()
expect(page.component).toBeDefined()
})
})
describe('when asked for a page with an unknown encoding', () => {
it('should serve the normal page', async () => {
const url = `http://localhost:${appPort}/_next/${app.renderOpts.buildId}/pages`
const res = await fetch(url, {
compress: false,
headers: {
'Accept-Encoding': 'br'
}
})
expect(res.headers.get('Content-Encoding')).toBeNull()
const page = await res.json()
expect(page.component).toBeDefined()
})
expect(text).toBe('About Page')
browser.close()
})
})
})
/* global describe, it, expect */
import Router from '../../dist/lib/router/router'
class PageLoader {
constructor (options = {}) {
this.options = options
this.loaded = {}
}
loadPage (route) {
this.loaded[route] = true
if (this.options.delay) {
return new Promise((resolve) => setTimeout(resolve, this.options.delay))
}
}
}
describe('Router', () => {
const request = { clone: () => null }
describe('.prefetch()', () => {
it('should prefetch a given page', async () => {
const router = new Router('/', {})
const promise = Promise.resolve(request)
const route = 'routex'
router.doFetchRoute = (r) => {
expect(r).toBe(route)
return promise
}
const pageLoader = new PageLoader()
const router = new Router('/', {}, '/', { pageLoader })
const route = '/routex'
await router.prefetch(route)
expect(router.fetchingRoutes[route]).toBe(promise)
})
it('should stop if it\'s prefetching already', async () => {
const router = new Router('/', {})
const route = 'routex'
router.fetchingRoutes[route] = Promise.resolve(request)
router.doFetchRoute = () => { throw new Error('Should not happen') }
await router.prefetch(route)
expect(pageLoader.loaded['/routex']).toBeTruthy()
})
it('should only run two jobs at a time', async () => {
const router = new Router('/', {})
let count = 0
router.doFetchRoute = () => {
count++
return new Promise((resolve) => {})
}
// delay loading pages for an hour
const pageLoader = new PageLoader({ delay: 1000 * 3600 })
const router = new Router('/', {}, '/', { pageLoader })
router.prefetch('route1')
router.prefetch('route2')
router.prefetch('route3')
router.prefetch('route4')
// Wait for a bit
await new Promise((resolve) => setTimeout(resolve, 50))
expect(count).toBe(2)
expect(Object.keys(router.fetchingRoutes)).toEqual(['route1', 'route2'])
expect(Object.keys(pageLoader.loaded).length).toBe(2)
expect(Object.keys(pageLoader.loaded)).toEqual(['route1', 'route2'])
})
it('should run all the jobs', async () => {
const router = new Router('/', {})
const pageLoader = new PageLoader()
const router = new Router('/', {}, '/', { pageLoader })
const routes = ['route1', 'route2', 'route3', 'route4']
router.doFetchRoute = () => Promise.resolve(request)
......@@ -56,7 +57,7 @@ describe('Router', () => {
await router.prefetch(routes[2])
await router.prefetch(routes[3])
expect(Object.keys(router.fetchingRoutes)).toEqual(routes)
expect(Object.keys(pageLoader.loaded)).toEqual(routes)
})
})
})
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册