diff --git a/src/index.ts b/src/index.ts index 58f7b58..9e8d940 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 @@ -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 `|` + * 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, source: string @@ -97,6 +126,7 @@ export default function loader( sourceMap, rootContext, resourcePath, + request, resourceQuery: _resourceQuery = '', } = loaderContext @@ -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` diff --git a/test/advanced.spec.ts b/test/advanced.spec.ts index 25cd622..36a65e2 100644 --- a/test/advanced.spec.ts +++ b/test/advanced.spec.ts @@ -1,3 +1,4 @@ +import path from 'path' import { SourceMapConsumer } from 'source-map' import { fs as mfs } from 'memfs' import cssesc from 'cssesc' @@ -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',