Skip to content

Commit

Permalink
WIP: [image] Fixing SSR support and improving error validation (#4013)
Browse files Browse the repository at this point in the history
* fix: SSR builds were hitting an undefined error and skipping the step for copying original assets

* chore: update lockfile

* chore: adding better error validation to getImage and getPicture

* refactor: cleaning up index.ts

* refactor: moving SSG build generation logic out of the integration

* splitting build to ssg & ssr helpers, re-enabling SSR image build tests

* sharp should automatically rotate based on EXIF

* cleaning up how static images are tracked for SSG builds

* undo unrelated mod.d.ts change

* chore: add changeset
  • Loading branch information
Tony Sullivan authored Jul 22, 2022
1 parent 41f4a8f commit ef93457
Show file tree
Hide file tree
Showing 48 changed files with 557 additions and 238 deletions.
6 changes: 6 additions & 0 deletions .changeset/tiny-glasses-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astrojs/image': minor
---

- Fixes two bugs that were blocking SSR support when deployed to a hosting service
- The built-in `sharp` service now automatically rotates images based on EXIF data
7 changes: 3 additions & 4 deletions packages/integrations/image/components/Image.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
---
// @ts-ignore
import loader from 'virtual:image-loader';
import { getImage } from '../src/index.js';
import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../src/types.js';
import { getImage } from '../dist/index.js';
import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../dist/types';
export interface LocalImageProps extends Omit<TransformOptions, 'src'>, Omit<ImageAttributes, 'src' | 'width' | 'height'> {
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
Expand All @@ -19,7 +18,7 @@ export type Props = LocalImageProps | RemoteImageProps;
const { loading = "lazy", decoding = "async", ...props } = Astro.props as Props;
const attrs = await getImage(loader, props);
const attrs = await getImage(props);
---

<img {...attrs} {loading} {decoding} />
Expand Down
8 changes: 3 additions & 5 deletions packages/integrations/image/components/Picture.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
---
// @ts-ignore
import loader from 'virtual:image-loader';
import { getPicture } from '../src/get-picture.js';
import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../src/types.js';
import { getPicture } from '../dist/index.js';
import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../dist/types';
export interface LocalImageProps extends Omit<PictureAttributes, 'src' | 'width' | 'height'>, Omit<TransformOptions, 'src'>, Pick<ImageAttributes, 'loading' | 'decoding'> {
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
Expand All @@ -25,7 +23,7 @@ export type Props = LocalImageProps | RemoteImageProps;
const { src, alt, sizes, widths, aspectRatio, formats = ['avif', 'webp'], loading = 'lazy', decoding = 'async', ...attrs } = Astro.props as Props;
const { image, sources } = await getPicture({ loader, src, widths, formats, aspectRatio });
const { image, sources } = await getPicture({ src, widths, formats, aspectRatio });
---

<picture {...attrs}>
Expand Down
3 changes: 2 additions & 1 deletion packages/integrations/image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@types/etag": "^1.8.1",
"@types/sharp": "^0.30.4",
"astro": "workspace:*",
"astro-scripts": "workspace:*"
"astro-scripts": "workspace:*",
"tiny-glob": "^0.2.9"
}
}
79 changes: 79 additions & 0 deletions packages/integrations/image/src/build/ssg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { OUTPUT_DIR } from '../constants.js';
import { ensureDir } from '../utils/paths.js';
import { isRemoteImage, loadRemoteImage, loadLocalImage } from '../utils/images.js';
import type { SSRImageService, TransformOptions } from '../types.js';

export interface SSGBuildParams {
loader: SSRImageService;
staticImages: Map<string, Map<string, TransformOptions>>;
srcDir: URL;
outDir: URL;
}

export async function ssgBuild({
loader,
staticImages,
srcDir,
outDir,
}: SSGBuildParams) {
const inputFiles = new Set<string>();

// process transforms one original image file at a time
for await (const [src, transformsMap] of staticImages) {
let inputFile: string | undefined = undefined;
let inputBuffer: Buffer | undefined = undefined;

if (isRemoteImage(src)) {
// try to load the remote image
inputBuffer = await loadRemoteImage(src);
} else {
const inputFileURL = new URL(`.${src}`, srcDir);
inputFile = fileURLToPath(inputFileURL);
inputBuffer = await loadLocalImage(inputFile);

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

if (!inputBuffer) {
// eslint-disable-next-line no-console
console.warn(`"${src}" image could not be fetched`);
continue;
}

const transforms = Array.from(transformsMap.entries());

// process each transformed versiono of the
for await (const [filename, transform] of transforms) {
let outputFile: string;

if (isRemoteImage(src)) {
const outputFileURL = new URL(
path.join('./', OUTPUT_DIR, path.basename(filename)),
outDir
);
outputFile = fileURLToPath(outputFileURL);
} else {
const outputFileURL = new URL(path.join('./', OUTPUT_DIR, filename), outDir);
outputFile = fileURLToPath(outputFileURL);
}

const { data } = await loader.transform(inputBuffer, transform);

ensureDir(path.dirname(outputFile));

await fs.writeFile(outputFile, data);
}
}

// copy all original local images to dist
for await (const original of inputFiles) {
const to = original.replace(fileURLToPath(srcDir), fileURLToPath(outDir));

await ensureDir(path.dirname(to));
await fs.copyFile(original, to);
}
}
29 changes: 29 additions & 0 deletions packages/integrations/image/src/build/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import fs from 'fs/promises';
import path from 'path';
import glob from 'tiny-glob';
import { fileURLToPath } from 'url';
import { ensureDir } from '../utils/paths.js';

async function globImages(dir: URL) {
const srcPath = fileURLToPath(dir);
return await glob(
`${srcPath}/**/*.{heic,heif,avif,jpeg,jpg,png,tiff,webp,gif}`,
{ absolute: true }
);
}

export interface SSRBuildParams {
srcDir: URL;
outDir: URL;
}

export async function ssrBuild({ srcDir, outDir }: SSRBuildParams) {
const images = await globImages(srcDir);

for await (const image of images) {
const to = image.replace(fileURLToPath(srcDir), fileURLToPath(outDir));

await ensureDir(path.dirname(to));
await fs.copyFile(image, to);
}
}
5 changes: 2 additions & 3 deletions packages/integrations/image/src/endpoints/dev.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { APIRoute } from 'astro';
import { lookup } from 'mrmime';
import { loadImage } from '../utils.js';
import loader from '../loaders/sharp.js';
import { loadImage } from '../utils/images.js';

export const get: APIRoute = async ({ request }) => {
const loader = globalThis.astroImage.ssrLoader;

try {
const url = new URL(request.url);
const transform = loader.parseTransform(url.searchParams);
Expand Down
15 changes: 9 additions & 6 deletions packages/integrations/image/src/endpoints/prod.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { APIRoute } from 'astro';
import etag from 'etag';
import { lookup } from 'mrmime';
import { fileURLToPath } from 'url';
// @ts-ignore
import loader from 'virtual:image-loader';
import { isRemoteImage, loadRemoteImage } from '../utils.js';
import { isRemoteImage, loadLocalImage, loadRemoteImage } from '../utils/images.js';

export const get: APIRoute = async ({ request }) => {
try {
Expand All @@ -14,12 +15,14 @@ export const get: APIRoute = async ({ request }) => {
return new Response('Bad Request', { status: 400 });
}

// TODO: Can we lean on fs to load local images in SSR prod builds?
const href = isRemoteImage(transform.src)
? new URL(transform.src)
: new URL(transform.src, url.origin);
let inputBuffer: Buffer | undefined = undefined;

const inputBuffer = await loadRemoteImage(href.toString());
if (isRemoteImage(transform.src)) {
inputBuffer = await loadRemoteImage(transform.src);
} else {
const pathname = fileURLToPath(new URL(`../client${transform.src}`, import.meta.url));
inputBuffer = await loadLocalImage(pathname);
}

if (!inputBuffer) {
return new Response(`"${transform.src} not found`, { status: 404 });
Expand Down
140 changes: 4 additions & 136 deletions packages/integrations/image/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,137 +1,5 @@
import type { AstroConfig, AstroIntegration } from 'astro';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { OUTPUT_DIR, PKG_NAME, ROUTE_PATTERN } from './constants.js';
import sharp from './loaders/sharp.js';
import { IntegrationOptions, TransformOptions } from './types.js';
import {
ensureDir,
isRemoteImage,
loadLocalImage,
loadRemoteImage,
propsToFilename,
} from './utils.js';
import { createPlugin } from './vite-plugin-astro-image.js';
export * from './get-image.js';
export * from './get-picture.js';
import integration from './integration.js';
export * from './lib/get-image.js';
export * from './lib/get-picture.js';

const createIntegration = (options: IntegrationOptions = {}): AstroIntegration => {
const resolvedOptions = {
serviceEntryPoint: '@astrojs/image/sharp',
...options,
};

// During SSG builds, this is used to track all transformed images required.
const staticImages = new Map<string, TransformOptions>();

let _config: AstroConfig;

function getViteConfiguration() {
return {
plugins: [createPlugin(_config, resolvedOptions)],
optimizeDeps: {
include: ['image-size', 'sharp'],
},
ssr: {
noExternal: ['@astrojs/image', resolvedOptions.serviceEntryPoint],
},
};
}

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

// Always treat `astro dev` as SSR mode, even without an adapter
const mode = command === 'dev' || config.adapter ? 'ssr' : 'ssg';

updateConfig({ vite: getViteConfiguration() });

// Used to cache all images rendered to HTML
// Added to globalThis to share the same map in Node and Vite
function addStaticImage(transform: TransformOptions) {
staticImages.set(propsToFilename(transform), transform);
}

// TODO: Add support for custom, user-provided filename format functions
function filenameFormat(transform: TransformOptions, searchParams: URLSearchParams) {
if (mode === 'ssg') {
return isRemoteImage(transform.src)
? path.join(OUTPUT_DIR, path.basename(propsToFilename(transform)))
: path.join(
OUTPUT_DIR,
path.dirname(transform.src),
path.basename(propsToFilename(transform))
);
} else {
return `${ROUTE_PATTERN}?${searchParams.toString()}`;
}
}

// Initialize the integration's globalThis namespace
// This is needed to share scope between Node and Vite
globalThis.astroImage = {
loader: undefined, // initialized in first getImage() call
ssrLoader: sharp,
command,
addStaticImage,
filenameFormat,
};

if (mode === 'ssr') {
injectRoute({
pattern: ROUTE_PATTERN,
entryPoint:
command === 'dev' ? '@astrojs/image/endpoints/dev' : '@astrojs/image/endpoints/prod',
});
}
},
'astro:build:done': async ({ dir }) => {
for await (const [filename, transform] of staticImages) {
const loader = globalThis.astroImage.loader;

if (!loader || !('transform' in loader)) {
// this should never be hit, how was a staticImage added without an SSR service?
return;
}

let inputBuffer: Buffer | undefined = undefined;
let outputFile: string;

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

const outputFileURL = new URL(
path.join('./', OUTPUT_DIR, path.basename(filename)),
dir
);
outputFile = fileURLToPath(outputFileURL);
} else {
const inputFileURL = new URL(`.${transform.src}`, _config.srcDir);
const inputFile = fileURLToPath(inputFileURL);
inputBuffer = await loadLocalImage(inputFile);

const outputFileURL = new URL(path.join('./', OUTPUT_DIR, filename), dir);
outputFile = fileURLToPath(outputFileURL);
}

if (!inputBuffer) {
// eslint-disable-next-line no-console
console.warn(`"${transform.src}" image could not be fetched`);
continue;
}

const { data } = await loader.transform(inputBuffer, transform);
ensureDir(path.dirname(outputFile));
await fs.writeFile(outputFile, data);
}
},
},
};
};

export default createIntegration;
export default integration;
Loading

0 comments on commit ef93457

Please sign in to comment.