Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ export interface VueLoaderOptions {

hotReload?: boolean
exposeFilename?: boolean
/**
* Attach a `__moduleIdentifier` property to the component, holding a stable
* identifier of the module it was compiled from.
*
* This is meant for server-side rendering: the server can record which
* components were actually rendered and map them back to the chunks listed in
* the client manifest, to emit preload links for exactly those assets.
*
* - `true` - attach a hash of the module request
* - function - returns the identifier to attach, so that it can be kept in
* sync with whatever the consuming plugin uses as its key. Receives the
* module request, plus the resource path and the root context for
* consumers that key their manifest by relative path instead of a hash.
*/
exposeModuleIdentifier?:
| boolean
| ((
request: string,
context: { resourcePath: string; rootContext: string }
) => string)
appendExtension?: boolean
enableTsInTemplate?: boolean
experimentalInlineMatchResource?: boolean
Expand All @@ -68,6 +88,15 @@ function hash(text: string): string {
return crypto.createHash('sha256').update(text).digest('hex').substring(0, 8)
}

/**
* A request built with an inline match resource carries a trailing `|<hash>`
* suffix. Strip it so that the module identifier stays stable and lines up with
* `Module.identifier()` as seen by bundler plugins.
*/
function stripModuleIdHash(request: string): string {
return request.replace(/\|\w+$/, '')
}

export default function loader(
this: LoaderContext<VueLoaderOptions>,
source: string
Expand Down Expand Up @@ -97,6 +126,7 @@ export default function loader(
sourceMap,
rootContext,
resourcePath,
request,
resourceQuery: _resourceQuery = '',
} = loaderContext

Expand Down Expand Up @@ -323,6 +353,21 @@ export default function loader(
propsToAttach.push([`__file`, JSON.stringify(path.basename(resourcePath))])
}

// Expose a stable module identifier, so that a server-side renderer can map
// the components it rendered back to the client manifest.
if (options.exposeModuleIdentifier) {
const moduleRequest = stripModuleIdHash(request)
const moduleIdentifier =
typeof options.exposeModuleIdentifier === 'function'
? options.exposeModuleIdentifier(moduleRequest, {
resourcePath,
rootContext,
})
: hash(moduleRequest)

propsToAttach.push([`__moduleIdentifier`, JSON.stringify(moduleIdentifier)])
}

// custom blocks
if (descriptor.customBlocks && descriptor.customBlocks.length) {
code += `\n/* custom blocks */\n`
Expand Down
50 changes: 50 additions & 0 deletions test/advanced.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'path'
import { SourceMapConsumer } from 'source-map'
import { fs as mfs } from 'memfs'
import cssesc from 'cssesc'
Expand Down Expand Up @@ -67,6 +68,55 @@ test('expose file basename as __file in production when exposeFilename enabled',
expect(componentModule.__file).toBe('basic.vue')
})

test('no __moduleIdentifier unless exposeModuleIdentifier is set', async () => {
const { componentModule } = await mockBundleAndRun({
entry: 'basic.vue',
})

expect(componentModule.__moduleIdentifier).toBe(undefined)
})

test('expose a hashed __moduleIdentifier when exposeModuleIdentifier is true', async () => {
const { componentModule } = await mockBundleAndRun({
entry: 'basic.vue',
vue: {
exposeModuleIdentifier: true,
},
})

expect(componentModule.__moduleIdentifier).toMatch(/^[0-9a-f]{8}$/)
})

test('derive __moduleIdentifier from exposeModuleIdentifier callback', async () => {
const calls: Array<[string, { resourcePath: string; rootContext: string }]> =
[]

const { componentModule } = await mockBundleAndRun({
entry: 'basic.vue',
vue: {
exposeModuleIdentifier: (
request: string,
context: { resourcePath: string; rootContext: string }
) => {
calls.push([request, context])
return 'custom-identifier'
},
},
})

expect(componentModule.__moduleIdentifier).toBe('custom-identifier')
expect(calls.length).toBe(1)

const [request, context] = calls[0]
expect(request).toContain('basic.vue')
// the inline match resource hash suffix has to be stripped beforehand
expect(request).not.toMatch(/\|\w+$/)
// enough context to key a manifest by relative path instead of a hash
expect(path.relative(context.rootContext, context.resourcePath)).toBe(
'test/fixtures/basic.vue'
)
})

test.skip('source map', async () => {
const { code } = await bundle({
entry: 'basic.vue',
Expand Down