summaryrefslogtreecommitdiff
path: root/packages/astro/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro/src')
-rw-r--r--packages/astro/src/@types/astro.ts80
-rw-r--r--packages/astro/src/assets/build/generate.ts174
-rw-r--r--packages/astro/src/assets/build/remote.ts48
-rw-r--r--packages/astro/src/assets/generate.ts132
-rw-r--r--packages/astro/src/assets/image-endpoint.ts17
-rw-r--r--packages/astro/src/assets/internal.ts39
-rw-r--r--packages/astro/src/assets/services/service.ts55
-rw-r--r--packages/astro/src/assets/utils/remotePattern.ts63
-rw-r--r--packages/astro/src/assets/utils/transformToPath.ts11
-rw-r--r--packages/astro/src/assets/vite-plugin-assets.ts12
-rw-r--r--packages/astro/src/core/app/index.ts44
-rw-r--r--packages/astro/src/core/app/node.ts135
-rw-r--r--packages/astro/src/core/build/generate.ts2
-rw-r--r--packages/astro/src/core/build/static-build.ts13
-rw-r--r--packages/astro/src/core/config/schema.ts24
-rw-r--r--packages/astro/src/core/config/tsconfig.ts26
-rw-r--r--packages/astro/src/vite-plugin-integrations-container/index.ts4
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;