-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
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
Changes from all commits
ee07427
80d325c
eb77c02
a79f081
a8f6403
1cf7c94
b8e1f50
5fc519e
fdc5935
9d765de
75ffacc
56ed3e2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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); | ||
} | ||
} |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} |
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 { | ||
|
@@ -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)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }); | ||
|
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; |
There was a problem hiding this comment.
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