diff --git a/.changeset/fluffy-readers-add.md b/.changeset/fluffy-readers-add.md new file mode 100644 index 000000000000..22adc9bc621f --- /dev/null +++ b/.changeset/fluffy-readers-add.md @@ -0,0 +1,26 @@ +--- +"@astrojs/internal-helpers": minor +"astro": minor +--- + +Adds the option to pass an object to `build.assetsPrefix`. This allows for the use of multiple CDN prefixes based on the target file type. + +When passing an object to `build.assetsPrefix`, you must also specify a `fallback` domain to be used for all other file types not specified. + +Specify a file extension as the key (e.g. 'js', 'png') and the URL serving your assets of that file type as the value: + +```js +// astro.config.mjs +import { defineConfig } from "astro/config" + +export default defineConfig({ + build: { + assetsPrefix: { + 'js': "https://js.cdn.example.com", + 'mjs': "https://js.cdn.example.com", // if you have .mjs files, you must add a new entry like this + 'png': "https://images.cdn.example.com", + 'fallback': "https://generic.cdn.example.com" + } + } +}) +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index a3fc4b50bf40..1e149520559b 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -13,7 +13,7 @@ import type * as babel from '@babel/core'; import type * as rollup from 'rollup'; import type * as vite from 'vite'; import type { RemotePattern } from '../assets/utils/remotePattern.js'; -import type { SerializedSSRManifest } from '../core/app/types.js'; +import type { SerializedSSRManifest, AssetsPrefix } from '../core/app/types.js'; import type { PageBuildData } from '../core/build/types.js'; import type { AstroConfigType } from '../core/config/index.js'; import type { AstroTimer } from '../core/config/timer.js'; @@ -67,7 +67,7 @@ export type { UnresolvedImageTransform, } from '../assets/types.js'; export type { RemotePattern } from '../assets/utils/remotePattern.js'; -export type { SSRManifest } from '../core/app/types.js'; +export type { SSRManifest, AssetsPrefix } from '../core/app/types.js'; export type { AstroCookieGetOptions, AstroCookieSetOptions, @@ -881,15 +881,15 @@ export interface AstroUserConfig { /** * @docs * @name build.assetsPrefix - * @type {string} + * @type {string | Record} * @default `undefined` * @version 2.2.0 * @description * Specifies the prefix for Astro-generated asset links. This can be used if assets are served from a different domain than the current site. * - * For example, if this is set to `https://cdn.example.com`, assets will be fetched from `https://cdn.example.com/_astro/...` (regardless of the `base` option). - * You would need to upload the files in `./dist/_astro/` to `https://cdn.example.com/_astro/` to serve the assets. - * The process varies depending on how the third-party domain is hosted. + * This requires uploading the assets in your local `./dist/_astro` folder to a corresponding `/_astro/` folder on the remote domain. + * + * To fetch all assets uploaded to the same domain (e.g. `https://cdn.example.com/_astro/...`), set `assetsPrefix` to the root domain as a string (regardless of your `base` configuration): * To rename the `_astro` path, specify a new directory in `build.assets`. * * ```js @@ -899,8 +899,27 @@ export interface AstroUserConfig { * } * } * ``` + * + * **Added in 4.5.0** + * + * You can also pass an object to `assetsPrefix` to specify a different domain for each file type. + * In this case, a `fallback` property is required and will be used by default for any other files. + * + * ```js + * { + * build: { + * assetsPrefix: { + * 'js': 'https://js.cdn.example.com', + * 'mjs': 'https://js.cdn.example.com', + * 'css': 'https://css.cdn.example.com', + * 'fallback': 'https://cdn.example.com' + * } + * } + * } + * ``` + * */ - assetsPrefix?: string; + assetsPrefix?: AssetsPrefix; /** * @docs * @name build.serverEntry diff --git a/packages/astro/src/assets/utils/getAssetsPrefix.ts b/packages/astro/src/assets/utils/getAssetsPrefix.ts new file mode 100644 index 000000000000..1a8947b54978 --- /dev/null +++ b/packages/astro/src/assets/utils/getAssetsPrefix.ts @@ -0,0 +1,12 @@ +import type { AssetsPrefix } from '../../core/app/types.js'; + +export function getAssetsPrefix(fileExtension: string, assetsPrefix?: AssetsPrefix): string { + if (!assetsPrefix) return ''; + if (typeof assetsPrefix === 'string') return assetsPrefix; + // we assume the file extension has a leading '.' and we remove it + const dotLessFileExtension = fileExtension.slice(1); + if (assetsPrefix[dotLessFileExtension]) { + return assetsPrefix[dotLessFileExtension]; + } + return assetsPrefix.fallback; +} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 6afd01c2a6ba..8ca2f122a8de 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -1,6 +1,7 @@ import MagicString from 'magic-string'; import type * as vite from 'vite'; import { normalizePath } from 'vite'; +import { extname } from 'node:path'; import type { AstroPluginOptions, ImageTransform } from '../@types/astro.js'; import { extendManualChunks } from '../core/build/plugins/util.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; @@ -16,6 +17,7 @@ import { emitESMImage } from './utils/emitAsset.js'; import { isESMImportedImage } from './utils/imageKind.js'; import { getProxyCode } from './utils/proxy.js'; import { hashTransform, propsToFilename } from './utils/transformToPath.js'; +import { getAssetsPrefix } from './utils/getAssetsPrefix.js'; const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; @@ -95,9 +97,12 @@ export default function assets({ } // Rollup will copy the file to the output directory, this refer to this final path, not to the original path - const finalOriginalImagePath = ( - isESMImportedImage(options.src) ? options.src.src : options.src - ).replace(settings.config.build.assetsPrefix || '', ''); + const ESMImportedImageSrc = isESMImportedImage(options.src) + ? options.src.src + : options.src; + const fileExtension = extname(ESMImportedImageSrc); + const pf = getAssetsPrefix(fileExtension, settings.config.build.assetsPrefix); + const finalOriginalImagePath = ESMImportedImageSrc.replace(pf, ''); const hash = hashTransform( options, @@ -132,7 +137,7 @@ export default function assets({ // The paths here are used for URLs, so we need to make sure they have the proper format for an URL // (leading slash, prefixed with the base / assets prefix, encoded, etc) if (settings.config.build.assetsPrefix) { - return encodeURI(joinPaths(settings.config.build.assetsPrefix, finalFilePath)); + return encodeURI(joinPaths(pf, finalFilePath)); } else { return encodeURI(prependForwardSlash(joinPaths(settings.config.base, finalFilePath))); } @@ -149,9 +154,9 @@ export default function assets({ const [full, hash, postfix = ''] = match; const file = this.getFileName(hash); - const prefix = settings.config.build.assetsPrefix - ? appendForwardSlash(settings.config.build.assetsPrefix) - : resolvedConfig.base; + const fileExtension = extname(file); + const pf = getAssetsPrefix(fileExtension, settings.config.build.assetsPrefix); + const prefix = pf ? appendForwardSlash(pf) : resolvedConfig.base; const outputFilepath = prefix + normalizePath(file + postfix); s.overwrite(match.index, match.index + full.length, outputFilepath); diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts index af12c8b89cca..fc0f5e7599a8 100644 --- a/packages/astro/src/content/vite-plugin-content-assets.ts +++ b/packages/astro/src/content/vite-plugin-content-assets.ts @@ -19,6 +19,7 @@ import { STYLES_PLACEHOLDER, } from './consts.js'; import { hasContentFlag } from './utils.js'; +import { getAssetsPrefix } from '../assets/utils/getAssetsPrefix.js'; export function astroContentAssetPropagationPlugin({ mode, @@ -148,8 +149,11 @@ export function astroConfigBuildPlugin( 'build:post': ({ ssrOutputs, clientOutputs, mutate }) => { const outputs = ssrOutputs.flatMap((o) => o.output); const prependBase = (src: string) => { - if (options.settings.config.build.assetsPrefix) { - return joinPaths(options.settings.config.build.assetsPrefix, src); + const { assetsPrefix } = options.settings.config.build; + if (assetsPrefix) { + const fileExtension = extname(src); + const pf = getAssetsPrefix(fileExtension, assetsPrefix); + return joinPaths(pf, src); } else { return prependForwardSlash(joinPaths(options.settings.config.base, src)); } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index dbea389b981a..2596ab3a69f2 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -35,6 +35,13 @@ export type SerializedRouteInfo = Omit & { export type ImportComponentInstance = () => Promise; +export type AssetsPrefix = + | string + | ({ + fallback: string; + } & Record) + | undefined; + export type SSRManifest = { adapterName: string; routes: RouteInfo[]; @@ -43,7 +50,7 @@ export type SSRManifest = { trailingSlash: 'always' | 'never' | 'ignore'; buildFormat: 'file' | 'directory' | 'preserve'; compressHTML: boolean; - assetsPrefix?: string; + assetsPrefix?: AssetsPrefix; renderers: SSRLoadedRenderer[]; /** * Map of directive name (e.g. `load`) to the directive script code diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 8e883a20687d..9765c34a6404 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -11,13 +11,14 @@ import type { SerializedRouteInfo, SerializedSSRManifest, } from '../../app/types.js'; -import { joinPaths, prependForwardSlash } from '../../path.js'; +import { joinPaths, prependForwardSlash, fileExtension } from '../../path.js'; import { serializeRouteData } from '../../routing/index.js'; import { addRollupInput } from '../add-rollup-input.js'; import { getOutFile, getOutFolder } from '../common.js'; import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; +import { getAssetsPrefix } from '../../../assets/utils/getAssetsPrefix.js'; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const replaceExp = new RegExp(`['"]${manifestReplace}['"]`, 'g'); @@ -163,7 +164,8 @@ function buildManifest( const prefixAssetPath = (pth: string) => { if (settings.config.build.assetsPrefix) { - return joinPaths(settings.config.build.assetsPrefix, pth); + const pf = getAssetsPrefix(fileExtension(pth), settings.config.build.assetsPrefix); + return joinPaths(pf, pth); } else { return prependForwardSlash(joinPaths(settings.config.base, pth)); } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 7a3f3838f655..248190929ad9 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -11,7 +11,7 @@ import type { AstroUserConfig, ViteUserConfig } from '../../@types/astro.js'; import type { OutgoingHttpHeaders } from 'node:http'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { z } from 'zod'; +import { type TypeOf, z } from 'zod'; import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../path.js'; // These imports are required to appease TypeScript! @@ -134,7 +134,23 @@ export const AstroConfigSchema = z.object({ .default(ASTRO_CONFIG_DEFAULTS.build.server) .transform((val) => new URL(val)), assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets), - assetsPrefix: z.string().optional(), + assetsPrefix: z + .string() + .optional() + .or(z.object({ fallback: z.string() }).and(z.record(z.string())).optional()) + .refine( + (value) => { + if (value && typeof value !== 'string') { + if (!value.fallback) { + return false; + } + } + return true; + }, + { + message: 'The `fallback` is mandatory when defining the option as an object.', + } + ), serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry), redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects), inlineStylesheets: z @@ -524,7 +540,23 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) { .default(ASTRO_CONFIG_DEFAULTS.build.server) .transform((val) => resolveDirAsUrl(val, fileProtocolRoot)), assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets), - assetsPrefix: z.string().optional(), + assetsPrefix: z + .string() + .optional() + .or(z.object({ fallback: z.string() }).and(z.record(z.string())).optional()) + .refine( + (value) => { + if (value && typeof value !== 'string') { + if (!value.fallback) { + return false; + } + } + return true; + }, + { + message: 'The `fallback` is mandatory when defining the option as an object.', + } + ), serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry), redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects), inlineStylesheets: z diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 662644fd3762..9ef4a117075e 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -35,6 +35,8 @@ import type { Logger } from './logger/core.js'; import { createViteLogger } from './logger/vite.js'; import { vitePluginMiddleware } from './middleware/vite-plugin.js'; import { joinPaths } from './path.js'; +import { isObject } from './util.js'; +import { getAssetsPrefix } from '../assets/utils/getAssetsPrefix.js'; interface CreateViteOptions { settings: AstroSettings; @@ -214,9 +216,9 @@ export async function createVite( const assetsPrefix = settings.config.build.assetsPrefix; if (assetsPrefix) { commonConfig.experimental = { - renderBuiltUrl(filename, { type }) { + renderBuiltUrl(filename, { type, hostType }) { if (type === 'asset') { - return joinPaths(assetsPrefix, filename); + return joinPaths(getAssetsPrefix(`.${hostType}`, assetsPrefix), filename); } }, }; @@ -318,6 +320,6 @@ function isCommonNotAstro(dep: string): boolean { ); } -function stringifyForDefine(value: string | undefined): string { - return typeof value === 'string' ? JSON.stringify(value) : 'undefined'; +function stringifyForDefine(value: string | undefined | object): string { + return typeof value === 'string' || isObject(value) ? JSON.stringify(value) : 'undefined'; } diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts index 8593dc0bfcc8..e1a319f401ee 100644 --- a/packages/astro/src/core/render/ssr-element.ts +++ b/packages/astro/src/core/render/ssr-element.ts @@ -1,10 +1,12 @@ -import type { SSRElement } from '../../@types/astro.js'; -import { joinPaths, prependForwardSlash, slash } from '../../core/path.js'; +import type { SSRElement, AssetsPrefix } from '../../@types/astro.js'; +import { fileExtension, joinPaths, prependForwardSlash, slash } from '../../core/path.js'; import type { StylesheetAsset } from '../app/types.js'; +import { getAssetsPrefix } from '../../assets/utils/getAssetsPrefix.js'; -export function createAssetLink(href: string, base?: string, assetsPrefix?: string): string { +export function createAssetLink(href: string, base?: string, assetsPrefix?: AssetsPrefix): string { if (assetsPrefix) { - return joinPaths(assetsPrefix, slash(href)); + const pf = getAssetsPrefix(fileExtension(href), assetsPrefix); + return joinPaths(pf, slash(href)); } else if (base) { return prependForwardSlash(joinPaths(base, slash(href))); } else { @@ -15,7 +17,7 @@ export function createAssetLink(href: string, base?: string, assetsPrefix?: stri export function createStylesheetElement( stylesheet: StylesheetAsset, base?: string, - assetsPrefix?: string + assetsPrefix?: AssetsPrefix ): SSRElement { if (stylesheet.type === 'inline') { return { @@ -36,7 +38,7 @@ export function createStylesheetElement( export function createStylesheetElementSet( stylesheets: StylesheetAsset[], base?: string, - assetsPrefix?: string + assetsPrefix?: AssetsPrefix ): Set { return new Set(stylesheets.map((s) => createStylesheetElement(s, base, assetsPrefix))); } @@ -44,7 +46,7 @@ export function createStylesheetElementSet( export function createModuleScriptElement( script: { type: 'inline' | 'external'; value: string }, base?: string, - assetsPrefix?: string + assetsPrefix?: AssetsPrefix ): SSRElement { if (script.type === 'external') { return createModuleScriptElementWithSrc(script.value, base, assetsPrefix); @@ -61,7 +63,7 @@ export function createModuleScriptElement( export function createModuleScriptElementWithSrc( src: string, base?: string, - assetsPrefix?: string + assetsPrefix?: AssetsPrefix ): SSRElement { return { props: { @@ -75,7 +77,7 @@ export function createModuleScriptElementWithSrc( export function createModuleScriptElementWithSrcSet( srces: string[], site?: string, - assetsPrefix?: string + assetsPrefix?: AssetsPrefix ): Set { return new Set( srces.map((src) => createModuleScriptElementWithSrc(src, site, assetsPrefix)) @@ -85,7 +87,7 @@ export function createModuleScriptElementWithSrcSet( export function createModuleScriptsSet( scripts: { type: 'inline' | 'external'; value: string }[], base?: string, - assetsPrefix?: string + assetsPrefix?: AssetsPrefix ): Set { return new Set( scripts.map((script) => createModuleScriptElement(script, base, assetsPrefix)) diff --git a/packages/astro/test/astro-assets-prefix-multi-cdn.test.js b/packages/astro/test/astro-assets-prefix-multi-cdn.test.js new file mode 100644 index 000000000000..9dbf259b2d96 --- /dev/null +++ b/packages/astro/test/astro-assets-prefix-multi-cdn.test.js @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import testAdapter from './test-adapter.js'; +import { loadFixture } from './test-utils.js'; + +const defaultAssetsPrefixRegex = /^https:\/\/example.com\/_astro\/.*/; +const jsAssetsPrefixRegex = /^https:\/\/js\.example\.com\/_astro\/.*/; +const cssAssetsPrefixRegex = /^https:\/\/css\.example\.com\/_astro\/.*/; +const assetsPrefix = { + js: 'https://js.example.com', + css: 'https://css.example.com', + fallback: 'https://example.com', +}; + +// Asset prefix for CDN support +describe('Assets Prefix Multiple CDN - Static', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-assets-prefix', + build: { + assetsPrefix, + }, + }); + await fixture.build(); + }); + + it('all stylesheets should start with cssAssetPrefix', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + const stylesheets = $('link[rel="stylesheet"]'); + stylesheets.each((i, el) => { + assert.match(el.attribs.href, cssAssetsPrefixRegex); + }); + }); + + it('image src start with fallback', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + const imgAsset = $('#image-asset'); + assert.match(imgAsset.attr('src'), defaultAssetsPrefixRegex); + }); + + it('react component astro-island should import from jsAssetsPrefix', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + const island = $('astro-island'); + assert.match(island.attr('component-url'), jsAssetsPrefixRegex); + assert.match(island.attr('renderer-url'), jsAssetsPrefixRegex); + }); + + it('import.meta.env.ASSETS_PREFIX works', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + const env = $('#assets-prefix-env'); + assert.deepEqual(JSON.parse(env.text()), assetsPrefix); + }); + + it('markdown image src start with assetsPrefix', async () => { + const html = await fixture.readFile('/markdown/index.html'); + const $ = cheerio.load(html); + const imgAssets = $('img'); + imgAssets.each((i, el) => { + assert.match(el.attribs.src, defaultAssetsPrefixRegex); + }); + }); + + it('content collections image src start with assetsPrefix', async () => { + const html = await fixture.readFile('/blog/index.html'); + const $ = cheerio.load(html); + const imgAsset = $('img'); + assert.match(imgAsset.attr('src'), defaultAssetsPrefixRegex); + }); +}); + +describe('Assets Prefix Multiple CDN, server', () => { + let app; + + before(async () => { + const fixture = await loadFixture({ + root: './fixtures/astro-assets-prefix', + output: 'server', + adapter: testAdapter(), + build: { + assetsPrefix, + }, + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('all stylesheets should start with assetPrefix', async () => { + const request = new Request('http://example.com/custom-base/'); + const response = await app.render(request); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + const stylesheets = $('link[rel="stylesheet"]'); + stylesheets.each((i, el) => { + assert.match(el.attribs.href, cssAssetsPrefixRegex); + }); + }); + + it('image src start with assetsPrefix', async () => { + const request = new Request('http://example.com/custom-base/'); + const response = await app.render(request); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + const imgAsset = $('#image-asset'); + assert.match(imgAsset.attr('src'), defaultAssetsPrefixRegex); + }); + + it('react component astro-island should import from assetsPrefix', async () => { + const request = new Request('http://example.com/custom-base/'); + const response = await app.render(request); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + const island = $('astro-island'); + assert.match(island.attr('component-url'), jsAssetsPrefixRegex); + assert.match(island.attr('renderer-url'), jsAssetsPrefixRegex); + }); + + it('markdown optimized image src does not start with assetsPrefix in SSR', async () => { + const request = new Request('http://example.com/custom-base/markdown/'); + const response = await app.render(request); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + const imgAsset = $('img'); + assert.doesNotMatch(imgAsset.attr('src'), defaultAssetsPrefixRegex); + }); +}); diff --git a/packages/astro/test/fixtures/astro-assets-prefix/src/pages/index.astro b/packages/astro/test/fixtures/astro-assets-prefix/src/pages/index.astro index 67f6e97fa56c..4158153205f4 100644 --- a/packages/astro/test/fixtures/astro-assets-prefix/src/pages/index.astro +++ b/packages/astro/test/fixtures/astro-assets-prefix/src/pages/index.astro @@ -13,7 +13,7 @@ import Counter from '../components/Counter.jsx'; penguin penguin -

{import.meta.env.ASSETS_PREFIX}

+

{typeof import.meta.env.ASSETS_PREFIX === 'string' ? import.meta.env.ASSETS_PREFIX : JSON.stringify(import.meta.env.ASSETS_PREFIX)}