rsnext/packages/next/server/on-demand-entry-handler.ts
Jan Potoms 1b36f0c029
Fix pages/index.js and pages/index/index.js behavior (#13699)
Disambiguate between pages/index.js and pages/index/index.js so that they resolve differently.
It all started with a bug in pagesmanifest that propagated throughout the codebase. After fixing pagesmanifest I was able to remove a few hacks here and there and more logic is shared now. especially the logic that resolves an entrypoint back into a route path. To sum up what happened:

- `getRouteFromEntrypoint` is the inverse operation of `getPageFile` that's under `pages/_document.tsx`
- `denormalizePagePath` is the inverse operation of `normalizePagePath`.

Everything is refactored in terms of these operations, that makes their behavior uniform and easier to update/patch in a central place. Before there were subtle differences between those that made `index/index.js` hard to handle.

Some potential follow up on this PR:
- [`hot-reloader`](https://github.com/vercel/next.js/pull/13699/files#diff-6161346d2c5f4b7abc87059d8768c44bR207) still has one place that does very similar behavior to `getRouteFromEntrypoint`. It can probably be rewritten in terms of `getRouteFromEntrypoint`.
- There are a few places where `denormalizePagePath(normalizePagePath(...))` is happening. This is a sign that `normalizePagePath` is doing some validation that is independent of its rewriting logic. That should probably be factored out in its own function. after that I should probably investigate whether `normalizePagePath` is even still needed at all.
- a lot of code is doing `.replace(/\\/g, '')`. If wanted, that could be replaced with `normalizePathSep`.
- It looks to me like some logic that's spread across the project can be centralized in 4 functions 
  - `getRouteFromEntrypoint` (part of this PR)
  - its inverse `getEntrypointFromRoute` (already exists in `_document.tsx` as `getPageFile`)
  - `getRouteFromPageFile` 
  - its inverse `getPageFileFromRoute` (already exists as `findPageFile ` in `server/lib/find-page-file.ts`)

  It could be beneficial to structure the code to keep these fuctionalities close together and name them similarly.
 - revise `index.amp` handling in pagesmanifest. I left it alone in this PR to keep it scoped, but it may be broken wrt nested index files as well. It might even make sense to reshape the pagesmanifest altogether to handle html/json/amp/... better
2020-06-04 17:32:45 +00:00

361 lines
10 KiB
TypeScript

import { EventEmitter } from 'events'
import { IncomingMessage, ServerResponse } from 'http'
import WebpackDevMiddleware from 'next/dist/compiled/webpack-dev-middleware'
import { join, posix } from 'path'
import { stringify } from 'querystring'
import { parse } from 'url'
import webpack from 'webpack'
import DynamicEntryPlugin from 'webpack/lib/DynamicEntryPlugin'
import { isWriteable } from '../build/is-writeable'
import * as Log from '../build/output/log'
import { ClientPagesLoaderOptions } from '../build/webpack/loaders/next-client-pages-loader'
import { API_ROUTE } from '../lib/constants'
import {
normalizePagePath,
normalizePathSep,
} from '../next-server/server/normalize-page-path'
import { pageNotFoundError } from '../next-server/server/require'
import { findPageFile } from './lib/find-page-file'
import getRouteFromEntrypoint from '../next-server/server/get-route-from-entrypoint'
const ADDED = Symbol('added')
const BUILDING = Symbol('building')
const BUILT = Symbol('built')
// Based on https://github.com/webpack/webpack/blob/master/lib/DynamicEntryPlugin.js#L29-L37
function addEntry(
compilation: webpack.compilation.Compilation,
context: string,
name: string,
entry: string[]
) {
return new Promise((resolve, reject) => {
const dep = DynamicEntryPlugin.createDependency(entry, name)
compilation.addEntry(context, dep, name, (err: Error) => {
if (err) return reject(err)
resolve()
})
})
}
export default function onDemandEntryHandler(
devMiddleware: WebpackDevMiddleware.WebpackDevMiddleware,
multiCompiler: webpack.MultiCompiler,
{
buildId,
pagesDir,
pageExtensions,
maxInactiveAge,
pagesBufferLength,
}: {
buildId: string
pagesDir: string
pageExtensions: string[]
maxInactiveAge: number
pagesBufferLength: number
}
) {
const { compilers } = multiCompiler
const invalidator = new Invalidator(devMiddleware, multiCompiler)
let entries: any = {}
let lastAccessPages = ['']
let doneCallbacks: EventEmitter | null = new EventEmitter()
for (const compiler of compilers) {
compiler.hooks.make.tapPromise(
'NextJsOnDemandEntries',
(compilation: webpack.compilation.Compilation) => {
invalidator.startBuilding()
const allEntries = Object.keys(entries).map(async (page) => {
if (compiler.name === 'client' && page.match(API_ROUTE)) {
return
}
const { name, absolutePagePath } = entries[page]
const pageExists = await isWriteable(absolutePagePath)
if (!pageExists) {
// page was removed
delete entries[page]
return
}
entries[page].status = BUILDING
const pageLoaderOpts: ClientPagesLoaderOptions = {
page,
absolutePagePath,
}
return addEntry(compilation, compiler.context, name, [
compiler.name === 'client'
? `next-client-pages-loader?${stringify(pageLoaderOpts)}!`
: absolutePagePath,
])
})
return Promise.all(allEntries).catch((err) => console.error(err))
}
)
}
function getPagePathsFromEntrypoints(entrypoints: any): string[] {
const pagePaths = []
for (const entrypoint of entrypoints.values()) {
const page = getRouteFromEntrypoint(entrypoint.name)
if (page) {
pagePaths.push(page)
}
}
return pagePaths
}
multiCompiler.hooks.done.tap('NextJsOnDemandEntries', (multiStats) => {
const [clientStats, serverStats] = multiStats.stats
const pagePaths = new Set([
...getPagePathsFromEntrypoints(clientStats.compilation.entrypoints),
...getPagePathsFromEntrypoints(serverStats.compilation.entrypoints),
])
for (const page of pagePaths) {
const entry = entries[page]
if (!entry) {
continue
}
if (entry.status !== BUILDING) {
continue
}
entry.status = BUILT
entry.lastActiveTime = Date.now()
doneCallbacks!.emit(page)
}
invalidator.doneBuilding()
})
const disposeHandler = setInterval(function () {
disposeInactiveEntries(
devMiddleware,
entries,
lastAccessPages,
maxInactiveAge
)
}, 5000)
disposeHandler.unref()
function handlePing(pg: string) {
const page = normalizePathSep(pg)
const entryInfo = entries[page]
let toSend
// If there's no entry, it may have been invalidated and needs to be re-built.
if (!entryInfo) {
// if (page !== lastEntry) client pings, but there's no entry for page
return { invalid: true }
}
// 404 is an on demand entry but when a new page is added we have to refresh the page
if (page === '/_error') {
toSend = { invalid: true }
} else {
toSend = { success: true }
}
// We don't need to maintain active state of anything other than BUILT entries
if (entryInfo.status !== BUILT) return
// If there's an entryInfo
if (!lastAccessPages.includes(page)) {
lastAccessPages.unshift(page)
// Maintain the buffer max length
if (lastAccessPages.length > pagesBufferLength) {
lastAccessPages.pop()
}
}
entryInfo.lastActiveTime = Date.now()
return toSend
}
return {
async ensurePage(page: string) {
let normalizedPagePath: string
try {
normalizedPagePath = normalizePagePath(page)
} catch (err) {
console.error(err)
throw pageNotFoundError(page)
}
let pagePath = await findPageFile(
pagesDir,
normalizedPagePath,
pageExtensions
)
// Default the /_error route to the Next.js provided default page
if (page === '/_error' && pagePath === null) {
pagePath = 'next/dist/pages/_error'
}
if (pagePath === null) {
throw pageNotFoundError(normalizedPagePath)
}
let pageUrl = pagePath.replace(/\\/g, '/')
pageUrl = `${pageUrl[0] !== '/' ? '/' : ''}${pageUrl
.replace(new RegExp(`\\.+(?:${pageExtensions.join('|')})$`), '')
.replace(/\/index$/, '')}`
pageUrl = pageUrl === '' ? '/' : pageUrl
const bundleFile = `${normalizePagePath(pageUrl)}.js`
const name = join('static', buildId, 'pages', bundleFile)
const absolutePagePath = pagePath.startsWith('next/dist/pages')
? require.resolve(pagePath)
: join(pagesDir, pagePath)
page = posix.normalize(pageUrl)
return new Promise((resolve, reject) => {
// Makes sure the page that is being kept in on-demand-entries matches the webpack output
const normalizedPage = normalizePathSep(page)
const entryInfo = entries[normalizedPage]
if (entryInfo) {
if (entryInfo.status === BUILT) {
resolve()
return
}
if (entryInfo.status === BUILDING) {
doneCallbacks!.once(normalizedPage, handleCallback)
return
}
}
Log.event(`build page: ${normalizedPage}`)
entries[normalizedPage] = { name, absolutePagePath, status: ADDED }
doneCallbacks!.once(normalizedPage, handleCallback)
invalidator.invalidate()
function handleCallback(err: Error) {
if (err) return reject(err)
resolve()
}
})
},
middleware() {
return (req: IncomingMessage, res: ServerResponse, next: Function) => {
if (!/^\/_next\/webpack-hmr/.test(req.url!)) return next()
const { query } = parse(req.url!, true)
const page = query.page
if (!page) return next()
const runPing = () => {
const data = handlePing(query.page as string)
if (!data) return
res.write('data: ' + JSON.stringify(data) + '\n\n')
}
const pingInterval = setInterval(() => runPing(), 5000)
req.on('close', () => {
clearInterval(pingInterval)
})
next()
}
},
}
}
function disposeInactiveEntries(
devMiddleware: WebpackDevMiddleware.WebpackDevMiddleware,
entries: any,
lastAccessPages: any,
maxInactiveAge: number
) {
const disposingPages: any = []
Object.keys(entries).forEach((page) => {
const { lastActiveTime, status } = entries[page]
// This means this entry is currently building or just added
// We don't need to dispose those entries.
if (status !== BUILT) return
// We should not build the last accessed page even we didn't get any pings
// Sometimes, it's possible our XHR ping to wait before completing other requests.
// In that case, we should not dispose the current viewing page
if (lastAccessPages.includes(page)) return
if (Date.now() - lastActiveTime > maxInactiveAge) {
disposingPages.push(page)
}
})
if (disposingPages.length > 0) {
disposingPages.forEach((page: any) => {
delete entries[page]
})
// disposing inactive page(s)
devMiddleware.invalidate()
}
}
// Make sure only one invalidation happens at a time
// Otherwise, webpack hash gets changed and it'll force the client to reload.
class Invalidator {
private multiCompiler: webpack.MultiCompiler
private devMiddleware: WebpackDevMiddleware.WebpackDevMiddleware
private building: boolean
private rebuildAgain: boolean
constructor(
devMiddleware: WebpackDevMiddleware.WebpackDevMiddleware,
multiCompiler: webpack.MultiCompiler
) {
this.multiCompiler = multiCompiler
this.devMiddleware = devMiddleware
// contains an array of types of compilers currently building
this.building = false
this.rebuildAgain = false
}
invalidate() {
// If there's a current build is processing, we won't abort it by invalidating.
// (If aborted, it'll cause a client side hard reload)
// But let it to invalidate just after the completion.
// So, it can re-build the queued pages at once.
if (this.building) {
this.rebuildAgain = true
return
}
this.building = true
// Work around a bug in webpack, calling `invalidate` on Watching.js
// doesn't trigger the invalid call used to keep track of the `.done` hook on multiCompiler
for (const compiler of this.multiCompiler.compilers) {
compiler.hooks.invalid.call()
}
this.devMiddleware.invalidate()
}
startBuilding() {
this.building = true
}
doneBuilding() {
this.building = false
if (this.rebuildAgain) {
this.rebuildAgain = false
this.invalidate()
}
}
}