Skip to content

Commit

Permalink
Adding an optional image service based on Squoosh (#4738)
Browse files Browse the repository at this point in the history
* WIP: adding a service built on @squoosh/lib

* WIP: investigating memory leaks in Squoosh

* WIP: vendoring Squoosh to work with our build

* chore: a bit of cleanup and a small perf gain

* removing a few unused deps

* fix: removing temp .only() in sharp test

* hooking up the last build steps to copy over .wasm files

* removing the duplicated lib/*.wasm files

* defaulting to Sharp for the initial @next release

* make sure pnpm always runs the postbuild script

* removing a few node dependencies

* refactor: move the copy .wasm build step out of the SSR bundle

* linter fixes

* fixing lock file

* chore: add TEMP changeset

* fix built wasm location for SSG builds

* Revert "defaulting to Sharp for the initial @next release"

This reverts commit 1a8d4f7.

* removing sharp dependency

* Revert "fix built wasm location for SSG builds"

This reverts commit 446b80b.

* chore: update lockfile

* fixing up image tests for the wasm loader

* updating the README for squoosh

* parallel wasm builds

* refactor: a bit of house keeping

* perf: allow a thread for each output encoding format

* fix: dev broke with the shift to wasm workers

* adds a new `astro:build:generated` hook for SSG builds

* fix: typo + calling cleanup methods in wasm codecs

* adding @astrojs/webapi for the TransformStream polyfill

* Revert "adding @astrojs/webapi for the TransformStream polyfill"

This reverts commit 39e5b84.

* perf: using sharp for most of the CI tests

* chore: update lockfile

* removing hard-coded squoosh imports

* fix: adding sharp to rollup externals

* test: using dev for the squoosh tests

* fix: updating the build output dir for wasm filles in SSG builds

* updating the changeset with migration details

* Revert "adds a new `astro:build:generated` hook for SSG builds"

This reverts commit 59b5fec.

* nit: adding comments for the wasm file copy

* chore: fix eslint warning
  • Loading branch information
Tony Sullivan committed Sep 22, 2022
1 parent 6a1a17d commit fad3867
Show file tree
Hide file tree
Showing 82 changed files with 13,865 additions and 542 deletions.
24 changes: 24 additions & 0 deletions .changeset/old-ladybugs-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@astrojs/image': minor
---

Adds a new built-in image service based on web assembly libraries :drum: web container support!

**Migration:** Happy with the previous image service based on [`sharp`](https://sharp.pixelplumbing.com/)? No problem! Install `sharp` in your project and update your Astro config to match.

```sh
npm install sharp
```

```astro title="astro.config.mjs"
---
import image from '@astrojs/image';
export default {
// ...
integrations: [image({
serviceEntryPoint: '@astrojs/image/sharp'
})],
}
---
```
45 changes: 41 additions & 4 deletions packages/integrations/image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This **[Astro integration][astro-integration]** makes it easy to optimize images

Images play a big role in overall site performance and usability. Serving properly sized images makes all the difference but is often tricky to automate.

This integration provides `<Image />` and `<Picture>` components as well as a basic image transformer powered by [sharp](https://sharp.pixelplumbing.com/), with full support for static sites and server-side rendering. The built-in `sharp` transformer is also replaceable, opening the door for future integrations that work with your favorite hosted image service.
This integration provides `<Image />` and `<Picture>` components as well as a basic image transformer, with full support for static sites and server-side rendering. The built-in image transformer is also replaceable, opening the door for future integrations that work with your favorite hosted image service.

## Installation

Expand Down Expand Up @@ -57,6 +57,31 @@ export default {
}
```

### Installing `sharp` (optional)

The default image transformer is based on [Squoosh](https://github.com/GoogleChromeLabs/squoosh) and uses web assembly libraries to support most deployment environments.

If you are building a static site or using an SSR deployment host that supports NodeJS, we recommend installing [sharp](https://sharp.pixelplumbing.com/) for faster builds and more fine-grained control of image transformations.


First, install the `sharp` package using your package manger. If you're using npm or aren't sure, run this in the terminal:
```sh
npm install sharp
```
Then, update the integration in you `astro.config.*` file to use the built-in `sharp` image transformer.
```astro title="astro.config.mjs"
---
import image from '@astrojs/image';
export default {
// ...
integrations: [image({
serviceEntryPoint: '@astrojs/image/sharp'
})],
}
---
```

### Update `env.d.ts`

For the best development experience, add the integrations type definitions to your project's `env.d.ts` file.
Expand Down Expand Up @@ -85,7 +110,7 @@ import { Image, Picture } from '@astrojs/image/components';
---
```

The included `sharp` transformer supports resizing images and encoding them to different image formats. Third-party image services will be able to add support for custom transformations as well (ex: `blur`, `filter`, `rotate`, etc).
The included image transformers support resizing images and encoding them to different image formats. Third-party image services will be able to add support for custom transformations as well (ex: `blur`, `filter`, `rotate`, etc).

Astro’s `<Image />` and `<Picture />` components require the `alt` attribute, which provides descriptive text for images. A warning will be logged if alt text is missing, and a future release of the integration will throw an error if no alt text is provided.

Expand Down Expand Up @@ -195,6 +220,8 @@ A `number` can also be provided, useful when the aspect ratio is calculated at b
**Default:** `undefined`
</p>

> This is not supported by the default Squoosh service. See the [installation section](#installing-sharp-optional) for details on using the `sharp` service instead.
The background color is used to fill the remaining background when using `contain` for the `fit` property.

The background color is also used for replacing the alpha channel with `sharp`'s `flatten` method. In case the output format
Expand All @@ -215,6 +242,8 @@ color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`,
**Default:** `'cover'`
</p>

> This is not supported by the default Squoosh service. See the [installation section](#installing-sharp-optional) for details on using the `sharp` service instead.
How the image should be resized to fit both `height` and `width`.

#### position
Expand All @@ -225,6 +254,8 @@ How the image should be resized to fit both `height` and `width`.
**Default:** `'centre'`
</p>

> This is not supported by the default Squoosh service. See the [installation section](#installing-sharp-optional) for details on using the `sharp` service instead.
Position of the crop when fit is `cover` or `contain`.

### `<Picture />`
Expand Down Expand Up @@ -316,6 +347,8 @@ The output formats to be used in the optimized image. If not provided, `webp` an
**Default:** `undefined`
</p>

> This is not supported by the default Squoosh service. See the [installation section](#installing-sharp-optional) for details on using the `sharp` service instead.
The background color to use for replacing the alpha channel with `sharp`'s `flatten` method. In case the output format
doesn't support transparency (i.e. `jpeg`), it's advisable to include a background color, otherwise black will be used
as default replacement for transparent pixels.
Expand All @@ -334,6 +367,8 @@ color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`,
**Default:** `'cover'`
</p>

> This is not supported by the default Squoosh service. See the [installation section](#installing-sharp-optional) for details on using the `sharp` service instead.
How the image should be resized to fit both `height` and `width`.

#### position
Expand All @@ -346,6 +381,8 @@ How the image should be resized to fit both `height` and `width`.
**Default:** `'centre'`
</p>

> This is not supported by the default Squoosh service. See the [installation section](#installing-sharp-optional) for details on using the `sharp` service instead.
Position of the crop when fit is `cover` or `contain`.

### `getImage`
Expand Down Expand Up @@ -380,12 +417,12 @@ This helper takes in an object with the same properties as the `<Picture />` com

The integration can be configured to run with a different image service, either a hosted image service or a full image transformer that runs locally in your build or SSR deployment.

> During development, local images may not have been published yet and would not be available to hosted image services. Local images will always use the built-in `sharp` service when using `astro dev`.
> During development, local images may not have been published yet and would not be available to hosted image services. Local images will always use the built-in image service when using `astro dev`.

### config.serviceEntryPoint

The `serviceEntryPoint` should resolve to the image service installed from NPM. The default entry point is `@astrojs/image/sharp`, which resolves to the entry point exported from this integration's `package.json`.
The `serviceEntryPoint` should resolve to the image service installed from NPM. The default entry point is `@astrojs/image/squoosh`, which resolves to the entry point exported from this integration's `package.json`.

```js
// astro.config.mjs
Expand Down
13 changes: 8 additions & 5 deletions packages/integrations/image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
".": "./dist/index.js",
"./endpoint": "./dist/endpoint.js",
"./sharp": "./dist/loaders/sharp.js",
"./squoosh": "./dist/loaders/squoosh.js",
"./components": "./components/index.js",
"./package.json": "./package.json",
"./client": "./client.d.ts",
Expand All @@ -34,22 +35,24 @@
"client.d.ts"
],
"scripts": {
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
"build:ci": "astro-scripts build \"src/**/*.ts\"",
"build": "astro-scripts build \"src/**/*.ts\" && tsc && pnpm run postbuild",
"build:ci": "astro-scripts build \"src/**/*.ts\" && pnpm run postbuild",
"postbuild": "astro-scripts copy \"src/**/*.wasm\"",
"dev": "astro-scripts dev \"src/**/*.ts\"",
"test": "mocha --exit --timeout 20000 test"
},
"dependencies": {
"@altano/tiny-async-pool": "^1.0.2",
"image-size": "^1.0.2",
"magic-string": "^0.25.9",
"mime": "^3.0.0",
"sharp": "^0.30.6"
"mime": "^3.0.0"
},
"devDependencies": {
"@types/sharp": "^0.30.5",
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"kleur": "^4.1.4"
"kleur": "^4.1.4",
"rollup-plugin-copy": "^3.4.0",
"web-streams-polyfill": "^3.2.1"
}
}
28 changes: 22 additions & 6 deletions packages/integrations/image/src/build/ssg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,31 @@ import OS from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { SSRImageService, TransformOptions } from '../loaders/index.js';
import { loadLocalImage, loadRemoteImage } from '../utils/images.js';
import { debug, info, LoggerLevel, warn } from '../utils/logger.js';
import { isRemoteImage } from '../utils/paths.js';

async function loadLocalImage(src: string | URL) {
try {
return await fs.readFile(src);
} catch {
return undefined;
}
}

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

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

return Buffer.from(await res.arrayBuffer());
} catch {
return undefined;
}
}

function getTimeStat(timeStart: number, timeEnd: number) {
const buildTime = timeEnd - timeStart;
return buildTime < 750 ? `${Math.round(buildTime)}ms` : `${(buildTime / 1000).toFixed(2)}s`;
Expand Down Expand Up @@ -39,8 +60,6 @@ export async function ssgBuild({ loader, staticImages, config, outDir, logLevel
)}`,
});

const inputFiles = new Set<string>();

async function processStaticImage([src, transformsMap]: [
string,
Map<string, TransformOptions>
Expand All @@ -61,9 +80,6 @@ export async function ssgBuild({ loader, staticImages, config, outDir, logLevel
const inputFileURL = new URL(`.${src}`, outDir);
inputFile = fileURLToPath(inputFileURL);
inputBuffer = await loadLocalImage(inputFile);

// track the local file used so the original can be copied over
inputFiles.add(inputFile);
}

if (!inputBuffer) {
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/image/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const get: APIRoute = async ({ request }) => {
},
});
} catch (err: unknown) {
console.error(err);
return new Response(`Server Error: ${err}`, { status: 500 });
}
};
84 changes: 57 additions & 27 deletions packages/integrations/image/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { AstroConfig, AstroIntegration } from 'astro';
import type { AstroConfig, AstroIntegration, BuildConfig } from 'astro';
import { ssgBuild } from './build/ssg.js';
import type { ImageService, TransformOptions } from './loaders/index.js';
import type { ImageService, SSRImageService, TransformOptions } from './loaders/index.js';
import type { LoggerLevel } from './utils/logger.js';
import { joinPaths, prependForwardSlash, propsToFilename } from './utils/paths.js';
import { createPlugin } from './vite-plugin-astro-image.js';
import { copyWasmFiles } from './vendor/squoosh/copy-wasm.js';

export { getImage } from './lib/get-image.js';
export { getPicture } from './lib/get-picture.js';
Expand All @@ -13,12 +14,13 @@ const ROUTE_PATTERN = '/_image';

interface ImageIntegration {
loader?: ImageService;
defaultLoader: SSRImageService;
addStaticImage?: (transform: TransformOptions) => string;
}

declare global {
// eslint-disable-next-line no-var
var astroImage: ImageIntegration | undefined;
var astroImage: ImageIntegration;
}

export interface IntegrationOptions {
Expand All @@ -31,12 +33,13 @@ export interface IntegrationOptions {

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

let _config: AstroConfig;
let _buildConfig: BuildConfig;

// During SSG builds, this is used to track all transformed images required.
const staticImages = new Map<string, Map<string, TransformOptions>>();
Expand All @@ -45,18 +48,26 @@ export default function integration(options: IntegrationOptions = {}): AstroInte
return {
plugins: [createPlugin(_config, resolvedOptions)],
optimizeDeps: {
include: ['image-size', 'sharp'],
include: [
'image-size',
].filter(Boolean),
},
build: {
rollupOptions: {
external: ["sharp"]
}
},
ssr: {
noExternal: ['@astrojs/image', resolvedOptions.serviceEntryPoint],
},
assetsInclude: ['**/*.wasm']
};
}

return {
name: PKG_NAME,
hooks: {
'astro:config:setup': ({ command, config, updateConfig, injectRoute }) => {
'astro:config:setup': async ({ command, config, updateConfig, injectRoute }) => {
_config = config;

updateConfig({ vite: getViteConfiguration() });
Expand All @@ -67,8 +78,20 @@ export default function integration(options: IntegrationOptions = {}): AstroInte
entryPoint: '@astrojs/image/endpoint',
});
}

const { default: defaultLoader } = await import(resolvedOptions.serviceEntryPoint === '@astrojs/image/sharp'
? './loaders/sharp.js'
: './loaders/squoosh.js'
);

globalThis.astroImage = {
defaultLoader
}
},
'astro:build:start': async ({ buildConfig }) => {
_buildConfig = buildConfig
},
'astro:build:setup': () => {
'astro:build:setup': async () => {
// Used to cache all images rendered to HTML
// Added to globalThis to share the same map in Node and Vite
function addStaticImage(transform: TransformOptions) {
Expand All @@ -89,29 +112,36 @@ export default function integration(options: IntegrationOptions = {}): AstroInte
}

// Helpers for building static images should only be available for SSG
globalThis.astroImage =
_config.output === 'static'
? {
addStaticImage,
}
: {};
},
'astro:build:done': async ({ dir }) => {
if (_config.output === 'static') {
// for SSG builds, build all requested image transforms to dist
const loader = globalThis?.astroImage?.loader;

if (loader && 'transform' in loader && staticImages.size > 0) {
await ssgBuild({
loader,
staticImages,
config: _config,
outDir: dir,
logLevel: resolvedOptions.logLevel,
});
}
globalThis.astroImage.addStaticImage = addStaticImage;
}
},
'astro:build:generated': async ({ dir }) => {
// for SSG builds, build all requested image transforms to dist
const loader = globalThis?.astroImage?.loader;

if (resolvedOptions.serviceEntryPoint === '@astrojs/image/squoosh') {
// For the Squoosh service, copy all wasm files to dist/chunks.
// Because the default loader is dynamically imported (above),
// Vite will bundle squoosh to dist/chunks and expect to find the wasm files there
await copyWasmFiles(new URL('./chunks', dir));
}

if (loader && 'transform' in loader && staticImages.size > 0) {
await ssgBuild({
loader,
staticImages,
config: _config,
outDir: dir,
logLevel: resolvedOptions.logLevel,
});
}
},
'astro:build:ssr': async () => {
if (resolvedOptions.serviceEntryPoint === '@astrojs/image/squoosh') {
await copyWasmFiles(_buildConfig.server);
}
}
},
};
}
Loading

0 comments on commit fad3867

Please sign in to comment.