index.ts 14.8 KB
Newer Older
G
Guy Bedford 已提交
1
import chalk from 'next/dist/compiled/chalk'
G
find-up  
Guy Bedford 已提交
2
import findUp from 'next/dist/compiled/find-up'
J
Joe Haddad 已提交
3
import {
4
  promises,
J
Joe Haddad 已提交
5
  existsSync,
6
  exists as existsOrig,
J
Joe Haddad 已提交
7 8 9
  readFileSync,
  writeFileSync,
} from 'fs'
10
import Worker from 'jest-worker'
J
Joe Haddad 已提交
11
import { cpus } from 'os'
12
import { dirname, join, resolve, sep } from 'path'
J
Joe Haddad 已提交
13 14
import { promisify } from 'util'
import { AmpPageStatus, formatAmpMessages } from '../build/output/index'
15
import * as Log from '../build/output/log'
J
Joe Haddad 已提交
16
import createSpinner from '../build/spinner'
17
import { API_ROUTE, SSG_FALLBACK_EXPORT_ERROR } from '../lib/constants'
18 19
import { recursiveCopy } from '../lib/recursive-copy'
import { recursiveDelete } from '../lib/recursive-delete'
20 21
import {
  BUILD_ID_FILE,
J
Joe Haddad 已提交
22 23 24
  CLIENT_PUBLIC_FILES_PATH,
  CLIENT_STATIC_FILES_PATH,
  CONFIG_FILE,
J
Joe Haddad 已提交
25
  EXPORT_DETAIL,
J
Joe Haddad 已提交
26 27
  PAGES_MANIFEST,
  PHASE_EXPORT,
J
JJ Kasper 已提交
28 29
  PRERENDER_MANIFEST,
  SERVERLESS_DIRECTORY,
J
Joe Haddad 已提交
30
  SERVER_DIRECTORY,
31
} from '../next-server/lib/constants'
J
Joe Haddad 已提交
32 33 34
import loadConfig, {
  isTargetLikeServerless,
} from '../next-server/server/config'
35
import { eventCliSession } from '../telemetry/events'
J
Joe Haddad 已提交
36
import { Telemetry } from '../telemetry/storage'
37 38 39 40
import {
  normalizePagePath,
  denormalizePagePath,
} from '../next-server/server/normalize-page-path'
41
import { loadEnvConfig } from '@next/env'
42
import { PrerenderManifest } from '../build'
43
import type exportPage from './worker'
J
Jan Potoms 已提交
44
import { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin'
45
import { getPagePath } from '../next-server/server/require'
46

47
const exists = promisify(existsOrig)
48

49 50 51 52 53 54 55 56 57 58 59
function divideSegments(number: number, segments: number): number[] {
  const result = []
  while (number > 0 && segments > 0) {
    const dividedNumber = Math.floor(number / segments)
    number -= dividedNumber
    segments--
    result.push(dividedNumber)
  }
  return result
}

60
const createProgress = (total: number, label: string) => {
61 62 63 64
  const segments = divideSegments(total, 4)

  let currentSegmentTotal = segments.shift()
  let currentSegmentCount = 0
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
  let curProgress = 0
  let progressSpinner = createSpinner(`${label} (${curProgress}/${total})`, {
    spinner: {
      frames: [
        '[    ]',
        '[=   ]',
        '[==  ]',
        '[=== ]',
        '[ ===]',
        '[  ==]',
        '[   =]',
        '[    ]',
        '[   =]',
        '[  ==]',
        '[ ===]',
        '[====]',
        '[=== ]',
        '[==  ]',
J
Joe Haddad 已提交
83
        '[=   ]',
84
      ],
J
Joe Haddad 已提交
85 86
      interval: 80,
    },
87 88 89 90
  })

  return () => {
    curProgress++
91 92 93 94 95 96 97 98 99
    currentSegmentCount++

    // Make sure we only log once per fully generated segment
    if (currentSegmentCount !== currentSegmentTotal) {
      return
    }

    currentSegmentTotal = segments.shift()
    currentSegmentCount = 0
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114

    const newText = `${label} (${curProgress}/${total})`
    if (progressSpinner) {
      progressSpinner.text = newText
    } else {
      console.log(newText)
    }

    if (curProgress === total && progressSpinner) {
      progressSpinner.stop()
      console.log(newText)
    }
  }
}

J
Joe Haddad 已提交
115 116 117 118
type ExportPathMap = {
  [page: string]: { page: string; query?: { [key: string]: string } }
}

J
Jan Potoms 已提交
119 120 121 122 123 124
interface ExportOptions {
  outdir: string
  silent?: boolean
  threads?: number
  pages?: string[]
  buildExport?: boolean
125
  statusMessage?: string
J
Jan Potoms 已提交
126 127 128
}

export default async function exportApp(
J
Joe Haddad 已提交
129
  dir: string,
J
Jan Potoms 已提交
130
  options: ExportOptions,
J
Joe Haddad 已提交
131 132
  configuration?: any
): Promise<void> {
133
  dir = resolve(dir)
134 135

  // attempt to load global env values so they are available in next.config.js
136
  loadEnvConfig(dir, false, Log)
137

138
  const nextConfig = configuration || loadConfig(PHASE_EXPORT, dir)
139
  const threads = options.threads || Math.max(cpus().length - 1, 1)
140
  const distDir = join(dir, nextConfig.distDir)
141 142 143 144

  const telemetry = options.buildExport ? null : new Telemetry({ distDir })

  if (telemetry) {
145
    telemetry.record(
146
      eventCliSession(PHASE_EXPORT, distDir, {
147 148 149 150 151 152
        cliCommand: 'export',
        isSrcDir: null,
        hasNowJson: !!(await findUp('now.json', { cwd: dir })),
        isCustomServer: null,
      })
    )
153 154
  }

155
  const subFolders = nextConfig.trailingSlash
J
JJ Kasper 已提交
156
  const isLikeServerless = nextConfig.target !== 'server'
157

158 159 160
  if (!options.silent && !options.buildExport) {
    Log.info(`using build directory: ${distDir}`)
  }
161

162
  if (!existsSync(distDir)) {
163 164 165
    throw new Error(
      `Build directory ${distDir} does not exist. Make sure you run "next build" before running "next start" or "next export".`
    )
166 167
  }

168
  const buildId = readFileSync(join(distDir, BUILD_ID_FILE), 'utf8')
169
  const pagesManifest =
170
    !options.pages &&
J
Jan Potoms 已提交
171
    (require(join(
172 173 174
      distDir,
      isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY,
      PAGES_MANIFEST
J
Jan Potoms 已提交
175
    )) as PagesManifest)
176

177
  let prerenderManifest: PrerenderManifest | undefined = undefined
J
JJ Kasper 已提交
178 179 180 181
  try {
    prerenderManifest = require(join(distDir, PRERENDER_MANIFEST))
  } catch (_) {}

182
  const excludedPrerenderRoutes = new Set<string>()
J
JJ Kasper 已提交
183
  const pages = options.pages || Object.keys(pagesManifest)
J
Joe Haddad 已提交
184
  const defaultPathMap: ExportPathMap = {}
185
  let hasApiRoutes = false
186 187

  for (const page of pages) {
188 189
    // _document and _app are not real pages
    // _error is exported as 404.html later on
190
    // API Routes are Node.js functions
191 192 193 194 195 196 197

    if (page.match(API_ROUTE)) {
      hasApiRoutes = true
      continue
    }

    if (page === '/_document' || page === '/_app' || page === '/_error') {
198 199 200
      continue
    }

201 202 203 204
    // iSSG pages that are dynamic should not export templated version by
    // default. In most cases, this would never work. There is no server that
    // could run `getStaticProps`. If users make their page work lazily, they
    // can manually add it to the `exportPathMap`.
205
    if (prerenderManifest?.dynamicRoutes[page]) {
206
      excludedPrerenderRoutes.add(page)
207 208 209
      continue
    }

210 211
    defaultPathMap[page] = { page }
  }
212 213

  // Initialize the output directory
214
  const outDir = options.outdir
215 216 217

  if (outDir === join(dir, 'public')) {
    throw new Error(
218
      `The 'public' directory is reserved in Next.js and can not be used as the export out directory. https://err.sh/vercel/next.js/can-not-output-to-public`
219 220 221
    )
  }

222
  await recursiveDelete(join(outDir))
223
  await promises.mkdir(join(outDir, '_next', buildId), { recursive: true })
224

J
Joe Haddad 已提交
225 226 227 228 229 230 231 232 233 234
  writeFileSync(
    join(distDir, EXPORT_DETAIL),
    JSON.stringify({
      version: 1,
      outDirectory: outDir,
      success: false,
    }),
    'utf8'
  )

235
  // Copy static directory
236
  if (!options.buildExport && existsSync(join(dir, 'static'))) {
237 238 239
    if (!options.silent) {
      Log.info('Copying "static" directory')
    }
240
    await recursiveCopy(join(dir, 'static'), join(outDir, 'static'))
241 242
  }

243
  // Copy .next/static directory
244 245 246 247 248 249 250
  if (
    !options.buildExport &&
    existsSync(join(distDir, CLIENT_STATIC_FILES_PATH))
  ) {
    if (!options.silent) {
      Log.info('Copying "static build" directory')
    }
251
    await recursiveCopy(
252 253
      join(distDir, CLIENT_STATIC_FILES_PATH),
      join(outDir, '_next', CLIENT_STATIC_FILES_PATH)
254 255 256
    )
  }

257
  // Get the exportPathMap from the config file
258
  if (typeof nextConfig.exportPathMap !== 'function') {
259 260 261 262 263
    if (!options.silent) {
      Log.info(
        `No "exportPathMap" found in "${CONFIG_FILE}". Generating map from "./pages"`
      )
    }
J
Joe Haddad 已提交
264
    nextConfig.exportPathMap = async (defaultMap: ExportPathMap) => {
265 266
      return defaultMap
    }
267 268
  }

269 270 271 272
  // Start the rendering process
  const renderOpts = {
    dir,
    buildId,
273
    nextExport: true,
274
    assetPrefix: nextConfig.assetPrefix.replace(/\/$/, ''),
275
    distDir,
276
    dev: false,
277
    hotReloader: null,
278
    basePath: nextConfig.basePath,
279
    canonicalBase: nextConfig.amp?.canonicalBase || '',
J
Joe Haddad 已提交
280
    isModern: nextConfig.experimental.modern,
281
    ampValidatorPath: nextConfig.experimental.amp?.validator || undefined,
282 283
    ampSkipValidation: nextConfig.experimental.amp?.skipValidation || false,
    ampOptimizerConfig: nextConfig.experimental.amp?.optimizer || undefined,
284 285
  }

286
  const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig
287

288
  if (Object.keys(publicRuntimeConfig).length > 0) {
J
Joe Haddad 已提交
289
    ;(renderOpts as any).runtimeConfig = publicRuntimeConfig
290 291
  }

292
  // We need this for server rendering the Link component.
J
Joe Haddad 已提交
293 294
  ;(global as any).__NEXT_DATA__ = {
    nextExport: true,
295 296
  }

297 298 299
  if (!options.silent && !options.buildExport) {
    Log.info(`Launching ${threads} workers`)
  }
300 301 302 303 304
  const exportPathMap = await nextConfig.exportPathMap(defaultPathMap, {
    dev: false,
    dir,
    outDir,
    distDir,
J
Joe Haddad 已提交
305
    buildId,
306
  })
307

308 309
  if (!exportPathMap['/404'] && !exportPathMap['/404.html']) {
    exportPathMap['/404'] = exportPathMap['/404.html'] = {
J
Joe Haddad 已提交
310
      page: '/_error',
311
    }
312
  }
313 314 315 316

  // make sure to prevent duplicates
  const exportPaths = [
    ...new Set(
317 318
      Object.keys(exportPathMap).map((path) =>
        denormalizePagePath(normalizePagePath(path))
319 320 321 322
      )
    ),
  ]

323 324
  const filteredPaths = exportPaths.filter(
    // Remove API routes
J
Joe Haddad 已提交
325
    (route) => !exportPathMap[route].page.match(API_ROUTE)
326
  )
327 328 329 330

  if (filteredPaths.length !== exportPaths.length) {
    hasApiRoutes = true
  }
331

332
  if (prerenderManifest && !options.buildExport) {
333
    const fallbackEnabledPages = new Set()
334 335 336 337 338 339 340 341

    for (const key of Object.keys(prerenderManifest.dynamicRoutes)) {
      // only error if page is included in path map
      if (!exportPathMap[key] && !excludedPrerenderRoutes.has(key)) {
        continue
      }

      if (prerenderManifest.dynamicRoutes[key].fallback !== false) {
342
        fallbackEnabledPages.add(key)
343 344 345
      }
    }

346
    if (fallbackEnabledPages.size) {
347
      throw new Error(
348 349 350
        `Found pages with \`fallback\` enabled:\n${[
          ...fallbackEnabledPages,
        ].join('\n')}\n${SSG_FALLBACK_EXPORT_ERROR}\n`
351 352 353 354
      )
    }
  }

355 356
  // Warn if the user defines a path for an API page
  if (hasApiRoutes) {
357 358
    if (!options.silent) {
      Log.warn(
359 360 361
        chalk.yellow(
          `Statically exporting a Next.js application via \`next export\` disables API routes.`
        ) +
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377
          `\n` +
          chalk.yellow(
            `This command is meant for static-only hosts, and is` +
              ' ' +
              chalk.bold(`not necessary to make your application static.`)
          ) +
          `\n` +
          chalk.yellow(
            `Pages in your application without server-side data dependencies will be automatically statically exported by \`next build\`, including pages powered by \`getStaticProps\`.`
          ) +
          `\n` +
          chalk.yellow(
            `Learn more: https://err.sh/vercel/next.js/api-routes-static-export`
          )
      )
    }
378
  }
379

380 381 382 383
  const progress =
    !options.silent &&
    createProgress(
      filteredPaths.length,
384
      `${Log.prefixes.info} ${options.statusMessage || 'Exporting'}`
385
    )
386
  const pagesDataDir = options.buildExport
J
JJ Kasper 已提交
387 388
    ? outDir
    : join(outDir, '_next/data', buildId)
A
Arunoda Susiripala 已提交
389

J
Joe Haddad 已提交
390
  const ampValidations: AmpPageStatus = {}
J
JJ Kasper 已提交
391 392
  let hadValidationError = false

393 394
  const publicDir = join(dir, CLIENT_PUBLIC_FILES_PATH)
  // Copy public directory
395
  if (!options.buildExport && existsSync(publicDir)) {
396 397 398
    if (!options.silent) {
      Log.info('Copying "public" directory')
    }
399
    await recursiveCopy(publicDir, outDir, {
J
Joe Haddad 已提交
400
      filter(path) {
401
        // Exclude paths used by pages
402
        return !exportPathMap[path]
J
Joe Haddad 已提交
403
      },
404
    })
405
  }
406

J
Jan Potoms 已提交
407 408 409 410 411
  const worker = new Worker(require.resolve('./worker'), {
    maxRetries: 0,
    numWorkers: threads,
    enableWorkerThreads: nextConfig.experimental.workerThreads,
    exposedMethods: ['default'],
412
  }) as Worker & { default: typeof exportPage }
413 414 415 416 417

  worker.getStdout().pipe(process.stdout)
  worker.getStderr().pipe(process.stderr)

  let renderError = false
418
  const errorPaths: string[] = []
419

420
  await Promise.all(
J
Joe Haddad 已提交
421
    filteredPaths.map(async (path) => {
422 423 424 425 426
      const result = await worker.default({
        path,
        pathMap: exportPathMap[path],
        distDir,
        outDir,
427
        pagesDataDir,
428 429 430
        renderOpts,
        serverRuntimeConfig,
        subFolders,
J
JJ Kasper 已提交
431
        buildExport: options.buildExport,
J
Joe Haddad 已提交
432
        serverless: isTargetLikeServerless(nextConfig.target),
P
Prateek Bhatnagar 已提交
433
        optimizeFonts: nextConfig.experimental.optimizeFonts,
434
        optimizeImages: nextConfig.experimental.optimizeImages,
435 436 437
      })

      for (const validation of result.ampValidations || []) {
438 439
        const { page, result: ampValidationResult } = validation
        ampValidations[page] = ampValidationResult
J
Joe Haddad 已提交
440 441
        hadValidationError =
          hadValidationError ||
442 443
          (Array.isArray(ampValidationResult?.errors) &&
            ampValidationResult.errors.length > 0)
444
      }
J
Joe Haddad 已提交
445
      renderError = renderError || !!result.error
446
      if (!!result.error) errorPaths.push(path)
J
JJ Kasper 已提交
447 448 449 450 451 452 453 454

      if (
        options.buildExport &&
        typeof result.fromBuildExportRevalidate !== 'undefined'
      ) {
        configuration.initialPageRevalidationMap[path] =
          result.fromBuildExportRevalidate
      }
455 456
      if (progress) progress()
    })
457
  )
458

459
  worker.end()
J
JJ Kasper 已提交
460

J
JJ Kasper 已提交
461 462 463
  // copy prerendered routes to outDir
  if (!options.buildExport && prerenderManifest) {
    await Promise.all(
J
Joe Haddad 已提交
464
      Object.keys(prerenderManifest.routes).map(async (route) => {
465 466 467 468 469 470 471 472 473 474 475 476 477
        const { srcRoute } = prerenderManifest!.routes[route]
        const pageName = srcRoute || route
        const pagePath = getPagePath(pageName, distDir, isLikeServerless)
        const distPagesDir = join(
          pagePath,
          // strip leading / and then recurse number of nested dirs
          // to place from base folder
          pageName
            .substr(1)
            .split('/')
            .map(() => '..')
            .join('/')
        )
478
        route = normalizePagePath(route)
479

J
JJ Kasper 已提交
480
        const orig = join(distPagesDir, route)
481 482 483 484 485 486
        const htmlDest = join(
          outDir,
          `${route}${
            subFolders && route !== '/index' ? `${sep}index` : ''
          }.html`
        )
487 488 489 490
        const ampHtmlDest = join(
          outDir,
          `${route}.amp${subFolders ? `${sep}index` : ''}.html`
        )
491
        const jsonDest = join(pagesDataDir, `${route}.json`)
J
JJ Kasper 已提交
492

493 494 495 496
        await promises.mkdir(dirname(htmlDest), { recursive: true })
        await promises.mkdir(dirname(jsonDest), { recursive: true })
        await promises.copyFile(`${orig}.html`, htmlDest)
        await promises.copyFile(`${orig}.json`, jsonDest)
497 498

        if (await exists(`${orig}.amp.html`)) {
499 500
          await promises.mkdir(dirname(ampHtmlDest), { recursive: true })
          await promises.copyFile(`${orig}.amp.html`, ampHtmlDest)
501
        }
J
JJ Kasper 已提交
502 503 504 505
      })
    )
  }

J
JJ Kasper 已提交
506 507 508 509
  if (Object.keys(ampValidations).length) {
    console.log(formatAmpMessages(ampValidations))
  }
  if (hadValidationError) {
510
    throw new Error(
511
      `AMP Validation caused the export to fail. https://err.sh/vercel/next.js/amp-export-validation`
512
    )
J
JJ Kasper 已提交
513 514
  }

515
  if (renderError) {
516 517 518 519 520
    throw new Error(
      `Export encountered errors on following paths:\n\t${errorPaths
        .sort()
        .join('\n\t')}`
    )
521
  }
522

J
Joe Haddad 已提交
523 524 525 526 527 528 529 530 531 532
  writeFileSync(
    join(distDir, EXPORT_DETAIL),
    JSON.stringify({
      version: 1,
      outDirectory: outDir,
      success: true,
    }),
    'utf8'
  )

533 534 535
  if (telemetry) {
    await telemetry.flush()
  }
536
}