diff --git a/packages/server/src/middleware/modern.js b/packages/server/src/middleware/modern.js index ddc4a32cf0..e087ebd81d 100644 --- a/packages/server/src/middleware/modern.js +++ b/packages/server/src/middleware/modern.js @@ -1,5 +1,3 @@ -import chalk from 'chalk' -import consola from 'consola' import UAParser from 'ua-parser-js' import semver from 'semver' @@ -23,22 +21,6 @@ const isModernBrowser = (ua) => { return Boolean(modernBrowsers[browser.name] && semver.gte(browserVersion, modernBrowsers[browser.name])) } -const distinctModernModeOptions = [false, 'client', 'server'] - -const detectModernBuild = ({ options, resources }) => { - if (distinctModernModeOptions.includes(options.modern)) { - return - } - - if (!resources.modernManifest) { - options.modern = false - return - } - - options.modern = options.render.ssr ? 'server' : 'client' - consola.info(`Modern bundles are detected. Modern mode (${chalk.green.bold(options.modern)}) is enabled now.`) -} - const detectModernBrowser = ({ socket = {}, headers }) => { if (socket.isModernBrowser === undefined) { const ua = headers && headers['user-agent'] @@ -48,15 +30,10 @@ const detectModernBrowser = ({ socket = {}, headers }) => { return socket.isModernBrowser } -export default ({ context }) => { - let detected = false +export default ({ serverContext }) => { return (req, res, next) => { - if (!detected) { - detectModernBuild(context) - detected = true - } - if (context.options.modern !== false) { - req.modernMode = detectModernBrowser(req) + if (serverContext.options.modern !== false) { + req._modern = detectModernBrowser(req) } next() } diff --git a/packages/server/src/middleware/nuxt.js b/packages/server/src/middleware/nuxt.js index 8c3dbfc365..501366af1a 100644 --- a/packages/server/src/middleware/nuxt.js +++ b/packages/server/src/middleware/nuxt.js @@ -25,7 +25,7 @@ export default ({ options, nuxt, renderRoute, resources }) => async function nux cspScriptSrcHashes, error, redirected, - getPreloadFiles + preloadFiles } = result if (redirected) { @@ -52,8 +52,6 @@ export default ({ options, nuxt, renderRoute, resources }) => async function nux if (!error && options.render.http2.push) { // Parse resourceHints to extract HTTP.2 prefetch/push headers // https://w3c.github.io/preload/#server-push-http-2 - const preloadFiles = getPreloadFiles() - const { shouldPush, pushAssets } = options.render.http2 const { publicPath } = resources.clientManifest diff --git a/packages/server/src/server.js b/packages/server/src/server.js index 483aa091fc..3fb4f20af3 100644 --- a/packages/server/src/server.js +++ b/packages/server/src/server.js @@ -56,8 +56,8 @@ export default class Server { // Initialize vue-renderer const { VueRenderer } = await import('@nuxt/vue-renderer') - const context = new ServerContext(this) - this.renderer = new VueRenderer(context) + this.serverContext = new ServerContext(this) + this.renderer = new VueRenderer(this.serverContext) await this.renderer.ready() // Setup nuxt middleware @@ -112,7 +112,7 @@ export default class Server { } this.useMiddleware(createModernMiddleware({ - context: this.renderer.context + serverContext: this.serverContext })) // Dev middleware diff --git a/packages/server/test/middleware/modern.test.js b/packages/server/test/middleware/modern.test.js index ecde8fd619..7d55df1b5a 100644 --- a/packages/server/test/middleware/modern.test.js +++ b/packages/server/test/middleware/modern.test.js @@ -1,19 +1,17 @@ -import consola from 'consola' - jest.mock('chalk', () => ({ green: { bold: modern => `greenBold(${modern})` } })) -const createContext = () => ({ +const createServerContext = () => ({ resources: {}, options: { render: {} } }) -const createServerContext = () => ({ +const createRenderContext = () => ({ req: { headers: {} }, next: jest.fn() }) @@ -34,107 +32,74 @@ describe('server: modernMiddleware', () => { }) test('should not detect modern build if modern mode is specified', () => { - const context = createContext() - const modernMiddleware = createModernMiddleware({ context }) - const ctx = createServerContext() + const serverContext = createServerContext() + const modernMiddleware = createModernMiddleware({ serverContext }) + const ctx = createRenderContext() - context.options.modern = false + serverContext.options.modern = false modernMiddleware(ctx.req, ctx.res, ctx.next) - context.options.modern = 'client' + serverContext.options.modern = 'client' modernMiddleware(ctx.req, ctx.res, ctx.next) - context.options.modern = 'server' + serverContext.options.modern = 'server' modernMiddleware(ctx.req, ctx.res, ctx.next) - expect(ctx.req.modernMode).toEqual(false) - }) - - test('should detect client modern build and display message', () => { - const context = createContext() - const modernMiddleware = createModernMiddleware({ context }) - const ctx = createServerContext() - - context.resources.modernManifest = {} - modernMiddleware(ctx.req, ctx.res, ctx.next) - expect(context.options.modern).toEqual('client') - expect(consola.info).toBeCalledWith('Modern bundles are detected. Modern mode (greenBold(client)) is enabled now.') - }) - - test('should detect server modern build and display message', () => { - const context = createContext() - const modernMiddleware = createModernMiddleware({ context }) - const ctx = createServerContext() - - context.options.render.ssr = true - context.resources.modernManifest = {} - modernMiddleware(ctx.req, ctx.res, ctx.next) - expect(context.options.modern).toEqual('server') - expect(consola.info).toBeCalledWith('Modern bundles are detected. Modern mode (greenBold(server)) is enabled now.') - }) - - test('should not detect modern browser if modern build is not found', () => { - const context = createContext() - const modernMiddleware = createModernMiddleware({ context }) - const ctx = createServerContext() - - modernMiddleware(ctx.req, ctx.res, ctx.next) - - expect(ctx.req.modernMode).toBeUndefined() + expect(ctx.req._modern).toEqual(false) }) test('should not detect modern browser if connect has been detected', () => { - const context = createContext() - const modernMiddleware = createModernMiddleware({ context }) - const ctx = createServerContext() + const serverContext = createServerContext() + const modernMiddleware = createModernMiddleware({ serverContext }) + const ctx = createRenderContext() ctx.req.socket = { isModernBrowser: true } - context.options.dev = true - context.options.modern = 'server' + serverContext.options.dev = true + serverContext.options.modern = 'server' modernMiddleware(ctx.req, ctx.res, ctx.next) - expect(ctx.req.modernMode).toEqual(true) + expect(ctx.req._modern).toEqual(true) }) test('should detect modern browser based on user-agent', () => { - const context = createContext() - const modernMiddleware = createModernMiddleware({ context }) - const ctx = createServerContext() + const serverContext = createServerContext() + const modernMiddleware = createModernMiddleware({ serverContext }) + const ctx = createRenderContext() const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36' ctx.req.headers['user-agent'] = ua ctx.req.socket = {} - context.options.dev = true - context.options.modern = 'server' + serverContext.options.dev = true + serverContext.options.modern = 'server' modernMiddleware(ctx.req, ctx.res, ctx.next) expect(ctx.req.socket.isModernBrowser).toEqual(true) - expect(ctx.req.modernMode).toEqual(true) + expect(ctx.req._modern).toEqual(true) }) test('should detect legacy browser based on user-agent', () => { - const context = createContext() - const modernMiddleware = createModernMiddleware({ context }) - const ctx = createServerContext() + const serverContext = createServerContext() + const modernMiddleware = createModernMiddleware({ serverContext }) + const ctx = createRenderContext() const ua = 'Mozilla/5.0 (Windows; U; MSIE 9.0; WIndows NT 9.0; en-US))' ctx.req.headers['user-agent'] = ua ctx.req.socket = {} - context.options.dev = true - context.options.modern = 'client' + serverContext.options.dev = true + serverContext.options.modern = 'client' modernMiddleware(ctx.req, ctx.res, ctx.next) expect(ctx.req.socket.isModernBrowser).toEqual(false) }) test('should ignore illegal user-agent', () => { - const context = createContext() - const modernMiddleware = createModernMiddleware({ context }) - const ctx = createServerContext() + const serverContext = createServerContext() + const modernMiddleware = createModernMiddleware({ serverContext }) + const ctx = createRenderContext() const ua = 'illegal user agent' ctx.req.headers['user-agent'] = ua ctx.req.socket = {} - context.options.dev = true - context.options.modern = 'client' + serverContext.options.dev = true + serverContext.options.modern = 'client' modernMiddleware(ctx.req, ctx.res, ctx.next) expect(ctx.req.socket.isModernBrowser).toEqual(false) diff --git a/packages/server/test/middleware/nuxt.test.js b/packages/server/test/middleware/nuxt.test.js index 9bf104b0ac..74d6d0ccf9 100644 --- a/packages/server/test/middleware/nuxt.test.js +++ b/packages/server/test/middleware/nuxt.test.js @@ -137,7 +137,7 @@ describe('server: nuxtMiddleware', () => { const context = createContext() const result = { html: 'rendered html', - getPreloadFiles: jest.fn(() => ['/nuxt/preload1.js', '/nuxt/preload2.js']) + preloadFiles: ['/nuxt/preload1.js', '/nuxt/preload2.js'] } context.renderRoute.mockReturnValue(result) const pushAssets = jest.fn((req, res, publicPath, preloadFiles) => preloadFiles) @@ -158,12 +158,12 @@ describe('server: nuxtMiddleware', () => { const context = createContext() const result = { html: 'rendered html', - getPreloadFiles: jest.fn(() => [ + preloadFiles: [ { file: '/nuxt/preload1.js', asType: 'script' }, { file: '/nuxt/preload2.js', asType: 'script' }, { file: '/nuxt/style.css', asType: 'style' }, { file: '/nuxt/font.woff', asType: 'font' } - ]) + ] } context.renderRoute.mockReturnValue(result) context.options.render.http2 = { push: true } @@ -182,12 +182,12 @@ describe('server: nuxtMiddleware', () => { const context = createContext() const result = { html: 'rendered html', - getPreloadFiles: jest.fn(() => [ + preloadFiles: [ { file: '/nuxt/preload1.js', asType: 'script' }, { file: '/nuxt/preload2.js', asType: 'script', modern: true }, { file: '/nuxt/style.css', asType: 'style' }, { file: '/nuxt/font.woff', asType: 'font' } - ]) + ] } context.renderRoute.mockReturnValue(result) context.options.dev = true diff --git a/packages/server/test/server.test.js b/packages/server/test/server.test.js index ae0727c4bc..82b3dc6e98 100644 --- a/packages/server/test/server.test.js +++ b/packages/server/test/server.test.js @@ -170,9 +170,7 @@ describe('server: server', () => { const nuxt = createNuxt() const server = new Server(nuxt) server.useMiddleware = jest.fn() - server.renderer = { - context: { id: 'test-server-context' } - } + server.serverContext = { id: 'test-server-context' } await server.setupMiddleware() @@ -198,8 +196,9 @@ describe('server: server', () => { }) const modernMiddleware = { - context: server.renderer.context + serverContext: server.serverContext } + expect(createModernMiddleware).toBeCalledTimes(1) expect(createModernMiddleware).toBeCalledWith(modernMiddleware) expect(server.useMiddleware).nthCalledWith(3, { diff --git a/packages/vue-renderer/src/renderer.js b/packages/vue-renderer/src/renderer.js index 898c5a7f2d..4b8c3577fe 100644 --- a/packages/vue-renderer/src/renderer.js +++ b/packages/vue-renderer/src/renderer.js @@ -1,21 +1,17 @@ import path from 'path' -import crypto from 'crypto' import fs from 'fs-extra' +import chalk from 'chalk' import consola from 'consola' -import devalue from '@nuxt/devalue' -import invert from 'lodash/invert' import template from 'lodash/template' -import { isUrl, urlJoin } from '@nuxt/utils' -import { createBundleRenderer } from 'vue-server-renderer' -import SPAMetaRenderer from './spa-meta' +import SPARenderer from './renderers/spa' +import SSRRenderer from './renderers/ssr' +import ModernRenderer from './renderers/modern' export default class VueRenderer { constructor(context) { - this.context = context - - const { build: { publicPath }, router: { base } } = this.context.options - this.publicPath = isUrl(publicPath) ? publicPath : urlJoin(base, publicPath) + this.serverContext = context + this.options = this.serverContext.options // Will be set by createRenderer this.renderer = { @@ -25,7 +21,7 @@ export default class VueRenderer { } // Renderer runtime resources - Object.assign(this.context.resources, { + Object.assign(this.serverContext.resources, { clientManifest: undefined, modernManifest: undefined, serverManifest: undefined, @@ -39,91 +35,6 @@ export default class VueRenderer { this._error = null } - get assetsMapping() { - if (this._assetsMapping) { - return this._assetsMapping - } - - const legacyAssets = this.context.resources.clientManifest.assetsMapping - const modernAssets = invert(this.context.resources.modernManifest.assetsMapping) - const mapping = {} - - for (const legacyJsFile in legacyAssets) { - const chunkNamesHash = legacyAssets[legacyJsFile] - mapping[legacyJsFile] = modernAssets[chunkNamesHash] - } - delete this.context.resources.clientManifest.assetsMapping - delete this.context.resources.modernManifest.assetsMapping - this._assetsMapping = mapping - - return mapping - } - - renderScripts(context) { - if (this.context.options.modern === 'client') { - const scriptPattern = /]*?src="([^"]*?)"[^>]*?>[^<]*?<\/script>/g - - return context.renderScripts().replace(scriptPattern, (scriptTag, jsFile) => { - const legacyJsFile = jsFile.replace(this.publicPath, '') - const modernJsFile = this.assetsMapping[legacyJsFile] - const { build: { crossorigin } } = this.context.options - const cors = `${crossorigin ? ` crossorigin="${crossorigin}"` : ''}` - const moduleTag = modernJsFile - ? scriptTag - .replace(']*?href="([^"]*?)"[^>]*?as="script"[^>]*?>/g - - return context.renderResourceHints().replace(linkPattern, (linkTag, jsFile) => { - const legacyJsFile = jsFile.replace(this.publicPath, '') - const modernJsFile = this.assetsMapping[legacyJsFile] - if (!modernJsFile) { - return '' - } - const { crossorigin } = this.context.options.build - const cors = `${crossorigin ? ` crossorigin="${crossorigin}"` : ''}` - return linkTag.replace('rel="preload"', `rel="modulepreload"${cors}`).replace(legacyJsFile, modernJsFile) - }) - } - - return context.renderResourceHints() - } - ready() { if (!this._readyPromise) { this._state = 'loading' @@ -144,11 +55,11 @@ export default class VueRenderer { async _ready() { // Resolve dist path - this.distPath = path.resolve(this.context.options.buildDir, 'dist', 'server') + this.distPath = path.resolve(this.options.buildDir, 'dist', 'server') // -- Development mode -- - if (this.context.options.dev) { - this.context.nuxt.hook('build:resources', mfs => this.loadResources(mfs)) + if (this.options.dev) { + this.serverContext.nuxt.hook('build:resources', mfs => this.loadResources(mfs)) return } @@ -158,13 +69,13 @@ export default class VueRenderer { await this.loadResources(fs) // Without using `nuxt start` (programmatic, tests and generate) - if (!this.context.options._start) { - this.context.nuxt.hook('build:resources', () => this.loadResources(fs)) + if (!this.options._start) { + this.serverContext.nuxt.hook('build:resources', () => this.loadResources(fs)) return } // Verify resources - if (this.context.options.modern && !this.isModernReady) { + if (this.options.modern && !this.isModernReady) { throw new Error( `No modern build files found in ${this.distPath}.\nUse either \`nuxt build --modern\` or \`modern\` option to build modern files.` ) @@ -210,7 +121,7 @@ export default class VueRenderer { } // Update resource - this.context.resources[resourceName] = resource + this.serverContext.resources[resourceName] = resource updated.push(resourceName) } @@ -219,53 +130,50 @@ export default class VueRenderer { // Detect if any resource updated if (updated.length > 0) { - // Invalidate assetsMapping cache - delete this._assetsMapping - // Create new renderer this.createRenderer() } - return this.context.nuxt.callHook('render:resourcesLoaded', this.context.resources) + return this.serverContext.nuxt.callHook('render:resourcesLoaded', this.serverContext.resources) } async loadTemplates() { // Reload error template - const errorTemplatePath = path.resolve(this.context.options.buildDir, 'views/error.html') + const errorTemplatePath = path.resolve(this.options.buildDir, 'views/error.html') if (await fs.exists(errorTemplatePath)) { const errorTemplate = await fs.readFile(errorTemplatePath, 'utf8') - this.context.resources.errorTemplate = this.parseTemplate(errorTemplate) + this.serverContext.resources.errorTemplate = this.parseTemplate(errorTemplate) } // Reload loading template - const loadingHTMLPath = path.resolve(this.context.options.buildDir, 'loading.html') + const loadingHTMLPath = path.resolve(this.options.buildDir, 'loading.html') if (await fs.exists(loadingHTMLPath)) { - this.context.resources.loadingHTML = await fs.readFile(loadingHTMLPath, 'utf8') - this.context.resources.loadingHTML = this.context.resources.loadingHTML.replace(/\r|\n|[\t\s]{3,}/g, '') + this.serverContext.resources.loadingHTML = await fs.readFile(loadingHTMLPath, 'utf8') + this.serverContext.resources.loadingHTML = this.serverContext.resources.loadingHTML.replace(/\r|\n|[\t\s]{3,}/g, '') } else { - this.context.resources.loadingHTML = '' + this.serverContext.resources.loadingHTML = '' } } // TODO: Remove in Nuxt 3 get noSSR() { /* Backward compatibility */ - return this.context.options.render.ssr === false + return this.options.render.ssr === false } get SSR() { - return this.context.options.render.ssr === true + return this.options.render.ssr === true } get isReady() { // SPA - if (!this.context.resources.spaTemplate || !this.renderer.spa) { + if (!this.serverContext.resources.spaTemplate || !this.renderer.spa) { return false } // SSR - if (this.SSR && (!this.context.resources.ssrTemplate || !this.renderer.ssr)) { + if (this.SSR && (!this.serverContext.resources.ssrTemplate || !this.renderer.ssr)) { return false } @@ -273,7 +181,7 @@ export default class VueRenderer { } get isModernReady() { - return this.isReady && this.context.resources.modernManifest + return this.isReady && this.serverContext.resources.modernManifest } // TODO: Remove in Nuxt 3 @@ -281,190 +189,63 @@ export default class VueRenderer { return this.isReady } - createRenderer() { - // Resource clientManifest is always required - if (!this.context.resources.clientManifest) { + detectModernBuild() { + const { options, resources } = this.serverContext + if ([false, 'client', 'server'].includes(options.modern)) { return } + if (!resources.modernManifest) { + options.modern = false + return + } + + options.modern = options.render.ssr ? 'server' : 'client' + consola.info(`Modern bundles are detected. Modern mode (${chalk.green.bold(options.modern)}) is enabled now.`) + } + + createRenderer() { + // Resource clientManifest is always required + if (!this.serverContext.resources.clientManifest) { + return + } + + this.detectModernBuild() + // Create SPA renderer - if (this.context.resources.spaTemplate) { - this.renderer.spa = new SPAMetaRenderer(this) + if (this.serverContext.resources.spaTemplate) { + this.renderer.spa = new SPARenderer(this.serverContext) } // Skip the rest if SSR resources are not available - if (!this.context.resources.ssrTemplate || !this.context.resources.serverManifest) { - return - } + if (this.serverContext.resources.ssrTemplate && this.serverContext.resources.serverManifest) { + // Create bundle renderer for SSR + this.renderer.ssr = new SSRRenderer(this.serverContext) - const hasModules = fs.existsSync(path.resolve(this.context.options.rootDir, 'node_modules')) - - const rendererOptions = { - clientManifest: this.context.resources.clientManifest, - // for globally installed nuxt command, search dependencies in global dir - basedir: hasModules ? this.context.options.rootDir : __dirname, - ...this.context.options.render.bundleRenderer - } - - // Create bundle renderer for SSR - this.renderer.ssr = createBundleRenderer( - this.context.resources.serverManifest, - rendererOptions - ) - - if (this.context.resources.modernManifest && - !['client', false].includes(this.context.options.modern)) { - this.renderer.modern = createBundleRenderer( - this.context.resources.serverManifest, - { - ...rendererOptions, - clientManifest: this.context.resources.modernManifest - } - ) - } - } - - renderTemplate(ssr, opts) { - // Fix problem with HTMLPlugin's minify option (#3392) - opts.html_attrs = opts.HTML_ATTRS - opts.head_attrs = opts.HEAD_ATTRS - opts.body_attrs = opts.BODY_ATTRS - - const templateFn = ssr ? this.context.resources.ssrTemplate : this.context.resources.spaTemplate - - return templateFn(opts) - } - - async renderSPA(context) { - const content = await this.renderer.spa.render(context) - - const APP = `
${this.context.resources.loadingHTML}
${content.BODY_SCRIPTS}` - - // Prepare template params - const templateParams = { - ...content, - APP, - ENV: this.context.options.env - } - - // Call spa:templateParams hook - this.context.nuxt.callHook('vue-renderer:spa:templateParams', templateParams) - - // Render with SPA template - const html = this.renderTemplate(false, templateParams) - - return { - html, - getPreloadFiles: content.getPreloadFiles - } - } - - async renderSSR(context) { - // Call renderToString from the bundleRenderer and generate the HTML (will update the context as well) - const renderer = context.modern ? this.renderer.modern : this.renderer.ssr - - // Call ssr:context hook to extend context from modules - await this.context.nuxt.callHook('vue-renderer:ssr:prepareContext', context) - - // Call Vue renderer renderToString - let APP = await renderer.renderToString(context) - - // Call ssr:context hook - await this.context.nuxt.callHook('vue-renderer:ssr:context', context) - // TODO: Remove in next major release - await this.context.nuxt.callHook('render:routeContext', context.nuxt) - - // Fallback to empty response - if (!context.nuxt.serverRendered) { - APP = `
` - } - - // Inject head meta - const m = context.meta.inject() - let HEAD = - m.title.text() + - m.meta.text() + - m.link.text() + - m.style.text() + - m.script.text() + - m.noscript.text() - - // Add meta if router base specified - if (this.context.options._routerBaseSpecified) { - HEAD += `` - } - - // Inject resource hints - if (this.context.options.render.resourceHints) { - HEAD += this.renderSsrResourceHints(context) - } - - // Inject styles - HEAD += context.renderStyles() - - // Serialize state - const serializedSession = `window.${this.context.globals.context}=${devalue(context.nuxt)};` - APP += `` - - // Calculate CSP hashes - const { csp } = this.context.options.render - const cspScriptSrcHashes = [] - if (csp) { - // Only add the hash if 'unsafe-inline' rule isn't present to avoid conflicts (#5387) - const containsUnsafeInlineScriptSrc = csp.policies && csp.policies['script-src'] && csp.policies['script-src'].includes(`'unsafe-inline'`) - if (!containsUnsafeInlineScriptSrc) { - const hash = crypto.createHash(csp.hashAlgorithm) - hash.update(serializedSession) - cspScriptSrcHashes.push(`'${csp.hashAlgorithm}-${hash.digest('base64')}'`) - } - - // Call ssr:csp hook - await this.context.nuxt.callHook('vue-renderer:ssr:csp', cspScriptSrcHashes) - - // Add csp meta tags - if (csp.addMeta) { - HEAD += `` + if (this.options.modern !== false) { + this.renderer.modern = new ModernRenderer(this.serverContext) } } - - // Prepend scripts - APP += this.renderScripts(context) - APP += m.script.text({ body: true }) - APP += m.noscript.text({ body: true }) - - // Template params - const templateParams = { - HTML_ATTRS: 'data-n-head-ssr ' + m.htmlAttrs.text(), - HEAD_ATTRS: m.headAttrs.text(), - BODY_ATTRS: m.bodyAttrs.text(), - HEAD, - APP, - ENV: this.context.options.env - } - - // Call ssr:templateParams hook - await this.context.nuxt.callHook('vue-renderer:ssr:templateParams', templateParams) - - // Render with SSR template - const html = this.renderTemplate(true, templateParams) - - return { - html, - cspScriptSrcHashes, - getPreloadFiles: this.getSsrPreloadFiles.bind(this, context), - error: context.nuxt.error, - redirected: context.redirected - } } - async renderRoute(url, context = {}, _retried) { + renderSPA(renderContext) { + return this.renderer.spa.render(renderContext) + } + + renderSSR(renderContext) { + // Call renderToString from the bundleRenderer and generate the HTML (will update the renderContext as well) + const renderer = renderContext.modern ? this.renderer.modern : this.renderer.ssr + return renderer.render(renderContext) + } + + async renderRoute(url, renderContext = {}, _retried) { /* istanbul ignore if */ if (!this.isReady) { // Production - if (!this.context.options.dev) { + if (!this.options.dev) { if (!_retried && ['loading', 'created'].includes(this._state)) { await this.ready() - return this.renderRoute(url, context, true) + return this.renderRoute(url, renderContext, true) } switch (this._state) { case 'created': @@ -486,29 +267,29 @@ export default class VueRenderer { // Log rendered url consola.debug(`Rendering url ${url}`) - // Add url to the context - context.url = url + // Add url to the renderContext + renderContext.url = url - const { req = {} } = context + const { req = {} } = renderContext - // context.spa - if (context.spa === undefined) { - // TODO: Remove reading from context.res in Nuxt3 - context.spa = !this.SSR || req.spa || (context.res && context.res.spa) + // renderContext.spa + if (renderContext.spa === undefined) { + // TODO: Remove reading from renderContext.res in Nuxt3 + renderContext.spa = !this.SSR || req.spa || (renderContext.res && renderContext.res.spa) } - // context.modern - if (context.modern === undefined) { - context.modern = req.modernMode && this.context.options.modern === 'server' + // renderContext.modern + if (renderContext.modern === undefined) { + renderContext.modern = req._modern || this.options.modern === 'client' } - // Call context hook - await this.context.nuxt.callHook('vue-renderer:context', context) + // Call renderContext hook + await this.serverContext.nuxt.callHook('vue-renderer:context', renderContext) // Render SPA or SSR - return context.spa - ? this.renderSPA(context) - : this.renderSSR(context) + return renderContext.spa + ? this.renderSPA(renderContext) + : this.renderSSR(renderContext) } get resourceMap() { diff --git a/packages/vue-renderer/src/renderers/base.js b/packages/vue-renderer/src/renderers/base.js new file mode 100644 index 0000000000..c8f4e039d0 --- /dev/null +++ b/packages/vue-renderer/src/renderers/base.js @@ -0,0 +1,25 @@ +export default class BaseRenderer { + constructor(serverContext) { + this.serverContext = serverContext + this.options = serverContext.options + + this.vueRenderer = this.createRenderer() + } + + createRenderer() { + throw new Error('`createRenderer()` needs to be implemented') + } + + renderTemplate(templateFn, opts) { + // Fix problem with HTMLPlugin's minify option (#3392) + opts.html_attrs = opts.HTML_ATTRS + opts.head_attrs = opts.HEAD_ATTRS + opts.body_attrs = opts.BODY_ATTRS + + return templateFn(opts) + } + + render() { + throw new Error('`render()` needs to be implemented') + } +} diff --git a/packages/vue-renderer/src/renderers/modern.js b/packages/vue-renderer/src/renderers/modern.js new file mode 100644 index 0000000000..d4938b4852 --- /dev/null +++ b/packages/vue-renderer/src/renderers/modern.js @@ -0,0 +1,112 @@ +import invert from 'lodash/invert' +import { isUrl, urlJoin } from '@nuxt/utils' +import SSRRenderer from './ssr' + +export default class ModernRenderer extends SSRRenderer { + constructor(serverContext) { + super(serverContext) + + const { build: { publicPath }, router: { base } } = this.options + this.publicPath = isUrl(publicPath) ? publicPath : urlJoin(base, publicPath) + } + + get assetsMapping() { + if (this._assetsMapping) { + return this._assetsMapping + } + + const { clientManifest, modernManifest } = this.serverContext.resources + const legacyAssets = clientManifest.assetsMapping + const modernAssets = invert(modernManifest.assetsMapping) + const mapping = {} + + for (const legacyJsFile in legacyAssets) { + const chunkNamesHash = legacyAssets[legacyJsFile] + mapping[legacyJsFile] = modernAssets[chunkNamesHash] + } + delete clientManifest.assetsMapping + delete modernManifest.assetsMapping + this._assetsMapping = mapping + + return mapping + } + + get isServerMode() { + return this.options.modern === 'server' + } + + get rendererOptions() { + const rendererOptions = super.rendererOptions + if (this.isServerMode) { + rendererOptions.clientManifest = this.serverContext.resources.modernManifest + } + return rendererOptions + } + + renderScripts(renderContext) { + const scripts = super.renderScripts(renderContext) + + if (this.isServerMode) { + return scripts + } + + const scriptPattern = /]*?src="([^"]*?)"[^>]*?>[^<]*?<\/script>/g + + return scripts.replace(scriptPattern, (scriptTag, jsFile) => { + const legacyJsFile = jsFile.replace(this.publicPath, '') + const modernJsFile = this.assetsMapping[legacyJsFile] + const { build: { crossorigin } } = this.options + const cors = `${crossorigin ? ` crossorigin="${crossorigin}"` : ''}` + const moduleTag = modernJsFile + ? scriptTag + .replace(']*?href="([^"]*?)"[^>]*?as="script"[^>]*?>/g + + return resourceHints.replace(linkPattern, (linkTag, jsFile) => { + const legacyJsFile = jsFile.replace(this.publicPath, '') + const modernJsFile = this.assetsMapping[legacyJsFile] + if (!modernJsFile) { + return '' + } + const { crossorigin } = this.options.build + const cors = `${crossorigin ? ` crossorigin="${crossorigin}"` : ''}` + return linkTag.replace('rel="preload"', `rel="modulepreload"${cors}`).replace(legacyJsFile, modernJsFile) + }) + } +} diff --git a/packages/vue-renderer/src/spa-meta.js b/packages/vue-renderer/src/renderers/spa.js similarity index 76% rename from packages/vue-renderer/src/spa-meta.js rename to packages/vue-renderer/src/renderers/spa.js index a151da48a3..655b8f0a98 100644 --- a/packages/vue-renderer/src/spa-meta.js +++ b/packages/vue-renderer/src/renderers/spa.js @@ -3,12 +3,12 @@ import Vue from 'vue' import VueMeta from 'vue-meta' import { createRenderer } from 'vue-server-renderer' import LRU from 'lru-cache' +import BaseRenderer from './base' + +export default class SPARenderer extends BaseRenderer { + constructor(serverContext) { + super(serverContext) -export default class SPAMetaRenderer { - constructor(renderer) { - this.renderer = renderer - this.options = this.renderer.context.options - this.vueRenderer = createRenderer() this.cache = new LRU() // Add VueMeta to Vue (this is only for SPA mode) @@ -21,6 +21,10 @@ export default class SPAMetaRenderer { }) } + createRenderer() { + return createRenderer() + } + async getMeta() { const vm = new Vue({ render: h => h(), // Render empty html tag @@ -30,8 +34,9 @@ export default class SPAMetaRenderer { return vm.$meta().inject() } - async render({ url = '/', req = {}, _generate }) { - const modern = req.modernMode || (this.options.modern && _generate) + async render(renderContext) { + const { url = '/', req = {}, _generate } = renderContext + const modern = req._modern || (this.options.modern && _generate) const cacheKey = `${modern ? 'modern:' : 'legacy:'}${url}` let meta = this.cache.get(cacheKey) @@ -75,7 +80,7 @@ export default class SPAMetaRenderer { meta.resourceHints = '' - const { resources: { modernManifest, clientManifest } } = this.renderer.context + const { resources: { modernManifest, clientManifest } } = this.serverContext const manifest = modern ? modernManifest : clientManifest const { shouldPreload, shouldPrefetch } = this.options.render.bundleRenderer @@ -89,7 +94,7 @@ export default class SPAMetaRenderer { const cors = `${crossorigin ? ` crossorigin="${crossorigin}"` : ''}` meta.preloadFiles = manifest.initial - .map(SPAMetaRenderer.normalizeFile) + .map(SPARenderer.normalizeFile) .filter(({ fileWithoutQuery, asType }) => shouldPreload(fileWithoutQuery, asType)) .map(file => ({ ...file, modern })) @@ -108,7 +113,7 @@ export default class SPAMetaRenderer { // Prefetch async resources if (Array.isArray(manifest.async)) { meta.resourceHints += manifest.async - .map(SPAMetaRenderer.normalizeFile) + .map(SPARenderer.normalizeFile) .filter(({ fileWithoutQuery, asType }) => shouldPrefetch(fileWithoutQuery, asType)) .map(({ file }) => ``) .join('') @@ -120,13 +125,29 @@ export default class SPAMetaRenderer { } } - // Emulate getPreloadFiles from vue-server-renderer (works for JS chunks only) - meta.getPreloadFiles = () => (meta.preloadFiles || []) + const APP = `
${this.serverContext.resources.loadingHTML}
${meta.BODY_SCRIPTS}` + + // Prepare template params + const templateParams = { + ...meta, + APP, + ENV: this.options.env + } + + // Call spa:templateParams hook + this.serverContext.nuxt.callHook('vue-renderer:spa:templateParams', templateParams) + + // Render with SPA template + const html = this.renderTemplate(this.serverContext.resources.spaTemplate, templateParams) + const content = { + html, + preloadFiles: meta.preloadFiles || [] + } // Set meta tags inside cache - this.cache.set(cacheKey, meta) + this.cache.set(cacheKey, content) - return meta + return content } static normalizeFile(file) { @@ -136,7 +157,7 @@ export default class SPAMetaRenderer { file, extension, fileWithoutQuery: withoutQuery, - asType: SPAMetaRenderer.getPreloadType(extension) + asType: SPARenderer.getPreloadType(extension) } } diff --git a/packages/vue-renderer/src/renderers/ssr.js b/packages/vue-renderer/src/renderers/ssr.js new file mode 100644 index 0000000000..b2d7cf1dcf --- /dev/null +++ b/packages/vue-renderer/src/renderers/ssr.js @@ -0,0 +1,139 @@ +import path from 'path' +import crypto from 'crypto' +import fs from 'fs-extra' +import devalue from '@nuxt/devalue' +import { createBundleRenderer } from 'vue-server-renderer' +import BaseRenderer from './base' + +export default class SSRRenderer extends BaseRenderer { + get rendererOptions() { + const hasModules = fs.existsSync(path.resolve(this.options.rootDir, 'node_modules')) + + return { + clientManifest: this.serverContext.resources.clientManifest, + // for globally installed nuxt command, search dependencies in global dir + basedir: hasModules ? this.options.rootDir : __dirname, + ...this.options.render.bundleRenderer + } + } + + renderScripts(renderContext) { + return renderContext.renderScripts() + } + + getPreloadFiles(renderContext) { + return renderContext.getPreloadFiles() + } + + renderResourceHints(renderContext) { + return renderContext.renderResourceHints() + } + + createRenderer() { + // Create bundle renderer for SSR + return createBundleRenderer( + this.serverContext.resources.serverManifest, + this.rendererOptions + ) + } + + async render(renderContext) { + // Call ssr:context hook to extend context from modules + await this.serverContext.nuxt.callHook('vue-renderer:ssr:prepareContext', renderContext) + + // Call Vue renderer renderToString + let APP = await this.vueRenderer.renderToString(renderContext) + + // Call ssr:context hook + await this.serverContext.nuxt.callHook('vue-renderer:ssr:context', renderContext) + // TODO: Remove in next major release + await this.serverContext.nuxt.callHook('render:routeContext', renderContext.nuxt) + + // Fallback to empty response + if (!renderContext.nuxt.serverRendered) { + APP = `
` + } + + // Inject head meta + const m = renderContext.meta.inject() + let HEAD = + m.title.text() + + m.meta.text() + + m.link.text() + + m.style.text() + + m.script.text() + + m.noscript.text() + + // Add meta if router base specified + if (this.options._routerBaseSpecified) { + HEAD += `` + } + + // Inject resource hints + if (this.options.render.resourceHints) { + HEAD += this.renderResourceHints(renderContext) + } + + // Inject styles + HEAD += renderContext.renderStyles() + + // Serialize state + const serializedSession = `window.${this.serverContext.globals.context}=${devalue(renderContext.nuxt)};` + APP += `` + + // Calculate CSP hashes + const { csp } = this.options.render + const cspScriptSrcHashes = [] + if (csp) { + // Only add the hash if 'unsafe-inline' rule isn't present to avoid conflicts (#5387) + const containsUnsafeInlineScriptSrc = csp.policies && csp.policies['script-src'] && csp.policies['script-src'].includes(`'unsafe-inline'`) + if (!containsUnsafeInlineScriptSrc) { + const hash = crypto.createHash(csp.hashAlgorithm) + hash.update(serializedSession) + cspScriptSrcHashes.push(`'${csp.hashAlgorithm}-${hash.digest('base64')}'`) + } + + // Call ssr:csp hook + await this.serverContext.nuxt.callHook('vue-renderer:ssr:csp', cspScriptSrcHashes) + + // Add csp meta tags + if (csp.addMeta) { + HEAD += `` + } + } + + // Prepend scripts + APP += this.renderScripts(renderContext) + APP += m.script.text({ body: true }) + APP += m.noscript.text({ body: true }) + + // Template params + const templateParams = { + HTML_ATTRS: 'data-n-head-ssr ' + m.htmlAttrs.text(), + HEAD_ATTRS: m.headAttrs.text(), + BODY_ATTRS: m.bodyAttrs.text(), + HEAD, + APP, + ENV: this.options.env + } + + // Call ssr:templateParams hook + await this.serverContext.nuxt.callHook('vue-renderer:ssr:templateParams', templateParams) + + // Render with SSR template + const html = this.renderTemplate(this.serverContext.resources.ssrTemplate, templateParams) + + let preloadFiles + if (this.options.render.http2.push) { + preloadFiles = this.getPreloadFiles(renderContext) + } + + return { + html, + cspScriptSrcHashes, + preloadFiles, + error: renderContext.nuxt.error, + redirected: renderContext.redirected + } + } +} diff --git a/packages/webpack/src/builder.js b/packages/webpack/src/builder.js index c0fe63e476..b04e6c4653 100644 --- a/packages/webpack/src/builder.js +++ b/packages/webpack/src/builder.js @@ -204,7 +204,7 @@ export class WebpackBundler { } async middleware(req, res, next) { - const name = req.modernMode ? 'modern' : 'client' + const name = req._modern ? 'modern' : 'client' if (this.devMiddleware && this.devMiddleware[name]) { await this.devMiddleware[name](req, res)