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

WIP: [image] Fixing SSR support and improving error validation #4013

Merged
merged 12 commits into from
Jul 22, 2022
Merged
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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build step for generating SSG images moved out of the main integration

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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSR build step moved out of the main integration

This was actually a bug uncovered while fixing the undefined error, original images weren't being copied to dist for SSR


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)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in SSR, looking up the original files that were copied to dist during the build

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