diff options
Diffstat (limited to 'packages/astro/src')
-rw-r--r-- | packages/astro/src/@types/astro.ts | 80 | ||||
-rw-r--r-- | packages/astro/src/assets/build/generate.ts | 174 | ||||
-rw-r--r-- | packages/astro/src/assets/build/remote.ts | 48 | ||||
-rw-r--r-- | packages/astro/src/assets/generate.ts | 132 | ||||
-rw-r--r-- | packages/astro/src/assets/image-endpoint.ts | 17 | ||||
-rw-r--r-- | packages/astro/src/assets/internal.ts | 39 | ||||
-rw-r--r-- | packages/astro/src/assets/services/service.ts | 55 | ||||
-rw-r--r-- | packages/astro/src/assets/utils/remotePattern.ts | 63 | ||||
-rw-r--r-- | packages/astro/src/assets/utils/transformToPath.ts | 11 | ||||
-rw-r--r-- | packages/astro/src/assets/vite-plugin-assets.ts | 12 | ||||
-rw-r--r-- | packages/astro/src/core/app/index.ts | 44 | ||||
-rw-r--r-- | packages/astro/src/core/app/node.ts | 135 | ||||
-rw-r--r-- | packages/astro/src/core/build/generate.ts | 2 | ||||
-rw-r--r-- | packages/astro/src/core/build/static-build.ts | 13 | ||||
-rw-r--r-- | packages/astro/src/core/config/schema.ts | 24 | ||||
-rw-r--r-- | packages/astro/src/core/config/tsconfig.ts | 26 | ||||
-rw-r--r-- | packages/astro/src/vite-plugin-integrations-container/index.ts | 4 |
17 files changed, 626 insertions, 253 deletions
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index e2b3e6d63..f9568d417 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -13,6 +13,7 @@ import type { AddressInfo } from 'node:net'; import type * as rollup from 'rollup'; import type { TsConfigJson } from 'tsconfig-resolver'; import type * as vite from 'vite'; +import type { RemotePattern } from '../assets/utils/remotePattern'; import type { SerializedSSRManifest } from '../core/app/types'; import type { PageBuildData } from '../core/build/types'; import type { AstroConfigType } from '../core/config'; @@ -45,6 +46,7 @@ export type { ImageQualityPreset, ImageTransform, } from '../assets/types'; +export type { RemotePattern } from '../assets/utils/remotePattern'; export type { SSRManifest } from '../core/app/types'; export type { AstroCookies } from '../core/cookies'; @@ -367,10 +369,10 @@ export interface ViteUserConfig extends vite.UserConfig { ssr?: vite.SSROptions; } -export interface ImageServiceConfig { +export interface ImageServiceConfig<T extends Record<string, any> = Record<string, any>> { // eslint-disable-next-line @typescript-eslint/ban-types entrypoint: 'astro/assets/services/sharp' | 'astro/assets/services/squoosh' | (string & {}); - config?: Record<string, any>; + config?: T; } /** @@ -697,6 +699,10 @@ export interface AstroUserConfig { * - `file` - The `Astro.url.pathname` will include `.html`; ie `/foo.html`. * * This means that when you create relative URLs using `new URL('./relative', Astro.url)`, you will get consistent behavior between dev and build. + * + * To prevent inconsistencies with trailing slash behaviour in dev, you can restrict the [`trailingSlash` option](#trailingslash) to `'always'` or `'never'` depending on your build format: + * - `directory` - Set `trailingSlash: 'always'` + * - `file` - Set `trailingSlash: 'never'` */ format?: 'file' | 'directory'; /** @@ -833,10 +839,10 @@ export interface AstroUserConfig { * @default `never` * @version 2.6.0 * @description - * Control whether styles are sent to the browser in a separate css file or inlined into `<style>` tags. Choose from the following options: - * - `'always'` - all styles are inlined into `<style>` tags - * - `'auto'` - only stylesheets smaller than `ViteConfig.build.assetsInlineLimit` (default: 4kb) are inlined. Otherwise, styles are sent in external stylesheets. - * - `'never'` - all styles are sent in external stylesheets + * Control whether project styles are sent to the browser in a separate css file or inlined into `<style>` tags. Choose from the following options: + * - `'always'` - project styles are inlined into `<style>` tags + * - `'auto'` - only stylesheets smaller than `ViteConfig.build.assetsInlineLimit` (default: 4kb) are inlined. Otherwise, project styles are sent in external stylesheets. + * - `'never'` - project styles are sent in external stylesheets * * ```js * { @@ -1004,6 +1010,68 @@ export interface AstroUserConfig { * ``` */ service: ImageServiceConfig; + + /** + * @docs + * @name image.domains (Experimental) + * @type {string[]} + * @default `{domains: []}` + * @version 2.10.10 + * @description + * Defines a list of permitted image source domains for local image optimization. No other remote images will be optimized by Astro. + * + * This option requires an array of individual domain names as strings. Wildcards are not permitted. Instead, use [`image.remotePatterns`](#imageremotepatterns-experimental) to define a list of allowed source URL patterns. + * + * ```js + * // astro.config.mjs + * { + * image: { + * // Example: Allow remote image optimization from a single domain + * domains: ['astro.build'], + * }, + * } + * ``` + */ + domains?: string[]; + + /** + * @docs + * @name image.remotePatterns (Experimental) + * @type {RemotePattern[]} + * @default `{remotePatterns: []}` + * @version 2.10.10 + * @description + * Defines a list of permitted image source URL patterns for local image optimization. + * + * `remotePatterns` can be configured with four properties: + * 1. protocol + * 2. hostname + * 3. port + * 4. pathname + * + * ```js + * { + * image: { + * // Example: allow processing all images from your aws s3 bucket + * remotePatterns: [{ + * protocol: 'https', + * hostname: '**.amazonaws.com', + * }], + * }, + * } + * ``` + * + * You can use wildcards to define the permitted `hostname` and `pathname` values as described below. Otherwise, only the exact values provided will be configured: + * `hostname`: + * - Start with '**.' to allow all subdomains ('endsWith'). + * - Start with '*.' to allow only one level of subdomain. + * + * `pathname`: + * - End with '/**' to allow all sub-routes ('startsWith'). + * - End with '/*' to allow only one level of sub-route. + + */ + remotePatterns?: Partial<RemotePattern>[]; }; /** diff --git a/packages/astro/src/assets/build/generate.ts b/packages/astro/src/assets/build/generate.ts new file mode 100644 index 000000000..b78800a43 --- /dev/null +++ b/packages/astro/src/assets/build/generate.ts @@ -0,0 +1,174 @@ +import fs, { readFileSync } from 'node:fs'; +import { basename, join } from 'node:path/posix'; +import type { StaticBuildOptions } from '../../core/build/types.js'; +import { warn } from '../../core/logger/core.js'; +import { prependForwardSlash } from '../../core/path.js'; +import { isServerLikeOutput } from '../../prerender/utils.js'; +import { getConfiguredImageService, isESMImportedImage } from '../internal.js'; +import type { LocalImageService } from '../services/service.js'; +import type { ImageMetadata, ImageTransform } from '../types.js'; +import { loadRemoteImage, type RemoteCacheEntry } from './remote.js'; + +interface GenerationDataUncached { + cached: false; + weight: { + before: number; + after: number; + }; +} + +interface GenerationDataCached { + cached: true; +} + +type GenerationData = GenerationDataUncached | GenerationDataCached; + +export async function generateImage( + buildOpts: StaticBuildOptions, + options: ImageTransform, + filepath: string +): Promise<GenerationData | undefined> { + let useCache = true; + const assetsCacheDir = new URL('assets/', buildOpts.settings.config.cacheDir); + + // Ensure that the cache directory exists + try { + await fs.promises.mkdir(assetsCacheDir, { recursive: true }); + } catch (err) { + warn( + buildOpts.logging, + 'astro:assets', + `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${err}` + ); + useCache = false; + } + + let serverRoot: URL, clientRoot: URL; + if (isServerLikeOutput(buildOpts.settings.config)) { + serverRoot = buildOpts.settings.config.build.server; + clientRoot = buildOpts.settings.config.build.client; + } else { + serverRoot = buildOpts.settings.config.outDir; + clientRoot = buildOpts.settings.config.outDir; + } + + const isLocalImage = isESMImportedImage(options.src); + + const finalFileURL = new URL('.' + filepath, clientRoot); + const finalFolderURL = new URL('./', finalFileURL); + + // For remote images, instead of saving the image directly, we save a JSON file with the image data and expiration date from the server + const cacheFile = basename(filepath) + (isLocalImage ? '' : '.json'); + const cachedFileURL = new URL(cacheFile, assetsCacheDir); + + await fs.promises.mkdir(finalFolderURL, { recursive: true }); + + // Check if we have a cached entry first + try { + if (isLocalImage) { + await fs.promises.copyFile(cachedFileURL, finalFileURL); + + return { + cached: true, + }; + } else { + const JSONData = JSON.parse(readFileSync(cachedFileURL, 'utf-8')) as RemoteCacheEntry; + + // If the cache entry is not expired, use it + if (JSONData.expires < Date.now()) { + await fs.promises.writeFile(finalFileURL, Buffer.from(JSONData.data, 'base64')); + + return { + cached: true, + }; + } + } + } catch (e: any) { + if (e.code !== 'ENOENT') { + throw new Error(`An error was encountered while reading the cache file. Error: ${e}`); + } + // If the cache file doesn't exist, just move on, and we'll generate it + } + + // The original filepath or URL from the image transform + const originalImagePath = isLocalImage + ? (options.src as ImageMetadata).src + : (options.src as string); + + let imageData; + let resultData: { data: Buffer | undefined; expires: number | undefined } = { + data: undefined, + expires: undefined, + }; + + // If the image is local, we can just read it directly, otherwise we need to download it + if (isLocalImage) { + imageData = await fs.promises.readFile( + new URL( + '.' + + prependForwardSlash( + join(buildOpts.settings.config.build.assets, basename(originalImagePath)) + ), + serverRoot + ) + ); + } else { + const remoteImage = await loadRemoteImage(originalImagePath); + resultData.expires = remoteImage.expires; + imageData = remoteImage.data; + } + + const imageService = (await getConfiguredImageService()) as LocalImageService; + resultData.data = ( + await imageService.transform( + imageData, + { ...options, src: originalImagePath }, + buildOpts.settings.config.image + ) + ).data; + + try { + // Write the cache entry + if (useCache) { + if (isLocalImage) { + await fs.promises.writeFile(cachedFileURL, resultData.data); + } else { + await fs.promises.writeFile( + cachedFileURL, + JSON.stringify({ + data: Buffer.from(resultData.data).toString('base64'), + expires: resultData.expires, + }) + ); + } + } + } catch (e) { + warn( + buildOpts.logging, + 'astro:assets', + `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${e}` + ); + } finally { + // Write the final file + await fs.promises.writeFile(finalFileURL, resultData.data); + } + + return { + cached: false, + weight: { + // Divide by 1024 to get size in kilobytes + before: Math.trunc(imageData.byteLength / 1024), + after: Math.trunc(Buffer.from(resultData.data).byteLength / 1024), + }, + }; +} + +export function getStaticImageList(): Iterable< + [string, { path: string; options: ImageTransform }] +> { + if (!globalThis?.astroAsset?.staticImages) { + return []; + } + + return globalThis.astroAsset.staticImages?.entries(); +} diff --git a/packages/astro/src/assets/build/remote.ts b/packages/astro/src/assets/build/remote.ts new file mode 100644 index 000000000..c3d4bb9ba --- /dev/null +++ b/packages/astro/src/assets/build/remote.ts @@ -0,0 +1,48 @@ +import CachePolicy from 'http-cache-semantics'; + +export type RemoteCacheEntry = { data: string; expires: number }; + +export async function loadRemoteImage(src: string) { + const req = new Request(src); + const res = await fetch(req); + + if (!res.ok) { + throw new Error( + `Failed to load remote image ${src}. The request did not return a 200 OK response. (received ${res.status}))` + ); + } + + // 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, + }; +} + +function webToCachePolicyRequest({ url, method, headers: _headers }: Request): CachePolicy.Request { + let headers: CachePolicy.Headers = {}; + // Be defensive here due to a cookie header bug in node@18.14.1 + undici + try { + headers = Object.fromEntries(_headers.entries()); + } catch {} + return { + method, + url, + headers, + }; +} + +function webToCachePolicyResponse({ status, headers: _headers }: Response): CachePolicy.Response { + let headers: CachePolicy.Headers = {}; + // Be defensive here due to a cookie header bug in node@18.14.1 + undici + try { + headers = Object.fromEntries(_headers.entries()); + } catch {} + return { + status, + headers, + }; +} diff --git a/packages/astro/src/assets/generate.ts b/packages/astro/src/assets/generate.ts deleted file mode 100644 index 04488ed8f..000000000 --- a/packages/astro/src/assets/generate.ts +++ /dev/null @@ -1,132 +0,0 @@ -import fs from 'node:fs'; -import { basename, join } from 'node:path/posix'; -import type { StaticBuildOptions } from '../core/build/types.js'; -import { warn } from '../core/logger/core.js'; -import { prependForwardSlash } from '../core/path.js'; -import { isServerLikeOutput } from '../prerender/utils.js'; -import { getConfiguredImageService, isESMImportedImage } from './internal.js'; -import type { LocalImageService } from './services/service.js'; -import type { ImageTransform } from './types.js'; - -interface GenerationDataUncached { - cached: false; - weight: { - before: number; - after: number; - }; -} - -interface GenerationDataCached { - cached: true; -} - -type GenerationData = GenerationDataUncached | GenerationDataCached; - -export async function generateImage( - buildOpts: StaticBuildOptions, - options: ImageTransform, - filepath: string -): Promise<GenerationData | undefined> { - if (typeof buildOpts.settings.config.image === 'undefined') { - throw new Error( - "Astro hasn't set a default service for `astro:assets`. This is an internal error and you should report it." - ); - } - if (!isESMImportedImage(options.src)) { - return undefined; - } - - let useCache = true; - const assetsCacheDir = new URL('assets/', buildOpts.settings.config.cacheDir); - - // Ensure that the cache directory exists - try { - await fs.promises.mkdir(assetsCacheDir, { recursive: true }); - } catch (err) { - warn( - buildOpts.logging, - 'astro:assets', - `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${err}` - ); - useCache = false; - } - - let serverRoot: URL, clientRoot: URL; - if (isServerLikeOutput(buildOpts.settings.config)) { - serverRoot = buildOpts.settings.config.build.server; - clientRoot = buildOpts.settings.config.build.client; - } else { - serverRoot = buildOpts.settings.config.outDir; - clientRoot = buildOpts.settings.config.outDir; - } - - const finalFileURL = new URL('.' + filepath, clientRoot); - const finalFolderURL = new URL('./', finalFileURL); - const cachedFileURL = new URL(basename(filepath), assetsCacheDir); - - try { - await fs.promises.copyFile(cachedFileURL, finalFileURL); - - return { - cached: true, - }; - } catch (e) { - // no-op - } - - // The original file's path (the `src` attribute of the ESM imported image passed by the user) - const originalImagePath = options.src.src; - - const fileData = await fs.promises.readFile( - new URL( - '.' + - prependForwardSlash( - join(buildOpts.settings.config.build.assets, basename(originalImagePath)) - ), - serverRoot - ) - ); - - const imageService = (await getConfiguredImageService()) as LocalImageService; - const resultData = await imageService.transform( - fileData, - { ...options, src: originalImagePath }, - buildOpts.settings.config.image.service.config - ); - - await fs.promises.mkdir(finalFolderURL, { recursive: true }); - - if (useCache) { - try { - await fs.promises.writeFile(cachedFileURL, resultData.data); - await fs.promises.copyFile(cachedFileURL, finalFileURL); - } catch (e) { - warn( - buildOpts.logging, - 'astro:assets', - `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${e}` - ); - await fs.promises.writeFile(finalFileURL, resultData.data); - } - } else { - await fs.promises.writeFile(finalFileURL, resultData.data); - } - - return { - cached: false, - weight: { - before: Math.trunc(fileData.byteLength / 1024), - after: Math.trunc(resultData.data.byteLength / 1024), - }, - }; -} - -export function getStaticImageList(): Iterable< - [string, { path: string; options: ImageTransform }] -> { - if (!globalThis?.astroAsset?.staticImages) { - return []; - } - - return globalThis.astroAsset.staticImages?.entries(); -} diff --git a/packages/astro/src/assets/image-endpoint.ts b/packages/astro/src/assets/image-endpoint.ts index d9a101679..d83517379 100644 --- a/packages/astro/src/assets/image-endpoint.ts +++ b/packages/astro/src/assets/image-endpoint.ts @@ -1,8 +1,10 @@ import mime from 'mime/lite.js'; import type { APIRoute } from '../@types/astro.js'; import { etag } from './utils/etag.js'; +import { isRemotePath } from '../core/path.js'; +import { getConfiguredImageService, isRemoteAllowed } from './internal.js'; // @ts-expect-error -import { getConfiguredImageService, imageServiceConfig } from 'astro:assets'; +import { imageConfig } from 'astro:assets'; async function loadRemoteImage(src: URL) { try { @@ -30,7 +32,7 @@ export const GET: APIRoute = async ({ request }) => { } const url = new URL(request.url); - const transform = await imageService.parseURL(url, imageServiceConfig); + const transform = await imageService.parseURL(url, imageConfig); if (!transform?.src) { throw new Error('Incorrect transform returned by `parseURL`'); @@ -42,17 +44,18 @@ export const GET: APIRoute = async ({ request }) => { const sourceUrl = isRemotePath(transform.src) ? new URL(transform.src) : new URL(transform.src, url.origin); + + if (isRemotePath(transform.src) && isRemoteAllowed(transform.src, imageConfig) === false) { + return new Response('Forbidden', { status: 403 }); + } + inputBuffer = await loadRemoteImage(sourceUrl); if (!inputBuffer) { return new Response('Not Found', { status: 404 }); } - const { data, format } = await imageService.transform( - inputBuffer, - transform, - imageServiceConfig - ); + const { data, format } = await imageService.transform(inputBuffer, transform, imageConfig); return new Response(data, { status: 200, diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index a49828a46..dd5e427f6 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -1,4 +1,5 @@ -import type { AstroSettings } from '../@types/astro.js'; +import { isRemotePath } from '@astrojs/internal-helpers/path'; +import type { AstroConfig, AstroSettings } from '../@types/astro.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { isLocalService, type ImageService } from './services/service.js'; import type { @@ -7,6 +8,7 @@ import type { ImageTransform, UnresolvedImageTransform, } from './types.js'; +import { matchHostname, matchPattern } from './utils/remotePattern.js'; export function injectImageEndpoint(settings: AstroSettings) { // TODO: Add a setting to disable the image endpoint @@ -23,6 +25,26 @@ export function isESMImportedImage(src: ImageMetadata | string): src is ImageMet return typeof src === 'object'; } +export function isRemoteImage(src: ImageMetadata | string): src is string { + return typeof src === 'string'; +} + +export function isRemoteAllowed( + src: string, + { + domains = [], + remotePatterns = [], + }: Partial<Pick<AstroConfig['image'], 'domains' | 'remotePatterns'>> +): boolean { + if (!isRemotePath(src)) return false; + + const url = new URL(src); + return ( + domains.some((domain) => matchHostname(url, domain)) || + remotePatterns.some((remotePattern) => matchPattern(url, remotePattern)) + ); +} + export async function getConfiguredImageService(): Promise<ImageService> { if (!globalThis?.astroAsset?.imageService) { const { default: service }: { default: ImageService } = await import( @@ -44,7 +66,7 @@ export async function getConfiguredImageService(): Promise<ImageService> { export async function getImage( options: ImageTransform | UnresolvedImageTransform, - serviceConfig: Record<string, any> + imageConfig: AstroConfig['image'] ): Promise<GetImageResult> { if (!options || typeof options !== 'object') { throw new AstroError({ @@ -65,13 +87,18 @@ export async function getImage( }; const validatedOptions = service.validateOptions - ? await service.validateOptions(resolvedOptions, serviceConfig) + ? await service.validateOptions(resolvedOptions, imageConfig) : resolvedOptions; - let imageURL = await service.getURL(validatedOptions, serviceConfig); + let imageURL = await service.getURL(validatedOptions, imageConfig); // In build and for local services, we need to collect the requested parameters so we can generate the final images - if (isLocalService(service) && globalThis.astroAsset.addStaticImage) { + if ( + isLocalService(service) && + globalThis.astroAsset.addStaticImage && + // If `getURL` returned the same URL as the user provided, it means the service doesn't need to do anything + !(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src) + ) { imageURL = globalThis.astroAsset.addStaticImage(validatedOptions); } @@ -81,7 +108,7 @@ export async function getImage( src: imageURL, attributes: service.getHTMLAttributes !== undefined - ? service.getHTMLAttributes(validatedOptions, serviceConfig) + ? service.getHTMLAttributes(validatedOptions, imageConfig) : {}, }; } diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts index d3479c880..5af4a898b 100644 --- a/packages/astro/src/assets/services/service.ts +++ b/packages/astro/src/assets/services/service.ts @@ -1,7 +1,8 @@ +import type { AstroConfig } from '../../@types/astro.js'; import { AstroError, AstroErrorData } from '../../core/errors/index.js'; import { joinPaths } from '../../core/path.js'; import { VALID_SUPPORTED_FORMATS } from '../consts.js'; -import { isESMImportedImage } from '../internal.js'; +import { isESMImportedImage, isRemoteAllowed } from '../internal.js'; import type { ImageOutputFormat, ImageTransform } from '../types.js'; export type ImageService = LocalImageService | ExternalImageService; @@ -23,7 +24,11 @@ export function parseQuality(quality: string): string | number { return result; } -interface SharedServiceProps { +type ImageConfig<T> = Omit<AstroConfig['image'], 'service'> & { + service: { entrypoint: string; config: T }; +}; + +interface SharedServiceProps<T extends Record<string, any> = Record<string, any>> { /** * Return the URL to the endpoint or URL your images are generated from. * @@ -32,7 +37,7 @@ interface SharedServiceProps { * For external services, this should point to the URL your images are coming from, for instance, `/_vercel/image` * */ - getURL: (options: ImageTransform, serviceConfig: Record<string, any>) => string | Promise<string>; + getURL: (options: ImageTransform, imageConfig: ImageConfig<T>) => string | Promise<string>; /** * Return any additional HTML attributes separate from `src` that your service requires to show the image properly. * @@ -41,7 +46,7 @@ interface SharedServiceProps { */ getHTMLAttributes?: ( options: ImageTransform, - serviceConfig: Record<string, any> + imageConfig: ImageConfig<T> ) => Record<string, any> | Promise<Record<string, any>>; /** * Validate and return the options passed by the user. @@ -53,18 +58,20 @@ interface SharedServiceProps { */ validateOptions?: ( options: ImageTransform, - serviceConfig: Record<string, any> + imageConfig: ImageConfig<T> ) => ImageTransform | Promise<ImageTransform>; } -export type ExternalImageService = SharedServiceProps; +export type ExternalImageService<T extends Record<string, any> = Record<string, any>> = + SharedServiceProps<T>; export type LocalImageTransform = { src: string; [key: string]: any; }; -export interface LocalImageService extends SharedServiceProps { +export interface LocalImageService<T extends Record<string, any> = Record<string, any>> + extends SharedServiceProps<T> { /** * Parse the requested parameters passed in the URL from `getURL` back into an object to be used later by `transform`. * @@ -72,7 +79,7 @@ export interface LocalImageService extends SharedServiceProps { */ parseURL: ( url: URL, - serviceConfig: Record<string, any> + imageConfig: ImageConfig<T> ) => LocalImageTransform | undefined | Promise<LocalImageTransform> | Promise<undefined>; /** * Performs the image transformations on the input image and returns both the binary data and @@ -81,7 +88,7 @@ export interface LocalImageService extends SharedServiceProps { transform: ( inputBuffer: Buffer, transform: LocalImageTransform, - serviceConfig: Record<string, any> + imageConfig: ImageConfig<T> ) => Promise<{ data: Buffer; format: ImageOutputFormat }>; } @@ -202,21 +209,31 @@ export const baseService: Omit<LocalImageService, 'transform'> = { decoding: attributes.decoding ?? 'async', }; }, - getURL(options: ImageTransform) { - // Both our currently available local services don't handle remote images, so we return the path as is. - if (!isESMImportedImage(options.src)) { + getURL(options, imageConfig) { + const searchParams = new URLSearchParams(); + + if (isESMImportedImage(options.src)) { + searchParams.append('href', options.src.src); + } else if (isRemoteAllowed(options.src, imageConfig)) { + searchParams.append('href', options.src); + } else { + // If it's not an imported image, nor is it allowed using the current domains or remote patterns, we'll just return the original URL return options.src; } - const searchParams = new URLSearchParams(); - searchParams.append('href', options.src.src); + const params: Record<string, keyof typeof options> = { + w: 'width', + h: 'height', + q: 'quality', + f: 'format', + }; - options.width && searchParams.append('w', options.width.toString()); - options.height && searchParams.append('h', options.height.toString()); - options.quality && searchParams.append('q', options.quality.toString()); - options.format && searchParams.append('f', options.format); + Object.entries(params).forEach(([param, key]) => { + options[key] && searchParams.append(param, options[key].toString()); + }); - return joinPaths(import.meta.env.BASE_URL, '/_image?') + searchParams; + const imageEndpoint = joinPaths(import.meta.env.BASE_URL, '/_image'); + return `${imageEndpoint}?${searchParams}`; }, parseURL(url) { const params = url.searchParams; diff --git a/packages/astro/src/assets/utils/remotePattern.ts b/packages/astro/src/assets/utils/remotePattern.ts new file mode 100644 index 000000000..7708b42e7 --- /dev/null +++ b/packages/astro/src/assets/utils/remotePattern.ts @@ -0,0 +1,63 @@ +export type RemotePattern = { + hostname?: string; + pathname?: string; + protocol?: string; + port?: string; +}; + +export function matchPattern(url: URL, remotePattern: RemotePattern) { + return ( + matchProtocol(url, remotePattern.protocol) && + matchHostname(url, remotePattern.hostname, true) && + matchPort(url, remotePattern.port) && + matchPathname(url, remotePattern.pathname, true) + ); +} + +export function matchPort(url: URL, port?: string) { + return !port || port === url.port; +} + +export function matchProtocol(url: URL, protocol?: string) { + return !protocol || protocol === url.protocol.slice(0, -1); +} + +export function matchHostname(url: URL, hostname?: string, allowWildcard?: boolean) { + if (!hostname) { + return true; + } else if (!allowWildcard || !hostname.startsWith('*')) { + return hostname === url.hostname; + } else if (hostname.startsWith('**.')) { + const slicedHostname = hostname.slice(2); // ** length + return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname); + } else if (hostname.startsWith('*.')) { + const slicedHostname = hostname.slice(1); // * length + const additionalSubdomains = url.hostname + .replace(slicedHostname, '') + .split('.') + .filter(Boolean); + return additionalSubdomains.length === 1; + } + + return false; +} + +export function matchPathname(url: URL, pathname?: string, allowWildcard?: boolean) { + if (!pathname) { + return true; + } else if (!allowWildcard || !pathname.endsWith('*')) { + return pathname === url.pathname; + } else if (pathname.endsWith('/**')) { + const slicedPathname = pathname.slice(0, -2); // ** length + return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname); + } else if (pathname.endsWith('/*')) { + const slicedPathname = pathname.slice(0, -1); // * length + const additionalPathChunks = url.pathname + .replace(slicedPathname, '') + .split('/') + .filter(Boolean); + return additionalPathChunks.length === 1; + } + + return false; +} diff --git a/packages/astro/src/assets/utils/transformToPath.ts b/packages/astro/src/assets/utils/transformToPath.ts index 04ddee0a1..d5535137b 100644 --- a/packages/astro/src/assets/utils/transformToPath.ts +++ b/packages/astro/src/assets/utils/transformToPath.ts @@ -5,14 +5,13 @@ import { isESMImportedImage } from '../internal.js'; import type { ImageTransform } from '../types.js'; export function propsToFilename(transform: ImageTransform, hash: string) { - if (!isESMImportedImage(transform.src)) { - return transform.src; - } - - let filename = removeQueryString(transform.src.src); + let filename = removeQueryString( + isESMImportedImage(transform.src) ? transform.src.src : transform.src + ); const ext = extname(filename); filename = basename(filename, ext); - const outputExt = transform.format ? `.${transform.format}` : ext; + + let outputExt = transform.format ? `.${transform.format}` : ext; return `/${filename}_${hash}${outputExt}`; } diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 24de955ba..f194e5288 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -9,7 +9,6 @@ import { removeQueryString, } from '../core/path.js'; import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js'; -import { isESMImportedImage } from './internal.js'; import { emitESMImage } from './utils/emitAsset.js'; import { hashTransform, propsToFilename } from './utils/transformToPath.js'; @@ -45,8 +44,8 @@ export default function assets({ import { getImage as getImageInternal } from "astro/assets"; export { default as Image } from "astro/components/Image.astro"; - export const imageServiceConfig = ${JSON.stringify(settings.config.image.service.config)}; - export const getImage = async (options) => await getImageInternal(options, imageServiceConfig); + export const imageConfig = ${JSON.stringify(settings.config.image)}; + export const getImage = async (options) => await getImageInternal(options, imageConfig); `; } }, @@ -69,15 +68,10 @@ export default function assets({ if (globalThis.astroAsset.staticImages.has(hash)) { filePath = globalThis.astroAsset.staticImages.get(hash)!.path; } else { - // If the image is not imported, we can return the path as-is, since static references - // should only point ot valid paths for builds or remote images - if (!isESMImportedImage(options.src)) { - return options.src; - } - filePath = prependForwardSlash( joinPaths(settings.config.build.assets, propsToFilename(options, hash)) ); + globalThis.astroAsset.staticImages.set(hash, { path: filePath, options: options }); } diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index c3b687d01..92f671b85 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -258,10 +258,19 @@ export class App { const errorRouteData = matchRoute('/' + status, this.#manifestData); const url = new URL(request.url); if (errorRouteData) { - if (errorRouteData.prerender && !errorRouteData.route.endsWith(`/${status}`)) { - const statusURL = new URL(`${this.#baseWithoutTrailingSlash}/${status}`, url); + if (errorRouteData.prerender) { + const maybeDotHtml = errorRouteData.route.endsWith(`/${status}`) ? '.html' : ''; + const statusURL = new URL( + `${this.#baseWithoutTrailingSlash}/${status}${maybeDotHtml}`, + url + ); const response = await fetch(statusURL.toString()); - return this.#mergeResponses(response, originalResponse); + + // response for /404.html and 500.html is 200, which is not meaningful + // so we create an override + const override = { status }; + + return this.#mergeResponses(response, originalResponse, override); } const mod = await this.#getModuleForRoute(errorRouteData); try { @@ -287,14 +296,31 @@ export class App { return response; } - #mergeResponses(newResponse: Response, oldResponse?: Response) { - if (!oldResponse) return newResponse; - const { status, statusText, headers } = oldResponse; + #mergeResponses(newResponse: Response, oldResponse?: Response, override?: { status: 404 | 500 }) { + if (!oldResponse) { + if (override !== undefined) { + return new Response(newResponse.body, { + status: override.status, + statusText: newResponse.statusText, + headers: newResponse.headers, + }); + } + return newResponse; + } + + const { statusText, headers } = oldResponse; + + // If the the new response did not have a meaningful status, an override may have been provided + // If the original status was 200 (default), override it with the new status (probably 404 or 500) + // Otherwise, the user set a specific status while rendering and we should respect that one + const status = override?.status + ? override.status + : oldResponse.status === 200 + ? newResponse.status + : oldResponse.status; return new Response(newResponse.body, { - // If the original status was 200 (default), override it with the new status (probably 404 or 500) - // Otherwise, the user set a specific status while rendering and we should respect that one - status: status === 200 ? newResponse.status : status, + status, statusText: status === 200 ? newResponse.statusText : statusText, headers: new Headers(Array.from(headers)), }); diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index f31af36db..054064a08 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -10,20 +10,33 @@ export { apply as applyPolyfills } from '../polyfill.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); -function createRequestFromNodeRequest(req: NodeIncomingMessage, body?: Uint8Array): Request { +type CreateNodeRequestOptions = { + emptyBody?: boolean; +}; + +type BodyProps = Partial<RequestInit>; + +function createRequestFromNodeRequest( + req: NodeIncomingMessage, + options?: CreateNodeRequestOptions +): Request { const protocol = req.socket instanceof TLSSocket || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; const hostname = req.headers.host || req.headers[':authority']; const url = `${protocol}://${hostname}${req.url}`; - const rawHeaders = req.headers as Record<string, any>; - const entries = Object.entries(rawHeaders); + const headers = makeRequestHeaders(req); const method = req.method || 'GET'; + let bodyProps: BodyProps = {}; + const bodyAllowed = method !== 'HEAD' && method !== 'GET' && !options?.emptyBody; + if (bodyAllowed) { + bodyProps = makeRequestBody(req); + } const request = new Request(url, { method, - headers: new Headers(entries), - body: ['HEAD', 'GET'].includes(method) ? null : body, + headers, + ...bodyProps, }); if (req.socket?.remoteAddress) { Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress); @@ -31,63 +44,83 @@ function createRequestFromNodeRequest(req: NodeIncomingMessage, body?: Uint8Arra return request; } -class NodeIncomingMessage extends IncomingMessage { - /** - * The read-only body property of the Request interface contains a ReadableStream with the body contents that have been added to the request. - */ - body?: unknown; +function makeRequestHeaders(req: NodeIncomingMessage): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); + } + } else { + headers.append(name, value); + } + } + return headers; } -export class NodeApp extends App { - match(req: NodeIncomingMessage | Request, opts: MatchOptions = {}) { - return super.match(req instanceof Request ? req : createRequestFromNodeRequest(req), opts); - } - render(req: NodeIncomingMessage | Request, routeData?: RouteData, locals?: object) { +function makeRequestBody(req: NodeIncomingMessage): BodyProps { + if (req.body !== undefined) { if (typeof req.body === 'string' && req.body.length > 0) { - return super.render( - req instanceof Request ? req : createRequestFromNodeRequest(req, Buffer.from(req.body)), - routeData, - locals - ); + return { body: Buffer.from(req.body) }; } if (typeof req.body === 'object' && req.body !== null && Object.keys(req.body).length > 0) { - return super.render( - req instanceof Request - ? req - : createRequestFromNodeRequest(req, Buffer.from(JSON.stringify(req.body))), - routeData, - locals - ); + return { body: Buffer.from(JSON.stringify(req.body)) }; } - if ('on' in req) { - let body = Buffer.from([]); - let reqBodyComplete = new Promise((resolve, reject) => { - req.on('data', (d) => { - body = Buffer.concat([body, d]); - }); - req.on('end', () => { - resolve(body); - }); - req.on('error', (err) => { - reject(err); - }); - }); + // This covers all async iterables including Readable and ReadableStream. + if ( + typeof req.body === 'object' && + req.body !== null && + typeof (req.body as any)[Symbol.asyncIterator] !== 'undefined' + ) { + return asyncIterableToBodyProps(req.body as AsyncIterable<any>); + } + } + + // Return default body. + return asyncIterableToBodyProps(req); +} + +function asyncIterableToBodyProps(iterable: AsyncIterable<any>): BodyProps { + return { + // Node uses undici for the Request implementation. Undici accepts + // a non-standard async iterable for the body. + // @ts-expect-error + body: iterable, + // The duplex property is required when using a ReadableStream or async + // iterable for the body. The type definitions do not include the duplex + // property because they are not up-to-date. + // @ts-expect-error + duplex: 'half', + } satisfies BodyProps; +} + +class NodeIncomingMessage extends IncomingMessage { + /** + * Allow the request body to be explicitly overridden. For example, this + * is used by the Express JSON middleware. + */ + body?: unknown; +} - return reqBodyComplete.then(() => { - return super.render( - req instanceof Request ? req : createRequestFromNodeRequest(req, body), - routeData, - locals - ); +export class NodeApp extends App { + match(req: NodeIncomingMessage | Request, opts: MatchOptions = {}) { + if (!(req instanceof Request)) { + req = createRequestFromNodeRequest(req, { + emptyBody: true, }); } - return super.render( - req instanceof Request ? req : createRequestFromNodeRequest(req), - routeData, - locals - ); + return super.match(req, opts); + } + render(req: NodeIncomingMessage | Request, routeData?: RouteData, locals?: object) { + if (!(req instanceof Request)) { + req = createRequestFromNodeRequest(req); + } + return super.render(req, routeData, locals); } } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 00be46ea9..4e89dfb61 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -19,7 +19,7 @@ import type { import { generateImage as generateImageInternal, getStaticImageList, -} from '../../assets/generate.js'; +} from '../../assets/build/generate.js'; import { hasPrerenderedPages, type BuildInternals } from '../../core/build/internal.js'; import { isRelativePath, diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index cbb259e03..a1c7c3e56 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -311,8 +311,12 @@ async function runPostBuildHooks( async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInternals) { const allStaticFiles = new Set(); for (const pageData of eachPageData(internals)) { - if (pageData.route.prerender) - allStaticFiles.add(internals.pageToBundleMap.get(pageData.moduleSpecifier)); + if (pageData.route.prerender) { + const { moduleSpecifier } = pageData; + const pageBundleId = internals.pageToBundleMap.get(moduleSpecifier); + const entryBundleId = internals.entrySpecifierToBundleMap.get(moduleSpecifier); + allStaticFiles.add(pageBundleId ?? entryBundleId); + } } const ssr = isServerLikeOutput(opts.settings.config); const out = ssr @@ -340,7 +344,8 @@ async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInter // Replace exports (only prerendered pages) with a noop let value = 'const noop = () => {};'; for (const e of exports) { - value += `\nexport const ${e.n} = noop;`; + if (e.n === 'default') value += `\n export default noop;`; + else value += `\nexport const ${e.n} = noop;`; } await fs.promises.writeFile(url, value, { encoding: 'utf8' }); }) @@ -355,6 +360,8 @@ async function cleanServerOutput(opts: StaticBuildOptions) { // The SSR output is all .mjs files, the client output is not. const files = await glob('**/*.mjs', { cwd: fileURLToPath(out), + // Important! Also cleanup dotfiles like `node_modules/.pnpm/**` + dot: true, }); if (files.length) { // Remove all the SSR generated .mjs files diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 48b0f3a59..bff55b392 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -189,6 +189,30 @@ export const AstroConfigSchema = z.object({ ]), config: z.record(z.any()).default({}), }), + domains: z.array(z.string()).default([]), + remotePatterns: z + .array( + z.object({ + protocol: z.string().optional(), + hostname: z + .string() + .refine( + (val) => !val.includes('*') || val.startsWith('*.') || val.startsWith('**.'), + { + message: 'wildcards can only be placed at the beginning of the hostname', + } + ) + .optional(), + port: z.string().optional(), + pathname: z + .string() + .refine((val) => !val.includes('*') || val.endsWith('/*') || val.endsWith('/**'), { + message: 'wildcards can only be placed at the end of a pathname', + }) + .optional(), + }) + ) + .default([]), }) .default({ service: { entrypoint: 'astro/assets/services/sharp', config: {} }, diff --git a/packages/astro/src/core/config/tsconfig.ts b/packages/astro/src/core/config/tsconfig.ts index 5a5d3fc64..a0c78f08c 100644 --- a/packages/astro/src/core/config/tsconfig.ts +++ b/packages/astro/src/core/config/tsconfig.ts @@ -1,4 +1,3 @@ -import { deepmerge } from 'deepmerge-ts'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import * as tsr from 'tsconfig-resolver'; @@ -96,5 +95,28 @@ export function updateTSConfigForFramework( return target; } - return deepmerge(target, presets.get(framework)!); + return deepMergeObjects(target, presets.get(framework)!); +} + +// Simple deep merge implementation that merges objects and strings +function deepMergeObjects<T extends Record<string, any>>(a: T, b: T): T { + const merged: T = { ...a }; + + for (const key in b) { + const value = b[key]; + + if (a[key] == null) { + merged[key] = value; + continue; + } + + if (typeof a[key] === 'object' && typeof value === 'object') { + merged[key] = deepMergeObjects(a[key], value); + continue; + } + + merged[key] = value; + } + + return merged; } diff --git a/packages/astro/src/vite-plugin-integrations-container/index.ts b/packages/astro/src/vite-plugin-integrations-container/index.ts index d6bfd76d7..6cc2da152 100644 --- a/packages/astro/src/vite-plugin-integrations-container/index.ts +++ b/packages/astro/src/vite-plugin-integrations-container/index.ts @@ -16,9 +16,9 @@ export default function astroIntegrationsContainerPlugin({ }): VitePlugin { return { name: 'astro:integration-container', - configureServer(server) { + async configureServer(server) { if (server.config.isProduction) return; - runHookServerSetup({ config: settings.config, server, logging }); + await runHookServerSetup({ config: settings.config, server, logging }); }, async buildStart() { if (settings.injectedRoutes.length === settings.resolvedInjectedRoutes.length) return; |