feat: astro features (#7815)

This commit is contained in:
Emanuele Stoppa 2023-07-28 12:15:09 +01:00
parent 3fdf509b27
commit 9b4f70a629
20 changed files with 598 additions and 32 deletions

View file

@ -0,0 +1,32 @@
---
'@astrojs/cloudflare': minor
'@astrojs/netlify': minor
'@astrojs/vercel': minor
'@astrojs/deno': minor
'@astrojs/node': minor
'astro': minor
---
Introduced the concept of feature map. A feature map is a list of features that are built-in in Astro, and an Adapter
can tell Astro if it can support it.
```ts
import {AstroIntegration} from "./astro";
function myIntegration(): AstroIntegration {
return {
name: 'astro-awesome-list',
// new feature map
supportedAstroFeatures: {
hybridOutput: 'experimental',
staticOutput: 'stable',
serverOutput: 'stable',
assets: {
supportKind: 'stable',
isSharpCompatible: false,
isSquooshCompatible: false,
},
}
}
}
```

View file

@ -1680,6 +1680,39 @@ export type PaginateFunction = (data: any[], args?: PaginateOptions) => GetStati
export type Params = Record<string, string | undefined>; export type Params = Record<string, string | undefined>;
export type SupportsKind = 'unsupported' | 'stable' | 'experimental' | 'deprecated';
export type AstroFeatureMap = {
/**
* The adapter is able serve static pages
*/
staticOutput?: SupportsKind;
/**
* The adapter is able to serve pages that are static or rendered via server
*/
hybridOutput?: SupportsKind;
/**
* The adapter is able to serve SSR pages
*/
serverOutput?: SupportsKind;
/**
* The adapter can emit static assets
*/
assets?: AstroAssetsFeature;
};
export interface AstroAssetsFeature {
supportKind?: SupportsKind;
/**
* Whether if this adapter deploys files in an enviroment that is compatible with the library `sharp`
*/
isSharpCompatible?: boolean;
/**
* Whether if this adapter deploys files in an enviroment that is compatible with the library `squoosh`
*/
isSquooshCompatible?: boolean;
}
export interface AstroAdapter { export interface AstroAdapter {
name: string; name: string;
serverEntrypoint?: string; serverEntrypoint?: string;
@ -1687,6 +1720,12 @@ export interface AstroAdapter {
exports?: string[]; exports?: string[];
args?: any; args?: any;
adapterFeatures?: AstroAdapterFeatures; adapterFeatures?: AstroAdapterFeatures;
/**
* List of features supported by an adapter.
*
* If the adapter is not able to handle certain configurations, Astro will throw an error.
*/
supportedAstroFeatures?: AstroFeatureMap;
} }
type Body = string; type Body = string;

View file

@ -27,6 +27,11 @@ export async function generateImage(
options: ImageTransform, options: ImageTransform,
filepath: string filepath: string
): Promise<GenerationData | undefined> { ): 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)) { if (!isESMImportedImage(options.src)) {
return undefined; return undefined;
} }

View file

@ -1,10 +1,8 @@
import { bold } from 'kleur/colors';
import MagicString from 'magic-string'; import MagicString from 'magic-string';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import type * as vite from 'vite'; import type * as vite from 'vite';
import { normalizePath } from 'vite'; import { normalizePath } from 'vite';
import type { AstroPluginOptions, ImageTransform } from '../@types/astro'; import type { AstroPluginOptions, ImageTransform } from '../@types/astro';
import { error } from '../core/logger/core.js';
import { import {
appendForwardSlash, appendForwardSlash,
joinPaths, joinPaths,
@ -23,37 +21,12 @@ const urlRE = /(\?|&)url(?:&|$)/;
export default function assets({ export default function assets({
settings, settings,
logging,
mode, mode,
}: AstroPluginOptions & { mode: string }): vite.Plugin[] { }: AstroPluginOptions & { mode: string }): vite.Plugin[] {
let resolvedConfig: vite.ResolvedConfig; let resolvedConfig: vite.ResolvedConfig;
globalThis.astroAsset = {}; globalThis.astroAsset = {};
const UNSUPPORTED_ADAPTERS = new Set([
'@astrojs/cloudflare',
'@astrojs/deno',
'@astrojs/netlify/edge-functions',
'@astrojs/vercel/edge',
]);
const adapterName = settings.config.adapter?.name;
if (
['astro/assets/services/sharp', 'astro/assets/services/squoosh'].includes(
settings.config.image.service.entrypoint
) &&
adapterName &&
UNSUPPORTED_ADAPTERS.has(adapterName)
) {
error(
logging,
'assets',
`The currently selected adapter \`${adapterName}\` does not run on Node, however the currently used image service depends on Node built-ins. ${bold(
'Your project will NOT be able to build.'
)}`
);
}
return [ return [
// Expose the components and different utilities from `astro:assets` and handle serving images from `/_image` in dev // Expose the components and different utilities from `astro:assets` and handle serving images from `/_image` in dev
{ {

View file

@ -0,0 +1,162 @@
import type {
AstroAssetsFeature,
AstroConfig,
AstroFeatureMap,
SupportsKind,
} from '../@types/astro';
import { error, type LogOptions, warn } from '../core/logger/core.js';
import { bold } from 'kleur/colors';
const STABLE = 'stable';
const DEPRECATED = 'deprecated';
const UNSUPPORTED = 'unsupported';
const EXPERIMENTAL = 'experimental';
const UNSUPPORTED_ASSETS_FEATURE: AstroAssetsFeature = {
supportKind: UNSUPPORTED,
isSquooshCompatible: false,
isSharpCompatible: false,
};
// NOTE: remove for Astro 4.0
const ALL_UNSUPPORTED: Required<AstroFeatureMap> = {
serverOutput: UNSUPPORTED,
staticOutput: UNSUPPORTED,
hybridOutput: UNSUPPORTED,
assets: UNSUPPORTED_ASSETS_FEATURE,
};
type ValidationResult = {
[Property in keyof AstroFeatureMap]: boolean;
};
/**
* Checks whether an adapter supports certain features that are enabled via Astro configuration.
*
* If a configuration is enabled and "unlocks" a feature, but the adapter doesn't support, the function
* will throw a runtime error.
*
*/
export function validateSupportedFeatures(
adapterName: string,
featureMap: AstroFeatureMap = ALL_UNSUPPORTED,
config: AstroConfig,
logging: LogOptions
): ValidationResult {
const {
assets = UNSUPPORTED_ASSETS_FEATURE,
serverOutput = UNSUPPORTED,
staticOutput = UNSUPPORTED,
hybridOutput = UNSUPPORTED,
} = featureMap;
const validationResult: ValidationResult = {};
validationResult.staticOutput = validateSupportKind(
staticOutput,
adapterName,
logging,
'staticOutput',
() => config?.output === 'static'
);
validationResult.hybridOutput = validateSupportKind(
hybridOutput,
adapterName,
logging,
'hybridOutput',
() => config?.output === 'hybrid'
);
validationResult.serverOutput = validateSupportKind(
serverOutput,
adapterName,
logging,
'serverOutput',
() => config?.output === 'server'
);
validationResult.assets = validateAssetsFeature(assets, adapterName, config, logging);
return validationResult;
}
function validateSupportKind(
supportKind: SupportsKind,
adapterName: string,
logging: LogOptions,
featureName: string,
hasCorrectConfig: () => boolean
): boolean {
if (supportKind === STABLE) {
return true;
} else if (supportKind === DEPRECATED) {
featureIsDeprecated(adapterName, logging);
} else if (supportKind === EXPERIMENTAL) {
featureIsExperimental(adapterName, logging);
}
if (hasCorrectConfig() && supportKind === UNSUPPORTED) {
featureIsUnsupported(adapterName, logging, featureName);
return false;
} else {
return true;
}
}
function featureIsUnsupported(adapterName: string, logging: LogOptions, featureName: string) {
error(
logging,
`${adapterName}`,
`The feature ${featureName} is not supported by the adapter ${adapterName}.`
);
}
function featureIsExperimental(adapterName: string, logging: LogOptions) {
warn(logging, `${adapterName}`, 'The feature is experimental and subject to issues or changes.');
}
function featureIsDeprecated(adapterName: string, logging: LogOptions) {
warn(
logging,
`${adapterName}`,
'The feature is deprecated and will be moved in the next release.'
);
}
const SHARP_SERVICE = 'astro/assets/services/sharp';
const SQUOOSH_SERVICE = 'astro/assets/services/squoosh';
function validateAssetsFeature(
assets: AstroAssetsFeature,
adapterName: string,
config: AstroConfig,
logging: LogOptions
): boolean {
const {
supportKind = UNSUPPORTED,
isSharpCompatible = false,
isSquooshCompatible = false,
} = assets;
if (config?.image?.service?.entrypoint === SHARP_SERVICE && !isSharpCompatible) {
error(
logging,
'astro',
`The currently selected adapter \`${adapterName}\` is not compatible with the service "Sharp". ${bold(
'Your project will NOT be able to build.'
)}`
);
return false;
}
if (config?.image?.service?.entrypoint === SQUOOSH_SERVICE && !isSquooshCompatible) {
error(
logging,
'astro',
`The currently selected adapter \`${adapterName}\` is not compatible with the service "Squoosh". ${bold(
'Your project will NOT be able to build.'
)}`
);
return false;
}
return validateSupportKind(supportKind, adapterName, logging, 'assets', () => true);
}

View file

@ -18,8 +18,9 @@ import type { SerializedSSRManifest } from '../core/app/types';
import type { PageBuildData } from '../core/build/types'; import type { PageBuildData } from '../core/build/types';
import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js'; import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js';
import { mergeConfig } from '../core/config/index.js'; import { mergeConfig } from '../core/config/index.js';
import { info, type LogOptions, AstroIntegrationLogger } from '../core/logger/core.js'; import { info, warn, error, type LogOptions, AstroIntegrationLogger } from '../core/logger/core.js';
import { isServerLikeOutput } from '../prerender/utils.js'; import { isServerLikeOutput } from '../prerender/utils.js';
import { validateSupportedFeatures } from './astroFeaturesValidation.js';
async function withTakingALongTimeMsg<T>({ async function withTakingALongTimeMsg<T>({
name, name,
@ -197,6 +198,30 @@ export async function runHookConfigDone({
`Integration "${integration.name}" conflicts with "${settings.adapter.name}". You can only configure one deployment integration.` `Integration "${integration.name}" conflicts with "${settings.adapter.name}". You can only configure one deployment integration.`
); );
} }
if (!adapter.supportedAstroFeatures) {
// NOTE: throw an error in Astro 4.0
warn(
logging,
'astro',
`The adapter ${adapter.name} doesn't provide a feature map. From Astro 3.0, an adapter can provide a feature map. Not providing a feature map will cause an error in Astro 4.0.`
);
} else {
const validationResult = validateSupportedFeatures(
adapter.name,
adapter.supportedAstroFeatures,
settings.config,
logging
);
for (const [featureName, supported] of Object.entries(validationResult)) {
if (!supported) {
error(
logging,
'astro',
`The adapter ${adapter.name} doesn't support the feature ${featureName}. Your project won't be built. You should not use it.`
);
}
}
}
settings.adapter = adapter; settings.adapter = adapter;
}, },
logger, logger,

View file

@ -0,0 +1,55 @@
import { loadFixture } from './test-utils.js';
import { expect } from 'chai';
import testAdapter from './test-adapter.js';
describe('Adapter', () => {
let fixture;
it("should error if the adapter doesn't support edge middleware", async () => {
try {
fixture = await loadFixture({
root: './fixtures/middleware-dev/',
output: 'server',
build: {
excludeMiddleware: true,
},
adapter: testAdapter({
extendAdapter: {
supportsFeatures: {
edgeMiddleware: 'Unsupported',
},
},
}),
});
await fixture.build();
} catch (e) {
expect(e.toString()).to.contain(
"The adapter my-ssr-adapter doesn't support the feature build.excludeMiddleware."
);
}
});
it("should error if the adapter doesn't support split build", async () => {
try {
fixture = await loadFixture({
root: './fixtures/middleware-dev/',
output: 'server',
build: {
split: true,
},
adapter: testAdapter({
extendAdapter: {
supportsFeatures: {
functionPerPage: 'Unsupported',
},
},
}),
});
await fixture.build();
} catch (e) {
expect(e.toString()).to.contain(
"The adapter my-ssr-adapter doesn't support the feature build.split."
);
}
});
});

View file

@ -71,6 +71,15 @@ export default function (
name: 'my-ssr-adapter', name: 'my-ssr-adapter',
serverEntrypoint: '@my-ssr', serverEntrypoint: '@my-ssr',
exports: ['manifest', 'createApp'], exports: ['manifest', 'createApp'],
supportedFeatures: {
assets: {
supportKind: 'Stable',
isNodeCompatible: true,
},
serverOutput: 'Stable',
staticOutput: 'Stable',
hybridOutput: 'Stable',
},
...extendAdapter, ...extendAdapter,
}); });
}, },

View file

@ -1,5 +1,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { runHookBuildSetup } from '../../../dist/integrations/index.js'; import { runHookBuildSetup } from '../../../dist/integrations/index.js';
import { validateSupportedFeatures } from '../../../dist/integrations/astroFeaturesValidation.js';
import { defaultLogging } from '../test-utils.js';
describe('Integration API', () => { describe('Integration API', () => {
it('runHookBuildSetup should work', async () => { it('runHookBuildSetup should work', async () => {
@ -28,3 +30,187 @@ describe('Integration API', () => {
expect(updatedViteConfig).to.haveOwnProperty('define'); expect(updatedViteConfig).to.haveOwnProperty('define');
}); });
}); });
describe('Astro feature map', function () {
it('should support the feature when stable', () => {
let result = validateSupportedFeatures(
'test',
{
hybridOutput: 'stable',
},
{
output: 'hybrid',
},
defaultLogging
);
expect(result['hybridOutput']).to.be.true;
});
it('should not support the feature when not provided', () => {
let result = validateSupportedFeatures(
'test',
undefined,
{
output: 'hybrid',
},
defaultLogging
);
expect(result['hybridOutput']).to.be.false;
});
it('should not support the feature when an empty object is provided', () => {
let result = validateSupportedFeatures(
'test',
{},
{
output: 'hybrid',
},
defaultLogging
);
expect(result['hybridOutput']).to.be.false;
});
describe('static output', function () {
it('should be supported with the correct config', () => {
let result = validateSupportedFeatures(
'test',
{ staticOutput: 'stable' },
{
output: 'static',
},
defaultLogging
);
expect(result['staticOutput']).to.be.true;
});
it("should not be valid if the config is correct, but the it's unsupported", () => {
let result = validateSupportedFeatures(
'test',
{ staticOutput: 'unsupported' },
{
output: 'static',
},
defaultLogging
);
expect(result['staticOutput']).to.be.false;
});
});
describe('hybrid output', function () {
it('should be supported with the correct config', () => {
let result = validateSupportedFeatures(
'test',
{ hybridOutput: 'stable' },
{
output: 'hybrid',
},
defaultLogging
);
expect(result['hybridOutput']).to.be.true;
});
it("should not be valid if the config is correct, but the it's unsupported", () => {
let result = validateSupportedFeatures(
'test',
{
hybridOutput: 'unsupported',
},
{
output: 'hybrid',
},
defaultLogging
);
expect(result['hybridOutput']).to.be.false;
});
});
describe('server output', function () {
it('should be supported with the correct config', () => {
let result = validateSupportedFeatures(
'test',
{ serverOutput: 'stable' },
{
output: 'server',
},
defaultLogging
);
expect(result['serverOutput']).to.be.true;
});
it("should not be valid if the config is correct, but the it's unsupported", () => {
let result = validateSupportedFeatures(
'test',
{
serverOutput: 'unsupported',
},
{
output: 'server',
},
defaultLogging
);
expect(result['serverOutput']).to.be.false;
});
});
describe('assets', function () {
it('should be supported when it is sharp compatible', () => {
let result = validateSupportedFeatures(
'test',
{
assets: {
supportKind: 'stable',
isSharpCompatible: true,
},
},
{
image: {
service: {
entrypoint: 'astro/assets/services/sharp',
},
},
},
defaultLogging
);
expect(result['assets']).to.be.true;
});
it('should be supported when it is squoosh compatible', () => {
let result = validateSupportedFeatures(
'test',
{
assets: {
supportKind: 'stable',
isSquooshCompatible: true,
},
},
{
image: {
service: {
entrypoint: 'astro/assets/services/squoosh',
},
},
},
defaultLogging
);
expect(result['assets']).to.be.true;
});
it("should not be valid if the config is correct, but the it's unsupported", () => {
let result = validateSupportedFeatures(
'test',
{
assets: {
supportKind: 'unsupported',
isNodeCompatible: false,
},
},
{
image: {
service: {
entrypoint: 'astro/assets/services/sharp',
},
},
},
defaultLogging
);
expect(result['assets']).to.be.false;
});
});
});

View file

@ -24,11 +24,31 @@ export function getAdapter(isModeDirectory: boolean): AstroAdapter {
name: '@astrojs/cloudflare', name: '@astrojs/cloudflare',
serverEntrypoint: '@astrojs/cloudflare/server.directory.js', serverEntrypoint: '@astrojs/cloudflare/server.directory.js',
exports: ['onRequest', 'manifest'], exports: ['onRequest', 'manifest'],
supportedAstroFeatures: {
hybridOutput: 'stable',
staticOutput: 'unsupported',
serverOutput: 'stable',
assets: {
supportKind: 'unsupported',
isSharpCompatible: false,
isSquooshCompatible: false,
},
},
} }
: { : {
name: '@astrojs/cloudflare', name: '@astrojs/cloudflare',
serverEntrypoint: '@astrojs/cloudflare/server.advanced.js', serverEntrypoint: '@astrojs/cloudflare/server.advanced.js',
exports: ['default'], exports: ['default'],
supportedAstroFeatures: {
hybridOutput: 'stable',
staticOutput: 'unsupported',
serverOutput: 'stable',
assets: {
supportKind: 'stable',
isSharpCompatible: false,
isSquooshCompatible: false,
},
},
}; };
} }

View file

@ -6,5 +6,5 @@ process.env.SECRET_STUFF = 'secret'
export default defineConfig({ export default defineConfig({
adapter: cloudflare(), adapter: cloudflare(),
output: 'server', output: 'server'
}); });

View file

@ -89,6 +89,16 @@ export function getAdapter(args?: Options): AstroAdapter {
serverEntrypoint: '@astrojs/deno/server.js', serverEntrypoint: '@astrojs/deno/server.js',
args: args ?? {}, args: args ?? {},
exports: ['stop', 'handle', 'start', 'running'], exports: ['stop', 'handle', 'start', 'running'],
supportedAstroFeatures: {
hybridOutput: 'stable',
staticOutput: 'stable',
serverOutput: 'stable',
assets: {
supportKind: 'stable',
isSharpCompatible: false,
isSquooshCompatible: false,
},
},
}; };
} }

View file

@ -6,5 +6,5 @@ import mdx from '@astrojs/mdx';
export default defineConfig({ export default defineConfig({
adapter: deno(), adapter: deno(),
integrations: [react(), mdx()], integrations: [react(), mdx()],
output: 'server', output: 'server'
}) })

View file

@ -3,5 +3,5 @@ import deno from '@astrojs/deno';
export default defineConfig({ export default defineConfig({
adapter: deno(), adapter: deno(),
output: 'server', output: 'server'
}) })

View file

@ -11,6 +11,16 @@ export function getAdapter(): AstroAdapter {
name: '@astrojs/netlify/edge-functions', name: '@astrojs/netlify/edge-functions',
serverEntrypoint: '@astrojs/netlify/netlify-edge-functions.js', serverEntrypoint: '@astrojs/netlify/netlify-edge-functions.js',
exports: ['default'], exports: ['default'],
supportedAstroFeatures: {
hybridOutput: 'stable',
staticOutput: 'stable',
serverOutput: 'stable',
assets: {
supportKind: 'stable',
isSharpCompatible: false,
isSquooshCompatible: false,
},
},
}; };
} }

View file

@ -18,6 +18,16 @@ export function getAdapter({ functionPerRoute, edgeMiddleware, ...args }: Args):
functionPerRoute, functionPerRoute,
edgeMiddleware, edgeMiddleware,
}, },
supportedAstroFeatures: {
hybridOutput: 'stable',
staticOutput: 'stable',
serverOutput: 'stable',
assets: {
supportKind: 'stable',
isSharpCompatible: true,
isSquooshCompatible: true,
},
},
}; };
} }

View file

@ -8,6 +8,16 @@ export function getAdapter(options: Options): AstroAdapter {
previewEntrypoint: '@astrojs/node/preview.js', previewEntrypoint: '@astrojs/node/preview.js',
exports: ['handler', 'startServer'], exports: ['handler', 'startServer'],
args: options, args: options,
supportedAstroFeatures: {
hybridOutput: 'stable',
staticOutput: 'stable',
serverOutput: 'stable',
assets: {
supportKind: 'stable',
isSharpCompatible: true,
isSquooshCompatible: true,
},
},
}; };
} }

View file

@ -27,6 +27,16 @@ function getAdapter(): AstroAdapter {
name: PACKAGE_NAME, name: PACKAGE_NAME,
serverEntrypoint: `${PACKAGE_NAME}/entrypoint`, serverEntrypoint: `${PACKAGE_NAME}/entrypoint`,
exports: ['default'], exports: ['default'],
supportedAstroFeatures: {
hybridOutput: 'stable',
staticOutput: 'stable',
serverOutput: 'stable',
assets: {
supportKind: 'stable',
isSharpCompatible: false,
isSquooshCompatible: false,
},
},
}; };
} }

View file

@ -44,6 +44,16 @@ function getAdapter({
edgeMiddleware, edgeMiddleware,
functionPerRoute, functionPerRoute,
}, },
supportedAstroFeatures: {
hybridOutput: 'stable',
staticOutput: 'stable',
serverOutput: 'stable',
assets: {
supportKind: 'stable',
isSharpCompatible: true,
isSquooshCompatible: true,
},
},
}; };
} }

View file

@ -19,6 +19,6 @@ describe('Missing output config', () => {
error = err; error = err;
} }
expect(error).to.not.be.equal(undefined); expect(error).to.not.be.equal(undefined);
expect(error.message).to.include(`output: "server"`); expect(error.message).to.include('output: "server"');
}); });
}); });