Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions vite-plugin-ruby/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { createDebug } from 'obug'

import type { Plugin, ResolvedConfig } from 'vite'

import type { OutputBundle, PluginContext } from 'rollup'
import type { OutputBundle, OutputAsset, PluginContext } from 'rollup'
import { UnifiedConfig } from './types'
import { filterEntrypointAssets } from './config'
import { slash } from './utils'

const debug = createDebug('vite-plugin-ruby:assets-manifest')

Expand All @@ -17,6 +18,38 @@ interface AssetsManifestChunk {

type AssetsManifest = Map<string, AssetsManifestChunk>

// Internal: Older Rollup types don't know about the `names`/`originalFileNames`
// arrays introduced for Vite v8, so we widen the shape locally instead of
// bumping the (type-only) `rollup` dependency.
type EmittedOutputAsset = OutputAsset & { originalFileNames?: string[] }

// Internal: Finds an asset that Vite's own build pipeline already emitted for
// this exact source file (e.g. because it's also imported from JS/CSS, or
// referenced from an HTML entrypoint), so we can reuse its hashed file name.
//
// Emitting the same file a second time isn't safe to assume idempotent: two
// independent `emitFile` calls for byte-identical content aren't guaranteed to
// resolve to the same hash (this differs between Rollup and Rolldown), and
// when they don't, only one of the two hashed files actually ends up on disk.
// Reusing the existing entry sidesteps the mismatch entirely, and also avoids
// writing the same asset to disk twice.
function findExistingAsset (bundle: OutputBundle, root: string, absoluteFilename: string): string | undefined {
const normalizedFilename = slash(absoluteFilename)

// Rollup documents `originalFileNames` as absolute paths, but Rolldown (Vite
// v8's default bundler) can report them relative to `config.root` instead.
const resolveOriginalFileName = (fileName: string) =>
slash(path.isAbsolute(fileName) ? fileName : path.resolve(root, fileName))

for (const chunk of Object.values(bundle)) {
if (chunk.type !== 'asset') continue

const asset = chunk as EmittedOutputAsset
const originalFileNames = asset.originalFileNames ?? (asset.originalFileName ? [asset.originalFileName] : [])
if (originalFileNames.some(fileName => resolveOriginalFileName(fileName) === normalizedFilename)) return asset.fileName
}
}

// Internal: Writes a manifest file that allows to map an entrypoint asset file
// name to the corresponding output file name.
export function assetsManifestPlugin (): Plugin {
Expand All @@ -29,13 +62,17 @@ export function assetsManifestPlugin (): Plugin {
const remainingAssets = filterEntrypointAssets(viteRubyConfig.entrypoints)

for (const [filename, absoluteFilename] of remainingAssets) {
const content = await fsp.readFile(absoluteFilename)
const ref = ctx.emitFile({ name: path.basename(filename), type: 'asset', source: content })
const hashedFilename = ctx.getFileName(ref)
const hashedFilename = findExistingAsset(bundle, config.root, absoluteFilename) ?? await emitAsset(ctx, filename, absoluteFilename)
manifest.set(path.relative(config.root, absoluteFilename), { file: hashedFilename, src: filename })
}
}

async function emitAsset (ctx: PluginContext, filename: string, absoluteFilename: string) {
const content = await fsp.readFile(absoluteFilename)
const ref = ctx.emitFile({ name: path.basename(filename), type: 'asset', source: content })
return ctx.getFileName(ref)
}

return {
name: 'vite-plugin-ruby:assets-manifest',
apply: 'build',
Expand Down
132 changes: 132 additions & 0 deletions vite-plugin-ruby/tests/manifest.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { resolve } from 'path'
import { describe, test, expect, vi } from 'vitest'
import type { OutputBundle } from 'rollup'
import { assetsManifestPlugin } from '@plugin/manifest'

describe('assetsManifestPlugin', () => {
const root = resolve('example/app/frontend')

function setup (entrypoints: [string, string][]) {
const plugin = assetsManifestPlugin()

;(plugin.configResolved as any)({
root,
build: { manifest: true },
viteRuby: { entrypoints },
})

const emitFile = vi.fn(() => 'ref')
const getFileName = vi.fn(() => 'assets/re-fingerprinted-hash.svg')
const ctx: any = { emitFile, getFileName }

return { plugin, ctx, emitFile, getFileName }
}

function manifestAssetsSource (emitFile: ReturnType<typeof vi.fn>) {
const call = emitFile.mock.calls.find(([asset]) => asset.fileName === '.vite/manifest-assets.json')
return JSON.parse(call![0].source)
}

test('reuses an asset Vite already emitted for the same source file, instead of fingerprinting it again', async () => {
const absoluteFilename = resolve(root, 'images/logo.svg')
const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]])

// Simulates Vite/Rolldown having already emitted this exact file elsewhere
// in the bundle (e.g. it's also imported from JS or referenced from HTML).
const bundle = {
'assets/logo-existingHash.svg': {
type: 'asset',
fileName: 'assets/logo-existingHash.svg',
originalFileNames: [absoluteFilename],
source: '',
},
} as unknown as OutputBundle

await (plugin.generateBundle as any).call(ctx, {}, bundle)

// Re-emitting the same content isn't guaranteed to resolve to the same
// hash (this can differ between Rollup and Rolldown), so the existing
// entry must be reused rather than fingerprinted a second time.
expect(getFileName).not.toHaveBeenCalled()
expect(manifestAssetsSource(emitFile)).toEqual({
'images/logo.svg': { file: 'assets/logo-existingHash.svg', src: 'images/logo.svg' },
})
})

test('fingerprints the asset when Vite has not already emitted it elsewhere', async () => {
const absoluteFilename = resolve(root, 'images/logo.svg')
const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]])

const bundle = {} as OutputBundle

await (plugin.generateBundle as any).call(ctx, {}, bundle)

expect(getFileName).toHaveBeenCalledTimes(1)
expect(manifestAssetsSource(emitFile)).toEqual({
'images/logo.svg': { file: 'assets/re-fingerprinted-hash.svg', src: 'images/logo.svg' },
})
})

test('does not match an asset emitted for a different source file', async () => {
const absoluteFilename = resolve(root, 'images/logo.svg')
const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]])

const bundle = {
'assets/other-hash.svg': {
type: 'asset',
fileName: 'assets/other-hash.svg',
originalFileNames: [resolve(root, 'images/other.svg')],
source: '',
},
} as unknown as OutputBundle

await (plugin.generateBundle as any).call(ctx, {}, bundle)

expect(getFileName).toHaveBeenCalledTimes(1)
expect(manifestAssetsSource(emitFile)).toEqual({
'images/logo.svg': { file: 'assets/re-fingerprinted-hash.svg', src: 'images/logo.svg' },
})
})

test('resolves `originalFileNames` reported relative to config.root (observed with Rolldown)', async () => {
const absoluteFilename = resolve(root, 'images/logo.svg')
const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]])

const bundle = {
'assets/logo-existingHash.svg': {
type: 'asset',
fileName: 'assets/logo-existingHash.svg',
originalFileNames: ['images/logo.svg'],
source: '',
},
} as unknown as OutputBundle

await (plugin.generateBundle as any).call(ctx, {}, bundle)

expect(getFileName).not.toHaveBeenCalled()
expect(manifestAssetsSource(emitFile)).toEqual({
'images/logo.svg': { file: 'assets/logo-existingHash.svg', src: 'images/logo.svg' },
})
})

test('supports the legacy singular `originalFileName` (pre-Vite-v8 shape)', async () => {
const absoluteFilename = resolve(root, 'images/logo.svg')
const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]])

const bundle = {
'assets/logo-existingHash.svg': {
type: 'asset',
fileName: 'assets/logo-existingHash.svg',
originalFileName: absoluteFilename,
source: '',
},
} as unknown as OutputBundle

await (plugin.generateBundle as any).call(ctx, {}, bundle)

expect(getFileName).not.toHaveBeenCalled()
expect(manifestAssetsSource(emitFile)).toEqual({
'images/logo.svg': { file: 'assets/logo-existingHash.svg', src: 'images/logo.svg' },
})
})
})
Loading