diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts new file mode 100644 index 00000000..049f2992 --- /dev/null +++ b/packages/next/build/entries.ts @@ -0,0 +1,67 @@ +import {join} from 'path' +import {stringify} from 'querystring' +import {PAGES_DIR_ALIAS, DOT_NEXT_ALIAS} from '../lib/constants' +import {ServerlessLoaderQuery} from './webpack/loaders/next-serverless-loader' + +type PagesMapping = { + [page: string]: string +} + +export function createPagesMapping(pagePaths: string[], extensions: string[]): PagesMapping { + const pages: PagesMapping = pagePaths.reduce((result: PagesMapping, pagePath): PagesMapping => { + const page = `/${pagePath.replace(new RegExp(`\\.+(${extensions.join('|')})$`), '').replace(/\\/g, '/')}`.replace(/\/index$/, '') + result[page === '' ? '/' : page] = join(PAGES_DIR_ALIAS, pagePath).replace(/\\/g, '/') + return result + }, {}) + + pages['/_app'] = pages['/_app'] || 'next/dist/pages/_app' + pages['/_error'] = pages['/_error'] || 'next/dist/pages/_error' + pages['/_document'] = pages['/_document'] || 'next/dist/pages/_document' + + return pages +} + +type WebpackEntrypoints = { + [bundle: string]: string|string[] +} + +type Entrypoints = { + client: WebpackEntrypoints + server: WebpackEntrypoints +} + +export function createEntrypoints(pages: PagesMapping, target: 'server'|'serverless', buildId: string, config: any): Entrypoints { + const client: WebpackEntrypoints = {} + const server: WebpackEntrypoints = {} + + const defaultServerlessOptions = { + absoluteAppPath: pages['/_app'], + absoluteDocumentPath: pages['/_document'], + absoluteErrorPath: pages['/_error'], + distDir: DOT_NEXT_ALIAS, + buildId, + assetPrefix: config.assetPrefix, + generateEtags: config.generateEtags + } + + Object.keys(pages).forEach((page) => { + const absolutePagePath = pages[page] + const bundleFile = page === '/' ? '/index.js' : `${page}.js` + const bundlePath = join('static', buildId, 'pages', bundleFile) + if(target === 'serverless') { + const serverlessLoaderOptions: ServerlessLoaderQuery = {page, absolutePagePath, ...defaultServerlessOptions} + server[join('pages', bundleFile)] = `next-serverless-loader?${stringify(serverlessLoaderOptions)}!` + } else if(target === 'server') { + server[bundlePath] = [absolutePagePath] + } + if (page === '/_document') { + return + } + client[bundlePath] = `next-client-pages-loader?${stringify({page, absolutePagePath})}!` + }) + + return { + client, + server + } +} diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index f3dd6852..4933b822 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -9,8 +9,7 @@ import {isWriteable} from './is-writeable' import {runCompiler, CompilerResult} from './compiler' import globModule from 'glob' import {promisify} from 'util' -import {stringify} from 'querystring' -import {ServerlessLoaderQuery} from './webpack/loaders/next-serverless-loader' +import {createPagesMapping, createEntrypoints} from './entries' const glob = promisify(globModule) @@ -29,58 +28,11 @@ export default async function build (dir: string, conf = null): Promise { const pagesDir = join(dir, 'pages') const pagePaths = await collectPages(pagesDir, config.pageExtensions) - type Result = {[page: string]: string} - const pages: Result = pagePaths.reduce((result: Result, pagePath): Result => { - let page = `/${pagePath.replace(new RegExp(`\\.+(${config.pageExtensions.join('|')})$`), '').replace(/\\/g, '/')}`.replace(/\/index$/, '') - page = page === '' ? '/' : page - result[page] = pagePath - return result - }, {}) - - let entrypoints - if (config.target === 'serverless') { - const serverlessEntrypoints: any = {} - // Because on Windows absolute paths in the generated code can break because of numbers, eg 1 in the path, - // we have to use a private alias - const pagesDirAlias = 'private-next-pages' - const dotNextDirAlias = 'private-dot-next' - const absoluteAppPath = pages['/_app'] ? join(pagesDirAlias, pages['/_app']).replace(/\\/g, '/') : 'next/dist/pages/_app' - const absoluteDocumentPath = pages['/_document'] ? join(pagesDirAlias, pages['/_document']).replace(/\\/g, '/') : 'next/dist/pages/_document' - const absoluteErrorPath = pages['/_error'] ? join(pagesDirAlias, pages['/_error']).replace(/\\/g, '/') : 'next/dist/pages/_error' - - const defaultOptions = { - absoluteAppPath, - absoluteDocumentPath, - absoluteErrorPath, - distDir: dotNextDirAlias, - buildId, - assetPrefix: config.assetPrefix, - generateEtags: config.generateEtags - } - - Object.keys(pages).forEach(async (page) => { - if (page === '/_app' || page === '/_document') { - return - } - - const absolutePagePath = join(pagesDirAlias, pages[page]).replace(/\\/g, '/') - const bundleFile = page === '/' ? '/index.js' : `${page}.js` - const serverlessLoaderOptions: ServerlessLoaderQuery = {page, absolutePagePath, ...defaultOptions} - serverlessEntrypoints[join('pages', bundleFile)] = `next-serverless-loader?${stringify(serverlessLoaderOptions)}!` - }) - - const errorPage = join('pages', '/_error.js') - if (!serverlessEntrypoints[errorPage]) { - const serverlessLoaderOptions: ServerlessLoaderQuery = {page: '/_error', absolutePagePath: 'next/dist/pages/_error', ...defaultOptions} - serverlessEntrypoints[errorPage] = `next-serverless-loader?${stringify(serverlessLoaderOptions)}!` - } - - entrypoints = serverlessEntrypoints - } - + const pages = createPagesMapping(pagePaths, config.pageExtensions) + const entrypoints = createEntrypoints(pages, config.target, buildId, config) const configs: any = await Promise.all([ - getBaseWebpackConfig(dir, { buildId, isServer: false, config, target: config.target }), - getBaseWebpackConfig(dir, { buildId, isServer: true, config, target: config.target, entrypoints }) + getBaseWebpackConfig(dir, { buildId, isServer: false, config, target: config.target, entrypoints: entrypoints.client }), + getBaseWebpackConfig(dir, { buildId, isServer: true, config, target: config.target, entrypoints: entrypoints.server }) ]) let result: CompilerResult = {warnings: [], errors: []} diff --git a/packages/next/build/webpack-config.js b/packages/next/build/webpack-config.js index 15876147..88648c23 100644 --- a/packages/next/build/webpack-config.js +++ b/packages/next/build/webpack-config.js @@ -4,8 +4,6 @@ import resolve from 'resolve' import CaseSensitivePathPlugin from 'case-sensitive-paths-webpack-plugin' import FriendlyErrorsWebpackPlugin from 'friendly-errors-webpack-plugin' import WebpackBar from 'webpackbar' -import {getPages} from './webpack/utils' -import PagesPlugin from './webpack/plugins/pages-plugin' import NextJsSsrImportPlugin from './webpack/plugins/nextjs-ssr-import' import NextJsSSRModuleCachePlugin from './webpack/plugins/nextjs-ssr-module-cache' import NextJsRequireCacheHotReloader from './webpack/plugins/nextjs-require-cache-hot-reloader' @@ -15,7 +13,7 @@ import BuildManifestPlugin from './webpack/plugins/build-manifest-plugin' import ChunkNamesPlugin from './webpack/plugins/chunk-names-plugin' import { ReactLoadablePlugin } from './webpack/plugins/react-loadable-plugin' import {SERVER_DIRECTORY, REACT_LOADABLE_MANIFEST, CLIENT_STATIC_FILES_RUNTIME_WEBPACK, CLIENT_STATIC_FILES_RUNTIME_MAIN} from 'next-server/constants' -import {NEXT_PROJECT_ROOT, NEXT_PROJECT_ROOT_NODE_MODULES, NEXT_PROJECT_ROOT_DIST_CLIENT, DEFAULT_PAGES_DIR} from '../lib/constants' +import {NEXT_PROJECT_ROOT, NEXT_PROJECT_ROOT_NODE_MODULES, NEXT_PROJECT_ROOT_DIST_CLIENT, DEFAULT_PAGES_DIR, PAGES_DIR_ALIAS, DOT_NEXT_ALIAS} from '../lib/constants' import AutoDllPlugin from 'autodll-webpack-plugin' import TerserPlugin from 'terser-webpack-plugin' import AssetsSizePlugin from './webpack/plugins/assets-size-plugin' @@ -139,22 +137,15 @@ function optimizationConfig ({ dev, isServer, totalPages, target }) { return config } -export default async function getBaseWebpackConfig (dir, {dev = false, isServer = false, buildId, config, target = 'server', entrypoints = false}) { +export default async function getBaseWebpackConfig (dir, {dev = false, isServer = false, buildId, config, target = 'server', entrypoints}) { const defaultLoaders = { babel: { loader: 'next-babel-loader', options: {dev, isServer, cwd: dir} }, + // Backwards compat hotSelfAccept: { - loader: 'hot-self-accept-loader', - options: { - include: [ - path.join(dir, 'pages') - ], - // All pages are javascript files. So we apply hot-self-accept-loader here to facilitate hot reloading of pages. - // This makes sure plugins just have to implement `pageExtensions` instead of also implementing the loader - extensions: new RegExp(`\\.+(${config.pageExtensions.join('|')})$`) - } + loader: 'noop-loader' } } @@ -166,8 +157,7 @@ export default async function getBaseWebpackConfig (dir, {dev = false, isServer const distDir = path.join(dir, config.distDir) const outputDir = target === 'serverless' ? 'serverless' : SERVER_DIRECTORY const outputPath = path.join(distDir, isServer ? outputDir : '') - const pagesEntries = await getPages(dir, {nextPagesDir: DEFAULT_PAGES_DIR, dev, buildId, isServer, pageExtensions: config.pageExtensions.join('|')}) - const totalPages = Object.keys(pagesEntries).length + const totalPages = Object.keys(entrypoints).length const clientEntries = !isServer ? { // Backwards compatibility 'main.js': [], @@ -186,8 +176,8 @@ export default async function getBaseWebpackConfig (dir, {dev = false, isServer ], alias: { next: NEXT_PROJECT_ROOT, - 'private-next-pages': path.join(dir, 'pages'), - 'private-dot-next': distDir + [PAGES_DIR_ALIAS]: path.join(dir, 'pages'), + [DOT_NEXT_ALIAS]: distDir }, mainFields: isServer ? ['main'] : ['browser', 'module', 'main'] } @@ -205,13 +195,9 @@ export default async function getBaseWebpackConfig (dir, {dev = false, isServer context: dir, // Kept as function to be backwards compatible entry: async () => { - if (entrypoints) { - return entrypoints - } return { ...clientEntries, - // Only _error and _document when in development. The rest is handled by on-demand-entries - ...pagesEntries + ...entrypoints } }, output: { @@ -244,11 +230,6 @@ export default async function getBaseWebpackConfig (dir, {dev = false, isServer }, module: { rules: [ - dev && !isServer && { - test: defaultLoaders.hotSelfAccept.options.extensions, - include: defaultLoaders.hotSelfAccept.options.include, - use: defaultLoaders.hotSelfAccept - }, { test: /\.(js|jsx)$/, include: [dir, NEXT_PROJECT_ROOT_DIST_CLIENT, DEFAULT_PAGES_DIR, /next-server[\\/]dist[\\/]lib/], @@ -309,7 +290,6 @@ export default async function getBaseWebpackConfig (dir, {dev = false, isServer }), target !== 'serverless' && isServer && new PagesManifestPlugin(), !isServer && new BuildManifestPlugin(), - !isServer && new PagesPlugin(), isServer && new NextJsSsrImportPlugin(), target !== 'serverless' && isServer && new NextJsSSRModuleCachePlugin({outputPath}), !isServer && !dev && new AssetsSizePlugin(buildId, distDir) diff --git a/packages/next/build/webpack/loaders/hot-self-accept-loader.js b/packages/next/build/webpack/loaders/hot-self-accept-loader.js deleted file mode 100644 index c1d985a4..00000000 --- a/packages/next/build/webpack/loaders/hot-self-accept-loader.js +++ /dev/null @@ -1,50 +0,0 @@ -import { relative } from 'path' -import loaderUtils from 'loader-utils' - -module.exports = function (content, sourceMap) { - this.cacheable() - - const options = loaderUtils.getOptions(this) - if (!options.extensions) { - throw new Error('extensions is not provided to hot-self-accept-loader. Please upgrade all next-plugins to the latest version.') - } - - if (!options.include) { - throw new Error('include option is not provided to hot-self-accept-loader. Please upgrade all next-plugins to the latest version.') - } - - const route = getRoute(this.resourcePath, options) - - // Webpack has a built in system to prevent default from colliding, giving it a random letter per export. - // We can safely check if Component is undefined since all other pages imported into the entrypoint don't have __webpack_exports__.default - this.callback(null, `${content} - (function (Component, route) { - if(!Component) return - if (!module.hot) return - module.hot.accept() - Component.__route = route - - if (module.hot.status() === 'idle') return - - var components = next.router.components - for (var r in components) { - if (!components.hasOwnProperty(r)) continue - - if (components[r].Component.__route === route) { - next.router.update(r, Component) - } - } - })(typeof __webpack_exports__ !== 'undefined' ? __webpack_exports__.default : (module.exports.default || module.exports), ${JSON.stringify(route)}) - `, sourceMap) -} - -function getRoute (resourcePath, options) { - const dir = options.include.find((d) => resourcePath.indexOf(d) === 0) - - if (!dir) { - throw new Error(`'hot-self-accept-loader' was called on a file that isn't a page.`) - } - - const path = relative(dir, resourcePath).replace(options.extensions, '.js') - return '/' + path.replace(/((^|\/)index)?\.js$/, '') -} diff --git a/packages/next/build/webpack/loaders/next-client-pages-loader.ts b/packages/next/build/webpack/loaders/next-client-pages-loader.ts new file mode 100644 index 00000000..d084ec6a --- /dev/null +++ b/packages/next/build/webpack/loaders/next-client-pages-loader.ts @@ -0,0 +1,29 @@ +import {loader} from 'webpack' +import loaderUtils from 'loader-utils' + +export type ClientPagesLoaderOptions = { + absolutePagePath: string, + page: string +} + +const nextClientPagesLoader: loader.Loader = function () { + const {absolutePagePath, page}: any = loaderUtils.getOptions(this) + const stringifiedAbsolutePagePath = JSON.stringify(absolutePagePath) + const stringifiedPage = JSON.stringify(page) + + return ` + (window.__NEXT_P=window.__NEXT_P||[]).push([${stringifiedPage}, function() { + var page = require(${stringifiedAbsolutePagePath}) + if(module.hot) { + module.hot.accept(${stringifiedAbsolutePagePath}, function() { + if(!next.router.components[${stringifiedPage}]) return + var updatedPage = require(${stringifiedAbsolutePagePath}) + next.router.update(${stringifiedPage}, updatedPage.default || updatedPage) + }) + } + return { page: page.default || page } + }]); + ` +} + +export default nextClientPagesLoader diff --git a/packages/next/build/webpack/loaders/noop-loader.js b/packages/next/build/webpack/loaders/noop-loader.js new file mode 100644 index 00000000..ec0c4318 --- /dev/null +++ b/packages/next/build/webpack/loaders/noop-loader.js @@ -0,0 +1 @@ +module.exports = (source) => source diff --git a/packages/next/build/webpack/plugins/pages-plugin.js b/packages/next/build/webpack/plugins/pages-plugin.js deleted file mode 100644 index 42927ef8..00000000 --- a/packages/next/build/webpack/plugins/pages-plugin.js +++ /dev/null @@ -1,53 +0,0 @@ -import { ConcatSource } from 'webpack-sources' -import { - IS_BUNDLED_PAGE_REGEX, - ROUTE_NAME_REGEX -} from 'next-server/constants' - -export default class PagesPlugin { - apply (compiler) { - compiler.hooks.compilation.tap('PagesPlugin', (compilation) => { - // This hook is triggered right before a module gets wrapped into it's initializing function, - // For example when you look at the source of a bundle you'll see an object holding `'pages/_app.js': function(module, etc, etc)` - // This hook triggers right before that code is added and wraps the module into `__NEXT_REGISTER_PAGE` when the module is a page - // The reason we're doing this is that we don't want to execute the page code which has potential side effects before switching to a route - compilation.moduleTemplates.javascript.hooks.render.tap('PagesPluginRenderPageRegister', (moduleSourcePostModule, module, options) => { - const {chunk} = options - - // check if the current module is the entry module, we only want to wrap the topmost module - if (chunk.entryModule !== module) { - return moduleSourcePostModule - } - - // Check if the chunk is a page - if (!IS_BUNDLED_PAGE_REGEX.test(chunk.name)) { - return moduleSourcePostModule - } - - // Match the route the chunk belongs to - let routeName = ROUTE_NAME_REGEX.exec(chunk.name)[1] - - // We need to convert \ into / when we are in windows - // to get the proper route name - // Here we need to do windows check because it's possible - // to have "\" in the filename in unix. - // Anyway if someone did that, he'll be having issues here. - // But that's something we cannot avoid. - if (/^win/.test(process.platform)) { - routeName = routeName.replace(/\\/g, '/') - } - - routeName = `/${routeName.replace(/(^|\/)index$/, '')}` - - const source = new ConcatSource( - `(window.__NEXT_P=window.__NEXT_P||[]).push(['${routeName}', function() {\n`, - moduleSourcePostModule, - '\nreturn { page: module.exports.default }', - '}]);' - ) - - return source - }) - }) - } -} diff --git a/packages/next/build/webpack/utils.js b/packages/next/build/webpack/utils.js deleted file mode 100644 index 3da43a31..00000000 --- a/packages/next/build/webpack/utils.js +++ /dev/null @@ -1,80 +0,0 @@ -import path from 'path' -import promisify from '../../lib/promisify' -import globModule from 'glob' -import {CLIENT_STATIC_FILES_PATH} from 'next-server/constants' - -const glob = promisify(globModule) - -export async function getPages (dir, {nextPagesDir, dev, buildId, isServer, pageExtensions}) { - const pageFiles = await getPagePaths(dir, {dev, isServer, pageExtensions}) - - return getPageEntries(pageFiles, {nextPagesDir, buildId, isServer, pageExtensions}) -} - -export async function getPagePaths (dir, {dev, isServer, pageExtensions}) { - let pages - - if (dev) { - // In development we only compile _document.js, _error.js and _app.js when starting, since they're always needed. All other pages are compiled with on demand entries - pages = await glob(isServer ? `pages/+(_document|_app|_error).+(${pageExtensions})` : `pages/+(_app|_error).+(${pageExtensions})`, { cwd: dir }) - } else { - // In production get all pages from the pages directory - pages = await glob(isServer ? `pages/**/*.+(${pageExtensions})` : `pages/**/!(_document)*.+(${pageExtensions})`, { cwd: dir }) - } - - return pages -} - -// Convert page path into single entry -export function createEntry (filePath, {buildId = '', name, pageExtensions} = {}) { - const parsedPath = path.parse(filePath) - let entryName = name || filePath - - // This makes sure we compile `pages/blog/index.js` to `pages/blog.js`. - // Excludes `pages/index.js` from this rule since we do want `/` to route to `pages/index.js` - if (parsedPath.dir !== 'pages' && parsedPath.name === 'index') { - entryName = `${parsedPath.dir}.js` - } - - // Makes sure supported extensions are stripped off. The outputted file should always be `.js` - if (pageExtensions) { - entryName = entryName.replace(new RegExp(`\\.+(${pageExtensions})$`), '.js') - } - - return { - name: path.join(CLIENT_STATIC_FILES_PATH, buildId, entryName), - files: [parsedPath.root ? filePath : `./${filePath}`] // The entry always has to be an array. - } -} - -// Convert page paths into entries -export function getPageEntries (pagePaths, {nextPagesDir, buildId, isServer = false, pageExtensions} = {}) { - const entries = {} - - for (const filePath of pagePaths) { - const entry = createEntry(filePath, {pageExtensions, buildId}) - entries[entry.name] = entry.files - } - - const appPagePath = path.join(nextPagesDir, '_app.js') - const appPageEntry = createEntry(appPagePath, {buildId, name: 'pages/_app.js'}) // default app.js - if (!entries[appPageEntry.name]) { - entries[appPageEntry.name] = appPageEntry.files - } - - const errorPagePath = path.join(nextPagesDir, '_error.js') - const errorPageEntry = createEntry(errorPagePath, {buildId, name: 'pages/_error.js'}) // default error.js - if (!entries[errorPageEntry.name]) { - entries[errorPageEntry.name] = errorPageEntry.files - } - - if (isServer) { - const documentPagePath = path.join(nextPagesDir, '_document.js') - const documentPageEntry = createEntry(documentPagePath, {buildId, name: 'pages/_document.js'}) // default _document.js - if (!entries[documentPageEntry.name]) { - entries[documentPageEntry.name] = documentPageEntry.files - } - } - - return entries -} diff --git a/packages/next/client/webpack-hot-middleware-client.js b/packages/next/client/webpack-hot-middleware-client.js index f967bdc8..d1b97f85 100644 --- a/packages/next/client/webpack-hot-middleware-client.js +++ b/packages/next/client/webpack-hot-middleware-client.js @@ -1,65 +1,5 @@ import 'event-source-polyfill' import connect from './dev-error-overlay/hot-dev-client' -import Router from 'next/router' - -const handlers = { - reload (route) { - // If the App component changes we have to reload the current route, this is handled by hot-self-accept-loader - // So we just return - if (route === '/_app') { - return - } - - 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 - } - - // Since _document is server only we need to reload the full page when it changes. - if (route === '/_document') { - window.location.reload() - return - } - - Router.reload(route) - }, - - change (route) { - // If the App component changes we have to reload the current route, this is handled by hot-self-accept-loader - // So we just return - if (route === '/_app') { - return - } - - const { err, Component } = Router.components[route] || {} - - if (err) { - // reload to recover from runtime errors - Router.reload(route) - } - - if (Router.route !== route) { - // If this is a not a change for a currently viewing page. - // We don't need to worry about it. - return - } - - if (!Component) { - // This only happens when we create a new page without a default export. - // If you removed a default export from a exising viewing page, this has no effect. - console.warn(`Hard reloading due to no default component in page: ${route}`) - window.location.reload() - } - } -} - export default ({assetPrefix}) => { const options = { path: `${assetPrefix}/_next/webpack-hmr` @@ -68,13 +8,24 @@ export default ({assetPrefix}) => { const devClient = connect(options) devClient.subscribeToHmrEvent((obj) => { - const fn = handlers[obj.action] - if (fn) { - const data = obj.data || [] - fn(...data) - } else { - throw new Error('Unexpected action ' + obj.action) + if (obj.action === 'reloadPage') { + return window.location.reload() } + if (obj.action === 'removedPage') { + const [page] = obj.data + if (page === window.next.router.pathname) { + return window.location.reload() + } + return + } + if (obj.action === 'addedPage') { + const [page] = obj.data + if (page === window.next.router.pathname && typeof window.next.router.components[page] === 'undefined') { + return window.location.reload() + } + return + } + throw new Error('Unexpected action ' + obj.action) }) return devClient diff --git a/packages/next/lib/constants.js b/packages/next/lib/constants.js index ced263c8..14f074da 100644 --- a/packages/next/lib/constants.js +++ b/packages/next/lib/constants.js @@ -5,3 +5,8 @@ export const NEXT_PROJECT_ROOT_NODE_MODULES = join(NEXT_PROJECT_ROOT, 'node_modu export const DEFAULT_PAGES_DIR = join(NEXT_PROJECT_ROOT_DIST, 'pages') export const NEXT_PROJECT_ROOT_DIST_CLIENT = join(NEXT_PROJECT_ROOT_DIST, 'client') export const NEXT_PROJECT_ROOT_DIST_SERVER = join(NEXT_PROJECT_ROOT_DIST, 'server') + +// Because on Windows absolute paths in the generated code can break because of numbers, eg 1 in the path, +// we have to use a private alias +export const PAGES_DIR_ALIAS = 'private-next-pages' +export const DOT_NEXT_ALIAS = 'private-dot-next' diff --git a/packages/next/package.json b/packages/next/package.json index 4f3fc34f..20020361 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -48,6 +48,7 @@ "@babel/runtime": "7.1.2", "@babel/runtime-corejs2": "7.1.2", "@babel/template": "7.1.2", + "@types/loader-utils": "1.1.3", "@types/node-fetch": "2.1.4", "@types/rimraf": "2.0.2", "ansi-html": "0.0.7", diff --git a/packages/next/server/hot-reloader.js b/packages/next/server/hot-reloader.js index 763542ae..11128c7d 100644 --- a/packages/next/server/hot-reloader.js +++ b/packages/next/server/hot-reloader.js @@ -1,4 +1,4 @@ -import { join, relative, sep, normalize } from 'path' +import { join, normalize } from 'path' import WebpackDevMiddleware from 'webpack-dev-middleware' import WebpackHotMiddleware from 'webpack-hot-middleware' import errorOverlayMiddleware from './lib/error-overlay-middleware' @@ -7,8 +7,13 @@ import onDemandEntryHandler, {normalizePage} from './on-demand-entry-handler' import webpack from 'webpack' import WebSocket from 'ws' import getBaseWebpackConfig from '../build/webpack-config' -import {IS_BUNDLED_PAGE_REGEX, ROUTE_NAME_REGEX, BLOCKED_PAGES, CLIENT_STATIC_FILES_PATH} from 'next-server/constants' +import {IS_BUNDLED_PAGE_REGEX, ROUTE_NAME_REGEX, BLOCKED_PAGES} from 'next-server/constants' import {route} from 'next-server/dist/server/router' +import globModule from 'glob' +import {promisify} from 'util' +import {createPagesMapping, createEntrypoints} from '../build/entries' + +const glob = promisify(globModule) export async function renderScriptError (res, error) { // Asks CDNs and others to not to cache the errored page @@ -92,10 +97,6 @@ export default class HotReloader { this.webpackHotMiddleware = null this.initialized = false this.stats = null - this.compilationErrors = null - this.prevChunkNames = null - this.prevFailedChunkNames = null - this.prevChunkHashes = null this.serverPrevDocumentHash = null this.config = config @@ -115,7 +116,7 @@ export default class HotReloader { // we have to compile the page using on-demand-entries, this middleware will handle doing that // by adding the page to on-demand-entries, waiting till it's done // and then the bundle will be served like usual by the actual route in server/index.js - const handlePageBundleRequest = async (req, res, parsedUrl) => { + const handlePageBundleRequest = async (res, parsedUrl) => { const {pathname} = parsedUrl const params = matchNextPageBundleRequest(pathname) if (!params) { @@ -145,7 +146,7 @@ export default class HotReloader { return {} } - const {finished} = await handlePageBundleRequest(req, res, parsedUrl) + const {finished} = await handlePageBundleRequest(res, parsedUrl) for (const fn of this.middlewares) { await new Promise((resolve, reject) => { @@ -169,6 +170,16 @@ export default class HotReloader { })) } + async getWebpackConfig () { + const pagePaths = await glob(`+(_app|_document|_error).+(${this.config.pageExtensions.join('|')})`, {cwd: join(this.dir, 'pages')}) + const pages = createPagesMapping(pagePaths, this.config.pageExtensions) + const entrypoints = createEntrypoints(pages, 'server', this.buildId, this.config) + return Promise.all([ + getBaseWebpackConfig(this.dir, { dev: true, isServer: false, config: this.config, buildId: this.buildId, entrypoints: entrypoints.client }), + getBaseWebpackConfig(this.dir, { dev: true, isServer: true, config: this.config, buildId: this.buildId, entrypoints: entrypoints.server }) + ]) + } + async start () { await this.clean() @@ -188,10 +199,7 @@ export default class HotReloader { }) }) - const configs = await Promise.all([ - getBaseWebpackConfig(this.dir, { dev: true, isServer: false, config: this.config, buildId: this.buildId }), - getBaseWebpackConfig(this.dir, { dev: true, isServer: true, config: this.config, buildId: this.buildId }) - ]) + const configs = await this.getWebpackConfig() this.addWsPort(configs) const multiCompiler = webpack(configs) @@ -220,10 +228,7 @@ export default class HotReloader { await this.clean() - const configs = await Promise.all([ - getBaseWebpackConfig(this.dir, { dev: true, isServer: false, config: this.config, buildId: this.buildId }), - getBaseWebpackConfig(this.dir, { dev: true, isServer: true, config: this.config, buildId: this.buildId }) - ]) + const configs = await this.getWebpackConfig() this.addWsPort(configs) const compiler = webpack(configs) @@ -280,8 +285,7 @@ export default class HotReloader { } // Notify reload to reload the page, as _document.js was changed (different hash) - this.send('reload', '/_document') - + this.send('reloadPage') this.serverPrevDocumentHash = documentChunk.hash }) @@ -293,61 +297,32 @@ export default class HotReloader { .filter(name => IS_BUNDLED_PAGE_REGEX.test(name)) ) - const failedChunkNames = new Set(Object.keys(erroredPages(compilation))) - - const chunkHashes = new Map( - compilation.chunks - .filter(c => IS_BUNDLED_PAGE_REGEX.test(c.name)) - .map((c) => [c.name, c.hash]) - ) - if (this.initialized) { // detect chunks which have to be replaced with a new template // e.g, pages/index.js <-> pages/_error.js - const added = diff(chunkNames, this.prevChunkNames) - const removed = diff(this.prevChunkNames, chunkNames) - const succeeded = diff(this.prevFailedChunkNames, failedChunkNames) + const addedPages = diff(chunkNames, this.prevChunkNames) + const removedPages = diff(this.prevChunkNames, chunkNames) - // reload all failed chunks to replace the templace to the error ones, - // and to update error content - const failed = failedChunkNames - - const rootDir = join(CLIENT_STATIC_FILES_PATH, this.buildId, 'pages') - - for (const n of new Set([...added, ...succeeded, ...removed, ...failed])) { - const route = toRoute(relative(rootDir, n)) - this.send('reload', route) + if (addedPages.size > 0) { + for (const addedPage of addedPages) { + let page = '/' + ROUTE_NAME_REGEX.exec(addedPage)[1].replace(/\\/g, '/') + page = page === '/index' ? '/' : page + this.send('addedPage', page) + } } - let changedPageRoutes = [] - - for (const [n, hash] of chunkHashes) { - if (!this.prevChunkHashes.has(n)) continue - if (this.prevChunkHashes.get(n) === hash) continue - - const route = toRoute(relative(rootDir, n)) - - changedPageRoutes.push(route) - } - - // This means `/_app` is most likely included in the list, or a page was added/deleted in this compilation run. - // This means we should filter out `/_app` because `/_app` will be re-rendered with the page reload. - if (added.size !== 0 || removed.size !== 0 || changedPageRoutes.length > 1) { - changedPageRoutes = changedPageRoutes.filter((route) => route !== '/_app' && route !== '/_document') - } - - for (const changedPageRoute of changedPageRoutes) { - // notify change to recover from runtime errors - this.send('change', changedPageRoute) + if (removedPages.size > 0) { + for (const removedPage of removedPages) { + let page = '/' + ROUTE_NAME_REGEX.exec(removedPage)[1].replace(/\\/g, '/') + page = page === '/index' ? '/' : page + this.send('removedPage', page) + } } } this.initialized = true this.stats = stats - this.compilationErrors = null this.prevChunkNames = chunkNames - this.prevFailedChunkNames = failedChunkNames - this.prevChunkHashes = chunkHashes }) // We don’t watch .git/ .next/ and node_modules for changes @@ -381,7 +356,6 @@ export default class HotReloader { const onDemandEntries = onDemandEntryHandler(webpackDevMiddleware, multiCompiler, { dir: this.dir, buildId: this.buildId, - dev: true, reload: this.reload.bind(this), pageExtensions: this.config.pageExtensions, wsPort: this.wsPort, @@ -433,7 +407,7 @@ export default class HotReloader { async ensurePage (page) { // Make sure we don't re-build or dispose prebuilt pages - if (page === '/_error' || page === '/_document' || page === '/_app') { + if (BLOCKED_PAGES.indexOf(page) !== -1) { return } await this.onDemandEntries.ensurePage(page) @@ -443,8 +417,3 @@ export default class HotReloader { function diff (a, b) { return new Set([...a].filter((v) => !b.has(v))) } - -function toRoute (file) { - const f = sep === '\\' ? file.replace(/\\/g, '/') : file - return ('/' + f).replace(/(\/index)?\.js$/, '') || '/' -} diff --git a/packages/next/server/on-demand-entry-handler.js b/packages/next/server/on-demand-entry-handler.js index 0d13f2c4..14386c99 100644 --- a/packages/next/server/on-demand-entry-handler.js +++ b/packages/next/server/on-demand-entry-handler.js @@ -6,8 +6,8 @@ import promisify from '../lib/promisify' import globModule from 'glob' import {pageNotFoundError} from 'next-server/dist/server/require' import {normalizePagePath} from 'next-server/dist/server/normalize-page-path' -import {createEntry} from '../build/webpack/utils' import { ROUTE_NAME_REGEX, IS_BUNDLED_PAGE_REGEX } from 'next-server/constants' +import {stringify} from 'querystring' const ADDED = Symbol('added') const BUILDING = Symbol('building') @@ -30,7 +30,6 @@ function addEntry (compilation, context, name, entry) { export default function onDemandEntryHandler (devMiddleware, multiCompiler, { buildId, dir, - dev, reload, pageExtensions, maxInactiveAge, @@ -51,20 +50,17 @@ export default function onDemandEntryHandler (devMiddleware, multiCompiler, { invalidator.startBuilding() const allEntries = Object.keys(entries).map(async (page) => { - const { name, entry } = entries[page] - const files = Array.isArray(entry) ? entry : [entry] - // Is just one item. But it's passed as an array. - for (const file of files) { - try { - await access(join(dir, file), (fs.constants || fs).W_OK) - } catch (err) { - console.warn('Page was removed', page) - delete entries[page] - return - } + const { name, absolutePagePath } = entries[page] + try { + await access(absolutePagePath, (fs.constants || fs).W_OK) + } catch (err) { + console.warn('Page was removed', page) + delete entries[page] + return } + entries[page].status = BUILDING - return addEntry(compilation, compiler.context, name, entry) + return addEntry(compilation, compiler.context, name, [compiler.name === 'client' ? `next-client-pages-loader?${stringify({page, absolutePagePath})}!` : absolutePagePath]) }) return Promise.all(allEntries) @@ -178,17 +174,19 @@ export default function onDemandEntryHandler (devMiddleware, multiCompiler, { } const extensions = pageExtensions.join('|') - const paths = await glob(`pages/{${normalizedPagePath}/index,${normalizedPagePath}}.+(${extensions})`, {cwd: dir}) + const pagesDir = join(dir, 'pages') + const paths = await glob(`{${normalizedPagePath.slice(1)}/index,${normalizedPagePath.slice(1)}}.+(${extensions})`, {cwd: pagesDir}) if (paths.length === 0) { throw pageNotFoundError(normalizedPagePath) } - const relativePathToPage = paths[0] - - const pathname = join(dir, relativePathToPage) - - const {name, files} = createEntry(relativePathToPage, {buildId, pageExtensions: extensions}) + const pagePath = paths[0] + let pageUrl = `/${pagePath.replace(new RegExp(`\\.+(${extensions})$`), '').replace(/\\/g, '/')}`.replace(/\/index$/, '') + pageUrl = pageUrl === '' ? '/' : pageUrl + const bundleFile = pageUrl === '/' ? '/index.js' : `${pageUrl}.js` + const name = join('static', buildId, 'pages', bundleFile) + const absolutePagePath = join(pagesDir, pagePath) await new Promise((resolve, reject) => { const entryInfo = entries[page] @@ -207,7 +205,7 @@ export default function onDemandEntryHandler (devMiddleware, multiCompiler, { console.log(`> Building page: ${page}`) - entries[page] = { name, entry: files, pathname, status: ADDED } + entries[page] = { name, absolutePagePath, status: ADDED } doneCallbacks.once(page, handleCallback) invalidator.invalidate() diff --git a/test/integration/basic/test/error-recovery.js b/test/integration/basic/test/error-recovery.js index 57b50a9d..e33827cf 100644 --- a/test/integration/basic/test/error-recovery.js +++ b/test/integration/basic/test/error-recovery.js @@ -3,7 +3,7 @@ import webdriver from 'next-webdriver' import { join } from 'path' import { check, File, waitFor, getReactErrorOverlayContent, getBrowserBodyText } from 'next-test-utils' -export default (context) => { +export default (context, renderViaHTTP) => { describe('Error Recovery', () => { it('should recover from 404 after a page has been added', async () => { let browser @@ -110,6 +110,8 @@ export default (context) => { const aboutPage = new File(join(__dirname, '../', 'pages', 'hmr', 'about.js')) let browser try { + await renderViaHTTP('/hmr/about') + aboutPage.replace('', 'div') browser = await webdriver(context.appPort, '/hmr/contact') diff --git a/test/isolated/webpack-utils.test.js b/test/isolated/webpack-utils.test.js deleted file mode 100644 index dd60d4ed..00000000 --- a/test/isolated/webpack-utils.test.js +++ /dev/null @@ -1,90 +0,0 @@ -/* eslint-env jest */ - -import {normalize, join} from 'path' -import {getPageEntries, createEntry} from 'next/dist/build/webpack/utils' - -const buildId = 'development' - -describe('createEntry', () => { - it('Should turn a path into a page entry', () => { - const entry = createEntry('pages/index.js') - expect(entry.name).toBe(normalize(`static/pages/index.js`)) - expect(entry.files[0]).toBe('./pages/index.js') - }) - - it('Should have a custom name', () => { - const entry = createEntry('pages/index.js', {name: 'something-else.js'}) - expect(entry.name).toBe(normalize(`static/something-else.js`)) - expect(entry.files[0]).toBe('./pages/index.js') - }) - - it('Should allow custom extension like .ts to be turned into .js', () => { - const entry = createEntry('pages/index.ts', {pageExtensions: ['js', 'ts'].join('|')}) - expect(entry.name).toBe(normalize('static/pages/index.js')) - expect(entry.files[0]).toBe('./pages/index.ts') - }) - - it('Should allow custom extension like .jsx to be turned into .js', () => { - const entry = createEntry('pages/index.jsx', {pageExtensions: ['jsx', 'js'].join('|')}) - expect(entry.name).toBe(normalize('static/pages/index.js')) - expect(entry.files[0]).toBe('./pages/index.jsx') - }) - - it('Should allow custom extension like .tsx to be turned into .js', () => { - const entry = createEntry('pages/index.tsx', {pageExtensions: ['tsx', 'ts'].join('|')}) - expect(entry.name).toBe(normalize('static/pages/index.js')) - expect(entry.files[0]).toBe('./pages/index.tsx') - }) - - it('Should allow custom extension like .tsx to be turned into .js with another order', () => { - const entry = createEntry('pages/index.tsx', {pageExtensions: ['ts', 'tsx'].join('|')}) - expect(entry.name).toBe(normalize('static/pages/index.js')) - expect(entry.files[0]).toBe('./pages/index.tsx') - }) - - it('Should turn pages/blog/index.js into pages/blog.js', () => { - const entry = createEntry('pages/blog/index.js') - expect(entry.name).toBe(normalize('static/pages/blog.js')) - expect(entry.files[0]).toBe('./pages/blog/index.js') - }) - - it('Should add buildId when provided', () => { - const entry = createEntry('pages/blog/index.js', {buildId}) - expect(entry.name).toBe(normalize(`static/${buildId}/pages/blog.js`)) - expect(entry.files[0]).toBe('./pages/blog/index.js') - }) -}) - -describe('getPageEntries', () => { - const nextPagesDir = join(__dirname, '..', '..', 'dist', 'pages') - - it('Should return paths', () => { - const pagePaths = ['pages/index.js'] - const pageEntries = getPageEntries(pagePaths, {nextPagesDir}) - expect(pageEntries[normalize('static/pages/index.js')][0]).toBe('./pages/index.js') - }) - - it('Should include default _error', () => { - const pagePaths = ['pages/index.js'] - const pageEntries = getPageEntries(pagePaths, {nextPagesDir}) - expect(pageEntries[normalize('static/pages/_error.js')][0]).toMatch(/dist[/\\]pages[/\\]_error\.js/) - }) - - it('Should not include default _error when _error.js is inside the pages directory', () => { - const pagePaths = ['pages/index.js', 'pages/_error.js'] - const pageEntries = getPageEntries(pagePaths, {nextPagesDir}) - expect(pageEntries[normalize('static/pages/_error.js')][0]).toBe('./pages/_error.js') - }) - - it('Should include default _document when isServer is true', () => { - const pagePaths = ['pages/index.js'] - const pageEntries = getPageEntries(pagePaths, {nextPagesDir, isServer: true}) - expect(pageEntries[normalize('static/pages/_document.js')][0]).toMatch(/dist[/\\]pages[/\\]_document\.js/) - }) - - it('Should not include default _document when _document.js is inside the pages directory', () => { - const pagePaths = ['pages/index.js', 'pages/_document.js'] - const pageEntries = getPageEntries(pagePaths, {nextPagesDir, isServer: true}) - expect(pageEntries[normalize('static/pages/_document.js')][0]).toBe('./pages/_document.js') - }) -}) diff --git a/yarn.lock b/yarn.lock index 7f3f1684..8209839b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1411,6 +1411,14 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/loader-utils@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@types/loader-utils/-/loader-utils-1.1.3.tgz#82b9163f2ead596c68a8c03e450fbd6e089df401" + integrity sha512-euKGFr2oCB3ASBwG39CYJMR3N9T0nanVqXdiH7Zu/Nqddt6SmFRxytq/i2w9LQYNQekEtGBz+pE3qG6fQTNvRg== + dependencies: + "@types/node" "*" + "@types/webpack" "*" + "@types/mime@*": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b" @@ -1502,7 +1510,7 @@ dependencies: source-map "^0.6.1" -"@types/webpack@4.4.22": +"@types/webpack@*", "@types/webpack@4.4.22": version "4.4.22" resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.4.22.tgz#c4a5ea8b74a31b579537515bcfe86d2b2a34382c" integrity sha512-PxAAzli3krZX9rCeONSR5Z9v4CR/2HPsKsiVRFNDo9OZefN+dTemteMHZnYkddOu4bqoYqJTJ724gLy0ZySXOw==