Skip to content

Commit

Permalink
fix(nuxt): experimental build manifest + client route rules (#21641)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe committed Sep 19, 2023
1 parent 2bf9028 commit 7dce076
Show file tree
Hide file tree
Showing 28 changed files with 368 additions and 42 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,13 @@ jobs:
env: ['dev', 'built']
builder: ['vite', 'webpack']
context: ['async', 'default']
manifest: ['manifest-on', 'manifest-off']
node: [18]
exclude:
- env: 'dev'
builder: 'webpack'
- manifest: 'manifest-off'
builder: 'webpack'

timeout-minutes: 15

Expand Down Expand Up @@ -231,6 +234,7 @@ jobs:
env:
TEST_ENV: ${{ matrix.env }}
TEST_BUILDER: ${{ matrix.builder }}
TEST_MANIFEST: ${{ matrix.manifest }}
TEST_CONTEXT: ${{ matrix.context }}
SKIP_BUNDLE_SIZE: ${{ github.event_name != 'push' || matrix.env == 'dev' || matrix.builder == 'webpack' || matrix.context == 'default' || runner.os == 'Windows' }}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"play": "nuxi dev playground",
"play:build": "nuxi build 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:dev": "TEST_ENV=dev pnpm test:fixtures",
"test:fixtures:webpack": "TEST_BUILDER=webpack pnpm test:fixtures",
Expand Down
7 changes: 7 additions & 0 deletions packages/nuxt/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ declare global {
var __NUXT_VERSION__: string
var __NUXT_PREPATHS__: 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 {}
1 change: 1 addition & 0 deletions packages/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"pathe": "^1.1.1",
"perfect-debounce": "^1.0.0",
"pkg-types": "^1.0.3",
"radix3": "^1.1.0",
"scule": "^1.0.0",
"std-env": "^3.4.3",
"strip-literal": "^1.3.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/nuxt/src/app/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBefor
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'
export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload'
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 { reloadNuxtApp } from './chunk'
export { useRequestURL } from './url'
46 changes: 46 additions & 0 deletions packages/nuxt/src/app/composables/manifest.ts
Original file line number Diff line number Diff line change
@@ -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())
}
37 changes: 27 additions & 10 deletions packages/nuxt/src/app/composables/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,36 @@ import { useHead } from '@unhead/vue'
import { getCurrentInstance } from 'vue'
import { useNuxtApp, useRuntimeConfig } from '../nuxt'

import { getAppManifest, getRouteRules } from '#app/composables/manifest'
import { useRoute } from '#app/composables'

// @ts-expect-error virtual import
import { renderJsonPayloads } from '#build/nuxt.config.mjs'
import { appManifest, payloadExtraction, renderJsonPayloads } from '#build/nuxt.config.mjs'

interface LoadPayloadOptions {
fresh?: boolean
hash?: string
}

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 nuxtApp = useNuxtApp()
const cache = nuxtApp._payloadCache = nuxtApp._payloadCache || {}
if (cache[payloadURL]) {
if (payloadURL in cache) {
return cache[payloadURL]
}
cache[payloadURL] = _importPayload(payloadURL).then((payload) => {
if (!payload) {
delete cache[payloadURL]
cache[payloadURL] = isPrerendered().then((prerendered) => {
if (!prerendered) {
cache[payloadURL] = null
return null
}
return payload
return _importPayload(payloadURL).then((payload) => {
if (payload) { return payload }

delete cache[payloadURL]
return null
})
})
return cache[payloadURL]
}
Expand Down Expand Up @@ -55,7 +63,7 @@ function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) {
}

async function _importPayload (payloadURL: string) {
if (import.meta.server) { return null }
if (import.meta.server || !payloadExtraction) { return null }
const payloadPromise = renderJsonPayloads
? fetch(payloadURL).then(res => res.text().then(parsePayload))
: import(/* webpackIgnore: true */ /* @vite-ignore */ payloadURL).then(r => r.default || r)
Expand All @@ -68,10 +76,19 @@ async function _importPayload (payloadURL: string) {
return null
}

export function isPrerendered () {
export async function isPrerendered (url = useRoute().path) {
// Note: Alternative for server is checking x-nitro-prerender header
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
Expand Down
10 changes: 10 additions & 0 deletions packages/nuxt/src/app/middleware/manifest-route-rule.ts
Original file line number Diff line number Diff line change
@@ -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
}
})
4 changes: 3 additions & 1 deletion packages/nuxt/src/app/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { NuxtIslandContext } from '../core/runtime/nitro/renderer'
import type { RouteMiddleware } from '../../app'
import type { NuxtError } from '../app/composables/error'
import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
import type { NuxtAppManifestMeta } from '#app/composables'

const nuxtAppCtx = /* #__PURE__ */ getContext<NuxtApp>('nuxt-app', {
asyncContext: !!process.env.NUXT_ASYNC_CONTEXT && process.server
Expand All @@ -35,6 +36,7 @@ export interface RuntimeNuxtHooks {
'app:error:cleared': (options: { redirect?: string }) => HookResult
'app:chunkError': (options: { error: any }) => HookResult
'app:data:refresh': (keys?: string[]) => HookResult
'app:manifest:update': (meta?: NuxtAppManifestMeta) => HookResult
'link:prefetch': (link: string) => HookResult
'page:start': (Component?: VNode) => HookResult
'page:finish': (Component?: VNode) => HookResult
Expand Down Expand Up @@ -115,7 +117,7 @@ interface _NuxtApp {
/** @internal */
_observer?: { observe: (element: Element, callback: () => void) => () => void }
/** @internal */
_payloadCache?: Record<string, Promise<Record<string, any>> | Record<string, any>>
_payloadCache?: Record<string, Promise<Record<string, any>> | Record<string, any> | null>

/** @internal */
_appConfig: AppConfig
Expand Down
23 changes: 23 additions & 0 deletions packages/nuxt/src/app/plugins/check-outdated-build.client.ts
Original file line number Diff line number Diff line change
@@ -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) })
})
15 changes: 12 additions & 3 deletions packages/nuxt/src/app/plugins/chunk-reload.client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { joinURL } from 'ufo'
import type { RouteLocationNormalized } from 'vue-router'
import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt'
import { useRouter } from '#app/composables/router'
import { reloadNuxtApp } from '#app/composables/chunk'
Expand All @@ -14,11 +15,19 @@ export default defineNuxtPlugin({
router.beforeEach(() => { chunkErrors.clear() })
nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) })

function reloadAppAtPath (to: RouteLocationNormalized) {
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)
reloadNuxtApp({ path, persistState: true })
}

nuxtApp.hook('app:manifest:update', () => {
router.beforeResolve(reloadAppAtPath)
})

router.onError((error, to) => {
if (chunkErrors.has(error)) {
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)
reloadNuxtApp({ path, persistState: true })
reloadAppAtPath(to)
}
})
}
Expand Down
32 changes: 19 additions & 13 deletions packages/nuxt/src/app/plugins/payload.client.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
import { parseURL } from 'ufo'
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 { getAppManifest } from '#app/composables/manifest'
// @ts-expect-error virtual file
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'

export default defineNuxtPlugin({
name: 'nuxt:payload',
setup (nuxtApp) {
// Only enable behavior if initial page is prerendered
// TODO: Support hybrid and dev
if (!isPrerendered()) {
return
}

// Load payload into cache
nuxtApp.hooks.hook('link:prefetch', async (url) => {
if (!parseURL(url).protocol) {
await loadPayload(url)
}
})
// TODO: Support dev
if (process.dev) { return }

// Load payload after middleware & once final route is resolved
useRouter().beforeResolve(async (to, from) => {
Expand All @@ -26,5 +20,17 @@ export default defineNuxtPlugin({
if (!payload) { return }
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)
}
})
}
})

0 comments on commit 7dce076

Please sign in to comment.