-
-
Notifications
You must be signed in to change notification settings - Fork 4.8k
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
Changes from 45 commits
7c0b5b3
6898186
4a4de92
9d44679
baa5006
501623c
9ac0cb7
47aa7ea
7b1a96b
c9832cc
df9169e
3f4dd75
c94627c
d0a01c1
aa5610b
b6facf0
409db39
2da2db5
37892d0
3995b66
3ea7b00
07df72a
31b1e6e
c688d7b
227640d
dbdf091
0ee407d
3dd39d6
597bad1
b955e41
73c900c
21e493c
4f85cff
bc9ae12
69911eb
39ee4d0
4491e87
610cffa
a046662
d573f37
f15e617
aa355ef
0d7b326
7953aed
bc1d745
198daf6
8f97389
78a8813
14fbae6
e640ac2
c13f5d0
c712e19
d1ceaf3
71a25c2
86f5ea4
d2c9dcc
1066334
98b8918
2fae77f
650b6f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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()) | ||
} |
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 | ||
} | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import { joinURL } from 'ufo' | ||
import type { NuxtAppManifestMeta } from '#app' | ||
import { defineNuxtPlugin, getAppManifest, onNuxtReady, useRuntimeConfig } from '#app' | ||
|
||
export default defineNuxtPlugin((nuxtApp) => { | ||
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, '_builds/latest.json')) | ||
danielroe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) }) | ||
}) |
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' | ||
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} | ||
}) | ||
} | ||
|
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' | ||
|
@@ -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] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. push |
||
} | ||
|
||
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 () => {}' | ||
|
There was a problem hiding this comment.
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?