From 66a49c3cc008eb83ef454995c3ca24bb93a8d18d Mon Sep 17 00:00:00 2001 From: dangreen Date: Thu, 6 Aug 2026 19:37:34 +0400 Subject: [PATCH] feat(bundler-utils,vite-plugin): serve and cache variants via the core cache storage --- packages/bundler-utils/src/generate.ts | 8 +- .../bundler-utils/src/placeholder.spec.ts | 88 ++++++-- packages/bundler-utils/src/placeholder.ts | 68 +++++- packages/bundler-utils/src/query.spec.ts | 8 +- packages/bundler-utils/src/query.ts | 7 +- packages/vite-plugin/src/dev.spec.ts | 194 ++++++++++++++++-- packages/vite-plugin/src/dev.ts | 99 +++++---- packages/vite-plugin/src/plugin.spec.ts | 104 +++++++++- packages/vite-plugin/src/plugin.ts | 64 ++++-- packages/vite-plugin/src/query.spec.ts | 11 +- packages/vite-plugin/src/query.ts | 10 - packages/vite-plugin/src/types.ts | 8 +- 12 files changed, 534 insertions(+), 135 deletions(-) diff --git a/packages/bundler-utils/src/generate.ts b/packages/bundler-utils/src/generate.ts index 7accb81..8259c55 100644 --- a/packages/bundler-utils/src/generate.ts +++ b/packages/bundler-utils/src/generate.ts @@ -74,7 +74,13 @@ export async function generateSrcSetModule( const rules = query.rules ?? options.rules ?? [{}] const backend = backendFactory(options, emitImage, limit) const metadata = await getImageMetadata(source) - const placeholder = await createPlaceholder(source, query.placeholder ?? options.placeholder, limit) + const placeholder = await createPlaceholder( + source, + metadata, + query.placeholder ?? options.placeholder, + limit, + options.cache + ) const select = { format: metadata.format, width: metadata.width, diff --git a/packages/bundler-utils/src/placeholder.spec.ts b/packages/bundler-utils/src/placeholder.spec.ts index 42d9425..46f3c48 100644 --- a/packages/bundler-utils/src/placeholder.spec.ts +++ b/packages/bundler-utils/src/placeholder.spec.ts @@ -1,9 +1,17 @@ import { describe, it, - expect + expect, + vi } from 'vitest' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' import sharp from 'sharp' +import { + SrcSetCacheStorage, + getImageMetadata +} from '@srcset/core' import { createPlaceholder } from './placeholder.ts' async function createImage(width = 640, height = 480) { @@ -22,25 +30,40 @@ async function createImage(width = 640, height = 480) { } } +async function createFixture(width = 640, height = 480) { + const image = await createImage(width, height) + + return { + image, + metadata: await getImageMetadata(image) + } +} + describe('bundler-utils', () => { describe('placeholder', () => { describe('createPlaceholder', () => { it('should create webp data-url of default width', async () => { - const image = await createImage() - const placeholder = await createPlaceholder(image, true) + const { + image, + metadata + } = await createFixture() + const placeholder = await createPlaceholder(image, metadata, true) expect(placeholder).toMatch(/^data:image\/webp;base64,/) const decoded = Buffer.from((placeholder as string).split(',')[1], 'base64') - const metadata = await sharp(decoded).metadata() + const decodedMetadata = await sharp(decoded).metadata() - expect(metadata.format).toBe('webp') - expect(metadata.width).toBe(16) + expect(decodedMetadata.format).toBe('webp') + expect(decodedMetadata.width).toBe(16) }) it('should respect width and format options', async () => { - const image = await createImage() - const placeholder = await createPlaceholder(image, { + const { + image, + metadata + } = await createFixture() + const placeholder = await createPlaceholder(image, metadata, { width: 8, format: 'jpg' }) @@ -48,26 +71,55 @@ describe('bundler-utils', () => { expect(placeholder).toMatch(/^data:image\/jpeg;base64,/) const decoded = Buffer.from((placeholder as string).split(',')[1], 'base64') - const metadata = await sharp(decoded).metadata() + const decodedMetadata = await sharp(decoded).metadata() - expect(metadata.format).toBe('jpeg') - expect(metadata.width).toBe(8) + expect(decodedMetadata.format).toBe('jpeg') + expect(decodedMetadata.width).toBe(8) }) it('should not enlarge images smaller than the placeholder', async () => { - const image = await createImage(8, 6) - const placeholder = await createPlaceholder(image, true) + const { + image, + metadata + } = await createFixture(8, 6) + const placeholder = await createPlaceholder(image, metadata, true) const decoded = Buffer.from((placeholder as string).split(',')[1], 'base64') - const metadata = await sharp(decoded).metadata() + const decodedMetadata = await sharp(decoded).metadata() - expect(metadata.width).toBe(8) + expect(decodedMetadata.width).toBe(8) }) it('should return undefined when disabled', async () => { - const image = await createImage() + const { + image, + metadata + } = await createFixture() + + expect(await createPlaceholder(image, metadata, undefined)).toBeUndefined() + expect(await createPlaceholder(image, metadata, false)).toBeUndefined() + }) + + it('should reuse the stored placeholder from the cache', async () => { + const cache = new SrcSetCacheStorage(await mkdtemp(path.join(tmpdir(), 'srcset-placeholder-'))) + const { + image, + metadata + } = await createFixture() + const limit = vi.fn((task: () => Promise) => task()) + const placeholder = await createPlaceholder(image, metadata, true, limit, cache) + + expect(limit).toHaveBeenCalledTimes(1) + + const cached = await createPlaceholder(image, metadata, true, limit, cache) + + expect(limit).toHaveBeenCalledTimes(1) + expect(cached).toBe(placeholder) + + await createPlaceholder(image, metadata, { + width: 8 + }, limit, cache) - expect(await createPlaceholder(image, undefined)).toBeUndefined() - expect(await createPlaceholder(image, false)).toBeUndefined() + expect(limit).toHaveBeenCalledTimes(2) }) }) }) diff --git a/packages/bundler-utils/src/placeholder.ts b/packages/bundler-utils/src/placeholder.ts index 05011a2..e1c9186 100644 --- a/packages/bundler-utils/src/placeholder.ts +++ b/packages/bundler-utils/src/placeholder.ts @@ -1,11 +1,36 @@ import sharp from 'sharp' import { + type GenerateContext, + type ImageMetadata, type ImageSource, - mimeTypes + type SrcSetCacheStorage, + type SrcSetImage, + mimeTypes, + renameImagePath } from '@srcset/core' const defaultWidth = 16 const defaultFormat = 'webp' +const placeholderPostfix = '.placeholder' + +/** + * Make a generate context for the placeholder variant: constant inputs, + * distinct from the regular variants by the postfix. + * @param source - Image file. + * @param metadata - Image metadata. + * @returns Generate context. + */ +function createPlaceholderContext(source: ImageSource, metadata: ImageMetadata): GenerateContext { + return { + source, + metadata, + processing: {}, + optimization: {}, + postfix: placeholderPostfix, + skipOptimization: true, + scalingUp: false + } +} /** * Options of the `placeholder` module export. @@ -24,14 +49,18 @@ export interface PlaceholderOptions { /** * Create a tiny data-url variant of the image for blur-up placeholders. * @param source - Image file. + * @param metadata - Image metadata. * @param options - Placeholder options, `true` for the defaults. * @param limit - Concurrency limit for the sharp work. + * @param cache - Cache storage: repeated creation reads the stored placeholder. * @returns Data-url string, or `undefined` when disabled. */ export async function createPlaceholder( source: ImageSource, + metadata: ImageMetadata, options: PlaceholderOptions | boolean | undefined, - limit: (task: () => Promise) => Promise = task => task() + limit: (task: () => Promise) => Promise = task => task(), + cache?: SrcSetCacheStorage ): Promise { if (!options) { return undefined @@ -46,14 +75,35 @@ export async function createPlaceholder( throw new Error(`Invalid placeholder width: ${String(width)}`) } - const contents = await limit(() => { - const pipeline = sharp(source.contents).resize({ - width, - withoutEnlargement: true + const createImage = async (): Promise => { + const contents = await limit(() => { + const pipeline = sharp(source.contents).resize({ + width, + withoutEnlargement: true + }) + + return (format === 'webp' ? pipeline.webp() : pipeline.jpeg()).toBuffer() }) + // Informational dimensions: the data-url uses the contents only, + // so sharp's own rounding of the height is not worth a second pass. + const outputWidth = Math.min(width, metadata.width) - return (format === 'webp' ? pipeline.webp() : pipeline.jpeg()).toBuffer() - }) + return { + path: renameImagePath(source.path, placeholderPostfix, format), + contents, + format, + width: outputWidth, + height: Math.round(metadata.height * outputWidth / metadata.width), + postfix: placeholderPostfix, + originMultiplier: null + } + } + const image = cache + ? await cache.memo(createPlaceholderContext(source, metadata), { + format, + width + }, createImage) + : await createImage() - return `data:${mimeTypes[format]};base64,${contents.toString('base64')}` + return `data:${mimeTypes[format]};base64,${image.contents.toString('base64')}` } diff --git a/packages/bundler-utils/src/query.spec.ts b/packages/bundler-utils/src/query.spec.ts index 86661e2..e95e320 100644 --- a/packages/bundler-utils/src/query.spec.ts +++ b/packages/bundler-utils/src/query.spec.ts @@ -55,9 +55,13 @@ describe('bundler-utils', () => { expect(parseResourceQuery('?srcset&unknown=1')).toEqual({}) }) - it('should return empty options for query without question mark', () => { + it('should parse query without the leading question mark', () => { expect(parseResourceQuery('')).toEqual({}) - expect(parseResourceQuery('srcset')).toEqual({}) + expect(parseResourceQuery('width=320')).toEqual({ + select: { + width: 320 + } + }) }) }) }) diff --git a/packages/bundler-utils/src/query.ts b/packages/bundler-utils/src/query.ts index 35ea138..5fe5085 100644 --- a/packages/bundler-utils/src/query.ts +++ b/packages/bundler-utils/src/query.ts @@ -13,17 +13,18 @@ export interface QueryOptions { * - `{ "width": [1, 0.5], "format": ["webp", "jpg"] }` - JSON rule to generate variants; * - `id=`, `format=`, `width=` - selection of the variant for the default export; * - `placeholder` - add the `placeholder` module export. - * @param resourceQuery - Resource query string starting with `?`. + * @param resourceQuery - Resource query string, with or without the leading `?`. * @returns Parsed options. */ export function parseResourceQuery(resourceQuery: string): QueryOptions { const query: QueryOptions = {} + const pairs = resourceQuery.startsWith('?') ? resourceQuery.slice(1) : resourceQuery - if (!resourceQuery.startsWith('?')) { + if (!pairs) { return query } - for (const pair of resourceQuery.slice(1).split('&')) { + for (const pair of pairs.split('&')) { if (pair.startsWith('{')) { try { query.rules = [JSON.parse(pair) as SrcSetRule] diff --git a/packages/vite-plugin/src/dev.spec.ts b/packages/vite-plugin/src/dev.spec.ts index 1b47c15..f9014f1 100644 --- a/packages/vite-plugin/src/dev.spec.ts +++ b/packages/vite-plugin/src/dev.spec.ts @@ -1,30 +1,186 @@ import { describe, it, - expect + expect, + vi } from 'vitest' +import type { + IncomingMessage, + ServerResponse +} from 'node:http' +import { Writable } from 'node:stream' import { - type DevCache, - addDevImage -} from './dev.ts' + mkdtemp, + rm +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { SrcSetCacheStorage } from '@srcset/core' +import { createDevMiddleware } from './dev.ts' + +async function createStorage() { + const dir = await mkdtemp(path.join(tmpdir(), 'srcset-dev-')) + + return { + dir, + storage: new SrcSetCacheStorage(dir) + } +} + +function createResponse() { + const chunks: Buffer[] = [] + const headers: Record = {} + const stream = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk) + callback() + } + }) + const response = Object.assign(stream, { + headers, + statusCode: 200, + setHeader: (name: string, value: string) => { + headers[name] = value + } + }) + + return { + response: response as unknown as ServerResponse, + headers, + body: () => Buffer.concat(chunks), + finished: new Promise((resolvePromise) => { + stream.on('finish', resolvePromise) + }), + statusCode: () => response.statusCode + } +} describe('vite-plugin', () => { describe('dev', () => { - describe('addDevImage', () => { - it('should encode special characters in the pathname', () => { - const cache: DevCache = new Map() - const pathname = addDevImage(cache, { - path: '/images/my photo#1.jpg', - contents: Buffer.from('contents'), - format: 'jpg', - width: 640, - height: 480, - postfix: '', - originMultiplier: 1 - }) - - expect(pathname).toMatch(/^\/@srcset\/my%20photo%231\.[0-9a-f]{8}\.jpg$/) - expect(cache.has(pathname)).toBe(true) + describe('createDevMiddleware', () => { + it('should stream stored variants', async () => { + const { storage } = await createStorage() + const contents = Buffer.from('variant') + + await storage.write('image.webp', contents) + + const middleware = createDevMiddleware(storage) + const request = { + url: '/@srcset/image.webp' + } as IncomingMessage + const { + response, + headers, + body, + finished + } = createResponse() + const next = vi.fn() + + middleware(request, response, next) + await finished + + expect(next).not.toHaveBeenCalled() + expect(headers['Content-Type']).toBe('image/webp') + expect(body()).toEqual(contents) + }) + + it('should respond with 404 when the file is cleaned away', async () => { + const { + dir, + storage + } = await createStorage() + + await storage.write('image.webp', Buffer.from('variant')) + await rm(path.join(dir, 'image.webp')) + + const middleware = createDevMiddleware(storage) + const request = { + url: '/@srcset/image.webp' + } as IncomingMessage + const { + response, + finished, + statusCode + } = createResponse() + const next = vi.fn() + + middleware(request, response, next) + await finished + + expect(next).not.toHaveBeenCalled() + expect(statusCode()).toBe(404) + }) + + it('should serve variants under a non-root base', async () => { + const { storage } = await createStorage() + const contents = Buffer.from('variant') + + await storage.write('image.webp', contents) + + const middleware = createDevMiddleware(storage, '/assets/') + const request = { + url: '/assets/@srcset/image.webp' + } as IncomingMessage + const { + response, + body, + finished + } = createResponse() + const next = vi.fn() + + middleware(request, response, next) + await finished + + expect(next).not.toHaveBeenCalled() + expect(body()).toEqual(contents) + }) + + it('should serve variants with a query string', async () => { + const { storage } = await createStorage() + const contents = Buffer.from('variant') + + await storage.write('image.webp', contents) + + const middleware = createDevMiddleware(storage) + const request = { + url: '/@srcset/image.webp?v=1' + } as IncomingMessage + const { + response, + body, + finished + } = createResponse() + const next = vi.fn() + + middleware(request, response, next) + await finished + + expect(next).not.toHaveBeenCalled() + expect(body()).toEqual(contents) + }) + + it('should pass foreign and unsafe urls to the next handler', async () => { + const { storage } = await createStorage() + const middleware = createDevMiddleware(storage) + const next = vi.fn() + + middleware({ + url: '/assets/logo.svg' + } as IncomingMessage, createResponse().response, next) + middleware({ + url: '/api?next=/@srcset/image.webp' + } as IncomingMessage, createResponse().response, next) + middleware({ + url: '/@srcset/%' + } as IncomingMessage, createResponse().response, next) + middleware({ + url: '/@srcset/..%2Fsecret.jpg' + } as IncomingMessage, createResponse().response, next) + middleware({ + url: '/@srcset/manifest.json' + } as IncomingMessage, createResponse().response, next) + + expect(next).toHaveBeenCalledTimes(5) }) }) }) diff --git a/packages/vite-plugin/src/dev.ts b/packages/vite-plugin/src/dev.ts index 47b6f88..97fb570 100644 --- a/packages/vite-plugin/src/dev.ts +++ b/packages/vite-plugin/src/dev.ts @@ -2,66 +2,89 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -import { createHash } from 'node:crypto' +import type { ReadStream } from 'node:fs' import { + basename, + extname +} from 'node:path' +import { + type SrcSetCacheStorage, type SrcSetImage, mimeTypes } from '@srcset/core' export const devPathPrefix = '/@srcset/' -const hashLength = 8 - -interface DevImage { - contents: Buffer - type: string -} - -/** - * In-memory cache of the generated variants for the dev server middleware. - */ -export type DevCache = Map +// Relative form: emitted paths stay relative, public urls are prefixed with the base. +const relativeDevPathPrefix = devPathPrefix.slice(1) /** - * Add an image variant to the dev cache. - * @param cache - Dev cache. + * Make the dev server path of the variant, without the leading slash. + * The name is encoded: browsers percent-encode special characters + * in requests, the middleware matches the decoded form. * @param image - Image variant. - * @returns Dev server pathname of the variant. + * @returns Dev server path of the variant. */ -export function addDevImage(cache: DevCache, image: SrcSetImage) { - const hash = createHash('sha256').update(image.contents).digest('hex').slice(0, hashLength) - const extensionIndex = image.path.lastIndexOf('.') - const stem = image.path.slice(image.path.lastIndexOf('/') + 1, extensionIndex) - // Encoded pathname is both the cache key and the public url: browsers - // percent-encode special characters in requests, the keys must match. - const pathname = `${devPathPrefix}${encodeURIComponent(`${stem}.${hash}.${image.format}`)}` - - cache.set(pathname, { - contents: image.contents, - type: mimeTypes[image.format] - }) - - return pathname +export function getDevPath(image: SrcSetImage) { + return `${relativeDevPathPrefix}${encodeURIComponent(basename(image.path))}` } /** - * Create a dev server middleware serving the generated variants from the cache. - * @param cache - Dev cache. + * Create a dev server middleware streaming the generated variants + * from the cache storage. + * @param storage - Cache storage of the generated variants. + * @param base - Base public path of the served urls. * @returns Connect-style middleware. */ -export function createDevMiddleware(cache: DevCache) { +export function createDevMiddleware(storage: SrcSetCacheStorage, base = '/') { + const prefix = base + relativeDevPathPrefix + return (request: IncomingMessage, response: ServerResponse, next: () => void) => { - const url = request.url ?? '' - const index = url.indexOf(devPathPrefix) - const image = index < 0 ? undefined : cache.get(url.slice(index)) + let fileName: string + + // The prefix is matched on the pathname only: a prefix inside + // a query string of a foreign route is not ours. Url parsing also + // normalizes dot segments, so traversals fail the prefix check. + try { + const { pathname } = new URL(request.url ?? '', 'http://localhost') + + if (!pathname.startsWith(prefix)) { + next() + return + } + + fileName = decodeURIComponent(pathname.slice(prefix.length)) + } catch { + // Invalid url or malformed percent-encoding: not ours. + next() + return + } + + const format = extname(fileName).slice(1) as keyof typeof mimeTypes + + // The middleware serves plain variant files only. + if (!Object.hasOwn(mimeTypes, format)) { + next() + return + } + + let stream: ReadStream - if (!image) { + try { + stream = storage.readStream(fileName) + } catch { + // The storage rejects unsafe paths: not ours to serve. next() return } - response.setHeader('Content-Type', image.type) + stream.on('error', () => { + // The storage was cleaned under a running server. + response.statusCode = 404 + response.end() + }) + response.setHeader('Content-Type', mimeTypes[format]) response.setHeader('Cache-Control', 'no-cache') - response.end(image.contents) + stream.pipe(response) } } diff --git a/packages/vite-plugin/src/plugin.spec.ts b/packages/vite-plugin/src/plugin.spec.ts index 0ab6490..a85751a 100644 --- a/packages/vite-plugin/src/plugin.spec.ts +++ b/packages/vite-plugin/src/plugin.spec.ts @@ -1,7 +1,8 @@ import { describe, it, - expect + expect, + vi } from 'vitest' import { mkdir, @@ -177,6 +178,49 @@ export default logo expect(exports.default).toBe('/assets/logo.png') }) + it('should reuse the disk cache across builds', async () => { + const dir = await createFixtureProject(ruleEntry) + const optimize = vi.fn((contents: Buffer) => contents) + const buildOptions = { + skipOptimization: false, + optimization: { + jpg: optimize + } + } + const first = await buildFixture(dir, buildOptions) + const generatedCalls = optimize.mock.calls.length + + expect(generatedCalls).toBeGreaterThan(0) + + const second = await buildFixture(dir, buildOptions) + + expect(optimize.mock.calls.length).toBe(generatedCalls) + expect(second.exports.srcSet.length).toBe(4) + expect(second.exports.default).toBe(first.exports.default) + expect(second.assets.length).toBe(first.assets.length) + }) + + it('should regenerate with the cache disabled', async () => { + const dir = await createFixtureProject(ruleEntry) + const optimize = vi.fn((contents: Buffer) => contents) + const buildOptions = { + skipOptimization: false, + cache: false, + optimization: { + jpg: optimize + } + } + const first = await buildFixture(dir, buildOptions) + const generatedCalls = optimize.mock.calls.length + + expect(generatedCalls).toBeGreaterThan(0) + expect(first.exports.srcSet.length).toBe(4) + + await buildFixture(dir, buildOptions) + + expect(optimize.mock.calls.length).toBe(generatedCalls * 2) + }) + it('should export placeholder data-url when enabled', async () => { const dir = await createFixtureProject(defaultEntry) const { exports } = await buildFixture(dir, { @@ -193,6 +237,62 @@ export default logo }) describe('dev', () => { + it('should serve variants under a non-root base', async () => { + const dir = await createFixtureProject(ruleEntry) + const server = await createServer({ + configFile: false, + logLevel: 'error', + root: dir, + base: '/assets/', + plugins: [srcset({ + skipOptimization: true + })] + }) + + try { + await server.listen() + + const address = server.httpServer?.address() + const port = typeof address === 'object' && address ? address.port : 0 + const exports = await server.ssrLoadModule('/entry.js') as ModuleExports + + expect(exports.default).toBe('/assets/@srcset/image.jpg') + + const response = await fetch(`http://localhost:${port}${exports.default}`) + + expect(response.status).toBe(200) + } finally { + await server.close() + } + }) + + it('should serve stable variants across server restarts', async () => { + const dir = await createFixtureProject(ruleEntry) + const load = async () => { + const server = await createServer({ + configFile: false, + logLevel: 'error', + root: dir, + plugins: [srcset({ + skipOptimization: true + })] + }) + + try { + await server.listen() + + return await server.ssrLoadModule('/entry.js') as ModuleExports + } finally { + await server.close() + } + } + const first = await load() + const second = await load() + + expect(second.srcSet.length).toBe(4) + expect(second.srcSet).toEqual(first.srcSet) + }) + it('should serve module and variants from dev server', async () => { const dir = await createFixtureProject(ruleEntry) const server = await createServer({ @@ -213,7 +313,7 @@ export default logo const halfWidth = imageWidth / 2 expect(exports.srcSet.length).toBe(4) - expect(exports.default).toMatch(/^\/@srcset\/image\.[0-9a-f]{8}\.jpg$/) + expect(exports.default).toBe('/@srcset/image.jpg') const webpUrl = exports.srcMap[`webp${halfWidth}`] const response = await fetch(`http://localhost:${port}${webpUrl}`) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 0fa9957..df78551 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -3,27 +3,32 @@ import { access, readFile } from 'node:fs/promises' -import { join } from 'node:path' -import type { SrcSetImage } from '@srcset/core' +import { + basename, + join +} from 'node:path' +import { + type SrcSetImage, + SrcSetCacheStorage +} from '@srcset/core' import pLimit from 'p-limit' import { type Plugin, createFilter } from 'vite' import { + type SrcSetModuleOptions, parseResourceQuery, generateSrcSetModule } from '@srcset/bundler-utils' import type { SrcSetVitePluginOptions } from './types.ts' import { splitId, - getResourceQuery, createLoadFilter } from './query.ts' import { - type DevCache, - addDevImage, - createDevMiddleware + createDevMiddleware, + getDevPath } from './dev.ts' interface EmitContext { @@ -54,6 +59,7 @@ async function fileExists(path: string) { export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { const { concurrency = availableParallelism(), + cache = true, include, exclude } = options @@ -61,21 +67,19 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { const loadFilter = createLoadFilter(include, exclude) // Fallback for environments without hook filters, built from the same filter. const matchesLoadFilter = createFilter(loadFilter.id.include, loadFilter.id.exclude) - const devCache: DevCache = new Map() - let base = '/' - let origin = '' + let moduleOptions: SrcSetModuleOptions + let devUrlBase = '/' let publicDir = '' let isBuild = false - const generateModule = async (context: EmitContext, id: string) => { - const { path } = splitId(id) + const generateModule = async (context: EmitContext, path: string, rawQuery: string) => { const source = { path, contents: await readFile(path) } - const query = parseResourceQuery(getResourceQuery(id)) + const query = parseResourceQuery(rawQuery) const emitImage = (image: SrcSetImage) => { if (isBuild) { - const name = image.path.slice(image.path.lastIndexOf('/') + 1) + const name = basename(image.path) const referenceId = context.emitFile({ type: 'asset', name, @@ -89,18 +93,20 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { } } - const devPath = addDevImage(devCache, image).slice(1) + // The variant file is already stored: the storage + // memoizes the generation before the emit. + const devPath = getDevPath(image) return { outputPath: devPath, - publicPath: origin + base + devPath + publicPath: devUrlBase + devPath } } return generateSrcSetModule( source, query, - options, + moduleOptions, emitImage, limit ) @@ -110,14 +116,25 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { name: 'srcset', enforce: 'pre', configResolved(config) { - base = config.base // Vite includes the origin in the dev asset urls for backend integrations. - origin = config.server.origin ?? '' + devUrlBase = (config.server.origin ?? '') + config.base publicDir = config.publicDir isBuild = config.command === 'build' + // The dev server always uses the storage: variants are served from it. + moduleOptions = { + ...options, + cache: !isBuild || cache + ? new SrcSetCacheStorage(join(config.cacheDir, 'srcset')) + : undefined + } }, configureServer(server) { - server.middlewares.use(createDevMiddleware(devCache)) + if (moduleOptions.cache) { + server.middlewares.use(createDevMiddleware( + moduleOptions.cache, + server.config.base + )) + } }, load: { filter: loadFilter, @@ -126,14 +143,17 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { return null } - // Root-absolute imports of `publicDir` assets stay in the Vite asset pipeline. - const { path } = splitId(id) + const { + path, + query + } = splitId(id) + // Root-absolute imports of `publicDir` assets stay in the Vite asset pipeline. if (publicDir && !await fileExists(path) && await fileExists(join(publicDir, path))) { return null } - return generateModule(this, id) + return generateModule(this, path, query) } } } diff --git a/packages/vite-plugin/src/query.spec.ts b/packages/vite-plugin/src/query.spec.ts index be8d7e2..bc2fec0 100644 --- a/packages/vite-plugin/src/query.spec.ts +++ b/packages/vite-plugin/src/query.spec.ts @@ -6,8 +6,7 @@ import { import { createFilter } from 'vite' import { createLoadFilter, - splitId, - getResourceQuery + splitId } from './query.ts' function createMatcher(include?: Parameters[0], exclude?: Parameters[1]) { @@ -79,13 +78,5 @@ describe('vite-plugin', () => { }) }) }) - - describe('getResourceQuery', () => { - it('should keep the query as is', () => { - expect(getResourceQuery('/images/photo.jpg?{ "width": [320] }')).toBe( - '?{ "width": [320] }' - ) - }) - }) }) }) diff --git a/packages/vite-plugin/src/query.ts b/packages/vite-plugin/src/query.ts index f974934..89610f5 100644 --- a/packages/vite-plugin/src/query.ts +++ b/packages/vite-plugin/src/query.ts @@ -54,13 +54,3 @@ export function splitId(id: string) { query: id.slice(index + 1) } } - -/** - * Get the module id query for parsing. The id arrives already decoded: - * Vite decodes request urls before the plugins. - * @param id - Module id. - * @returns Query string starting with `?`. - */ -export function getResourceQuery(id: string) { - return `?${splitId(id).query}` -} diff --git a/packages/vite-plugin/src/types.ts b/packages/vite-plugin/src/types.ts index 38ba082..decc222 100644 --- a/packages/vite-plugin/src/types.ts +++ b/packages/vite-plugin/src/types.ts @@ -1,6 +1,12 @@ import type { SrcSetModuleOptions } from '@srcset/bundler-utils' -export interface SrcSetVitePluginOptions extends SrcSetModuleOptions { +export interface SrcSetVitePluginOptions extends Omit { + /** + * Cache generated variants on disk in the Vite cache directory: + * repeated builds skip the generation. Enabled by default. + * The dev server always uses the storage - variants are served from it. + */ + cache?: boolean /** * Paths to process, picomatch pattern(s). Defaults to all image imports. */