Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 60 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from 51 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
7c0b5b3
feat(nuxt): prerender build manifest
danielroe Jun 19, 2023
6898186
feat(nuxt): use manifest to determine whether to fetch payloads
danielroe Jun 19, 2023
4a4de92
feat: enable `payloadExtraction` in build by default
danielroe Jun 19, 2023
9d44679
fix: respect `buildAssetsURL`
danielroe Jun 19, 2023
baa5006
style: lint
danielroe Jun 19, 2023
501623c
chore: add dependency
danielroe Jun 19, 2023
9ac0cb7
fix: use correct var
danielroe Jun 19, 2023
47aa7ea
Merge remote-tracking branch 'origin/main' into feat/app-manifest
danielroe Jun 19, 2023
7b1a96b
test: prerender page with server component
danielroe Jun 19, 2023
c9832cc
Merge remote-tracking branch 'origin/main' into feat/app-manifest
danielroe Jun 19, 2023
df9169e
Merge branch 'main' into feat/app-manifest
danielroe Jun 19, 2023
3f4dd75
Merge remote-tracking branch 'origin/main' into feat/app-manifest
danielroe Jul 31, 2023
c94627c
fix(nuxt): use `_builds` prefix
danielroe Jul 31, 2023
d0a01c1
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 31, 2023
aa5610b
perf: don't include manifest handler in built site
danielroe Jun 20, 2023
b6facf0
fix: update imports and split out utilities
danielroe Jun 20, 2023
409db39
test: register manifest endpoint
danielroe Aug 1, 2023
2da2db5
refactor(nuxt): use timeout when getting app manifest
danielroe Aug 1, 2023
37892d0
test: update bundle size
danielroe Aug 2, 2023
3995b66
test: fix unit tests
danielroe Aug 2, 2023
3ea7b00
fix(nuxt): inject same build handler in dev mode
danielroe Aug 2, 2023
07df72a
refactor: use radix3-based matcher
danielroe Aug 2, 2023
31b1e6e
chore: simplify
danielroe Aug 2, 2023
c688d7b
feat: allow disabling build manifest
danielroe Aug 2, 2023
227640d
fix: render manifest in dev mode
danielroe Aug 2, 2023
dbdf091
refactor: expose as `getRouteRules`
danielroe Aug 2, 2023
0ee407d
feat: respect redirect route rules
danielroe Aug 2, 2023
3dd39d6
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 2, 2023
597bad1
fix: export `getRouteRules`
danielroe Aug 2, 2023
b955e41
fix: return empty array of prerendered routes in dev
danielroe Aug 2, 2023
73c900c
fix: use 1hr ttl for manifest
danielroe Aug 2, 2023
21e493c
fix: embed buildId in appConfig
danielroe Aug 2, 2023
4f85cff
fix: emit `app:manifest:update` when new build is detected
danielroe Aug 2, 2023
bc9ae12
fix: respect any provided buildId
danielroe Aug 2, 2023
69911eb
chore: remove unused import
danielroe Aug 2, 2023
39ee4d0
Merge remote-tracking branch 'origin/main' into feat/app-manifest
danielroe Aug 17, 2023
4491e87
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 17, 2023
610cffa
refactor: directly write manifest to temporary dir
danielroe Aug 17, 2023
a046662
test: update app config types
danielroe Aug 17, 2023
d573f37
test: normalise random `nuxt.buildId` from app config
danielroe Aug 17, 2023
f15e617
Merge remote-tracking branch 'origin/main' into feat/app-manifest
danielroe Aug 17, 2023
aa355ef
refactor: use import.meta
danielroe Aug 17, 2023
0d7b326
test: define `import.meta.test`
danielroe Aug 17, 2023
7953aed
perf: precompile route rules
danielroe Aug 17, 2023
bc1d745
chore: reduce lockfile changes
danielroe Aug 18, 2023
198daf6
fix: update url
danielroe Aug 18, 2023
8f97389
fix: disable timeout in test environment
danielroe Aug 18, 2023
78a8813
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 18, 2023
14fbae6
fix: don't set timeout to get app manifest on slow 2g
danielroe Aug 18, 2023
e640ac2
Merge branch 'main' into feat/app-manifest
danielroe Aug 21, 2023
c13f5d0
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 21, 2023
c712e19
Merge remote-tracking branch 'origin/main' into feat/app-manifest
danielroe Aug 23, 2023
d1ceaf3
test: update snapshot
danielroe Aug 23, 2023
71a25c2
Merge remote-tracking branch 'origin/main' into feat/app-manifest
danielroe Sep 15, 2023
86f5ea4
fix: disable by default
danielroe Sep 15, 2023
d2c9dcc
test: test both with and without app manifest
danielroe Sep 15, 2023
1066334
test: update env key
danielroe Sep 18, 2023
98b8918
Merge remote-tracking branch 'origin/main' into feat/app-manifest
danielroe Sep 18, 2023
2fae77f
fix: don't load app manifest when it is disabled
danielroe Sep 18, 2023
650b6f1
test: update bundle
danielroe Sep 18, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -90,6 +90,7 @@
"perfect-debounce": "^1.0.0",
"pkg-types": "^1.0.3",
"prompts": "^2.4.2",
"radix3": "^1.1.0",
"scule": "^1.0.0",
"std-env": "^3.4.0",
"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 }
try {
return renderJsonPayloads
? parsePayload(await fetch(payloadURL).then(res => res.text()))
Expand All @@ -66,10 +74,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 () {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worth exposing this or allow customising timeout?

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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment from @Atinux: consider checking for latest manifest when there are other router errors?

})

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
30 changes: 17 additions & 13 deletions packages/nuxt/src/app/plugins/payload.client.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
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'

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 +18,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 (navigator.connection?.effectiveType !== 'slow-2g') {
setTimeout(getAppManifest, 1000)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this 1s arbitrary?

If so, there is a specific threshold you need to exceed for the script not to be considered blocking (different for devices), if this isn't passing that, maybe it's worth just loading immediately on Nuxt ready?

}
})
}
})
77 changes: 77 additions & 0 deletions packages/nuxt/src/core/nitro.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { existsSync, promises as fsp, readFileSync } from 'node:fs'
import { cpus } from 'node:os'
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 type { Nitro, NitroConfig } from 'nitropack'
import { logger } from '@nuxt/kit'
Expand Down Expand Up @@ -205,6 +208,80 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
// Resolve user-provided paths
nitroConfig.srcDir = resolve(nuxt.options.rootDir, nuxt.options.srcDir, 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]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

push /** as a default route rule when prerendering site

}

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`
if (!nuxt.options.ssr) {
nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}'
Expand Down