diff --git a/.changeset/sharp-elephants-vanish.md b/.changeset/sharp-elephants-vanish.md new file mode 100644 index 000000000..fd53af891 --- /dev/null +++ b/.changeset/sharp-elephants-vanish.md @@ -0,0 +1,17 @@ +--- +'astro': minor +--- + +Now you can tell Astro if an adapter supports certain features. + +When creating ad adapter, you can specify an object like this: + +```js +// adapter.js +setAdapter({ + // ... + supportsFeatures: { + edgeMiddleware: "Experimental" + } +}) +``` diff --git a/.changeset/tiny-colts-call.md b/.changeset/tiny-colts-call.md new file mode 100644 index 000000000..070da826d --- /dev/null +++ b/.changeset/tiny-colts-call.md @@ -0,0 +1,7 @@ +--- +'@astrojs/cloudflare': patch +'@astrojs/vercel': patch +'@astrojs/node': patch +--- + +Signal Astro the kind of support for of the new created features diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index c25879806..d1a43f0df 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1638,12 +1638,31 @@ export type PaginateFunction = (data: any[], args?: PaginateOptions) => GetStati export type Params = Record; +export type SupportsKind = 'Unsupported' | 'Stable' | 'Experimental' | 'Deprecated'; + +export type AstroAdapterSupportsFeatures = { + /** + * Support when `build.split` is enabled. + */ + buildSplit?: SupportsKind; + /** + * Support when `build.ecludeMiddleware` is enabled. + */ + edgeMiddleware?: SupportsKind; +}; + 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. + */ + supportsFeatures?: AstroAdapterSupportsFeatures; } type Body = string; diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index a9fa78174..b0a2947c1 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -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 diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index b243ba979..5f9039e65 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -4,6 +4,7 @@ import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import type { InlineConfig, ViteDevServer } from 'vite'; import type { + AstroAdapter, AstroConfig, AstroRenderer, AstroSettings, @@ -11,13 +12,15 @@ 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/config.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'; async function withTakingALongTimeMsg({ name, @@ -178,6 +181,7 @@ export async function runHookConfigDone({ `Integration "${integration.name}" conflicts with "${settings.adapter.name}". You can only configure one deployment integration.` ); } + validateSupportedFeatures(adapter, settings.config, logging); settings.adapter = adapter; }, }), @@ -187,6 +191,101 @@ export async function runHookConfigDone({ } } +/** + * 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. + * + */ +function validateSupportedFeatures( + adapter: AstroAdapter, + config: AstroConfig, + logging: LogOptions +) { + let supportsFeatures = adapter.supportsFeatures ?? { + buildSplit: 'Unsupported', + edgeMiddleware: 'Unsupported', + }; + const { buildSplit, edgeMiddleware } = supportsFeatures; + if (buildSplit) { + validateSupportKind( + buildSplit, + adapter.name, + logging, + 'build.split', + () => config.build.split === true + ); + } + + if (edgeMiddleware) { + validateSupportKind( + edgeMiddleware, + adapter.name, + logging, + 'build.excludeMiddleware', + () => config.build.excludeMiddleware === true + ); + } +} + +const STABLE = 'Stable'; +const DEPRECATED = 'Deprecated'; +const UNSUPPORTED = 'Unsupported'; +const EXPERIMENTAL = 'Experimental'; + +function validateSupportKind( + supportKind: SupportsKind, + adapterName: string, + logging: LogOptions, + featureName: string, + validation: () => boolean +) { + switch (supportKind) { + case DEPRECATED: { + featureIsDeprecated(adapterName, logging); + } + case UNSUPPORTED: { + if (validation()) { + featureIsUnsupported(adapterName, logging, featureName); + } + } + case EXPERIMENTAL: { + featureIsExperimental(adapterName, logging); + } + case STABLE: { + } + } +} + +function featureIsUnsupported(adapterName: string, logging: LogOptions, featureName: string) { + error( + logging, + `adapter/${adapterName}`, + `The feature ${featureName} is not supported by ${adapterName}.` + ); + throw new AstroError({ + ...AstroErrorData.FeatureNotSupportedByAdapter, + message: AstroErrorData.FeatureNotSupportedByAdapter.message(adapterName, featureName), + }); +} + +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.' + ); +} + export async function runHookServerSetup({ config, server, diff --git a/packages/astro/test/featuresSupport.test.js b/packages/astro/test/featuresSupport.test.js new file mode 100644 index 000000000..b779c8cfb --- /dev/null +++ b/packages/astro/test/featuresSupport.test.js @@ -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: { + buildSplit: 'Unsupported', + }, + }, + }), + }); + await fixture.build(); + } catch (e) { + expect(e.toString()).to.contain( + "The adapter my-ssr-adapter doesn't support the feature build.split." + ); + } + }); +}); diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index ed79e5f21..77108148d 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -71,6 +71,10 @@ export default function ( name: 'my-ssr-adapter', serverEntrypoint: '@my-ssr', exports: ['manifest', 'createApp'], + supportsFeatures: { + edgeMiddleware: 'Stable', + buildSplit: 'Stable', + }, ...extendAdapter, }); }, diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 3dc237b72..121edf42a 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -24,11 +24,17 @@ export function getAdapter(isModeDirectory: boolean): AstroAdapter { name: '@astrojs/cloudflare', serverEntrypoint: '@astrojs/cloudflare/server.directory.js', exports: ['onRequest', 'manifest'], + supportsFeatures: { + buildSplit: 'Experimental', + }, } : { name: '@astrojs/cloudflare', serverEntrypoint: '@astrojs/cloudflare/server.advanced.js', exports: ['default'], + supportsFeatures: { + buildSplit: 'Experimental', + }, }; } diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index 17a8f4502..612406bcf 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -8,6 +8,10 @@ export function getAdapter(options: Options): AstroAdapter { previewEntrypoint: '@astrojs/node/preview.js', exports: ['handler', 'startServer'], args: options, + supportsFeatures: { + edgeMiddleware: 'Stable', + buildSplit: 'Stable', + }, }; } diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index 79e0a91ae..a687f9867 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -25,6 +25,10 @@ function getAdapter(): AstroAdapter { name: PACKAGE_NAME, serverEntrypoint: `${PACKAGE_NAME}/entrypoint`, exports: ['default'], + supportsFeatures: { + buildSplit: 'Experimental', + edgeMiddleware: 'Experimental', + }, }; }