fix(nuxt): experimental build manifest + client route rules (#21641)

This commit is contained in:
Daniel Roe 2023-09-19 22:31:18 +01:00 committed by GitHub
parent 2bf9028f7e
commit 7dce07653c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 368 additions and 42 deletions

View File

@ -170,10 +170,13 @@ jobs:
env: ['dev', 'built'] env: ['dev', 'built']
builder: ['vite', 'webpack'] builder: ['vite', 'webpack']
context: ['async', 'default'] context: ['async', 'default']
manifest: ['manifest-on', 'manifest-off']
node: [18] node: [18]
exclude: exclude:
- env: 'dev' - env: 'dev'
builder: 'webpack' builder: 'webpack'
- manifest: 'manifest-off'
builder: 'webpack'
timeout-minutes: 15 timeout-minutes: 15
@ -231,6 +234,7 @@ jobs:
env: env:
TEST_ENV: ${{ matrix.env }} TEST_ENV: ${{ matrix.env }}
TEST_BUILDER: ${{ matrix.builder }} TEST_BUILDER: ${{ matrix.builder }}
TEST_MANIFEST: ${{ matrix.manifest }}
TEST_CONTEXT: ${{ matrix.context }} TEST_CONTEXT: ${{ matrix.context }}
SKIP_BUNDLE_SIZE: ${{ github.event_name != 'push' || matrix.env == 'dev' || matrix.builder == 'webpack' || matrix.context == 'default' || runner.os == 'Windows' }} SKIP_BUNDLE_SIZE: ${{ github.event_name != 'push' || matrix.env == 'dev' || matrix.builder == 'webpack' || matrix.context == 'default' || runner.os == 'Windows' }}

View File

@ -18,7 +18,7 @@
"play": "nuxi dev playground", "play": "nuxi dev playground",
"play:build": "nuxi build playground", "play:build": "nuxi build playground",
"play:preview": "nuxi preview playground", "play:preview": "nuxi preview playground",
"test": "pnpm test:fixtures && pnpm test:fixtures:payload && pnpm test:fixtures:dev && pnpm test:fixtures:webpack && pnpm test:unit && pnpm typecheck", "test": "pnpm test:fixtures && pnpm test:fixtures:dev && pnpm test:fixtures:webpack && pnpm test:unit && pnpm test:runtime && pnpm test:types && pnpm typecheck",
"test:fixtures": "nuxi prepare test/fixtures/basic && nuxi prepare test/fixtures/runtime-compiler && vitest run --dir test", "test:fixtures": "nuxi prepare test/fixtures/basic && nuxi prepare test/fixtures/runtime-compiler && vitest run --dir test",
"test:fixtures:dev": "TEST_ENV=dev pnpm test:fixtures", "test:fixtures:dev": "TEST_ENV=dev pnpm test:fixtures",
"test:fixtures:webpack": "TEST_BUILDER=webpack pnpm test:fixtures", "test:fixtures:webpack": "TEST_BUILDER=webpack pnpm test:fixtures",

View File

@ -2,6 +2,13 @@ declare global {
var __NUXT_VERSION__: string var __NUXT_VERSION__: string
var __NUXT_PREPATHS__: string[] | string | undefined var __NUXT_PREPATHS__: string[] | string | undefined
var __NUXT_PATHS__: string[] | string | undefined var __NUXT_PATHS__: string[] | string | undefined
interface Navigator {
connection?: {
type: 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'wifi' | 'wimax' | 'other' | 'unknown'
effectiveType: 'slow-2g' | '2g' | '3g' | '4g'
}
}
} }
export {} export {}

View File

@ -89,6 +89,7 @@
"pathe": "^1.1.1", "pathe": "^1.1.1",
"perfect-debounce": "^1.0.0", "perfect-debounce": "^1.0.0",
"pkg-types": "^1.0.3", "pkg-types": "^1.0.3",
"radix3": "^1.1.0",
"scule": "^1.0.0", "scule": "^1.0.0",
"std-env": "^3.4.3", "std-env": "^3.4.3",
"strip-literal": "^1.3.0", "strip-literal": "^1.3.0",

View File

@ -29,6 +29,8 @@ export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBefor
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router' export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'
export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload' export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload'
export { isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver } from './payload' export { isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver } from './payload'
export { getAppManifest, getRouteRules } from './manifest'
export type { NuxtAppManifest, NuxtAppManifestMeta } from './manifest'
export type { ReloadNuxtAppOptions } from './chunk' export type { ReloadNuxtAppOptions } from './chunk'
export { reloadNuxtApp } from './chunk' export { reloadNuxtApp } from './chunk'
export { useRequestURL } from './url' export { useRequestURL } from './url'

View File

@ -0,0 +1,46 @@
import { joinURL } from 'ufo'
import type { MatcherExport, RouteMatcher } from 'radix3'
import { createMatcherFromExport } from 'radix3'
import { defu } from 'defu'
import { useAppConfig, useRuntimeConfig } from '#app'
// @ts-expect-error virtual file
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
export interface NuxtAppManifestMeta {
id: string
timestamp: number
}
export interface NuxtAppManifest extends NuxtAppManifestMeta {
matcher: MatcherExport
prerendered: string[]
}
let manifest: Promise<NuxtAppManifest>
let matcher: RouteMatcher
function fetchManifest () {
if (!isAppManifestEnabled) {
throw new Error('[nuxt] app manifest should be enabled with `experimental.appManifest`')
}
const config = useRuntimeConfig()
// @ts-expect-error private property
const buildId = useAppConfig().nuxt?.buildId
manifest = $fetch<NuxtAppManifest>(joinURL(config.app.cdnURL || config.app.baseURL, config.app.buildAssetsDir, `builds/meta/${buildId}.json`))
manifest.then((m) => {
matcher = createMatcherFromExport(m.matcher)
})
return manifest
}
export function getAppManifest (): Promise<NuxtAppManifest> {
if (!isAppManifestEnabled) {
throw new Error('[nuxt] app manifest should be enabled with `experimental.appManifest`')
}
return manifest || fetchManifest()
}
export async function getRouteRules (url: string) {
await getAppManifest()
return defu({} as Record<string, any>, ...matcher.matchAll(url).reverse())
}

View File

@ -4,8 +4,11 @@ import { useHead } from '@unhead/vue'
import { getCurrentInstance } from 'vue' import { getCurrentInstance } from 'vue'
import { useNuxtApp, useRuntimeConfig } from '../nuxt' import { useNuxtApp, useRuntimeConfig } from '../nuxt'
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
import { useRoute } from '#app/composables'
// @ts-expect-error virtual import // @ts-expect-error virtual import
import { renderJsonPayloads } from '#build/nuxt.config.mjs' import { appManifest, payloadExtraction, renderJsonPayloads } from '#build/nuxt.config.mjs'
interface LoadPayloadOptions { interface LoadPayloadOptions {
fresh?: boolean fresh?: boolean
@ -13,19 +16,24 @@ interface LoadPayloadOptions {
} }
export function loadPayload (url: string, opts: LoadPayloadOptions = {}): Record<string, any> | Promise<Record<string, any>> | null { export function loadPayload (url: string, opts: LoadPayloadOptions = {}): Record<string, any> | Promise<Record<string, any>> | null {
if (import.meta.server) { return null } if (import.meta.server || !payloadExtraction) { return null }
const payloadURL = _getPayloadURL(url, opts) const payloadURL = _getPayloadURL(url, opts)
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const cache = nuxtApp._payloadCache = nuxtApp._payloadCache || {} const cache = nuxtApp._payloadCache = nuxtApp._payloadCache || {}
if (cache[payloadURL]) { if (payloadURL in cache) {
return cache[payloadURL] return cache[payloadURL]
} }
cache[payloadURL] = _importPayload(payloadURL).then((payload) => { cache[payloadURL] = isPrerendered().then((prerendered) => {
if (!payload) { if (!prerendered) {
delete cache[payloadURL] cache[payloadURL] = null
return null return null
} }
return payload return _importPayload(payloadURL).then((payload) => {
if (payload) { return payload }
delete cache[payloadURL]
return null
})
}) })
return cache[payloadURL] return cache[payloadURL]
} }
@ -55,7 +63,7 @@ function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) {
} }
async function _importPayload (payloadURL: string) { async function _importPayload (payloadURL: string) {
if (import.meta.server) { return null } if (import.meta.server || !payloadExtraction) { return null }
const payloadPromise = renderJsonPayloads const payloadPromise = renderJsonPayloads
? fetch(payloadURL).then(res => res.text().then(parsePayload)) ? fetch(payloadURL).then(res => res.text().then(parsePayload))
: import(/* webpackIgnore: true */ /* @vite-ignore */ payloadURL).then(r => r.default || r) : import(/* webpackIgnore: true */ /* @vite-ignore */ payloadURL).then(r => r.default || r)
@ -68,10 +76,19 @@ async function _importPayload (payloadURL: string) {
return null return null
} }
export function isPrerendered () { export async function isPrerendered (url = useRoute().path) {
// Note: Alternative for server is checking x-nitro-prerender header // Note: Alternative for server is checking x-nitro-prerender header
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
return !!nuxtApp.payload.prerenderedAt if (nuxtApp.payload.prerenderedAt) {
return true
}
if (!appManifest) { return false }
const manifest = await getAppManifest()
if (manifest.prerendered.includes(url)) {
return true
}
const rules = await getRouteRules(url)
return !!rules.prerender
} }
let payloadCache: any = null let payloadCache: any = null

View File

@ -0,0 +1,10 @@
import { defineNuxtRouteMiddleware } from '#app/composables/router'
import { getRouteRules } from '#app/composables/manifest'
export default defineNuxtRouteMiddleware(async (to) => {
if (import.meta.server || import.meta.test) { return }
const rules = await getRouteRules(to.path)
if (rules.redirect) {
return rules.redirect
}
})

View File

@ -16,6 +16,7 @@ import type { NuxtIslandContext } from '../core/runtime/nitro/renderer'
import type { RouteMiddleware } from '../../app' import type { RouteMiddleware } from '../../app'
import type { NuxtError } from '../app/composables/error' import type { NuxtError } from '../app/composables/error'
import type { AsyncDataRequestStatus } from '../app/composables/asyncData' import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
import type { NuxtAppManifestMeta } from '#app/composables'
const nuxtAppCtx = /* #__PURE__ */ getContext<NuxtApp>('nuxt-app', { const nuxtAppCtx = /* #__PURE__ */ getContext<NuxtApp>('nuxt-app', {
asyncContext: !!process.env.NUXT_ASYNC_CONTEXT && process.server asyncContext: !!process.env.NUXT_ASYNC_CONTEXT && process.server
@ -35,6 +36,7 @@ export interface RuntimeNuxtHooks {
'app:error:cleared': (options: { redirect?: string }) => HookResult 'app:error:cleared': (options: { redirect?: string }) => HookResult
'app:chunkError': (options: { error: any }) => HookResult 'app:chunkError': (options: { error: any }) => HookResult
'app:data:refresh': (keys?: string[]) => HookResult 'app:data:refresh': (keys?: string[]) => HookResult
'app:manifest:update': (meta?: NuxtAppManifestMeta) => HookResult
'link:prefetch': (link: string) => HookResult 'link:prefetch': (link: string) => HookResult
'page:start': (Component?: VNode) => HookResult 'page:start': (Component?: VNode) => HookResult
'page:finish': (Component?: VNode) => HookResult 'page:finish': (Component?: VNode) => HookResult
@ -115,7 +117,7 @@ interface _NuxtApp {
/** @internal */ /** @internal */
_observer?: { observe: (element: Element, callback: () => void) => () => void } _observer?: { observe: (element: Element, callback: () => void) => () => void }
/** @internal */ /** @internal */
_payloadCache?: Record<string, Promise<Record<string, any>> | Record<string, any>> _payloadCache?: Record<string, Promise<Record<string, any>> | Record<string, any> | null>
/** @internal */ /** @internal */
_appConfig: AppConfig _appConfig: AppConfig

View File

@ -0,0 +1,23 @@
import { joinURL } from 'ufo'
import type { NuxtAppManifestMeta } from '#app'
import { defineNuxtPlugin, getAppManifest, onNuxtReady, useRuntimeConfig } from '#app'
export default defineNuxtPlugin((nuxtApp) => {
if (import.meta.test) { return }
let timeout: NodeJS.Timeout
const config = useRuntimeConfig()
async function getLatestManifest () {
const currentManifest = await getAppManifest()
if (timeout) { clearTimeout(timeout) }
timeout = setTimeout(getLatestManifest, 1000 * 60 * 60)
const meta = await $fetch<NuxtAppManifestMeta>(joinURL(config.app.cdnURL || config.app.baseURL, config.app.buildAssetsDir, 'builds/latest.json'))
if (meta.id !== currentManifest.id) {
// There is a newer build which we will let the user handle
nuxtApp.hooks.callHook('app:manifest:update', meta)
}
}
onNuxtReady(() => { timeout = setTimeout(getLatestManifest, 1000 * 60 * 60) })
})

View File

@ -1,4 +1,5 @@
import { joinURL } from 'ufo' import { joinURL } from 'ufo'
import type { RouteLocationNormalized } from 'vue-router'
import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt' import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt'
import { useRouter } from '#app/composables/router' import { useRouter } from '#app/composables/router'
import { reloadNuxtApp } from '#app/composables/chunk' import { reloadNuxtApp } from '#app/composables/chunk'
@ -14,12 +15,20 @@ export default defineNuxtPlugin({
router.beforeEach(() => { chunkErrors.clear() }) router.beforeEach(() => { chunkErrors.clear() })
nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) }) nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) })
router.onError((error, to) => { function reloadAppAtPath (to: RouteLocationNormalized) {
if (chunkErrors.has(error)) {
const isHash = 'href' in to && (to.href as string).startsWith('#') const isHash = 'href' in to && (to.href as string).startsWith('#')
const path = isHash ? config.app.baseURL + (to as any).href : joinURL(config.app.baseURL, to.fullPath) const path = isHash ? config.app.baseURL + (to as any).href : joinURL(config.app.baseURL, to.fullPath)
reloadNuxtApp({ path, persistState: true }) reloadNuxtApp({ path, persistState: true })
} }
nuxtApp.hook('app:manifest:update', () => {
router.beforeResolve(reloadAppAtPath)
})
router.onError((error, to) => {
if (chunkErrors.has(error)) {
reloadAppAtPath(to)
}
}) })
} }
}) })

View File

@ -1,23 +1,17 @@
import { parseURL } from 'ufo' import { parseURL } from 'ufo'
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
import { isPrerendered, loadPayload } from '#app/composables/payload' import { loadPayload } from '#app/composables/payload'
import { onNuxtReady } from '#app/composables/ready'
import { useRouter } from '#app/composables/router' import { useRouter } from '#app/composables/router'
import { getAppManifest } from '#app/composables/manifest'
// @ts-expect-error virtual file
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
export default defineNuxtPlugin({ export default defineNuxtPlugin({
name: 'nuxt:payload', name: 'nuxt:payload',
setup (nuxtApp) { setup (nuxtApp) {
// Only enable behavior if initial page is prerendered // TODO: Support dev
// TODO: Support hybrid and dev if (process.dev) { return }
if (!isPrerendered()) {
return
}
// Load payload into cache
nuxtApp.hooks.hook('link:prefetch', async (url) => {
if (!parseURL(url).protocol) {
await loadPayload(url)
}
})
// Load payload after middleware & once final route is resolved // Load payload after middleware & once final route is resolved
useRouter().beforeResolve(async (to, from) => { useRouter().beforeResolve(async (to, from) => {
@ -26,5 +20,17 @@ export default defineNuxtPlugin({
if (!payload) { return } if (!payload) { return }
Object.assign(nuxtApp.static.data, payload.data) Object.assign(nuxtApp.static.data, payload.data)
}) })
onNuxtReady(() => {
// Load payload into cache
nuxtApp.hooks.hook('link:prefetch', async (url) => {
if (!parseURL(url).protocol) {
await loadPayload(url)
}
})
if (isAppManifestEnabled && navigator.connection?.effectiveType !== 'slow-2g') {
setTimeout(getAppManifest, 1000)
}
})
} }
}) })

View File

@ -1,6 +1,9 @@
import { existsSync, promises as fsp, readFileSync } from 'node:fs' import { existsSync, promises as fsp, readFileSync } from 'node:fs'
import { cpus } from 'node:os' import { cpus } from 'node:os'
import { join, relative, resolve } from 'pathe' import { join, relative, resolve } from 'pathe'
import { createRouter as createRadixRouter, exportMatcher, toRouteMatcher } from 'radix3'
import { randomUUID } from 'uncrypto'
import { joinURL } from 'ufo'
import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, scanHandlers, writeTypes } from 'nitropack' import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, scanHandlers, writeTypes } from 'nitropack'
import type { Nitro, NitroConfig } from 'nitropack' import type { Nitro, NitroConfig } from 'nitropack'
import { logger, resolveIgnorePatterns } from '@nuxt/kit' import { logger, resolveIgnorePatterns } from '@nuxt/kit'
@ -198,6 +201,80 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
nitroConfig.srcDir = resolve(nuxt.options.rootDir, nuxt.options.srcDir, nitroConfig.srcDir!) nitroConfig.srcDir = resolve(nuxt.options.rootDir, nuxt.options.srcDir, nitroConfig.srcDir!)
nitroConfig.ignore = [...(nitroConfig.ignore || []), ...resolveIgnorePatterns(nitroConfig.srcDir)] nitroConfig.ignore = [...(nitroConfig.ignore || []), ...resolveIgnorePatterns(nitroConfig.srcDir)]
// Add app manifest handler and prerender configuration
if (nuxt.options.experimental.appManifest) {
// @ts-expect-error untyped nuxt property
const buildId = nuxt.options.appConfig.nuxt!.buildId ||= randomUUID()
const buildTimestamp = Date.now()
const manifestPrefix = joinURL(nuxt.options.app.buildAssetsDir, 'builds')
const tempDir = join(nuxt.options.buildDir, 'manifest')
nitroConfig.publicAssets!.unshift(
// build manifest
{
dir: join(tempDir, 'meta'),
maxAge: 31536000 /* 1 year */,
baseURL: joinURL(manifestPrefix, 'meta')
},
// latest build
{
dir: tempDir,
maxAge: 1,
baseURL: manifestPrefix
}
)
nuxt.hook('nitro:build:before', async (nitro) => {
const routeRules = {} as Record<string, any>
const _routeRules = nitro.options.routeRules
for (const key in _routeRules) {
if (key === '/__nuxt_error') { continue }
const filteredRules = Object.entries(_routeRules[key])
.filter(([key, value]) => ['prerender', 'redirect'].includes(key) && value)
.map(([key, value]: any) => {
if (key === 'redirect') {
return [key, typeof value === 'string' ? value : value.to]
}
return [key, value]
})
if (filteredRules.length > 0) {
routeRules[key] = Object.fromEntries(filteredRules)
}
}
// Add pages prerendered but not covered by route rules
const prerenderedRoutes = new Set<string>()
const routeRulesMatcher = toRouteMatcher(
createRadixRouter({ routes: routeRules })
)
const payloadSuffix = nuxt.options.experimental.renderJsonPayloads ? '/_payload.json' : '/_payload.js'
for (const route of nitro._prerenderedRoutes || []) {
if (!route.error && route.route.endsWith(payloadSuffix)) {
const url = route.route.slice(0, -payloadSuffix.length) || '/'
const rules = defu({}, ...routeRulesMatcher.matchAll(url).reverse()) as Record<string, any>
if (!rules.prerender) {
prerenderedRoutes.add(url)
}
}
}
const manifest = {
id: buildId,
timestamp: buildTimestamp,
matcher: exportMatcher(routeRulesMatcher),
prerendered: nuxt.options.dev ? [] : [...prerenderedRoutes]
}
await fsp.mkdir(join(tempDir, 'meta'), { recursive: true })
await fsp.writeFile(join(tempDir, 'latest.json'), JSON.stringify({
id: buildId,
timestamp: buildTimestamp
}))
await fsp.writeFile(join(tempDir, `meta/${buildId}.json`), JSON.stringify(manifest))
})
}
// Add fallback server for `ssr: false` // Add fallback server for `ssr: false`
if (!nuxt.options.ssr) { if (!nuxt.options.ssr) {
nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}' nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}'

View File

@ -1,7 +1,7 @@
import { join, normalize, relative, resolve } from 'pathe' import { join, normalize, relative, resolve } from 'pathe'
import { createDebugger, createHooks } from 'hookable' import { createDebugger, createHooks } from 'hookable'
import type { LoadNuxtOptions } from '@nuxt/kit' import type { LoadNuxtOptions } from '@nuxt/kit'
import { addBuildPlugin, addComponent, addPlugin, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit' import { addBuildPlugin, addComponent, addPlugin, addRouteMiddleware, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit'
import type { Nuxt, NuxtHooks, NuxtOptions } from 'nuxt/schema' import type { Nuxt, NuxtHooks, NuxtOptions } from 'nuxt/schema'
import escapeRE from 'escape-string-regexp' import escapeRE from 'escape-string-regexp'
@ -90,6 +90,16 @@ async function initNuxt (nuxt: Nuxt) {
addVitePlugin(() => ImportProtectionPlugin.vite(config)) addVitePlugin(() => ImportProtectionPlugin.vite(config))
addWebpackPlugin(() => ImportProtectionPlugin.webpack(config)) addWebpackPlugin(() => ImportProtectionPlugin.webpack(config))
if (nuxt.options.experimental.appManifest) {
addRouteMiddleware({
name: 'manifest-route-rule',
path: resolve(nuxt.options.appDir, 'middleware/manifest-route-rule'),
global: true
})
addPlugin(resolve(nuxt.options.appDir, 'plugins/check-outdated-build.client'))
}
// add resolver for modules used in virtual files // add resolver for modules used in virtual files
addVitePlugin(() => resolveDeepImportsPlugin(nuxt)) addVitePlugin(() => resolveDeepImportsPlugin(nuxt))
@ -295,6 +305,11 @@ async function initNuxt (nuxt: Nuxt) {
} }
} }
// Add prerender payload support
if (!nuxt.options.dev && nuxt.options.experimental.payloadExtraction) {
addPlugin(resolve(nuxt.options.appDir, 'plugins/payload.client'))
}
// Add experimental cross-origin prefetch support using Speculation Rules API // Add experimental cross-origin prefetch support using Speculation Rules API
if (nuxt.options.experimental.crossOriginPrefetch) { if (nuxt.options.experimental.crossOriginPrefetch) {
addPlugin(resolve(nuxt.options.appDir, 'plugins/cross-origin-prefetch.client')) addPlugin(resolve(nuxt.options.appDir, 'plugins/cross-origin-prefetch.client'))

View File

@ -337,6 +337,8 @@ export const nuxtConfigTemplate = {
...Object.entries(ctx.nuxt.options.app).map(([k, v]) => `export const ${camelCase('app-' + k)} = ${JSON.stringify(v)}`), ...Object.entries(ctx.nuxt.options.app).map(([k, v]) => `export const ${camelCase('app-' + k)} = ${JSON.stringify(v)}`),
`export const renderJsonPayloads = ${!!ctx.nuxt.options.experimental.renderJsonPayloads}`, `export const renderJsonPayloads = ${!!ctx.nuxt.options.experimental.renderJsonPayloads}`,
`export const componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`, `export const componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`,
`export const payloadExtraction = ${!!ctx.nuxt.options.experimental.payloadExtraction}`,
`export const appManifest = ${!!ctx.nuxt.options.experimental.appManifest}`,
`export const remoteComponentIslands = ${ctx.nuxt.options.experimental.componentIslands === 'local+remote'}`, `export const remoteComponentIslands = ${ctx.nuxt.options.experimental.componentIslands === 'local+remote'}`,
`export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`, `export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`,
`export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`, `export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`,

View File

@ -59,6 +59,8 @@ const appPreset = defineUnimportPreset({
'loadPayload', 'loadPayload',
'preloadPayload', 'preloadPayload',
'isPrerendered', 'isPrerendered',
'getAppManifest',
'getRouteRules',
'definePayloadReducer', 'definePayloadReducer',
'definePayloadReviver', 'definePayloadReviver',
'requestIdleCallback', 'requestIdleCallback',

View File

@ -474,7 +474,9 @@ export default defineUntypedSchema({
* *
* @type {typeof import('../src/types/config').AppConfig} * @type {typeof import('../src/types/config').AppConfig}
*/ */
appConfig: {}, appConfig: {
nuxt: {}
},
$schema: {} $schema: {}
}) })

View File

@ -120,11 +120,11 @@ export default defineUntypedSchema({
noVueServer: false, noVueServer: false,
/** /**
* When this option is enabled (by default) payload of pages generated with `nuxt generate` are extracted * When this option is enabled (by default) payload of pages that are prerendered are extracted
* *
* @type {boolean | undefined} * @type {boolean | undefined}
*/ */
payloadExtraction: undefined, payloadExtraction: true,
/** /**
* Whether to enable the experimental `<NuxtClientFallback>` component for rendering content on the client * Whether to enable the experimental `<NuxtClientFallback>` component for rendering content on the client
@ -207,6 +207,17 @@ export default defineUntypedSchema({
/** Enable the new experimental typed router using [unplugin-vue-router](https://github.com/posva/unplugin-vue-router). */ /** Enable the new experimental typed router using [unplugin-vue-router](https://github.com/posva/unplugin-vue-router). */
typedPages: false, typedPages: false,
/**
* Use app manifests to respect route rules on client-side.
*/
// TODO: enable by default in v3.8
appManifest: false,
// This is enabled when `experimental.payloadExtraction` is set to `true`.
// appManifest: {
// $resolve: (val, get) => val ?? get('experimental.payloadExtraction')
// },
/** /**
* Set an alternative watcher that will be used as the watching service for Nuxt. * Set an alternative watcher that will be used as the watching service for Nuxt.
* *

View File

@ -356,6 +356,9 @@ importers:
pkg-types: pkg-types:
specifier: ^1.0.3 specifier: ^1.0.3
version: 1.0.3 version: 1.0.3
radix3:
specifier: ^1.1.0
version: 1.1.0
scule: scule:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0 version: 1.0.0

View File

@ -11,6 +11,7 @@ import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro
import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage } from './utils' import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage } from './utils'
const isWebpack = process.env.TEST_BUILDER === 'webpack' const isWebpack = process.env.TEST_BUILDER === 'webpack'
const isTestingAppManifest = process.env.TEST_MANIFEST === 'manifest-on'
await setup({ await setup({
rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
@ -1622,18 +1623,24 @@ describe('app config', () => {
it('should work', async () => { it('should work', async () => {
const html = await $fetch('/app-config') const html = await $fetch('/app-config')
const expectedAppConfig = { const expectedAppConfig: Record<string, any> = {
fromNuxtConfig: true, fromNuxtConfig: true,
nested: { nested: {
val: 2 val: 2
}, },
nuxt: {},
fromLayer: true, fromLayer: true,
userConfig: 123 userConfig: 123
} }
if (isTestingAppManifest) {
expect(html).toContain(JSON.stringify(expectedAppConfig)) expectedAppConfig.nuxt.buildId = 'test'
}
expect.soft(html.replace(/"nuxt":\{"buildId":"[^"]+"\}/, '"nuxt":{"buildId":"test"}')).toContain(JSON.stringify(expectedAppConfig))
const serverAppConfig = await $fetch('/api/app-config') const serverAppConfig = await $fetch('/api/app-config')
if (isTestingAppManifest) {
serverAppConfig.appConfig.nuxt.buildId = 'test'
}
expect(serverAppConfig).toMatchObject({ appConfig: expectedAppConfig }) expect(serverAppConfig).toMatchObject({ appConfig: expectedAppConfig })
}) })
}) })

View File

@ -19,7 +19,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
for (const outputDir of ['.output', '.output-inline']) { for (const outputDir of ['.output', '.output-inline']) {
it('default client bundle size', async () => { it('default client bundle size', async () => {
const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public')) const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public'))
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"96.1k"') expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"97.2k"')
expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(` expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[ [
"_nuxt/entry.js", "_nuxt/entry.js",

View File

@ -3,6 +3,7 @@ import { addTypeTemplate } from 'nuxt/kit'
export default defineNuxtConfig({ export default defineNuxtConfig({
experimental: { experimental: {
typedPages: true, typedPages: true,
appManifest: true,
typescriptBundlerResolution: process.env.MODULE_RESOLUTION === 'bundler' typescriptBundlerResolution: process.env.MODULE_RESOLUTION === 'bundler'
}, },
buildDir: process.env.NITRO_BUILD_DIR, buildDir: process.env.NITRO_BUILD_DIR,

View File

@ -425,6 +425,7 @@ describe('composables', () => {
describe('app config', () => { describe('app config', () => {
it('merges app config as expected', () => { it('merges app config as expected', () => {
interface ExpectedMergedAppConfig { interface ExpectedMergedAppConfig {
nuxt: { buildId: string }
fromLayer: boolean fromLayer: boolean
fromNuxtConfig: boolean fromNuxtConfig: boolean
nested: { nested: {

View File

@ -56,7 +56,8 @@ export default defineNuxtConfig({
routes: [ routes: [
'/random/a', '/random/a',
'/random/b', '/random/b',
'/random/c' '/random/c',
'/prefetch/server-components'
] ]
} }
}, },
@ -193,8 +194,10 @@ export default defineNuxtConfig({
componentIslands: true, componentIslands: true,
reactivityTransform: true, reactivityTransform: true,
treeshakeClientOnly: true, treeshakeClientOnly: true,
payloadExtraction: true,
asyncContext: process.env.TEST_CONTEXT === 'async', asyncContext: process.env.TEST_CONTEXT === 'async',
// TODO: remove this in v3.8
payloadExtraction: true,
appManifest: process.env.TEST_MANIFEST === 'manifest-on',
headNext: true, headNext: true,
inlineRouteRules: true inlineRouteRules: true
}, },

View File

@ -1 +1,3 @@
export default defineNuxtConfig({}) export default defineNuxtConfig({
experimental: { appManifest: true }
})

View File

@ -43,6 +43,10 @@ describe('config typings', () => {
}) })
it('appConfig', () => { it('appConfig', () => {
expectTypeOf(useAppConfig()).toEqualTypeOf<{ [key: string]: unknown }>() expectTypeOf(useAppConfig().foo).toEqualTypeOf<unknown>()
expectTypeOf(useAppConfig()).toEqualTypeOf<{
nuxt: { buildId: string }
[key: string]: unknown
}>()
}) })
}) })

View File

@ -1,6 +1,9 @@
/// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" /> /// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" />
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { defineEventHandler } from 'h3'
import { registerEndpoint } from 'nuxt-vitest/utils'
import * as composables from '#app/composables' import * as composables from '#app/composables'
@ -10,11 +13,24 @@ import { onNuxtReady } from '#app/composables/ready'
import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders } from '#app/composables/ssr' import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders } from '#app/composables/ssr'
import { clearNuxtState, useState } from '#app/composables/state' import { clearNuxtState, useState } from '#app/composables/state'
import { useRequestURL } from '#app/composables/url' import { useRequestURL } from '#app/composables/url'
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
vi.mock('#app/compat/idle-callback', () => ({ vi.mock('#app/compat/idle-callback', () => ({
requestIdleCallback: (cb: Function) => cb() requestIdleCallback: (cb: Function) => cb()
})) }))
const timestamp = Date.now()
registerEndpoint('/_nuxt/builds/latest.json', defineEventHandler(() => ({
id: 'test',
timestamp
})))
registerEndpoint('/_nuxt/builds/meta/test.json', defineEventHandler(() => ({
id: 'test',
timestamp,
matcher: { static: { '/': null, '/pre': null }, wildcard: { '/pre': { prerender: true } }, dynamic: {} },
prerendered: ['/specific-prerendered']
})))
describe('composables', () => { describe('composables', () => {
it('are all tested', () => { it('are all tested', () => {
const testedComposables: string[] = [ const testedComposables: string[] = [
@ -27,10 +43,13 @@ describe('composables', () => {
'clearError', 'clearError',
'showError', 'showError',
'useError', 'useError',
'getAppManifest',
'getRouteRules',
'onNuxtReady', 'onNuxtReady',
'setResponseStatus', 'setResponseStatus',
'useRequestEvent', 'useRequestEvent',
'useRequestFetch', 'useRequestFetch',
'isPrerendered',
'useRequestHeaders', 'useRequestHeaders',
'clearNuxtState', 'clearNuxtState',
'useState', 'useState',
@ -43,7 +62,6 @@ describe('composables', () => {
'defineNuxtRouteMiddleware', 'defineNuxtRouteMiddleware',
'definePayloadReducer', 'definePayloadReducer',
'definePayloadReviver', 'definePayloadReviver',
'isPrerendered',
'loadPayload', 'loadPayload',
'navigateTo', 'navigateTo',
'onBeforeRouteLeave', 'onBeforeRouteLeave',
@ -204,3 +222,39 @@ describe('url', () => {
expect(url.protocol).toMatchInlineSnapshot('"http:"') expect(url.protocol).toMatchInlineSnapshot('"http:"')
}) })
}) })
describe.skipIf(process.env.TEST_MANIFEST !== 'manifest-on')('app manifests', () => {
it('getAppManifest', async () => {
const manifest = await getAppManifest()
delete manifest.timestamp
expect(manifest).toMatchInlineSnapshot(`
{
"id": "test",
"matcher": {
"dynamic": {},
"static": {
"/": null,
"/pre": null,
},
"wildcard": {
"/pre": {
"prerender": true,
},
},
},
"prerendered": [
"/specific-prerendered",
],
}
`)
})
it('getRouteRules', async () => {
const rules = await getRouteRules('/')
expect(rules).toMatchInlineSnapshot('{}')
})
it('isPrerendered', async () => {
expect(await isPrerendered('/specific-prerendered')).toBeTruthy()
expect(await isPrerendered('/prerendered/test')).toBeTruthy()
expect(await isPrerendered('/test')).toBeFalsy()
})
})

View File

@ -1,8 +1,23 @@
import { defineVitestConfig } from 'nuxt-vitest/config' import { defineVitestConfig } from 'nuxt-vitest/config'
export default defineVitestConfig({ export default defineVitestConfig({
// TODO: investigate
define: {
'import.meta.test': true
},
test: { test: {
dir: './test/nuxt', dir: './test/nuxt',
environment: 'nuxt' environment: 'nuxt',
environmentOptions: {
nuxt: {
overrides: {
appConfig: {
nuxt: {
buildId: 'test'
}
}
}
}
}
} }
}) })