feat: support features for adapters

This commit is contained in:
Emanuele Stoppa 2023-07-06 18:08:50 +01:00
parent e80896a67c
commit dd54199d7c
15 changed files with 586 additions and 6 deletions

View file

@ -1660,12 +1660,55 @@ export type PaginateFunction = (data: any[], args?: PaginateOptions) => GetStati
export type Params = Record<string, string | undefined>;
export type SupportsKind = 'Unsupported' | 'Stable' | 'Experimental' | 'Deprecated';
export type AstroAdapterFeatureMap = {
/**
* 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;
/**
* Support for emitting a SSR file per page
*/
functionPerPage?: SupportsKind;
/**
* Support when `build.ecludeMiddleware` is enabled.
*/
edgeMiddleware?: SupportsKind;
/**
* The adapter can emit static assets
*/
assets?: {
supportKind?: SupportsKind;
/**
* Whether if this adapter deploys files in an enviroment that is Node.js compatible.
*
* The default services used by Astro needs Node.js.
*/
isNodeCompatible?: boolean;
};
};
export interface AstroAdapter {
name: string;
serverEntrypoint?: string;
previewEntrypoint?: string;
exports?: string[];
args?: any;
/**
* List of features supported by an adapter.
*
* If the adapter is not able to handle certain configurations, Astro will throw an error.
*/
supportedFeatures?: AstroAdapterFeatureMap;
}
type Body = string;

View file

@ -27,6 +27,11 @@ export async function generateImage(
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;
}

View file

@ -26,6 +26,12 @@ export default function assets({
logging,
mode,
}: AstroPluginOptions & { mode: string }): vite.Plugin[] {
const imageService = settings.config.image;
if (typeof imageService === 'undefined') {
throw new Error(
"Astro hasn't set a default service for `astro:assets`. This is an internal error and you should report it."
);
}
let resolvedConfig: vite.ResolvedConfig;
globalThis.astroAsset = {};
@ -40,7 +46,7 @@ export default function assets({
const adapterName = settings.config.adapter?.name;
if (
['astro/assets/services/sharp', 'astro/assets/services/squoosh'].includes(
settings.config.image.service.entrypoint
imageService.service.entrypoint
) &&
adapterName &&
UNSUPPORTED_ADAPTERS.has(adapterName)
@ -72,7 +78,7 @@ export default function assets({
},
async resolveId(id) {
if (id === VIRTUAL_SERVICE_ID) {
return await this.resolve(settings.config.image.service.entrypoint);
return await this.resolve(imageService.service.entrypoint);
}
if (id === VIRTUAL_MODULE_ID) {
return resolvedVirtualModuleId;
@ -85,7 +91,7 @@ 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 imageServiceConfig = ${JSON.stringify(imageService.service.config)};
export const getImage = async (options) => await getImageInternal(options, imageServiceConfig);
`;
}
@ -103,7 +109,7 @@ export default function assets({
>();
}
const hash = hashTransform(options, settings.config.image.service.entrypoint);
const hash = hashTransform(options, imageService.service.entrypoint);
let filePath: string;
if (globalThis.astroAsset.staticImages.has(hash)) {

View file

@ -1071,6 +1071,19 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
},
},
/**
* @docs
* @message Feature not supported by the adapter.
* @description
* The adapter doesn't support certain feature enabled via configuration.
*/
FeatureNotSupportedByAdapter: {
title: 'Feature not supported by the adapter.',
message: (adapterName: string, featureName: string) => {
return `The adapter ${adapterName} doesn't support the feature ${featureName}. Please turn it off.`;
},
},
/**
* @docs
* @see

View file

@ -4,6 +4,7 @@ import type { AddressInfo } from 'node:net';
import { fileURLToPath } from 'node:url';
import type { InlineConfig, ViteDevServer } from 'vite';
import type {
AstroAdapterFeatureMap,
AstroConfig,
AstroRenderer,
AstroSettings,
@ -11,13 +12,20 @@ import type {
DataEntryType,
HookParameters,
RouteData,
SupportsKind,
} from '../@types/astro.js';
import type { SerializedSSRManifest } from '../core/app/types';
import type { PageBuildData } from '../core/build/types';
import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js';
import { mergeConfig } from '../core/config/index.js';
import { info, type LogOptions } from '../core/logger/core.js';
import { error, info, type LogOptions, warn } from '../core/logger/core.js';
import { isServerLikeOutput } from '../prerender/utils.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
const STABLE = 'Stable';
const DEPRECATED = 'Deprecated';
const UNSUPPORTED = 'Unsupported';
const EXPERIMENTAL = 'Experimental';
async function withTakingALongTimeMsg<T>({
name,
@ -178,6 +186,33 @@ export async function runHookConfigDone({
`Integration "${integration.name}" conflicts with "${settings.adapter.name}". You can only configure one deployment integration.`
);
}
if (!adapter.supportedFeatures) {
// 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.supportedFeatures,
settings.config,
logging
);
for (const [featureName, valid] of Object.entries(validationResult)) {
if (!valid) {
throw new AstroError({
...AstroErrorData.FeatureNotSupportedByAdapter,
message: AstroErrorData.FeatureNotSupportedByAdapter.message(
adapter.name,
featureName
),
});
}
}
}
settings.adapter = adapter;
},
}),
@ -185,6 +220,167 @@ export async function runHookConfigDone({
});
}
}
// Astro doesn't set a default on purpose when loading the configuration file.
// Not setting a default allows to check if the adapter doesn't support the feature in case the user overrides
// Astro's defaults.
//
// At the end of the validation, we push the correct defaults.
if (typeof settings.config.image === 'undefined') {
settings.config.image = {
service: {
entrypoint: 'astro/assets/services/squoosh',
config: {},
},
};
}
}
// NOTE: remove for Astro 4.0
const ALL_UNSUPPORTED: Required<AstroAdapterFeatureMap> = {
serverOutput: UNSUPPORTED,
staticOutput: UNSUPPORTED,
edgeMiddleware: UNSUPPORTED,
hybridOutput: UNSUPPORTED,
functionPerPage: UNSUPPORTED,
assets: { supportKind: UNSUPPORTED },
};
type ValidationResult = {
[Property in keyof AstroAdapterFeatureMap]: 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: AstroAdapterFeatureMap = ALL_UNSUPPORTED,
config: AstroConfig,
logging: LogOptions
): ValidationResult {
const {
functionPerPage = UNSUPPORTED,
edgeMiddleware = UNSUPPORTED,
assets,
serverOutput = UNSUPPORTED,
staticOutput = UNSUPPORTED,
hybridOutput = UNSUPPORTED,
} = featureMap;
const validationResult: ValidationResult = {};
validationResult.functionPerPage = validateSupportKind(
functionPerPage,
adapterName,
logging,
'functionPerPage',
() => config?.build?.split === true && config?.output === 'server'
);
validationResult.edgeMiddleware = validateSupportKind(
edgeMiddleware,
adapterName,
logging,
'edgeMiddleware',
() => config?.build?.excludeMiddleware === true
);
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,
`adapter/${adapterName}`,
`The feature ${featureName} is not supported by the adapter ${adapterName}.`
);
}
function featureIsExperimental(adapterName: string, logging: LogOptions) {
warn(
logging,
`adapter/${adapterName}`,
'The feature is experimental and subject to issues or changes.'
);
}
function featureIsDeprecated(adapterName: string, logging: LogOptions) {
warn(
logging,
`adapter/${adapterName}`,
'The feature is deprecated and will be moved in the next release.'
);
}
const NODE_BASED_SERVICES = ['astro/assets/services/sharp', 'astro/assets/services/squoosh'];
function validateAssetsFeature(
assets: { supportKind?: SupportsKind; isNodeCompatible?: boolean } = {},
adapterName: string,
config: AstroConfig,
logging: LogOptions
): boolean {
const { supportKind = UNSUPPORTED, isNodeCompatible = false } = assets;
if (NODE_BASED_SERVICES.includes(config?.image?.service?.entrypoint) && !isNodeCompatible) {
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 false;
}
return validateSupportKind(supportKind, adapterName, logging, 'assets', () => true);
}
export async function runHookServerSetup({

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,17 @@ export default function (
name: 'my-ssr-adapter',
serverEntrypoint: '@my-ssr',
exports: ['manifest', 'createApp'],
supportedFeatures: {
edgeMiddleware: 'Stable',
functionPerPage: 'Stable',
assets: {
supportKind: 'Stable',
isNodeCompatible: true,
},
serverOutput: 'Stable',
staticOutput: 'Stable',
hybridOutput: 'Stable',
},
...extendAdapter,
});
},

View file

@ -1,5 +1,7 @@
import { expect } from 'chai';
import { runHookBuildSetup } from '../../../dist/integrations/index.js';
import { validateSupportedFeatures } from '../../../dist/integrations/index.js';
import { defaultLogging } from '../test-utils.js';
describe('Integration API', () => {
it('runHookBuildSetup should work', async () => {
@ -28,3 +30,196 @@ describe('Integration API', () => {
expect(updatedViteConfig).to.haveOwnProperty('define');
});
});
describe('Integration feature map', function () {
it('should support the feature when stable', () => {
let result = validateSupportedFeatures(
'test',
{
edgeMiddleware: 'Stable',
},
{
build: {
excludeMiddleware: true,
},
},
defaultLogging
);
expect(result['edgeMiddleware']).to.be.true;
});
it('should not support the feature when not provided', () => {
let result = validateSupportedFeatures(
'test',
undefined,
{
build: {
excludeMiddleware: true,
},
},
defaultLogging
);
expect(result['edgeMiddleware']).to.be.false;
});
it('should not support the feature when an empty object is provided', () => {
let result = validateSupportedFeatures(
'test',
{},
{
build: {
excludeMiddleware: true,
},
},
defaultLogging
);
expect(result['edgeMiddleware']).to.be.false;
});
describe('edge middleware feature', function () {
it('should be supported with the correct config', () => {
let result = validateSupportedFeatures(
'test',
{
edgeMiddleware: 'Stable',
},
{
build: {
excludeMiddleware: true,
},
},
defaultLogging
);
expect(result['edgeMiddleware']).to.be.true;
});
it("should not be valid if the config is correct, but the it's unsupported", () => {
let result = validateSupportedFeatures(
'test',
{
edgeMiddleware: 'Unsupported',
},
{
build: {
excludeMiddleware: true,
},
},
defaultLogging
);
expect(result['edgeMiddleware']).to.be.false;
});
});
describe('function per page feature', function () {
it('should be supported with the correct config', () => {
let result = validateSupportedFeatures(
'test',
{ functionPerPage: 'Stable' },
{
build: {
split: true,
},
output: 'server',
},
defaultLogging
);
expect(result['functionPerPage']).to.be.true;
});
it("should not be valid if the config is correct, but the it's unsupported", () => {
// the output: server is missing
let result = validateSupportedFeatures(
'test',
{
functionPerPage: 'Unsupported',
},
{
build: {
split: true,
},
output: 'server',
},
defaultLogging
);
expect(result['functionPerPage']).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;
});
});
});

View file

@ -24,11 +24,27 @@ export function getAdapter(isModeDirectory: boolean): AstroAdapter {
name: '@astrojs/cloudflare',
serverEntrypoint: '@astrojs/cloudflare/server.directory.js',
exports: ['onRequest', 'manifest'],
supportedFeatures: {
functionPerPage: 'Experimental',
edgeMiddleware: 'Unsupported',
hybridOutput: 'Stable',
staticOutput: 'Unsupported',
serverOutput: 'Stable',
assets: 'Unsupported',
},
}
: {
name: '@astrojs/cloudflare',
serverEntrypoint: '@astrojs/cloudflare/server.advanced.js',
exports: ['default'],
supportedFeatures: {
functionPerPage: 'Experimental',
edgeMiddleware: 'Unsupported',
hybridOutput: 'Stable',
staticOutput: 'Unsupported',
serverOutput: 'Stable',
assets: 'Unsupported',
},
};
}

View file

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

View file

@ -89,6 +89,14 @@ export function getAdapter(args?: Options): AstroAdapter {
serverEntrypoint: '@astrojs/deno/server.js',
args: args ?? {},
exports: ['stop', 'handle', 'start', 'running'],
supportedFeatures: {
functionPerPage: 'Unsupported',
edgeMiddleware: 'Unsupported',
hybridOutput: 'Stable',
staticOutput: 'Stable',
serverOutput: 'Stable',
assets: 'Unsupported',
},
};
}

View file

@ -14,6 +14,14 @@ export function getAdapter(args: Args = {}): AstroAdapter {
serverEntrypoint: '@astrojs/netlify/netlify-functions.js',
exports: ['handler'],
args,
supportedFeatures: {
functionPerPage: 'Experimental',
edgeMiddleware: 'Experimental',
hybridOutput: 'Stable',
staticOutput: 'Stable',
serverOutput: 'Stable',
assets: 'Unsupported',
},
};
}

View file

@ -8,6 +8,14 @@ export function getAdapter(options: Options): AstroAdapter {
previewEntrypoint: '@astrojs/node/preview.js',
exports: ['handler', 'startServer'],
args: options,
supportedFeatures: {
functionPerPage: 'Stable',
edgeMiddleware: 'Stable',
hybridOutput: 'Stable',
staticOutput: 'Stable',
serverOutput: 'Stable',
assets: 'Experimental',
},
};
}

View file

@ -27,6 +27,14 @@ function getAdapter(): AstroAdapter {
name: PACKAGE_NAME,
serverEntrypoint: `${PACKAGE_NAME}/entrypoint`,
exports: ['default'],
supportedFeatures: {
functionPerPage: 'Unsupported',
edgeMiddleware: 'Unsupported',
hybridOutput: 'Stable',
staticOutput: 'Stable',
serverOutput: 'Stable',
assets: 'Unsupported',
},
};
}

View file

@ -34,6 +34,14 @@ function getAdapter(): AstroAdapter {
name: PACKAGE_NAME,
serverEntrypoint: `${PACKAGE_NAME}/entrypoint`,
exports: ['default'],
supportedFeatures: {
functionPerPage: 'Experimental',
edgeMiddleware: 'Experimental',
hybridOutput: 'Stable',
staticOutput: 'Stable',
serverOutput: 'Stable',
assets: 'Stable',
},
};
}