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

feat(ssr): add integrity and crossorigin support #11021

Open
wants to merge 1 commit into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion packages/vue-server-renderer/package.json
Expand Up @@ -26,7 +26,8 @@
"lodash.uniq": "^4.5.0",
"resolve": "^1.2.0",
"serialize-javascript": "^2.1.2",
"source-map": "0.5.6"
"source-map": "0.5.6",
"ssri": "^7.1.0"
},
"devDependencies": {
"vue": "file:../.."
Expand Down
10 changes: 8 additions & 2 deletions src/server/create-renderer.js
Expand Up @@ -31,6 +31,8 @@ export type RenderOptions = {
clientManifest?: ClientManifest;
serializer?: Function;
runInNewContext?: boolean | 'once';
integrity?: boolean;
crossorigin?: string;
};

export function createRenderer ({
Expand All @@ -43,7 +45,9 @@ export function createRenderer ({
shouldPreload,
shouldPrefetch,
clientManifest,
serializer
serializer,
integrity,
crossorigin
}: RenderOptions = {}): Renderer {
const render = createRenderFunction(modules, directives, isUnaryTag, cache)
const templateRenderer = new TemplateRenderer({
Expand All @@ -52,7 +56,9 @@ export function createRenderer ({
shouldPreload,
shouldPrefetch,
clientManifest,
serializer
serializer,
integrity,
crossorigin
})

return {
Expand Down
36 changes: 30 additions & 6 deletions src/server/template-renderer/index.js
Expand Up @@ -17,6 +17,8 @@ type TemplateRendererOptions = {
shouldPreload?: (file: string, type: string) => boolean;
shouldPrefetch?: (file: string, type: string) => boolean;
serializer?: Function;
integrity?: boolean;
crossorigin?: string;
};

export type ClientManifest = {
Expand Down Expand Up @@ -49,13 +51,22 @@ export default class TemplateRenderer {
prefetchFiles: Array<Resource>;
mapFiles: AsyncFileMapper;
serialize: Function;
integrity: boolean;
crossorigin: boolean | string;

constructor (options: TemplateRendererOptions) {
this.options = options
this.inject = options.inject !== false
this.integrity = options.integrity === true
this.crossorigin = ['', 'anonymous', 'use-credentials'].includes(options.crossorigin) ? options.crossorigin : false

if (options.crossorigin !== undefined && this.crossorigin === false) {
throw new Error("crossorigin option must be one of '', 'anonymous', or 'use-credentials'")
}

// if no template option is provided, the renderer is created
// as a utility object for rendering assets like preload links and scripts.

const { template } = options
this.parsedTemplate = template
? typeof template === 'string'
Expand All @@ -80,6 +91,8 @@ export default class TemplateRenderer {
this.prefetchFiles = (clientManifest.async || []).map(normalizeFile)
// initial async chunk mapping
this.mapFiles = createMapper(clientManifest)
} else if (this.integrity) {
throw new Error('integrity option only works if clientManifest supplied')
}
}

Expand Down Expand Up @@ -133,8 +146,11 @@ export default class TemplateRenderer {
return (
// render links for css files
(cssFiles.length
? cssFiles.map(({ file }) => `<link rel="stylesheet" href="${this.publicPath}${file}">`).join('')
: '') +
? cssFiles.map(({ file }) => {
const crossoriginAttr = this.crossorigin !== false ? ` crossorigin="${this.crossorigin}"` : ''
const integrityAttr = this.integrity ? ` integrity="${this.clientManifest.integrity[file]}"` : ''
return `<link${crossoriginAttr}${integrityAttr} rel="stylesheet" href="${this.publicPath}${file}">`
}).join('') : '') +
// context.styles is a getter exposed by vue-style-loader which contains
// the inline component styles collected during SSR
(context.styles || '')
Expand Down Expand Up @@ -168,8 +184,13 @@ export default class TemplateRenderer {
if (shouldPreload && !shouldPreload(fileWithoutQuery, asType)) {
return ''
}
let crossorigin = this.crossorigin
if (asType === 'font') {
extra = ` type="font/${extension}" crossorigin`
extra = ` type="font/${extension}"`
crossorigin = crossorigin || ''
}
if (crossorigin !== false) {
extra += ` crossorigin="${crossorigin}"`
}
return `<link rel="preload" href="${
this.publicPath}${file
Expand Down Expand Up @@ -198,7 +219,8 @@ export default class TemplateRenderer {
if (alreadyRendered(file)) {
return ''
}
return `<link rel="prefetch" href="${this.publicPath}${file}">`
const crossoriginAttr = this.crossorigin !== false ? ` crossorigin="${this.crossorigin}"` : ''
return `<link rel="prefetch" href="${this.publicPath}${file}"${crossoriginAttr}>`
}).join('')
} else {
return ''
Expand Down Expand Up @@ -226,7 +248,9 @@ export default class TemplateRenderer {
const async = (this.getUsedAsyncFiles(context) || []).filter(({ file }) => isJS(file))
const needed = [initial[0]].concat(async, initial.slice(1))
return needed.map(({ file }) => {
return `<script src="${this.publicPath}${file}" defer></script>`
const crossoriginAttr = this.crossorigin !== false ? ` crossorigin="${this.crossorigin}"` : ''
const integrityAttr = this.integrity ? ` integrity="${this.clientManifest.integrity[file]}"` : ''
return `<script${crossoriginAttr}${integrityAttr} src="${this.publicPath}${file}" defer></script>`
}).join('')
} else {
return ''
Expand Down
15 changes: 14 additions & 1 deletion src/server/webpack-plugin/client.js
@@ -1,4 +1,5 @@
const hash = require('hash-sum')
const ssri = require('ssri')
const uniq = require('lodash.uniq')
import { isJS, isCSS, onEmit } from './util'

Expand All @@ -25,12 +26,24 @@ export default class VueSSRClientPlugin {
.filter((file) => isJS(file) || isCSS(file))
.filter(file => initialFiles.indexOf(file) < 0)

const integrityHashes = allFiles.reduce((hashes, filename) => {
const asset = compilation.assets[filename]
const src = asset.source()
const integrity = ssri.fromData(src, {
algorithms: ['sha384']
})

hashes[filename] = integrity.toString()
return hashes
}, {})

const manifest = {
publicPath: stats.publicPath,
all: allFiles,
initial: initialFiles,
async: asyncFiles,
modules: { /* [identifier: string]: Array<index: number> */ }
modules: { /* [identifier: string]: Array<index: number> */ },
integrity: integrityHashes
}

const assetModules = stats.modules.filter(m => m.assets.length)
Expand Down
2 changes: 1 addition & 1 deletion test/ssr/ssr-bundle-render.spec.js
Expand Up @@ -28,7 +28,7 @@ export function createRenderer (file, options, cb) {
? JSON.parse(fs.readFileSync('/vue-ssr-server-bundle.json', 'utf-8'))
: fs.readFileSync('/bundle.js', 'utf-8')
const renderer = createBundleRenderer(bundle, options)
cb(renderer)
cb(renderer, options)
})
}

Expand Down
66 changes: 53 additions & 13 deletions test/ssr/ssr-template.spec.js
Expand Up @@ -357,30 +357,33 @@ describe('SSR: template option', () => {
})
})

const expectedHTMLWithManifest = (options = {}) =>
`<html><head>` +
const expectedHTMLWithManifest = (options = {}) => {
const crossoriginAttr = options.crossorigin ? ` crossorigin="${options.crossorigin}"` : ''

return `<html><head>` +
// used chunks should have preload
`<link rel="preload" href="/manifest.js" as="script">` +
`<link rel="preload" href="/main.js" as="script">` +
`<link rel="preload" href="/0.js" as="script">` +
`<link rel="preload" href="/test.css" as="style">` +
`<link rel="preload" href="/manifest.js" as="script"${crossoriginAttr}>` +
`<link rel="preload" href="/main.js" as="script"${crossoriginAttr}>` +
`<link rel="preload" href="/0.js" as="script"${crossoriginAttr}>` +
`<link rel="preload" href="/test.css" as="style"${crossoriginAttr}>` +
// images and fonts are only preloaded when explicitly asked for
(options.preloadOtherAssets ? `<link rel="preload" href="/test.png" as="image">` : ``) +
(options.preloadOtherAssets ? `<link rel="preload" href="/test.woff2" as="font" type="font/woff2" crossorigin>` : ``) +
(options.preloadOtherAssets ? `<link rel="preload" href="/test.png" as="image"${crossoriginAttr}>` : ``) +
(options.preloadOtherAssets ? `<link rel="preload" href="/test.woff2" as="font" type="font/woff2" ${options.crossorigin ? crossoriginAttr : 'crossorigin=""'}>` : ``) +
// unused chunks should have prefetch
(options.noPrefetch ? `` : `<link rel="prefetch" href="/1.js">`) +
(options.noPrefetch ? `` : `<link rel="prefetch" href="/1.js"${crossoriginAttr}>`) +
// css assets should be loaded
`<link rel="stylesheet" href="/test.css">` +
`<link${crossoriginAttr}${options.integrity ? ` integrity="${options.integrity['test.css']}"` : ''} rel="stylesheet" href="/test.css">` +
`</head><body>` +
`<div data-server-rendered="true"><div>async test.woff2 test.png</div></div>` +
// state should be inlined before scripts
`<script>window.${options.stateKey || '__INITIAL_STATE__'}={"a":1}</script>` +
// manifest chunk should be first
`<script src="/manifest.js" defer></script>` +
`<script${crossoriginAttr}${options.integrity ? ` integrity="${options.integrity['manifest.js']}"` : ''} src="/manifest.js" defer></script>` +
// async chunks should be before main chunk
`<script src="/0.js" defer></script>` +
`<script src="/main.js" defer></script>` +
`<script${crossoriginAttr}${options.integrity ? ` integrity="${options.integrity['0.js']}"` : ''} src="/0.js" defer></script>` +
`<script${crossoriginAttr}${options.integrity ? ` integrity="${options.integrity['main.js']}"` : ''} src="/main.js" defer></script>` +
`</body></html>`
}

createClientManifestAssertions(true)
createClientManifestAssertions(false)
Expand All @@ -396,6 +399,43 @@ describe('SSR: template option', () => {
})
})

it('bundleRenderer + renderToString + clientManifest + integrity ()', done => {
createRendererWithManifest('split.js', { runInNewContext, integrity: true }, (renderer, options) => {
renderer.renderToString({ state: { a: 1 }}, (err, res) => {
expect(err).toBeNull()
expect(res).toContain(expectedHTMLWithManifest({
integrity: options.clientManifest.integrity
}))
done()
})
})
})

it('bundleRenderer + renderToString + clientManifest + crossorigin ()', done => {
createRendererWithManifest('split.js', { runInNewContext, crossorigin: 'anonymous' }, renderer => {
renderer.renderToString({ state: { a: 1 }}, (err, res) => {
expect(err).toBeNull()
expect(res).toContain(expectedHTMLWithManifest({
crossorigin: 'anonymous'
}))
done()
})
})
})

it('bundleRenderer + renderToString + clientManifest + integrity + crossorigin ()', done => {
createRendererWithManifest('split.js', { runInNewContext, integrity: true, crossorigin: 'use-credentials' }, (renderer, options) => {
renderer.renderToString({ state: { a: 1 }}, (err, res) => {
expect(err).toBeNull()
expect(res).toContain(expectedHTMLWithManifest({
integrity: options.clientManifest.integrity,
crossorigin: 'use-credentials'
}))
done()
})
})
})

it('bundleRenderer + renderToStream + clientManifest + shouldPreload', done => {
createRendererWithManifest('split.js', {
runInNewContext,
Expand Down