Skip to content

Commit f0031b0

Browse files
feat(assets): Delete original assets unused outside of the optimization pipeline (withastro#8954)
Co-authored-by: Sarah Rainsberger <[email protected]>
1 parent 26b1484 commit f0031b0

13 files changed

+116
-30
lines changed

.changeset/nasty-elephants-provide.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Updates the Image Services API to now delete original images from the final build that are not used outside of the optimization pipeline. For users with a large number of these images (e.g. thumbnails), this should reduce storage consumption and deployment times.

packages/astro/src/assets/build/generate.ts

+26-5
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type GenerationData = GenerationDataUncached | GenerationDataCached;
3131

3232
type AssetEnv = {
3333
logger: Logger;
34+
isSSR: boolean;
3435
count: { total: number; current: number };
3536
useCache: boolean;
3637
assetsCacheDir: URL;
@@ -74,6 +75,7 @@ export async function prepareAssetsGenerationEnv(
7475

7576
return {
7677
logger,
78+
isSSR: isServerLikeOutput(config),
7779
count,
7880
useCache,
7981
assetsCacheDir,
@@ -84,20 +86,41 @@ export async function prepareAssetsGenerationEnv(
8486
};
8587
}
8688

89+
function getFullImagePath(originalFilePath: string, env: AssetEnv): URL {
90+
return new URL(
91+
'.' + prependForwardSlash(join(env.assetsFolder, basename(originalFilePath))),
92+
env.serverRoot
93+
);
94+
}
95+
8796
export async function generateImagesForPath(
8897
originalFilePath: string,
89-
transforms: MapValue<AssetsGlobalStaticImagesList>,
98+
transformsAndPath: MapValue<AssetsGlobalStaticImagesList>,
9099
env: AssetEnv,
91100
queue: PQueue
92101
) {
93102
const originalImageData = await loadImage(originalFilePath, env);
94103

95-
for (const [_, transform] of transforms) {
104+
for (const [_, transform] of transformsAndPath.transforms) {
96105
queue.add(async () =>
97106
generateImage(originalImageData, transform.finalPath, transform.transform)
98107
);
99108
}
100109

110+
// In SSR, we cannot know if an image is referenced in a server-rendered page, so we can't delete anything
111+
// For instance, the same image could be referenced in both a server-rendered page and build-time-rendered page
112+
if (
113+
!env.isSSR &&
114+
!isRemotePath(originalFilePath) &&
115+
!globalThis.astroAsset.referencedImages?.has(transformsAndPath.originalSrcPath)
116+
) {
117+
try {
118+
await fs.promises.unlink(getFullImagePath(originalFilePath, env));
119+
} catch (e) {
120+
/* No-op, it's okay if we fail to delete one of the file, we're not too picky. */
121+
}
122+
}
123+
101124
async function generateImage(
102125
originalImage: ImageData,
103126
filepath: string,
@@ -245,9 +268,7 @@ async function loadImage(path: string, env: AssetEnv): Promise<ImageData> {
245268
}
246269

247270
return {
248-
data: await fs.promises.readFile(
249-
new URL('.' + prependForwardSlash(join(env.assetsFolder, basename(path))), env.serverRoot)
250-
),
271+
data: await fs.promises.readFile(getFullImagePath(path, env)),
251272
expires: 0,
252273
};
253274
}

packages/astro/src/assets/internal.ts

+9
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ export async function getImage(
9191
: options.src,
9292
};
9393

94+
// Clone the `src` object if it's an ESM import so that we don't refer to any properties of the original object
95+
// Causing our generate step to think the image is used outside of the image optimization pipeline
96+
const clonedSrc = isESMImportedImage(resolvedOptions.src)
97+
? // @ts-expect-error - clone is a private, hidden prop
98+
resolvedOptions.src.clone ?? resolvedOptions.src
99+
: resolvedOptions.src;
100+
101+
resolvedOptions.src = clonedSrc;
102+
94103
const validatedOptions = service.validateOptions
95104
? await service.validateOptions(resolvedOptions, imageConfig)
96105
: resolvedOptions;

packages/astro/src/assets/types.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ export type ImageOutputFormat = (typeof VALID_OUTPUT_FORMATS)[number] | (string
1010

1111
export type AssetsGlobalStaticImagesList = Map<
1212
string,
13-
Map<string, { finalPath: string; transform: ImageTransform }>
13+
{
14+
originalSrcPath: string;
15+
transforms: Map<string, { finalPath: string; transform: ImageTransform }>;
16+
}
1417
>;
1518

1619
declare global {
@@ -19,6 +22,7 @@ declare global {
1922
imageService?: ImageService;
2023
addStaticImage?: ((options: ImageTransform, hashProperties: string[]) => string) | undefined;
2124
staticImages?: AssetsGlobalStaticImagesList;
25+
referencedImages?: Set<string>;
2226
};
2327
}
2428

@@ -31,6 +35,8 @@ export interface ImageMetadata {
3135
height: number;
3236
format: ImageInputFormat;
3337
orientation?: number;
38+
/** @internal */
39+
fsPath: string;
3440
}
3541

3642
/**

packages/astro/src/assets/utils/emitAsset.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,18 @@ export async function emitESMImage(
2424

2525
const fileMetadata = await imageMetadata(fileData, id);
2626

27-
const emittedImage: ImageMetadata = {
27+
const emittedImage: Omit<ImageMetadata, 'fsPath'> = {
2828
src: '',
2929
...fileMetadata,
3030
};
3131

32+
// Private for now, we generally don't want users to rely on filesystem paths, but we need it so that we can maybe remove the original asset from the build if it's unused.
33+
Object.defineProperty(emittedImage, 'fsPath', {
34+
enumerable: false,
35+
writable: false,
36+
value: url,
37+
});
38+
3239
// Build
3340
if (!watchMode) {
3441
const pathname = decodeURI(url.pathname);
@@ -50,7 +57,7 @@ export async function emitESMImage(
5057
emittedImage.src = `/@fs` + prependForwardSlash(fileURLToNormalizedPath(url));
5158
}
5259

53-
return emittedImage;
60+
return emittedImage as ImageMetadata;
5461
}
5562

5663
function fileURLToNormalizedPath(filePath: URL): string {

packages/astro/src/assets/utils/metadata.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { ImageInputFormat, ImageMetadata } from '../types.js';
55
export async function imageMetadata(
66
data: Buffer,
77
src?: string
8-
): Promise<Omit<ImageMetadata, 'src'>> {
8+
): Promise<Omit<ImageMetadata, 'src' | 'fsPath'>> {
99
const result = probe.sync(data);
1010

1111
if (result === null) {
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function getProxyCode(options: Record<string, any>, isSSR: boolean): string {
2+
return `
3+
new Proxy(${JSON.stringify(options)}, {
4+
get(target, name, receiver) {
5+
if (name === 'clone') {
6+
return structuredClone(target);
7+
}
8+
${!isSSR ? 'globalThis.astroAsset.referencedImages.add(target.fsPath);' : ''}
9+
return target[name];
10+
}
11+
})
12+
`;
13+
}

packages/astro/src/assets/utils/queryParams.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ImageInputFormat, ImageMetadata } from '../types.js';
22

33
export function getOrigQueryParams(
44
params: URLSearchParams
5-
): Omit<ImageMetadata, 'src'> | undefined {
5+
): Pick<ImageMetadata, 'width' | 'height' | 'format'> | undefined {
66
const width = params.get('origWidth');
77
const height = params.get('origHeight');
88
const format = params.get('origFormat');

packages/astro/src/assets/vite-plugin-assets.ts

+30-14
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { isServerLikeOutput } from '../prerender/utils.js';
1414
import { VALID_INPUT_FORMATS, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
1515
import { isESMImportedImage } from './internal.js';
1616
import { emitESMImage } from './utils/emitAsset.js';
17+
import { getProxyCode } from './utils/proxy.js';
1718
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
1819

1920
const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
@@ -26,7 +27,9 @@ export default function assets({
2627
}: AstroPluginOptions & { mode: string }): vite.Plugin[] {
2728
let resolvedConfig: vite.ResolvedConfig;
2829

29-
globalThis.astroAsset = {};
30+
globalThis.astroAsset = {
31+
referencedImages: new Set(),
32+
};
3033

3134
return [
3235
// Expose the components and different utilities from `astro:assets` and handle serving images from `/_image` in dev
@@ -81,22 +84,28 @@ export default function assets({
8184
if (!globalThis.astroAsset.staticImages) {
8285
globalThis.astroAsset.staticImages = new Map<
8386
string,
84-
Map<string, { finalPath: string; transform: ImageTransform }>
87+
{
88+
originalSrcPath: string;
89+
transforms: Map<string, { finalPath: string; transform: ImageTransform }>;
90+
}
8591
>();
8692
}
8793

88-
const originalImagePath = (
94+
// Rollup will copy the file to the output directory, this refer to this final path, not to the original path
95+
const finalOriginalImagePath = (
8996
isESMImportedImage(options.src) ? options.src.src : options.src
9097
).replace(settings.config.build.assetsPrefix || '', '');
91-
const hash = hashTransform(
92-
options,
93-
settings.config.image.service.entrypoint,
94-
hashProperties
95-
);
98+
99+
// This, however, is the real original path, in `src` and all.
100+
const originalSrcPath = isESMImportedImage(options.src)
101+
? options.src.fsPath
102+
: options.src;
103+
104+
const hash = hashTransform(options, settings.config.image.service.entrypoint, hashProperties);
96105

97106
let finalFilePath: string;
98-
let transformsForPath = globalThis.astroAsset.staticImages.get(originalImagePath);
99-
let transformForHash = transformsForPath?.get(hash);
107+
let transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalImagePath);
108+
let transformForHash = transformsForPath?.transforms.get(hash);
100109
if (transformsForPath && transformForHash) {
101110
finalFilePath = transformForHash.finalPath;
102111
} else {
@@ -105,11 +114,17 @@ export default function assets({
105114
);
106115

107116
if (!transformsForPath) {
108-
globalThis.astroAsset.staticImages.set(originalImagePath, new Map());
109-
transformsForPath = globalThis.astroAsset.staticImages.get(originalImagePath)!;
117+
globalThis.astroAsset.staticImages.set(finalOriginalImagePath, {
118+
originalSrcPath: originalSrcPath,
119+
transforms: new Map(),
120+
});
121+
transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalImagePath)!;
110122
}
111123

112-
transformsForPath.set(hash, { finalPath: finalFilePath, transform: options });
124+
transformsForPath.transforms.set(hash, {
125+
finalPath: finalFilePath,
126+
transform: options,
127+
});
113128
}
114129

115130
if (settings.config.build.assetsPrefix) {
@@ -171,7 +186,8 @@ export default function assets({
171186
});
172187
}
173188

174-
return `export default ${JSON.stringify(meta)}`;
189+
return `
190+
export default ${getProxyCode(meta, isServerLikeOutput(settings.config))}`;
175191
}
176192
},
177193
},

packages/astro/src/content/runtime-assets.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function createImage(pluginContext: PluginContext, entryFilePath: string)
2222
return z.never();
2323
}
2424

25-
return metadata;
25+
return { ...metadata, ASTRO_ASSET: true };
2626
});
2727
};
2828
}

packages/astro/src/content/vite-plugin-content-imports.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import type {
1212
DataEntryModule,
1313
DataEntryType,
1414
} from '../@types/astro.js';
15+
import { getProxyCode } from '../assets/utils/proxy.js';
1516
import { AstroError } from '../core/errors/errors.js';
1617
import { AstroErrorData } from '../core/errors/index.js';
18+
import { isServerLikeOutput } from '../prerender/utils.js';
1719
import { escapeViteEnvReferences } from '../vite-plugin-utils/index.js';
1820
import { CONTENT_FLAG, DATA_FLAG } from './consts.js';
1921
import {
@@ -94,7 +96,7 @@ export function astroContentImportPlugin({
9496
const code = escapeViteEnvReferences(`
9597
export const id = ${JSON.stringify(id)};
9698
export const collection = ${JSON.stringify(collection)};
97-
export const data = ${stringifyEntryData(data)};
99+
export const data = ${stringifyEntryData(data, isServerLikeOutput(settings.config))};
98100
export const _internal = {
99101
type: 'data',
100102
filePath: ${JSON.stringify(_internal.filePath)},
@@ -118,7 +120,7 @@ export const _internal = {
118120
export const collection = ${JSON.stringify(collection)};
119121
export const slug = ${JSON.stringify(slug)};
120122
export const body = ${JSON.stringify(body)};
121-
export const data = ${stringifyEntryData(data)};
123+
export const data = ${stringifyEntryData(data, isServerLikeOutput(settings.config))};
122124
export const _internal = {
123125
type: 'content',
124126
filePath: ${JSON.stringify(_internal.filePath)},
@@ -352,13 +354,19 @@ async function getContentConfigFromGlobal() {
352354
}
353355

354356
/** Stringify entry `data` at build time to be used as a Vite module */
355-
function stringifyEntryData(data: Record<string, any>): string {
357+
function stringifyEntryData(data: Record<string, any>, isSSR: boolean): string {
356358
try {
357359
return devalue.uneval(data, (value) => {
358360
// Add support for URL objects
359361
if (value instanceof URL) {
360362
return `new URL(${JSON.stringify(value.href)})`;
361363
}
364+
365+
// For Astro assets, add a proxy to track references
366+
if (typeof value === 'object' && 'ASTRO_ASSET' in value) {
367+
const { ASTRO_ASSET, ...asset } = value;
368+
return getProxyCode(asset, isSSR);
369+
}
362370
});
363371
} catch (e) {
364372
if (e instanceof Error) {

packages/astro/src/core/build/generate.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
205205
logger.info(null, `\n${bgGreen(black(` generating optimized images `))}`);
206206

207207
const totalCount = Array.from(staticImageList.values())
208-
.map((x) => x.size)
208+
.map((x) => x.transforms.size)
209209
.reduce((a, b) => a + b, 0);
210210
const cpuCount = os.cpus().length;
211211
const assetsCreationEnvironment = await prepareAssetsGenerationEnv(pipeline, totalCount);

tsconfig.base.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"module": "Node16",
1010
"esModuleInterop": true,
1111
"skipLibCheck": true,
12-
"verbatimModuleSyntax": true
12+
"verbatimModuleSyntax": true,
13+
"stripInternal": true
1314
}
1415
}

0 commit comments

Comments
 (0)