Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[@astrojs/image] adding caching support for SSG builds #4909

Merged
merged 7 commits into from
Sep 29, 2022
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
24 changes: 24 additions & 0 deletions .changeset/seven-shrimps-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@astrojs/image': patch
---

Adds caching support for transformed images :tada:

Local images will be cached for 1 year and invalidated when the original image file is changed.

Remote images will be cached based on the `fetch()` response's cache headers, similar to how a CDN would manage the cache.

**cacheDir**

By default, transformed images will be cached to `./node_modules/.astro/image`. This can be configured in the integration's config options.

```
export default defineConfig({
integrations: [image({
// may be useful if your hosting provider allows caching between CI builds
cacheDir: "./.cache/image"
})]
});
```

Caching can also be disabled by using `cacheDir: false`.
19 changes: 19 additions & 0 deletions packages/integrations/image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,25 @@ export default {
}
```

### config.cacheDir

During static builds, the integration will cache transformed images to avoid rebuilding the same image for every build. This can be particularly helpful if you are using a hosting service that allows you to cache build assets for future deployments.

Local images will be cached for 1 year and invalidated when the original image file is changed. Remote images will be cached based on the `fetch()` response's cache headers, similar to how a CDN would manage the cache.

By default, transformed images will be cached to `./node_modules/.astro/image`. This can be configured in the integration's config options.

```
export default defineConfig({
integrations: [image({
// may be useful if your hosting provider allows caching between CI builds
cacheDir: "./.cache/image"
})]
});
```

Caching can also be disabled by using `cacheDir: false`.

## Examples

### Local images
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,14 @@
"slash": "^4.0.0"
},
"devDependencies": {
"@types/http-cache-semantics": "^4.0.1",
"@types/mime": "^2.0.3",
"@types/sharp": "^0.30.5",
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"chai": "^4.3.6",
"cheerio": "^1.0.0-rc.11",
"http-cache-semantics": "^4.1.0",
"kleur": "^4.1.4",
"mocha": "^9.2.2",
"rollup-plugin-copy": "^3.4.0",
Expand Down
85 changes: 85 additions & 0 deletions packages/integrations/image/src/build/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { debug, error, warn } from '../utils/logger.js';
import type { LoggerLevel } from '../utils/logger.js';

const CACHE_FILE = `cache.json`;

interface Cache {
[filename: string]: { expires: number }
}

export class ImageCache {
#cacheDir: URL;
#cacheFile: URL;
#cache: Cache = { }
#logLevel: LoggerLevel;

constructor(dir: URL, logLevel: LoggerLevel) {
this.#logLevel = logLevel;
this.#cacheDir = dir;
this.#cacheFile = this.#toAbsolutePath(CACHE_FILE);
}

#toAbsolutePath(file: string) {
return new URL(path.join(this.#cacheDir.toString(), file));
}

async init() {
try {
const str = await fs.readFile(this.#cacheFile, 'utf-8');
this.#cache = JSON.parse(str) as Cache;
} catch {
// noop
debug({ message: 'no cache file found', level: this.#logLevel });
}
}

async finalize() {
try {
await fs.mkdir(path.dirname(fileURLToPath(this.#cacheFile)), { recursive: true });
await fs.writeFile(this.#cacheFile, JSON.stringify(this.#cache));
} catch {
// noop
warn({ message: 'could not save the cache file', level: this.#logLevel });
}
}

async get(file: string): Promise<Buffer | undefined> {
if (!this.has(file)) {
return undefined;
}

try {
const filepath = this.#toAbsolutePath(file);
return await fs.readFile(filepath);
} catch {
warn({ message: `could not load cached file for "${file}"`, level: this.#logLevel });
return undefined;
}
}

async set(file: string, buffer: Buffer, opts: Cache['string']): Promise<void> {
try {
const filepath = this.#toAbsolutePath(file);
await fs.mkdir(path.dirname(fileURLToPath(filepath)), { recursive: true });
await fs.writeFile(filepath, buffer);

this.#cache[file] = opts;
} catch {
// noop
warn({ message: `could not save cached copy of "${file}"`, level: this.#logLevel });
}
}

has(file: string): boolean {
if (!(file in this.#cache)) {
return false;
}

const { expires } = this.#cache[file];

return expires > Date.now();
}
}
99 changes: 91 additions & 8 deletions packages/integrations/image/src/build/ssg.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,74 @@
import { doWork } from '@altano/tiny-async-pool';
import type { AstroConfig } from 'astro';
import { bgGreen, black, cyan, dim, green } from 'kleur/colors';
import CachePolicy from 'http-cache-semantics';
import fs from 'node:fs/promises';
import OS from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { SSRImageService, TransformOptions } from '../loaders/index.js';
import { debug, info, LoggerLevel, warn } from '../utils/logger.js';
import { isRemoteImage } from '../utils/paths.js';
import { ImageCache } from './cache.js';

async function loadLocalImage(src: string | URL) {
try {
return await fs.readFile(src);
const data = await fs.readFile(src);

// Vite's file hash will change if the file is changed at all,
// we can safely cache local images here.
const timeToLive = new Date();
timeToLive.setFullYear(timeToLive.getFullYear() + 1);

return {
data,
expires: timeToLive.getTime(),
}
} catch {
return undefined;
}
}

function webToCachePolicyRequest({ url, method, headers: _headers }: Request): CachePolicy.Request {
const headers: CachePolicy.Headers = {};
for (const [key, value] of _headers) {
headers[key] = value;
}
return {
method,
url,
headers,
};
}

function webToCachePolicyResponse({ status, headers: _headers }: Response): CachePolicy.Response {
const headers: CachePolicy.Headers = {};
for (const [key, value] of _headers) {
headers[key] = value;
}
return {
status,
headers,
};
}

async function loadRemoteImage(src: string) {
try {
const res = await fetch(src);
const req = new Request(src);
const res = await fetch(req);

if (!res.ok) {
return undefined;
}

return Buffer.from(await res.arrayBuffer());
// calculate an expiration date based on the response's TTL
const policy = new CachePolicy(webToCachePolicyRequest(req), webToCachePolicyResponse(res));
const expires = policy.storable() ? policy.timeToLive() : 0;

return {
data: Buffer.from(await res.arrayBuffer()),
expires: Date.now() + expires,
};
} catch {
return undefined;
}
Expand All @@ -42,9 +85,17 @@ export interface SSGBuildParams {
config: AstroConfig;
outDir: URL;
logLevel: LoggerLevel;
cacheDir?: URL;
}

export async function ssgBuild({ loader, staticImages, config, outDir, logLevel }: SSGBuildParams) {
export async function ssgBuild({ loader, staticImages, config, outDir, logLevel, cacheDir }: SSGBuildParams) {
let cache: ImageCache | undefined = undefined;

if (cacheDir) {
cache = new ImageCache(cacheDir, logLevel);
await cache.init();
}

const timer = performance.now();
const cpuCount = OS.cpus().length;

Expand All @@ -67,6 +118,9 @@ export async function ssgBuild({ loader, staticImages, config, outDir, logLevel
let inputFile: string | undefined = undefined;
let inputBuffer: Buffer | undefined = undefined;

// tracks the cache duration for the original source image
let expires = 0;

// Vite will prefix a hashed image with the base path, we need to strip this
// off to find the actual file relative to /dist
if (config.base && src.startsWith(config.base)) {
Expand All @@ -75,11 +129,17 @@ export async function ssgBuild({ loader, staticImages, config, outDir, logLevel

if (isRemoteImage(src)) {
// try to load the remote image
inputBuffer = await loadRemoteImage(src);
const res = await loadRemoteImage(src);

inputBuffer = res?.data;
expires = res?.expires || 0;
} else {
const inputFileURL = new URL(`.${src}`, outDir);
inputFile = fileURLToPath(inputFileURL);
inputBuffer = await loadLocalImage(inputFile);

const res = await loadLocalImage(inputFile);
inputBuffer = res?.data;
expires = res?.expires || 0;
}

if (!inputBuffer) {
Expand All @@ -106,14 +166,32 @@ export async function ssgBuild({ loader, staticImages, config, outDir, logLevel
outputFile = fileURLToPath(outputFileURL);
}

const { data } = await loader.transform(inputBuffer, transform);
const pathRelative = outputFile.replace(fileURLToPath(outDir), '');

let data: Buffer | undefined;

// try to load the transformed image from cache, if available
if (cache?.has(pathRelative)) {
data = await cache.get(pathRelative);
}

// a valid cache file wasn't found, transform the image and cache it
if (!data) {
const transformed = await loader.transform(inputBuffer, transform);
data = transformed.data;

// cache the image, if available
if (cache) {
await cache.set(pathRelative, data, { expires });
}
}

await fs.writeFile(outputFile, data);

const timeEnd = performance.now();
const timeChange = getTimeStat(timeStart, timeEnd);
const timeIncrease = `(+${timeChange})`;
const pathRelative = outputFile.replace(fileURLToPath(outDir), '');

debug({
level: logLevel,
prefix: false,
Expand All @@ -125,6 +203,11 @@ export async function ssgBuild({ loader, staticImages, config, outDir, logLevel
// transform each original image file in batches
await doWork(cpuCount, staticImages, processStaticImage);

// saves the cache's JSON manifest to file
if (cache) {
await cache.finalize();
}

info({
level: logLevel,
prefix: false,
Expand Down
7 changes: 6 additions & 1 deletion packages/integrations/image/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@ export interface IntegrationOptions {
/**
* Entry point for the @type {HostedImageService} or @type {LocalImageService} to be used.
*/
serviceEntryPoint?: string;
serviceEntryPoint?: '@astrojs/image/squoosh' | '@astrojs/image/sharp' | string;
logLevel?: LoggerLevel;
cacheDir?: false | string;
}

export default function integration(options: IntegrationOptions = {}): AstroIntegration {
const resolvedOptions = {
serviceEntryPoint: '@astrojs/image/squoosh',
logLevel: 'info' as LoggerLevel,
cacheDir: './node_modules/.astro/image',
...options,
};

Expand Down Expand Up @@ -127,12 +129,15 @@ export default function integration(options: IntegrationOptions = {}): AstroInte
}

if (loader && 'transform' in loader && staticImages.size > 0) {
const cacheDir = !!resolvedOptions.cacheDir ? new URL(resolvedOptions.cacheDir, _config.root) : undefined;

await ssgBuild({
loader,
staticImages,
config: _config,
outDir: dir,
logLevel: resolvedOptions.logLevel,
cacheDir,
});
}
},
Expand Down
6 changes: 5 additions & 1 deletion packages/integrations/image/test/image-ssg.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import sizeOf from 'image-size';
import fs from 'fs/promises';
import { fileURLToPath } from 'url';
import { loadFixture } from './test-utils.js';

Expand Down Expand Up @@ -253,14 +254,17 @@ describe('SSG images - build', function () {
size: { width: 544, height: 184, type: 'jpg' },
},
].forEach(({ title, id, regex, size }) => {
it(title, () => {
it(title, async () => {
const image = $(id);

expect(image.attr('src')).to.match(regex);
expect(image.attr('width')).to.equal(size.width.toString());
expect(image.attr('height')).to.equal(size.height.toString());

verifyImage(image.attr('src'), size);

const url = new URL('./fixtures/basic-image/node_modules/.astro/image' + image.attr('src'), import.meta.url);
expect(await fs.stat(url), 'transformed image was cached').to.not.be.undefined;
});
});
});
Expand Down
Loading