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 14 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
14 changes: 0 additions & 14 deletions docs/1.getting-started/10.deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,6 @@ defineNuxtConfig({
})
```

When using this option with `nuxi build`, static payloads won't be generated by default at build time. For now, selective payload generation is under an experimental flag.

```ts [nuxt.config.ts|js]
defineNuxtConfig({
/* The /dynamic route won't be crawled */
nitro: {
prerender: { crawlLinks: true, ignore: ['/dynamic'] }
},
experimental: {
payloadExtraction: true
}
})
```

### Client-side Only Rendering

If you don't want to pre-render your routes, another way of using static hosting is to set the `ssr` property to `false` in the `nuxt.config` file. The `nuxi generate` command will then output an `.output/public/index.html` entrypoint and JavaScript bundles like a classic client-side Vue.js application.
Expand Down
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 @@
"perfect-debounce": "^1.0.0",
"pkg-types": "^1.0.3",
"prompts": "^2.4.2",
"radix3": "^1.0.1",
"scule": "^1.0.0",
"strip-literal": "^1.0.1",
"ufo": "^1.2.0",
Expand Down
18 changes: 18 additions & 0 deletions packages/nuxt/src/app/composables/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { joinURL } from 'ufo'
import { useRuntimeConfig } from '#app'

export interface NuxtAppManifest {
id: string
timestamp: number
routeRules: Record<string, any>
prerendered: string[]
}

let manifest: Promise<NuxtAppManifest>

export function getAppManifest (): Promise<NuxtAppManifest> {
const config = useRuntimeConfig()
// TODO: use build id injected
manifest = manifest || $fetch(joinURL(config.app.cdnURL || config.app.baseURL, '_builds/latest.json'))
danielroe marked this conversation as resolved.
Show resolved Hide resolved
return manifest
}
24 changes: 15 additions & 9 deletions packages/nuxt/src/app/composables/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,33 @@ import { getCurrentInstance } from 'vue'
import { useNuxtApp, useRuntimeConfig } from '../nuxt'

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

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

export function loadPayload (url: string, opts: LoadPayloadOptions = {}): Record<string, any> | Promise<Record<string, any>> | null {
if (process.server) { return null }
if (process.server || !payloadExtraction) { return null }
const payloadURL = _getPayloadURL(url, opts)
const nuxtApp = useNuxtApp()
const cache = nuxtApp._payloadCache = nuxtApp._payloadCache || {}
if (cache[payloadURL]) {
if (cache[payloadURL] || cache[payloadURL] === null) {
return cache[payloadURL]
}
cache[payloadURL] = _importPayload(payloadURL).then((payload) => {
if (!payload) {
delete cache[payloadURL]
return null
cache[payloadURL] = getAppManifest().then((manifest) => {
if (manifest.prerendered.includes(url) || Object.keys(manifest.routeRules).some(key => key === url || (key.endsWith('/**') && (url + '/').startsWith(key.replace('/**', '/'))))) {
danielroe marked this conversation as resolved.
Show resolved Hide resolved
return _importPayload(payloadURL).then((payload) => {
if (!payload) {
delete cache[payloadURL]
return null
}
return payload
})
}
return payload
cache[payloadURL] = null
})
return cache[payloadURL]
}
Expand Down Expand Up @@ -55,7 +61,7 @@ function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) {
}

async function _importPayload (payloadURL: string) {
if (process.server) { return null }
if (process.server || !payloadExtraction) { return null }
try {
return renderJsonPayloads
? parsePayload(await fetch(payloadURL).then(res => res.text()))
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt/src/app/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,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
28 changes: 15 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,15 @@ 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)
}
})
getAppManifest()
danielroe marked this conversation as resolved.
Show resolved Hide resolved
})
}
})
57 changes: 56 additions & 1 deletion packages/nuxt/src/core/nitro.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { existsSync, promises as fsp, readFileSync } from 'node:fs'
import { cpus } from 'node:os'
import { join, relative, resolve } from 'pathe'
import { createRouter as createRadixRouter, 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'
import escapeRE from 'escape-string-regexp'
import { defu } from 'defu'
import fsExtra from 'fs-extra'
import { dynamicEventHandler } from 'h3'
import { defineEventHandler, dynamicEventHandler } from 'h3'
import type { Nuxt } from 'nuxt/schema'
// @ts-expect-error TODO: add legacy type support for subpath imports
import { template as defaultSpaLoadingTemplate } from '@nuxt/ui-templates/templates/spa-loading-icon.mjs'
Expand Down Expand Up @@ -203,6 +206,36 @@ 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
// TODO: expose build id to nitro
const buildId = randomUUID()
if (nitroConfig.prerender?.routes) {
const manifestPrefix = '/_builds'
nitroConfig.prerender.routes.push(joinURL(manifestPrefix, 'latest.json'))
nitroConfig.prerender.routes.push(joinURL(manifestPrefix, `meta.${buildId}.json`))
nitroConfig.devHandlers!.push({
route: joinURL(manifestPrefix, '**'),
handler: defineEventHandler(() => ({
id: 'dev',
timestamp: Date.now(),
routeRules: {},
danielroe marked this conversation as resolved.
Show resolved Hide resolved
prerendered: []
}))
})

if (!nuxt.options.dev) {
const timestamp = Date.now()
nitroConfig.virtual!['#app-manifest'] = () => `
export const hashId = ${JSON.stringify(buildId)}
export const buildTimestamp = ${JSON.stringify(timestamp)}
`
nitroConfig.handlers!.push({
route: joinURL(manifestPrefix, '**'),
handler: resolve(distDir, 'core/runtime/nitro/manifest')
})
}
}

// Add fallback server for `ssr: false`
if (!nuxt.options.ssr) {
nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}'
Expand Down Expand Up @@ -369,8 +402,30 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
await prepare(nitro)
await copyPublicAssets(nitro)
await nuxt.callHook('nitro:build:public-assets', nitro)

// Add pages prerendered but not covered by route rules
const prerenderedRoutes = new Set<string>()
const routeRulesMatcher = toRouteMatcher(
createRadixRouter({ routes: nitro.options.routeRules })
)
const payloadSuffix = nuxt.options.experimental.renderJsonPayloads ? '/_payload.json' : '/_payload.js'
nitro.hooks.hook('prerender:route', (route) => {
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)
}
}
})
await prerender(nitro)

for (const file of ['latest.json', `meta.${buildId}.json`]) {
const manifestFile = join(nitro.options.output.publicDir, '_builds', file)
danielroe marked this conversation as resolved.
Show resolved Hide resolved
const manifest = await fsp.readFile(manifestFile, 'utf-8')
await fsp.writeFile(manifestFile, manifest.replace(/['"]__NUXT_PRERENDERED_ROUTES__['"]/, JSON.stringify([...prerenderedRoutes])))
}

logger.restoreAll()
await build(nitro)
logger.wrapAll()
Expand Down
5 changes: 5 additions & 0 deletions packages/nuxt/src/core/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,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
if (nuxt.options.experimental.crossOriginPrefetch) {
addPlugin(resolve(nuxt.options.appDir, 'plugins/cross-origin-prefetch.client'))
Expand Down
30 changes: 30 additions & 0 deletions packages/nuxt/src/core/runtime/nitro/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { defineEventHandler } from 'h3'
import { useRuntimeConfig } from '#internal/nitro'
// @ts-expect-error Virtual file
import { buildTimestamp, hashId } from '#app-manifest'

export default defineEventHandler(() => {
const routeRules = {} as Record<string, any>
const _routeRules = useRuntimeConfig().nitro.routeRules
for (const key in _routeRules) {
if (key === '/__nuxt_error') { continue }
const filteredRules = Object.entries(_routeRules[key])
.filter(([key]) => ['prerender', 'redirect'].includes(key))
.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)
}
}

return {
id: hashId,
timestamp: buildTimestamp,
routeRules,
prerendered: '__NUXT_PRERENDERED_ROUTES__'
}
})
1 change: 1 addition & 0 deletions packages/nuxt/src/core/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ export const nuxtConfigTemplate = {
...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 componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`,
`export const payloadExtraction = ${!!ctx.nuxt.options.experimental.payloadExtraction}`,
`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 devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`
Expand Down
4 changes: 2 additions & 2 deletions packages/schema/src/config/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ export default defineUntypedSchema({
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}
*/
payloadExtraction: undefined,
payloadExtraction: true,

/**
* Whether to enable the experimental `<NuxtClientFallback>` component for rendering content on the client
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions test/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
for (const outputDir of ['.output', '.output-inline']) {
it('default client bundle size', async () => {
const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public'))
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"97.4k"')
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"98.6k"')
expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[
"_nuxt/entry.js",
Expand All @@ -32,7 +32,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output/server')

const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"64.4k"')
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"65.9k"')

const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2330k"')
Expand Down Expand Up @@ -92,7 +92,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server')

const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"370k"')
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"371k"')

const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"591k"')
Expand Down
6 changes: 3 additions & 3 deletions test/fixtures/basic/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ export default defineNuxtConfig({
routes: [
'/random/a',
'/random/b',
'/random/c'
'/random/c',
'/prefetch/server-components'
]
}
},
Expand Down Expand Up @@ -186,8 +187,7 @@ export default defineNuxtConfig({
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
componentIslands: true,
reactivityTransform: true,
treeshakeClientOnly: true,
payloadExtraction: true
treeshakeClientOnly: true
},
appConfig: {
fromNuxtConfig: true,
Expand Down