0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-06 22:10:10 -05:00

Add multiple cdn v2 (#10189)

* feat: add multiple cdn

* add multiple cdn test

* assetsPrefix not empty string

* add changeset

* simplify code

* change docs

* replace getFileExtension with node path.extname

* Adapt node extname

* multiple image test

* wip space

* update docs

* update docs, assetsPrefix type

* update docs

* update docs

* chore: update types and rename to `fallback`

* enhance changelog

* change docs

* update change defaultAeestsPrefix to fallback key test

* move utility to a new to avoid importing `node:path` inside vite plugins

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* chore: address Bjorn's comments

* kill the variable

* kill the variable /2

* Fix CI fail

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* uniform code sample

* add `.` string for fit getAssetsPrefix

* Fix extension function

---------

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: bluwy <bjornlu.dev@gmail.com>
Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>
This commit is contained in:
Zhang Zhipeng 2024-03-08 21:10:07 +08:00 committed by GitHub
parent d9336668e5
commit 1ea0a25b94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 289 additions and 37 deletions

View file

@ -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"
}
}
})
```

View file

@ -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<string, string>}
* @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

View file

@ -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;
}

View file

@ -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);

View file

@ -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));
}

View file

@ -35,6 +35,13 @@ export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & {
export type ImportComponentInstance = () => Promise<SinglePageBuiltModule>;
export type AssetsPrefix =
| string
| ({
fallback: string;
} & Record<string, string>)
| 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

View file

@ -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));
}

View file

@ -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

View file

@ -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';
}

View file

@ -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<SSRElement> {
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<SSRElement> {
return new Set<SSRElement>(
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<SSRElement> {
return new Set<SSRElement>(
scripts.map((script) => createModuleScriptElement(script, base, assetsPrefix))

View file

@ -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);
});
});

View file

@ -13,7 +13,7 @@ import Counter from '../components/Counter.jsx';
<img id="image-asset" src={p1Image.src} width={p1Image.width} height={p1Image.height} alt="penguin" />
<Image src={p1Image} alt="penguin" />
<Counter client:load />
<p id="assets-prefix-env">{import.meta.env.ASSETS_PREFIX}</p>
<p id="assets-prefix-env">{typeof import.meta.env.ASSETS_PREFIX === 'string' ? import.meta.env.ASSETS_PREFIX : JSON.stringify(import.meta.env.ASSETS_PREFIX)}</p>
<style>
h1 {
color: red;

View file

@ -92,3 +92,8 @@ export function isRemotePath(src: string) {
export function slash(path: string) {
return path.replace(/\\/g, '/');
}
export function fileExtension(path: string) {
const ext = path.split('.').pop();
return ext !== path ? `.${ext}` : '';
}