0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-24 23:21:57 -05:00

feat(hybrid): Clean logging and misc tweaks for hybrid removal (#11942)

* feat(hybrid): Properly warn on every feature when used in wrong contexts

* fix: smoke tests

* fix: tests
This commit is contained in:
Erika 2024-09-09 23:18:32 +02:00 committed by GitHub
parent 50a0146e9a
commit d7e950f35f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 194 additions and 78 deletions

View file

@ -43,7 +43,7 @@ test.describe('Custom Client Directives - build server', () => {
adapter: testAdapter({
extendAdapter: {
adapterFeatures: {
forceServerOutput: false,
buildOutput: 'static',
},
},
}),

View file

@ -80,7 +80,6 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
// If we don't delete it here, it's technically not impossible (albeit improbable) for it to leak
if (ssr && !hasPrerenderedPages(internals)) {
delete globalThis?.astroAsset?.addStaticImage;
return;
}
const verb = ssr ? 'prerendering' : 'generating';
@ -417,7 +416,7 @@ async function generatePath(
url,
headers: new Headers(),
logger,
staticLike: true,
isPrerendered: true,
});
const renderContext = RenderContext.create({ pipeline, pathname, request, routeData: route });

View file

@ -125,7 +125,10 @@ class AstroBuilder {
injectImageEndpoint(this.settings, this.manifest, 'build');
}
await runHookConfigDone({ settings: this.settings, logger: logger, command: 'build' });
// If we're building for the server, we need to ensure that an adapter is installed.
// If the adapter installed does not support a server output, an error will be thrown when the adapter is added, so no need to check here.
if (!this.settings.config.adapter && this.settings.buildOutput === 'server') {
throw new AstroError(AstroErrorData.NoAdapterInstalled);
}
@ -147,7 +150,6 @@ class AstroBuilder {
manifest: this.manifest,
},
);
await runHookConfigDone({ settings: this.settings, logger: logger });
const { syncInternal } = await import('../sync/index.js');
await syncInternal({
@ -239,7 +241,7 @@ class AstroBuilder {
logger: this.logger,
timeStart: this.timer.init,
pageCount: pageNames.length,
buildMode: this.settings.config.output,
buildMode: this.settings.buildOutput!, // buildOutput is always set at this point
});
}
}

View file

@ -3,7 +3,7 @@ import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { teardown } from '@astrojs/compiler';
import glob from 'fast-glob';
import { bgGreen, bgMagenta, black, green } from 'kleur/colors';
import { bgGreen, black, green } from 'kleur/colors';
import * as vite from 'vite';
import { PROPAGATED_ASSET_FLAG } from '../../content/consts.js';
import {
@ -150,12 +150,11 @@ export async function staticBuild(
settings.timer.start('Server generate');
await generatePages(opts, internals);
await cleanStaticOutput(opts, internals);
opts.logger.info(null, `\n${bgMagenta(black(' finalizing server assets '))}\n`);
await ssrMoveAssets(opts);
settings.timer.end('Server generate');
return;
}
default: // `settings.buildOutput` will always be one of the above, but TS doesn't know that
default: // `settings.buildOutput` will always be one of the above at this point, but TS doesn't know that
return;
}
}

View file

@ -0,0 +1,50 @@
import { getAdapterStaticRecommendation } from '../../integrations/features-validation.js';
import type { AstroSettings } from '../../types/astro.js';
import type { AstroAdapter } from '../../types/public/integrations.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import type { Logger } from '../logger/core.js';
let hasWarnedMissingAdapter = false;
export function warnMissingAdapter(logger: Logger, settings: AstroSettings) {
if (hasWarnedMissingAdapter) return;
if (settings.buildOutput === 'server' && !settings.config.adapter) {
logger.warn(
'config',
'This project contains server-rendered routes, but no adapter is installed. This is fine for development, but an adapter will be required to build your site for production.',
);
hasWarnedMissingAdapter = true;
}
}
export function validateSetAdapter(
logger: Logger,
settings: AstroSettings,
adapter: AstroAdapter,
maybeConflictingIntegration: string,
command?: 'dev' | 'build' | string,
) {
if (settings.adapter && settings.adapter.name !== adapter.name) {
throw new Error(
`Integration "${maybeConflictingIntegration}" conflicts with "${settings.adapter.name}". You can only configure one deployment integration.`,
);
}
if (settings.buildOutput === 'server' && adapter.adapterFeatures?.buildOutput === 'static') {
// If the adapter is not compatible with the build output, throw an error
if (command === 'build') {
const adapterRecommendation = getAdapterStaticRecommendation(adapter.name);
throw new AstroError({
...AstroErrorData.AdapterSupportOutputMismatch,
message: AstroErrorData.AdapterSupportOutputMismatch.message(adapter.name),
hint: adapterRecommendation ? adapterRecommendation : undefined,
});
} else if (command === 'dev') {
logger.warn(
null,
`The adapter ${adapter.name} does not support emitting a server output, but the project contain server-rendered pages. Your project will not build correctly.`,
);
}
}
}

View file

@ -18,6 +18,7 @@ import { apply as applyPolyfill } from '../polyfill.js';
import { injectDefaultDevRoutes } from '../routing/dev-default.js';
import { createRouteManifest } from '../routing/index.js';
import { syncInternal } from '../sync/index.js';
import { warnMissingAdapter } from './adapter-validation.js';
export interface Container {
fs: typeof nodeFs;
@ -87,6 +88,10 @@ export async function createContainer({
manifest = injectDefaultDevRoutes(settings, devSSRManifest, manifest);
await runHookConfigDone({ settings, logger, command: 'dev' });
warnMissingAdapter(logger, settings);
const viteConfig = await createVite(
{
mode: 'development',
@ -107,7 +112,6 @@ export async function createContainer({
},
);
await runHookConfigDone({ settings, logger });
await syncInternal({
settings,
logger,

View file

@ -73,10 +73,8 @@ export const PrerenderClientAddressNotAvailable = {
*/
export const StaticClientAddressNotAvailable = {
name: 'StaticClientAddressNotAvailable',
title: '`Astro.clientAddress` is not available in static mode.',
// TODO: Update this for the new static mode? I'm not sure this error can even still happen.
message:
"`Astro.clientAddress` is only available when using `output: 'server'` or `output: 'hybrid'`. Update your Astro config if you need SSR features.",
title: '`Astro.clientAddress` is not available in prerendered pages.',
message: '`Astro.clientAddress` is only available on pages that are server-rendered.',
hint: 'See https://docs.astro.build/en/guides/server-side-rendering/ for more information on how to enable SSR.',
} satisfies ErrorData;
/**
@ -396,6 +394,24 @@ export const NoAdapterInstalled = {
message: `Cannot use server-rendered pages without an adapter. Please install and configure the appropriate server adapter for your final deployment.`,
hint: 'See https://docs.astro.build/en/guides/server-side-rendering/ for more information.',
} satisfies ErrorData;
/**
* @docs
* @see
* - [Server-side Rendering](https://docs.astro.build/en/guides/server-side-rendering/)
* @description
* The currently configured adapter does not support server-side rendering, which is required for the current project setup.
*
* Depending on your adapter, there may be a different entrypoint to use for server-side rendering. For example, the `@astrojs/vercel` adapter has a `@astrojs/vercel/static` entrypoint for static rendering, and a `@astrojs/vercel/serverless` entrypoint for server-side rendering.
*
*/
export const AdapterSupportOutputMismatch = {
name: 'AdapterSupportOutputMismatch',
title: 'Adapter does not support server output.',
message: (adapterName: string) =>
`The \`${adapterName}\` adapter is configured to output a static website, but the project contains server-rendered pages. Please install and configure the appropriate server adapter for your final deployment.`,
} satisfies ErrorData;
/**
* @docs
* @description

View file

@ -40,7 +40,7 @@ export default async function preview(inlineConfig: AstroInlineConfig): Promise<
// Create a route manifest so we can know if the build output is a static site or not
await createRouteManifest({ settings: settings, cwd: inlineConfig.root }, logger);
await runHookConfigDone({ settings: settings, logger: logger });
await runHookConfigDone({ settings: settings, logger: logger, command: 'preview' });
if (settings.buildOutput === 'static') {
if (!fs.existsSync(settings.config.outDir)) {

View file

@ -480,17 +480,15 @@ export class RenderContext {
return Reflect.get(request, clientAddressSymbol) as string;
}
if (pipeline.serverLike) {
if (request.body === null) {
throw new AstroError(AstroErrorData.PrerenderClientAddressNotAvailable);
}
if (request.body === null) {
throw new AstroError(AstroErrorData.PrerenderClientAddressNotAvailable);
}
if (pipeline.adapterName) {
throw new AstroError({
...AstroErrorData.ClientAddressNotAvailable,
message: AstroErrorData.ClientAddressNotAvailable.message(pipeline.adapterName),
});
}
if (pipeline.adapterName) {
throw new AstroError({
...AstroErrorData.ClientAddressNotAvailable,
message: AstroErrorData.ClientAddressNotAvailable.message(pipeline.adapterName),
});
}
throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable);

View file

@ -20,7 +20,7 @@ export interface CreateRequestOptions {
*
* @default false
*/
staticLike?: boolean;
isPrerendered?: boolean;
}
const clientAddressSymbol = Symbol.for('astro.clientAddress');
@ -41,10 +41,10 @@ export function createRequest({
body = undefined,
logger,
locals,
staticLike = false,
isPrerendered = false,
}: CreateRequestOptions): Request {
// headers are made available on the created request only if the request is for a page that will be on-demand rendered
const headersObj = staticLike
const headersObj = isPrerendered
? undefined
: headers instanceof Headers
? headers
@ -58,7 +58,8 @@ export function createRequest({
if (typeof url === 'string') url = new URL(url);
if (staticLike) {
// Remove search parameters if the request is for a page that will be on-demand rendered
if (isPrerendered) {
url.search = '';
}
@ -66,11 +67,11 @@ export function createRequest({
method: method,
headers: headersObj,
// body is made available only if the request is for a page that will be on-demand rendered
body: staticLike ? null : body,
body: isPrerendered ? null : body,
});
if (staticLike) {
// Warn when accessing headers in SSG mode
if (isPrerendered) {
// Warn when accessing headers in prerendered pages
const _headers = request.headers;
const headersDesc = Object.getOwnPropertyDescriptor(request, 'headers') || {};
Object.defineProperty(request, 'headers', {
@ -78,7 +79,7 @@ export function createRequest({
get() {
logger.warn(
null,
`\`Astro.request.headers\` is unavailable in "static" output mode, and in prerendered pages within "hybrid" and "server" output modes. If you need access to request headers, make sure that \`output\` is configured as either \`"server"\` or \`output: "hybrid"\` in your config file, and that the page accessing the headers is rendered on-demand.`,
`\`Astro.request.headers\` is not available on prerendered pages. If you need access to request headers, make sure that the page is server rendered using \`export const prerender = false;\` or by setting \`output\` to \`"server"\` in your Astro config to make all your pages server rendered.`,
);
return _headers;
},

View file

@ -8,7 +8,6 @@ import { fileURLToPath } from 'node:url';
import { bold } from 'kleur/colors';
import pLimit from 'p-limit';
import { toRoutingStrategy } from '../../../i18n/utils.js';
import { runHookRouteSetup } from '../../../integrations/hooks.js';
import { getPrerenderDefault } from '../../../prerender/utils.js';
import type { AstroConfig } from '../../../types/public/config.js';
import type { RouteData, RoutePart } from '../../../types/public/internal.js';
@ -20,6 +19,7 @@ import { resolvePages } from '../../util.js';
import { routeComparator } from '../priority.js';
import { getRouteGenerator } from './generator.js';
import { getPattern } from './pattern.js';
import { getRoutePrerenderOption } from './prerender.js';
const require = createRequire(import.meta.url);
interface Item {
@ -506,7 +506,16 @@ export async function createRouteManifest(
let promises = [];
for (const route of routes) {
promises.push(
limit(async () => await getRoutePrerenderOption(route, settings, params.fsMod, logger)),
limit(async () => {
if (route.type !== 'page' && route.type !== 'endpoint') return;
const localFs = params.fsMod ?? nodeFs;
const content = await localFs.promises.readFile(
fileURLToPath(new URL(route.component, settings.config.root)),
'utf-8',
);
await getRoutePrerenderOption(content, route, settings, logger);
}),
);
}
await Promise.all(promises);
@ -714,35 +723,6 @@ export async function createRouteManifest(
};
}
async function getRoutePrerenderOption(
route: RouteData,
settings: AstroSettings,
fsMod: typeof nodeFs | undefined,
logger: Logger,
) {
if (route.type !== 'page' && route.type !== 'endpoint') return;
const localFs = fsMod ?? nodeFs;
const content = await localFs.promises.readFile(
fileURLToPath(new URL(route.component, settings.config.root)),
'utf-8',
);
// Check if the route is pre-rendered or not
const match = /^\s*export\s+const\s+prerender\s*=\s*(true|false);?/m.exec(content);
if (match) {
route.prerender = match[1] === 'true';
}
await runHookRouteSetup({ route, settings, logger });
// If not explicitly set, default to the global setting
if (typeof route.prerender === undefined) {
route.prerender = getPrerenderDefault(settings.config);
}
if (!route.prerender) settings.buildOutput = 'server';
}
export function resolveInjectedRoute(entrypoint: string, root: URL, cwd?: string) {
let resolved;
try {

View file

@ -0,0 +1,29 @@
import { runHookRouteSetup } from '../../../integrations/hooks.js';
import { getPrerenderDefault } from '../../../prerender/utils.js';
import type { AstroSettings } from '../../../types/astro.js';
import type { RouteData } from '../../../types/public/internal.js';
import type { Logger } from '../../logger/core.js';
const PRERENDER_REGEX = /^\s*export\s+const\s+prerender\s*=\s*(true|false);?/m;
export async function getRoutePrerenderOption(
content: string,
route: RouteData,
settings: AstroSettings,
logger: Logger,
) {
// Check if the route is pre-rendered or not
const match = PRERENDER_REGEX.exec(content);
if (match) {
route.prerender = match[1] === 'true';
}
await runHookRouteSetup({ route, settings, logger });
// If not explicitly set, default to the global setting
if (typeof route.prerender === undefined) {
route.prerender = getPrerenderDefault(settings.config);
}
if (!route.prerender) settings.buildOutput = 'server';
}

View file

@ -66,7 +66,7 @@ export default async function sync(
logger,
});
const manifest = await createRouteManifest({ settings, fsMod: fs }, logger);
await runHookConfigDone({ settings, logger });
await runHookConfigDone({ settings, logger, command: 'sync' });
return await syncInternal({ settings, logger, fs, force: inlineConfig.force, manifest });
}

View file

@ -157,3 +157,10 @@ function validateAssetsFeature(
return validateSupportKind(supportKind, adapterName, logger, 'assets', () => true);
}
export function getAdapterStaticRecommendation(adapterName: string): string | undefined {
return {
'@astrojs/vercel/static':
'Update your configuration to use `@astrojs/vercel/serverless` to unlock server-side rendering capabilities.',
}[adapterName];
}

View file

@ -12,6 +12,7 @@ import type { SerializedSSRManifest } from '../core/app/types.js';
import type { PageBuildData } from '../core/build/types.js';
import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js';
import { mergeConfig } from '../core/config/index.js';
import { validateSetAdapter } from '../core/dev/adapter-validation.js';
import type { AstroIntegrationLogger, Logger } from '../core/logger/core.js';
import type { AstroSettings } from '../types/astro.js';
import type { AstroConfig } from '../types/public/config.js';
@ -296,9 +297,11 @@ export async function runHookConfigSetup({
export async function runHookConfigDone({
settings,
logger,
command,
}: {
settings: AstroSettings;
logger: Logger;
command?: 'dev' | 'build' | 'preview' | 'sync';
}) {
for (const integration of settings.config.integrations) {
if (integration?.hooks?.['astro:config:done']) {
@ -308,13 +311,9 @@ export async function runHookConfigDone({
hookResult: integration.hooks['astro:config:done']({
config: settings.config,
setAdapter(adapter) {
if (settings.adapter && settings.adapter.name !== adapter.name) {
throw new Error(
`Integration "${integration.name}" conflicts with "${settings.adapter.name}". You can only configure one deployment integration.`,
);
}
validateSetAdapter(logger, settings, adapter, integration.name, command);
if (adapter.adapterFeatures?.forceServerOutput) {
if (adapter.adapterFeatures?.buildOutput !== 'static') {
settings.buildOutput = 'server';
}

View file

@ -64,13 +64,13 @@ export type AdapterSupportsKind = 'unsupported' | 'stable' | 'experimental' | 'd
export interface AstroAdapterFeatures {
/**
* Creates an edge function that will communiate with the Astro middleware
* Creates an edge function that will communicate with the Astro middleware
*/
edgeMiddleware: boolean;
/**
* Force Astro to output a server output, even if all the pages are prerendered
* Determine the type of build output the adapter is intended for. Defaults to `server`;
*/
forceServerOutput?: boolean;
buildOutput?: 'static' | 'server';
}
export interface AstroAdapter {

View file

@ -3,6 +3,7 @@ import type fs from 'node:fs';
import { IncomingMessage } from 'node:http';
import type * as vite from 'vite';
import type { SSRManifest, SSRManifestI18n } from '../core/app/types.js';
import { warnMissingAdapter } from '../core/dev/adapter-validation.js';
import { createKey } from '../core/encryption.js';
import { getViteErrorPayload } from '../core/errors/dev/index.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
@ -57,6 +58,7 @@ export default function createVitePluginAstroServer({
devSSRManifest,
await createRouteManifest({ settings, fsMod }, logger), // TODO: Handle partial updates to the manifest
);
warnMissingAdapter(logger, settings);
pipeline.setManifestData(routeManifest);
}
}

View file

@ -175,7 +175,7 @@ export async function handleRoute({
body,
logger,
clientAddress: incomingRequest.socket.remoteAddress,
staticLike: route.prerender,
isPrerendered: route.prerender,
});
// Set user specified headers to response object.

View file

@ -3,7 +3,9 @@ import { fileURLToPath } from 'node:url';
import { bold } from 'kleur/colors';
import type { Plugin as VitePlugin } from 'vite';
import { normalizePath } from 'vite';
import { warnMissingAdapter } from '../core/dev/adapter-validation.js';
import type { Logger } from '../core/logger/core.js';
import { getRoutePrerenderOption } from '../core/routing/manifest/prerender.js';
import { isEndpoint, isPage } from '../core/util.js';
import { rootRelativePath } from '../core/viteUtils.js';
import type { AstroSettings, ManifestData } from '../types/astro.js';
@ -80,5 +82,33 @@ export default function astroScannerPlugin({
},
};
},
// Handle hot updates to update the prerender option
async handleHotUpdate(ctx) {
const filename = normalizePath(ctx.file);
let fileURL: URL;
try {
fileURL = new URL(`file://${filename}`);
} catch {
// If we can't construct a valid URL, exit early
return;
}
const fileIsPage = isPage(fileURL, settings);
const fileIsEndpoint = isEndpoint(fileURL, settings);
if (!(fileIsPage || fileIsEndpoint)) return;
const route = manifest.routes.find((r) => {
const filePath = new URL(`./${r.component}`, settings.config.root);
return normalizePath(fileURLToPath(filePath)) === filename;
});
if (!route) {
return;
}
await getRoutePrerenderOption(await ctx.read(), route, settings, logger);
warnMissingAdapter(logger, settings);
},
};
}

View file

@ -99,7 +99,7 @@ describe('build assets (server)', () => {
fixture = await loadFixture({
root: './fixtures/build-assets/',
integrations: [preact()],
adapter: testAdapter({ extendAdapter: { adapterFeatures: { forceServerOutput: false } } }),
adapter: testAdapter({ extendAdapter: { adapterFeatures: { buildOutput: 'static' } } }),
// test suite was authored when inlineStylesheets defaulted to never
build: { inlineStylesheets: 'never' },
});
@ -151,7 +151,7 @@ describe('build assets (server)', () => {
adapter: testAdapter({
extendAdapter: {
adapterFeatures: {
forceServerOutput: false,
buildOutput: 'static',
},
},
}),

View file

@ -186,7 +186,7 @@ describe('Static build', () => {
it('warns when accessing headers', async () => {
let found = false;
for (const log of logs) {
if (/`Astro\.request\.headers` is unavailable in "static" output mode/.test(log.message)) {
if (/`Astro\.request\.headers` is not available on prerendered pages./.test(log.message)) {
found = true;
}
}

View file

@ -114,7 +114,7 @@ export default function ({
i18nDomains: 'stable',
},
adapterFeatures: {
forceServerOutput: true,
buildOutput: 'server',
},
...extendAdapter,
});