diff options
Diffstat (limited to 'packages/astro/src')
87 files changed, 1878 insertions, 1715 deletions
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index cc5ddea7f..f9568d417 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -20,8 +20,10 @@ import type { AstroConfigType } from '../core/config'; import type { AstroTimer } from '../core/config/timer'; import type { AstroCookies } from '../core/cookies'; import type { LogOptions, LoggerLevel } from '../core/logger/core'; +import type { AstroIntegrationLogger } from '../core/logger/core'; import type { AstroComponentFactory, AstroComponentInstance } from '../runtime/server'; import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js'; + export type { MarkdownHeading, MarkdownMetadata, @@ -132,7 +134,6 @@ export interface CLIFlags { config?: string; drafts?: boolean; open?: boolean; - experimentalAssets?: boolean; } /** @@ -329,7 +330,7 @@ type ServerConfig = { /** * @name server.port * @type {number} - * @default `3000` + * @default `4321` * @description * Set which port the dev server should listen on. * @@ -543,14 +544,14 @@ export interface AstroUserConfig { * @docs * @name compressHTML * @type {boolean} - * @default `false` + * @default `true` * @description - * This is an option to minify your HTML output and reduce the size of your HTML files. When enabled, Astro removes all whitespace from your HTML, including line breaks, from `.astro` components. This occurs both in development mode and in the final build. - * To enable this, set the `compressHTML` flag to `true`. + * This is an option to minify your HTML output and reduce the size of your HTML files. By default, Astro removes all whitespace from your HTML, including line breaks, from `.astro` components. This occurs both in development mode and in the final build. + * To disable HTML compression, set the `compressHTML` flag to `false`. * * ```js * { - * compressHTML: true + * compressHTML: false * } * ``` */ @@ -573,12 +574,7 @@ export interface AstroUserConfig { * * When using this option, all of your static asset imports and URLs should add the base as a prefix. You can access this value via `import.meta.env.BASE_URL`. * - * By default, the value of `import.meta.env.BASE_URL` includes a trailing slash. If you have the [`trailingSlash`](https://docs.astro.build/en/reference/configuration-reference/#trailingslash) option set to `'never'`, you will need to add it manually in your static asset imports and URLs. - * - * ```astro - * <a href="/docs/about/">About</a> - * <img src=`${import.meta.env.BASE_URL}image.png`> - * ``` + * The value of `import.meta.env.BASE_URL` respects your `trailingSlash` config and will include a trailing slash if you explicitly include one or if `trailingSlash: "always"` is set. If `trailingSlash: "never"` is set, `BASE_URL` will not include a trailing slash, even if `base` includes one. */ base?: string; @@ -611,19 +607,21 @@ export interface AstroUserConfig { /** * @docs * @name scopedStyleStrategy - * @type {('where' | 'class')} + * @type {('where' | 'class' | 'attribute')} * @default `'where'` * @version 2.4 * @description * * Specify the strategy used for scoping styles within Astro components. Choose from: - * - `'where'` - Use `:where` selectors, causing no specifity increase. - * - `'class'` - Use class-based selectors, causing a +1 specifity increase. + * - `'where'` - Use `:where` selectors, causing no specifity increase. + * - `'class'` - Use class-based selectors, causing a +1 specifity increase. + * - `'attribute'` - Use `data-` attributes, causing no specifity increase. * * Using `'class'` is helpful when you want to ensure that element selectors within an Astro component override global style defaults (e.g. from a global stylesheet). * Using `'where'` gives you more control over specifity, but requires that you use higher-specifity selectors, layers, and other tools to control which selectors are applied. + * Using `'attribute'` is useful in case there's manipulation of the class attributes, so the styling emitted by Astro doesn't go in conflict with the user's business logic. */ - scopedStyleStrategy?: 'where' | 'class'; + scopedStyleStrategy?: 'where' | 'class' | 'attribute'; /** * @docs @@ -921,7 +919,7 @@ export interface AstroUserConfig { * ```js * { * // Example: Use the function syntax to customize based on command - * server: ({ command }) => ({ port: command === 'dev' ? 3000 : 4000 }) + * server: ({ command }) => ({ port: command === 'dev' ? 4321 : 4000 }) * } * ``` */ @@ -943,7 +941,7 @@ export interface AstroUserConfig { * @docs * @name server.port * @type {number} - * @default `3000` + * @default `4321` * @description * Set which port the server should listen on. * @@ -993,7 +991,7 @@ export interface AstroUserConfig { * @docs * @name image.service (Experimental) * @type {{entrypoint: 'astro/assets/services/sharp' | 'astro/assets/services/squoosh' | string, config: Record<string, any>}} - * @default `{entrypoint: 'astro/assets/services/squoosh', config?: {}}` + * @default `{entrypoint: 'astro/assets/services/sharp', config?: {}}` * @version 2.1.0 * @description * Set which image service is used for Astro’s experimental assets support. @@ -1302,27 +1300,6 @@ export interface AstroUserConfig { experimental?: { /** * @docs - * @name experimental.assets - * @type {boolean} - * @default `false` - * @version 2.1.0 - * @description - * Enable experimental support for optimizing and resizing images. With this enabled, a new `astro:assets` module will be exposed. - * - * To enable this feature, set `experimental.assets` to `true` in your Astro config: - * - * ```js - * { - * experimental: { - * assets: true, - * }, - * } - * ``` - */ - assets?: boolean; - - /** - * @docs * @name experimental.viewTransitions * @type {boolean} * @default `false` @@ -1505,6 +1482,17 @@ export interface DataEntryType { export type GetDataEntryInfoReturnType = { data: Record<string, unknown>; rawData?: string }; +export interface AstroAdapterFeatures { + /** + * Creates and edge function that will communiate with the Astro middleware + */ + edgeMiddleware: boolean; + /** + * SSR only. Each route becomes its own function/file. + */ + functionPerRoute: boolean; +} + export interface AstroSettings { config: AstroConfig; adapter: AstroAdapter | undefined; @@ -1621,10 +1609,7 @@ export type GetStaticPathsResultKeyed = GetStaticPathsResult & { */ export type GetStaticPaths = ( options: GetStaticPathsOptions -) => - | Promise<GetStaticPathsResult | GetStaticPathsResult[]> - | GetStaticPathsResult - | GetStaticPathsResult[]; +) => Promise<GetStaticPathsResult> | GetStaticPathsResult; /** * Infers the shape of the `params` property returned by `getStaticPaths()`. @@ -1767,12 +1752,52 @@ export type PaginateFunction = (data: any[], args?: PaginateOptions) => GetStati 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 { name: string; serverEntrypoint?: string; previewEntrypoint?: string; exports?: string[]; args?: any; + 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; @@ -1960,7 +1985,7 @@ export interface SSRLoadedRenderer extends AstroRenderer { export type HookParameters< Hook extends keyof AstroIntegration['hooks'], - Fn = AstroIntegration['hooks'][Hook] + Fn = AstroIntegration['hooks'][Hook], > = Fn extends (...args: any) => any ? Parameters<Fn>[0] : never; export interface AstroIntegration { @@ -1978,6 +2003,7 @@ export interface AstroIntegration { injectScript: (stage: InjectedScriptStage, content: string) => void; injectRoute: (injectRoute: InjectedRoute) => void; addClientDirective: (directive: ClientDirectiveConfig) => void; + logger: AstroIntegrationLogger; // TODO: Add support for `injectElement()` for full HTML element injection, not just scripts. // This may require some refactoring of `scripts`, `styles`, and `links` into something // more generalized. Consider the SSR use-case as well. @@ -1986,10 +2012,17 @@ export interface AstroIntegration { 'astro:config:done'?: (options: { config: AstroConfig; setAdapter: (adapter: AstroAdapter) => void; + logger: AstroIntegrationLogger; + }) => void | Promise<void>; + 'astro:server:setup'?: (options: { + server: vite.ViteDevServer; + logger: AstroIntegrationLogger; }) => void | Promise<void>; - 'astro:server:setup'?: (options: { server: vite.ViteDevServer }) => void | Promise<void>; - 'astro:server:start'?: (options: { address: AddressInfo }) => void | Promise<void>; - 'astro:server:done'?: () => void | Promise<void>; + 'astro:server:start'?: (options: { + address: AddressInfo; + logger: AstroIntegrationLogger; + }) => void | Promise<void>; + 'astro:server:done'?: (options: { logger: AstroIntegrationLogger }) => void | Promise<void>; 'astro:build:ssr'?: (options: { manifest: SerializedSSRManifest; /** @@ -2001,19 +2034,25 @@ export interface AstroIntegration { * File path of the emitted middleware */ middlewareEntryPoint: URL | undefined; + logger: AstroIntegrationLogger; }) => void | Promise<void>; - 'astro:build:start'?: () => void | Promise<void>; + 'astro:build:start'?: (options: { logger: AstroIntegrationLogger }) => void | Promise<void>; 'astro:build:setup'?: (options: { vite: vite.InlineConfig; pages: Map<string, PageBuildData>; target: 'client' | 'server'; updateConfig: (newConfig: vite.InlineConfig) => void; + logger: AstroIntegrationLogger; + }) => void | Promise<void>; + 'astro:build:generated'?: (options: { + dir: URL; + logger: AstroIntegrationLogger; }) => void | Promise<void>; - 'astro:build:generated'?: (options: { dir: URL }) => void | Promise<void>; 'astro:build:done'?: (options: { pages: { pathname: string }[]; dir: URL; routes: RouteData[]; + logger: AstroIntegrationLogger; }) => void | Promise<void>; }; } diff --git a/packages/astro/src/assets/image-endpoint.ts b/packages/astro/src/assets/image-endpoint.ts index fa62cbdd1..d83517379 100644 --- a/packages/astro/src/assets/image-endpoint.ts +++ b/packages/astro/src/assets/image-endpoint.ts @@ -1,9 +1,8 @@ import mime from 'mime/lite.js'; import type { APIRoute } from '../@types/astro.js'; +import { etag } from './utils/etag.js'; import { isRemotePath } from '../core/path.js'; import { getConfiguredImageService, isRemoteAllowed } from './internal.js'; -import { isLocalService } from './services/service.js'; -import { etag } from './utils/etag.js'; // @ts-expect-error import { imageConfig } from 'astro:assets'; @@ -24,11 +23,11 @@ async function loadRemoteImage(src: URL) { /** * Endpoint used in dev and SSR to serve optimized images by the base image services */ -export const get: APIRoute = async ({ request }) => { +export const GET: APIRoute = async ({ request }) => { try { const imageService = await getConfiguredImageService(); - if (!isLocalService(imageService)) { + if (!('transform' in imageService)) { throw new Error('Configured image service is not a local service'); } @@ -71,3 +70,7 @@ export const get: APIRoute = async ({ request }) => { return new Response(`Server Error: ${err}`, { status: 500 }); } }; + +function isRemotePath(src: string) { + return /^(http|ftp|https|ws):?\/\//.test(src) || src.startsWith('data:'); +} diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index ffc27333f..dd5e427f6 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -11,6 +11,7 @@ import type { import { matchHostname, matchPattern } from './utils/remotePattern.js'; export function injectImageEndpoint(settings: AstroSettings) { + // TODO: Add a setting to disable the image endpoint settings.injectedRoutes.push({ pattern: '/_image', entryPoint: 'astro/assets/image-endpoint', diff --git a/packages/astro/src/assets/services/noop.ts b/packages/astro/src/assets/services/noop.ts new file mode 100644 index 000000000..d57ffeb27 --- /dev/null +++ b/packages/astro/src/assets/services/noop.ts @@ -0,0 +1,17 @@ +import { baseService, type LocalImageService } from './service.js'; + +// Empty service used for platforms that neither support Squoosh or Sharp. +const noopService: LocalImageService = { + validateOptions: baseService.validateOptions, + getURL: baseService.getURL, + parseURL: baseService.parseURL, + getHTMLAttributes: baseService.getHTMLAttributes, + async transform(inputBuffer, transformOptions) { + return { + data: inputBuffer, + format: transformOptions.format, + }; + }, +}; + +export default noopService; diff --git a/packages/astro/src/assets/services/vendor/squoosh/impl.ts b/packages/astro/src/assets/services/vendor/squoosh/impl.ts index 7bb9aeeea..273957e4b 100644 --- a/packages/astro/src/assets/services/vendor/squoosh/impl.ts +++ b/packages/astro/src/assets/services/vendor/squoosh/impl.ts @@ -33,7 +33,7 @@ export async function decodeBuffer( .join('') // TODO (future PR): support more formats if (firstChunkString.includes('GIF')) { - throw Error(`GIF images are not supported, please install the @astrojs/image/sharp plugin`) + throw Error(`GIF images are not supported, please use the Sharp image service`) } const key = Object.entries(supportedFormats).find(([, { detectors }]) => detectors.some((detector) => detector.exec(firstChunkString)) @@ -78,7 +78,7 @@ export async function encodeJpeg( opts: { quality?: number } ): Promise<Uint8Array> { image = ImageData.from(image) - + const e = supportedFormats['mozjpeg'] const m = await e.enc() await maybeDelay() diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts index 9c5990cb7..ae74fc692 100644 --- a/packages/astro/src/assets/types.ts +++ b/packages/astro/src/assets/types.ts @@ -90,7 +90,7 @@ export type LocalImageProps<T> = ImageSharedProps<T> & { * * **Example**: * ```js - * import myImage from "~/assets/my_image.png"; + * import myImage from "../assets/my_image.png"; * ``` * And then refer to the image, like so: * ```astro diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 0f00e0ecb..f194e5288 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -1,10 +1,7 @@ -import { bold } from 'kleur/colors'; import MagicString from 'magic-string'; -import { fileURLToPath } from 'node:url'; import type * as vite from 'vite'; import { normalizePath } from 'vite'; import type { AstroPluginOptions, ImageTransform } from '../@types/astro'; -import { error } from '../core/logger/core.js'; import { appendForwardSlash, joinPaths, @@ -22,53 +19,16 @@ const urlRE = /(\?|&)url(?:&|$)/; export default function assets({ settings, - logging, mode, }: AstroPluginOptions & { mode: string }): vite.Plugin[] { let resolvedConfig: vite.ResolvedConfig; 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 [ // Expose the components and different utilities from `astro:assets` and handle serving images from `/_image` in dev { name: 'astro:assets', - config() { - return { - resolve: { - alias: [ - { - find: /^~\/assets\/(.+)$/, - replacement: fileURLToPath(new URL('./assets/$1', settings.config.srcDir)), - }, - ], - }, - }; - }, async resolveId(id) { if (id === VIRTUAL_SERVICE_ID) { return await this.resolve(settings.config.image.service.entrypoint); diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts index 82f590c25..fcaeb07c7 100644 --- a/packages/astro/src/cli/add/index.ts +++ b/packages/astro/src/cli/add/index.ts @@ -216,12 +216,6 @@ export async function add(names: string[], { flags }: AddOptions) { await fs.writeFile(fileURLToPath(configURL), ASTRO_CONFIG_STUB, { encoding: 'utf-8' }); } - // TODO: improve error handling for invalid configs - if (configURL?.pathname.endsWith('package.json')) { - throw new Error( - `Unable to use "astro add" with package.json configuration. Try migrating to \`astro.config.mjs\` and try again.` - ); - } let ast: t.File | null = null; try { ast = await parseAstroConfig(configURL); @@ -709,6 +703,7 @@ async function tryToInstallIntegrations({ } catch (err) { spinner.fail(); debug('add', 'Error installing dependencies', err); + // eslint-disable-next-line no-console console.error('\n', (err as any).stdout, '\n'); return UpdateResult.failure; } diff --git a/packages/astro/src/cli/check/index.ts b/packages/astro/src/cli/check/index.ts index 96bee308d..428027154 100644 --- a/packages/astro/src/cli/check/index.ts +++ b/packages/astro/src/cli/check/index.ts @@ -1,396 +1,43 @@ -import { - AstroCheck, - DiagnosticSeverity, - type GetDiagnosticsResult, -} from '@astrojs/language-server'; -import type { FSWatcher } from 'chokidar'; -import glob from 'fast-glob'; -import { bold, dim, red, yellow } from 'kleur/colors'; -import { createRequire } from 'module'; -import fs from 'node:fs'; -import { join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import ora from 'ora'; -import type { Arguments as Flags } from 'yargs-parser'; -import type { AstroSettings } from '../../@types/astro'; -import { resolveConfig } from '../../core/config/config.js'; -import { createNodeLogging } from '../../core/config/logging.js'; -import { createSettings } from '../../core/config/settings.js'; -import type { LogOptions } from '../../core/logger/core.js'; -import { debug, info } from '../../core/logger/core.js'; -import { printHelp } from '../../core/messages.js'; -import type { syncInternal } from '../../core/sync'; -import { eventCliSession, telemetry } from '../../events/index.js'; -import { runHookConfigSetup } from '../../integrations/index.js'; -import { flagsToAstroInlineConfig } from '../flags.js'; -import { printDiagnostic } from './print.js'; - -type DiagnosticResult = { - errors: number; - warnings: number; - hints: number; -}; - -export type CheckPayload = { - /** - * Flags passed via CLI - */ - flags: Flags; -}; - -type CheckFlags = { - /** - * Whether the `check` command should watch for `.astro` and report errors - * @default {false} - */ - watch: boolean; -}; - -/** - * - * Types of response emitted by the checker - */ -export enum CheckResult { - /** - * Operation finished without errors - */ - ExitWithSuccess, - /** - * Operation finished with errors - */ - ExitWithError, - /** - * The consumer should not terminate the operation - */ - Listen, -} - -const ASTRO_GLOB_PATTERN = '**/*.astro'; - -/** - * Checks `.astro` files for possible errors. - * - * If the `--watch` flag is provided, the command runs indefinitely and provides diagnostics - * when `.astro` files are modified. - * - * Every time an astro files is modified, content collections are also generated. - * - * @param {CheckPayload} options Options passed {@link AstroChecker} - * @param {Flags} options.flags Flags coming from the CLI - */ -export async function check({ flags }: CheckPayload): Promise<AstroChecker | undefined> { - if (flags.help || flags.h) { - printHelp({ - commandName: 'astro check', - usage: '[...flags]', - tables: { - Flags: [ - ['--watch', 'Watch Astro files for changes and re-run checks.'], - ['--help (-h)', 'See all available flags.'], - ], - }, - description: `Runs diagnostics against your project and reports errors to the console.`, - }); - return; - } - - // Load settings - const inlineConfig = flagsToAstroInlineConfig(flags); - const logging = createNodeLogging(inlineConfig); - const { userConfig, astroConfig } = await resolveConfig(inlineConfig, 'check'); - telemetry.record(eventCliSession('check', userConfig, flags)); - const settings = createSettings(astroConfig, fileURLToPath(astroConfig.root)); - - const checkFlags = parseFlags(flags); - if (checkFlags.watch) { - info(logging, 'check', 'Checking files in watch mode'); - } else { - info(logging, 'check', 'Checking files'); - } - - const { syncInternal } = await import('../../core/sync/index.js'); - const root = settings.config.root; - const require = createRequire(import.meta.url); - const diagnosticChecker = new AstroCheck( - root.toString(), - require.resolve('typescript/lib/tsserverlibrary.js', { - paths: [root.toString()], - }) - ); - - return new AstroChecker({ - syncInternal, - settings, - fileSystem: fs, +import path from 'node:path'; +import type { Arguments } from 'yargs-parser'; +import { error, info } from '../../core/logger/core.js'; +import { createLoggingFromFlags, flagsToAstroInlineConfig } from '../flags.js'; +import { getPackage } from '../install-package.js'; + +export async function check(flags: Arguments) { + const logging = createLoggingFromFlags(flags); + const getPackageOpts = { skipAsk: flags.yes || flags.y, cwd: flags.root }; + const checkPackage = await getPackage<typeof import('@astrojs/check')>( + '@astrojs/check', logging, - diagnosticChecker, - isWatchMode: checkFlags.watch, - }); -} - -type CheckerConstructor = { - diagnosticChecker: AstroCheck; - - isWatchMode: boolean; - - syncInternal: typeof syncInternal; - - settings: Readonly<AstroSettings>; - - logging: Readonly<LogOptions>; - - fileSystem: typeof fs; -}; - -/** - * Responsible to check files - classic or watch mode - and report diagnostics. - * - * When in watch mode, the class does a whole check pass, and then starts watching files. - * When a change occurs to an `.astro` file, the checker builds content collections again and lint all the `.astro` files. - */ -export class AstroChecker { - readonly #diagnosticsChecker: AstroCheck; - readonly #shouldWatch: boolean; - readonly #syncInternal: CheckerConstructor['syncInternal']; - - readonly #settings: AstroSettings; - - readonly #logging: LogOptions; - readonly #fs: typeof fs; - #watcher?: FSWatcher; - - #filesCount: number; - #updateDiagnostics: NodeJS.Timeout | undefined; - - constructor({ - diagnosticChecker, - isWatchMode, - syncInternal, - settings, - fileSystem, - logging, - }: CheckerConstructor) { - this.#diagnosticsChecker = diagnosticChecker; - this.#shouldWatch = isWatchMode; - this.#syncInternal = syncInternal; - this.#logging = logging; - this.#settings = settings; - this.#fs = fileSystem; - this.#filesCount = 0; - } - - /** - * Check all `.astro` files once and then finishes the operation. - */ - public async check(): Promise<CheckResult> { - return await this.#checkAllFiles(true); - } - - /** - * Check all `.astro` files and then start watching for changes. - */ - public async watch(): Promise<CheckResult> { - await this.#checkAllFiles(true); - await this.#watch(); - return CheckResult.Listen; - } - - /** - * Stops the watch. It terminates the inner server. - */ - public async stop() { - await this.#watcher?.close(); - } - - /** - * Whether the checker should run in watch mode - */ - public get isWatchMode(): boolean { - return this.#shouldWatch; - } - - async #openDocuments() { - this.#filesCount = await openAllDocuments( - this.#settings.config.root, - [], - this.#diagnosticsChecker - ); - } - - /** - * Lint all `.astro` files, and report the result in console. Operations executed, in order: - * 1. Compile content collections. - * 2. Optionally, traverse the file system for `.astro` files and saves their paths. - * 3. Get diagnostics for said files and print the result in console. - * - * @param openDocuments Whether the operation should open all `.astro` files - */ - async #checkAllFiles(openDocuments: boolean): Promise<CheckResult> { - // Run `astro:config:setup` before syncing to initialize integrations. - // We do this manually as we're calling `syncInternal` directly. - const syncSettings = await runHookConfigSetup({ - settings: this.#settings, - logging: this.#logging, - command: 'build', - }); - const processExit = await this.#syncInternal(syncSettings, { - logging: this.#logging, - fs: this.#fs, - }); - // early exit on sync failure - if (processExit === 1) return processExit; - - let spinner = ora( - ` Getting diagnostics for Astro files in ${fileURLToPath(this.#settings.config.root)}…` - ).start(); - - if (openDocuments) { - await this.#openDocuments(); - } - - let diagnostics = await this.#diagnosticsChecker.getDiagnostics(); - - spinner.succeed(); - - let brokenDownDiagnostics = this.#breakDownDiagnostics(diagnostics); - this.#logDiagnosticsSeverity(brokenDownDiagnostics); - return brokenDownDiagnostics.errors > 0 - ? CheckResult.ExitWithError - : CheckResult.ExitWithSuccess; - } - - #checkForDiagnostics() { - clearTimeout(this.#updateDiagnostics); - // @ematipico: I am not sure of `setTimeout`. I would rather use a debounce but let's see if this works. - // Inspiration from `svelte-check`. - this.#updateDiagnostics = setTimeout(async () => await this.#checkAllFiles(false), 500); - } - - /** - * This function is responsible to attach events to the server watcher - */ - async #watch() { - const { default: chokidar } = await import('chokidar'); - this.#watcher = chokidar.watch( - join(fileURLToPath(this.#settings.config.root), ASTRO_GLOB_PATTERN), - { - ignored: ['**/node_modules/**'], - ignoreInitial: true, - } - ); - - this.#watcher.on('add', (file) => { - this.#addDocument(file); - this.#filesCount += 1; - this.#checkForDiagnostics(); - }); - this.#watcher.on('change', (file) => { - this.#addDocument(file); - this.#checkForDiagnostics(); - }); - this.#watcher.on('unlink', (file) => { - this.#diagnosticsChecker.removeDocument(file); - this.#filesCount -= 1; - this.#checkForDiagnostics(); - }); - } - - /** - * Add a document to the diagnostics checker - * @param filePath Path to the file - */ - #addDocument(filePath: string) { - const text = fs.readFileSync(filePath, 'utf-8'); - this.#diagnosticsChecker.upsertDocument({ - uri: pathToFileURL(filePath).toString(), - text, - }); - } + getPackageOpts, + ['typescript'] + ); + const typescript = await getPackage('typescript', logging, getPackageOpts); - /** - * Logs the result of the various diagnostics - * - * @param result Result emitted by AstroChecker.#breakDownDiagnostics - */ - #logDiagnosticsSeverity(result: Readonly<DiagnosticResult>) { - info( - this.#logging, - 'diagnostics', - [ - bold(`Result (${this.#filesCount} file${this.#filesCount === 1 ? '' : 's'}): `), - bold(red(`${result.errors} ${result.errors === 1 ? 'error' : 'errors'}`)), - bold(yellow(`${result.warnings} ${result.warnings === 1 ? 'warning' : 'warnings'}`)), - dim(`${result.hints} ${result.hints === 1 ? 'hint' : 'hints'}\n`), - ].join(`\n${dim('-')} `) + if (!checkPackage || !typescript) { + error( + logging, + 'check', + 'The `@astrojs/check` and `typescript` packages are required for this command to work. Please manually install them into your project and try again.' ); + return; } - /** - * It loops through all diagnostics and break down diagnostics that are errors, warnings or hints. - */ - #breakDownDiagnostics(diagnostics: Readonly<GetDiagnosticsResult[]>): DiagnosticResult { - let result: DiagnosticResult = { - errors: 0, - warnings: 0, - hints: 0, - }; - - diagnostics.forEach((diag) => { - diag.diagnostics.forEach((d) => { - info(this.#logging, 'diagnostics', `\n ${printDiagnostic(diag.fileUri, diag.text, d)}`); - - switch (d.severity) { - case DiagnosticSeverity.Error: { - result.errors++; - break; - } - case DiagnosticSeverity.Warning: { - result.warnings++; - break; - } - case DiagnosticSeverity.Hint: { - result.hints++; - break; - } - } - }); - }); - - return result; + // Run sync before check to make sure types are generated. + // NOTE: In the future, `@astrojs/check` can expose a `before lint` hook so that this works during `astro check --watch` too. + // For now, we run this once as usually `astro check --watch` is ran alongside `astro dev` which also calls `astro sync`. + const { sync } = await import('../../core/sync/index.js'); + const inlineConfig = flagsToAstroInlineConfig(flags); + const exitCode = await sync(inlineConfig); + if (exitCode !== 0) { + process.exit(exitCode); } -} - -/** - * Open all Astro files in the given directory and return the number of files found. - */ -async function openAllDocuments( - workspaceUri: URL, - filePathsToIgnore: string[], - checker: AstroCheck -): Promise<number> { - const files = await glob(ASTRO_GLOB_PATTERN, { - cwd: fileURLToPath(workspaceUri), - ignore: ['node_modules/**'].concat(filePathsToIgnore.map((ignore) => `${ignore}/**`)), - absolute: true, - }); - for (const file of files) { - debug('check', `Adding file ${file} to the list of files to check.`); - const text = fs.readFileSync(file, 'utf-8'); - checker.upsertDocument({ - uri: pathToFileURL(file).toString(), - text, - }); - } + const { check: checker, parseArgsAsCheckConfig } = checkPackage; - return files.length; -} + const config = parseArgsAsCheckConfig(process.argv); -/** - * Parse flags and sets defaults - */ -function parseFlags(flags: Flags): CheckFlags { - return { - watch: flags.watch ?? false, - }; + info(logging, 'check', `Getting diagnostics for Astro files in ${path.resolve(config.root)}...`); + return await checker(config); } diff --git a/packages/astro/src/cli/check/print.ts b/packages/astro/src/cli/check/print.ts deleted file mode 100644 index bd8de2ddb..000000000 --- a/packages/astro/src/cli/check/print.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { DiagnosticSeverity, offsetAt, type Diagnostic } from '@astrojs/language-server'; -import { - bgRed, - bgWhite, - bgYellow, - black, - bold, - cyan, - gray, - red, - white, - yellow, -} from 'kleur/colors'; -import { fileURLToPath } from 'node:url'; -import stringWidth from 'string-width'; - -export function printDiagnostic(filePath: string, text: string, diag: Diagnostic): string { - let result = []; - - // Lines and characters are 0-indexed, so we need to add 1 to the offset to get the actual line and character - const realStartLine = diag.range.start.line + 1; - const realStartCharacter = diag.range.start.character + 1; - - // IDE friendly path that user can CTRL+Click to open the file at a specific line / character - const IDEFilePath = `${bold(cyan(fileURLToPath(filePath)))}:${bold(yellow(realStartLine))}:${bold( - yellow(realStartCharacter) - )}`; - result.push( - `${IDEFilePath} ${bold(getColorForSeverity(diag, getStringForSeverity(diag)))}: ${diag.message}` - ); - - // Optionally add the line before the error to add context if not empty - const previousLine = getLine(diag.range.start.line - 1, text); - if (previousLine) { - result.push(`${getPrintableLineNumber(realStartLine - 1)} ${gray(previousLine)}`); - } - - // Add the line with the error - const str = getLine(diag.range.start.line, text); - const lineNumStr = realStartLine.toString().padStart(2, '0'); - const lineNumLen = lineNumStr.length; - result.push(`${getBackgroundForSeverity(diag, lineNumStr)} ${str}`); - - // Adds tildes under the specific range where the diagnostic is - const tildes = generateString('~', diag.range.end.character - diag.range.start.character); - - // NOTE: This is not perfect, if the line include any characters that is made of multiple characters, for example - // regionals flags, but the terminal can't display it, then the number of spaces will be wrong. Not sure how to fix. - const beforeChars = stringWidth(str.substring(0, diag.range.start.character)); - const spaces = generateString(' ', beforeChars + lineNumLen - 1); - result.push(` ${spaces}${bold(getColorForSeverity(diag, tildes))}`); - - const nextLine = getLine(diag.range.start.line + 1, text); - if (nextLine) { - result.push(`${getPrintableLineNumber(realStartLine + 1)} ${gray(nextLine)}`); - } - - // Force a new line at the end - result.push(''); - - return result.join('\n'); -} - -function generateString(str: string, len: number): string { - return Array.from({ length: len }, () => str).join(''); -} - -function getStringForSeverity(diag: Diagnostic): string { - switch (diag.severity) { - case DiagnosticSeverity.Error: - return 'Error'; - case DiagnosticSeverity.Warning: - return 'Warning'; - case DiagnosticSeverity.Hint: - return 'Hint'; - default: - return 'Unknown'; - } -} - -function getColorForSeverity(diag: Diagnostic, text: string): string { - switch (diag.severity) { - case DiagnosticSeverity.Error: - return red(text); - case DiagnosticSeverity.Warning: - return yellow(text); - case DiagnosticSeverity.Hint: - return gray(text); - default: - return text; - } -} - -function getBackgroundForSeverity(diag: Diagnostic, text: string): string { - switch (diag.severity) { - case DiagnosticSeverity.Error: - return bgRed(white(text)); - case DiagnosticSeverity.Warning: - return bgYellow(white(text)); - case DiagnosticSeverity.Hint: - return bgWhite(black(text)); - default: - return text; - } -} - -function getPrintableLineNumber(line: number): string { - return bgWhite(black(line.toString().padStart(2, '0'))); -} - -function getLine(line: number, text: string): string { - return text - .substring( - offsetAt({ line, character: 0 }, text), - offsetAt({ line, character: Number.MAX_SAFE_INTEGER }, text) - ) - .replace(/\t/g, ' ') - .trimEnd(); -} diff --git a/packages/astro/src/cli/dev/index.ts b/packages/astro/src/cli/dev/index.ts index e55496c4a..5db47fb97 100644 --- a/packages/astro/src/cli/dev/index.ts +++ b/packages/astro/src/cli/dev/index.ts @@ -15,7 +15,7 @@ export async function dev({ flags }: DevOptions) { usage: '[...flags]', tables: { Flags: [ - ['--port', `Specify which port to run on. Defaults to 3000.`], + ['--port', `Specify which port to run on. Defaults to 4321.`], ['--host', `Listen on all addresses, including LAN and public addresses.`], ['--host <custom-address>', `Expose on a network IP address at <custom-address>`], ['--open', 'Automatically open the app in the browser on server start'], diff --git a/packages/astro/src/cli/flags.ts b/packages/astro/src/cli/flags.ts index 703422d50..3d7360a29 100644 --- a/packages/astro/src/cli/flags.ts +++ b/packages/astro/src/cli/flags.ts @@ -23,9 +23,6 @@ export function flagsToAstroInlineConfig(flags: Flags): AstroInlineConfig { typeof flags.host === 'string' || typeof flags.host === 'boolean' ? flags.host : undefined, open: typeof flags.open === 'boolean' ? flags.open : undefined, }, - experimental: { - assets: typeof flags.experimentalAssets === 'boolean' ? flags.experimentalAssets : undefined, - }, }; } diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index d16ea91e2..fdf43201f 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -154,18 +154,12 @@ async function runCommand(cmd: string, flags: yargs.Arguments) { } case 'check': { const { check } = await import('./check/index.js'); - // We create a server to start doing our operations - const checkServer = await check({ flags }); - if (checkServer) { - if (checkServer.isWatchMode) { - await checkServer.watch(); - return await new Promise(() => {}); // lives forever - } else { - const checkResult = await checkServer.check(); - return process.exit(checkResult); - } + const checkServer = await check(flags); + if (flags.watch) { + return await new Promise(() => {}); // lives forever + } else { + return process.exit(checkServer ? 1 : 0); } - return; } case 'sync': { const { sync } = await import('./sync/index.js'); diff --git a/packages/astro/src/cli/install-package.ts b/packages/astro/src/cli/install-package.ts new file mode 100644 index 000000000..8793d9985 --- /dev/null +++ b/packages/astro/src/cli/install-package.ts @@ -0,0 +1,124 @@ +import boxen from 'boxen'; +import { execa } from 'execa'; +import { bold, cyan, dim, magenta } from 'kleur/colors'; +import { createRequire } from 'node:module'; +import ora from 'ora'; +import prompts from 'prompts'; +import whichPm from 'which-pm'; +import { debug, info, type LogOptions } from '../core/logger/core.js'; + +type GetPackageOptions = { + skipAsk?: boolean; + cwd?: string; +}; + +export async function getPackage<T>( + packageName: string, + logging: LogOptions, + options: GetPackageOptions, + otherDeps: string[] = [] +): Promise<T | undefined> { + const require = createRequire(options.cwd ?? process.cwd()); + + let packageImport; + try { + require.resolve(packageName); + + // The `require.resolve` is required as to avoid Node caching the failed `import` + packageImport = await import(packageName); + } catch (e) { + info( + logging, + '', + `To continue, Astro requires the following dependency to be installed: ${bold(packageName)}.` + ); + const result = await installPackage([packageName, ...otherDeps], options, logging); + + if (result) { + packageImport = await import(packageName); + } else { + return undefined; + } + } + + return packageImport as T; +} + +function getInstallCommand(packages: string[], packageManager: string) { + switch (packageManager) { + case 'npm': + return { pm: 'npm', command: 'install', flags: [], dependencies: packages }; + case 'yarn': + return { pm: 'yarn', command: 'add', flags: [], dependencies: packages }; + case 'pnpm': + return { pm: 'pnpm', command: 'add', flags: [], dependencies: packages }; + default: + return null; + } +} + +async function installPackage( + packageNames: string[], + options: GetPackageOptions, + logging: LogOptions +): Promise<boolean> { + const cwd = options.cwd ?? process.cwd(); + const packageManager = (await whichPm(cwd)).name ?? 'npm'; + const installCommand = getInstallCommand(packageNames, packageManager); + + if (!installCommand) { + return false; + } + + const coloredOutput = `${bold(installCommand.pm)} ${installCommand.command}${[ + '', + ...installCommand.flags, + ].join(' ')} ${cyan(installCommand.dependencies.join(' '))}`; + const message = `\n${boxen(coloredOutput, { + margin: 0.5, + padding: 0.5, + borderStyle: 'round', + })}\n`; + info( + logging, + null, + `\n ${magenta('Astro will run the following command:')}\n ${dim( + 'If you skip this step, you can always run it yourself later' + )}\n${message}` + ); + + let response; + if (options.skipAsk) { + response = true; + } else { + response = ( + await prompts({ + type: 'confirm', + name: 'askToContinue', + message: 'Continue?', + initial: true, + }) + ).askToContinue; + } + + if (Boolean(response)) { + const spinner = ora('Installing dependencies...').start(); + try { + await execa( + installCommand.pm, + [installCommand.command, ...installCommand.flags, ...installCommand.dependencies], + { cwd: cwd } + ); + spinner.succeed(); + + return true; + } catch (err) { + debug('add', 'Error installing dependencies', err); + spinner.fail(); + + return false; + } + } else { + return false; + } +} diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 238e32c5f..078197cd0 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -155,8 +155,7 @@ export async function createContentTypesGenerator({ fileURLToPath(event.entry), contentPaths, contentEntryExts, - dataEntryExts, - settings.config.experimental.assets + dataEntryExts ); if (fileType === 'ignored') { return { shouldGenerateTypes: false }; diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index d273dc105..369e187a8 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -93,8 +93,7 @@ export async function getEntryData( _internal: EntryInternal; }, collectionConfig: CollectionConfig, - pluginContext: PluginContext, - config: AstroConfig + pluginContext: PluginContext ) { let data; if (collectionConfig.type === 'data') { @@ -106,12 +105,6 @@ export async function getEntryData( let schema = collectionConfig.schema; if (typeof schema === 'function') { - if (!config.experimental.assets) { - throw new Error( - 'The function shape for schema can only be used when `experimental.assets` is enabled.' - ); - } - schema = schema({ image: createImage(pluginContext, entry._internal.filePath), }); @@ -250,9 +243,7 @@ export function getEntryType( entryPath: string, paths: Pick<ContentPaths, 'config' | 'contentDir'>, contentFileExts: string[], - dataFileExts: string[], - // TODO: Unflag this when we're ready to release assets - erika, 2023-04-12 - experimentalAssets = false + dataFileExts: string[] ): 'content' | 'data' | 'config' | 'ignored' | 'unsupported' { const { ext, base } = path.parse(entryPath); const fileUrl = pathToFileURL(entryPath); @@ -260,7 +251,7 @@ export function getEntryType( if ( hasUnderscoreBelowContentDirectoryPath(fileUrl, paths.contentDir) || isOnIgnoreList(base) || - (experimentalAssets && isImageAsset(ext)) + isImageAsset(ext) ) { return 'ignored'; } else if (contentFileExts.includes(ext)) { diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts index a659dd4a0..4643e0922 100644 --- a/packages/astro/src/content/vite-plugin-content-imports.ts +++ b/packages/astro/src/content/vite-plugin-content-imports.ts @@ -131,13 +131,7 @@ export const _internal = { configureServer(viteServer) { viteServer.watcher.on('all', async (event, entry) => { if (CHOKIDAR_MODIFIED_EVENTS.includes(event)) { - const entryType = getEntryType( - entry, - contentPaths, - contentEntryExts, - dataEntryExts, - settings.config.experimental.assets - ); + const entryType = getEntryType(entry, contentPaths, contentEntryExts, dataEntryExts); if (!COLLECTION_TYPES_TO_INVALIDATE_ON.includes(entryType)) return; // The content config could depend on collection entries via `reference()`. @@ -194,7 +188,7 @@ type GetEntryModuleParams<TEntryType extends ContentEntryType | DataEntryType> = async function getContentEntryModule( params: GetEntryModuleParams<ContentEntryType> ): Promise<ContentEntryModule> { - const { fileId, contentDir, pluginContext, config } = params; + const { fileId, contentDir, pluginContext } = params; const { collectionConfig, entryConfig, entry, rawContents, collection } = await getEntryModuleBaseInfo(params); @@ -221,8 +215,7 @@ async function getContentEntryModule( ? await getEntryData( { id, collection, _internal, unvalidatedData }, collectionConfig, - pluginContext, - config + pluginContext ) : unvalidatedData; @@ -241,7 +234,7 @@ async function getContentEntryModule( async function getDataEntryModule( params: GetEntryModuleParams<DataEntryType> ): Promise<DataEntryModule> { - const { fileId, contentDir, pluginContext, config } = params; + const { fileId, contentDir, pluginContext } = params; const { collectionConfig, entryConfig, entry, rawContents, collection } = await getEntryModuleBaseInfo(params); @@ -256,8 +249,7 @@ async function getDataEntryModule( ? await getEntryData( { id, collection, _internal, unvalidatedData }, collectionConfig, - pluginContext, - config + pluginContext ) : unvalidatedData; diff --git a/packages/astro/src/core/README.md b/packages/astro/src/core/README.md index 7f5e4f89c..74f55a0bb 100644 --- a/packages/astro/src/core/README.md +++ b/packages/astro/src/core/README.md @@ -3,3 +3,16 @@ Code that executes within the top-level Node context. Contains the main Astro logic for the `build` and `dev` commands, and also manages the Vite server and SSR. [See CONTRIBUTING.md](../../../../CONTRIBUTING.md) for a code overview. + +## Pipeline + +The pipeline is an internal concept that describes how Astro pages are eventually created and rendered to the user. + +Each pipeline has different requirements, criteria and quirks. Although, each pipeline must use the same underline functions, because +the core of the pipeline is the same. + +The core of the pipeline is rendering a generic route (page, endpoint or redirect) and returning a `Response`. +When rendering a route, a pipeline must pass a `RenderContext` and `ComponentInstance`. The way these two information are +computed doesn't concern the core of a pipeline. In fact, these types will be computed in different manner based on the type of pipeline. + +Each consumer will decide how to handle a `Response`. diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 76958a549..92f671b85 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -1,13 +1,13 @@ -import mime from 'mime'; import type { EndpointHandler, ManifestData, + MiddlewareEndpointHandler, RouteData, SSRElement, SSRManifest, } from '../../@types/astro'; import type { SinglePageBuiltModule } from '../build/types'; -import { attachToResponse, getSetCookiesFromResponse } from '../cookies/index.js'; +import { getSetCookiesFromResponse } from '../cookies/index.js'; import { consoleLogDestination } from '../logger/console.js'; import { error, type LogOptions } from '../logger/core.js'; import { @@ -16,12 +16,10 @@ import { removeTrailingForwardSlash, } from '../path.js'; import { RedirectSinglePageBuiltModule } from '../redirects/index.js'; -import { isResponse } from '../render/core.js'; import { createEnvironment, createRenderContext, tryRenderRoute, - type Environment, type RenderContext, } from '../render/index.js'; import { RouteCache } from '../render/route-cache.js'; @@ -32,6 +30,7 @@ import { } from '../render/ssr-element.js'; import { matchRoute } from '../routing/match.js'; import type { RouteInfo } from './types'; +import { EndpointNotFoundError, SSRRoutePipeline } from './ssrPipeline.js'; export { deserializeManifest } from './common.js'; const clientLocalsSymbol = Symbol.for('astro.locals'); @@ -53,16 +52,15 @@ export class App { /** * The current environment of the application */ - #env: Environment; #manifest: SSRManifest; #manifestData: ManifestData; #routeDataToRouteInfo: Map<RouteData, RouteInfo>; - #encoder = new TextEncoder(); #logging: LogOptions = { dest: consoleLogDestination, level: 'info', }; #baseWithoutTrailingSlash: string; + #pipeline: SSRRoutePipeline; constructor(manifest: SSRManifest, streaming = true) { this.#manifest = manifest; @@ -71,7 +69,7 @@ export class App { }; this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route])); this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base); - this.#env = this.#createEnvironment(streaming); + this.#pipeline = new SSRRoutePipeline(this.#createEnvironment(streaming)); } set setManifest(newManifest: SSRManifest) { @@ -88,7 +86,6 @@ export class App { return createEnvironment({ adapterName: this.#manifest.adapterName, logging: this.#logging, - markdown: this.#manifest.markdown, mode: 'production', compressHTML: this.#manifest.compressHTML, renderers: this.#manifest.renderers, @@ -164,19 +161,21 @@ export class App { ); let response; try { - response = await tryRenderRoute( - routeData.type, - renderContext, - this.#env, - pageModule, - mod.onRequest - ); + // NOTE: ideally we could set the middleware function just once, but we don't have the infrastructure to that yet + if (mod.onRequest) { + this.#pipeline.setMiddlewareFunction(mod.onRequest as MiddlewareEndpointHandler); + } + response = await this.#pipeline.renderRoute(renderContext, pageModule); } catch (err: any) { - error(this.#logging, 'ssr', err.stack || err.message || String(err)); - return this.#renderError(request, { status: 500 }); + if (err instanceof EndpointNotFoundError) { + return this.#renderError(request, { status: 404, response: err.originalResponse }); + } else { + error(this.#logging, 'ssr', err.stack || err.message || String(err)); + return this.#renderError(request, { status: 500 }); + } } - if (isResponse(response, routeData.type)) { + if (SSRRoutePipeline.isResponse(response, routeData.type)) { if (STATUS_CODES.has(response.status)) { return this.#renderError(request, { response, @@ -185,35 +184,8 @@ export class App { } Reflect.set(response, responseSentSymbol, true); return response; - } else { - if (response.type === 'response') { - if (response.response.headers.get('X-Astro-Response') === 'Not-Found') { - return this.#renderError(request, { - response: response.response, - status: 404, - }); - } - return response.response; - } else { - const headers = new Headers(); - const mimeType = mime.getType(url.pathname); - if (mimeType) { - headers.set('Content-Type', `${mimeType};charset=utf-8`); - } else { - headers.set('Content-Type', 'text/plain;charset=utf-8'); - } - const bytes = - response.encoding !== 'binary' ? this.#encoder.encode(response.body) : response.body; - headers.set('Content-Length', bytes.byteLength.toString()); - - const newResponse = new Response(bytes, { - status: 200, - headers, - }); - attachToResponse(newResponse, response.cookies); - return newResponse; - } } + return response; } setCookieHeaders(response: Response) { @@ -239,7 +211,7 @@ export class App { pathname, route: routeData, status, - env: this.#env, + env: this.#pipeline.env, mod: handler as any, }); } else { @@ -273,7 +245,7 @@ export class App { route: routeData, status, mod, - env: this.#env, + env: this.#pipeline.env, }); } } @@ -311,9 +283,8 @@ export class App { ); const page = (await mod.page()) as any; const response = (await tryRenderRoute( - 'page', // this is hardcoded to ensure proper behavior for missing endpoints newRenderContext, - this.#env, + this.#pipeline.env, page )) as Response; return this.#mergeResponses(response, originalResponse); diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index 4ae6e98a9..054064a08 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -6,6 +6,7 @@ import { IncomingMessage } from 'node:http'; import { TLSSocket } from 'node:tls'; import { deserializeManifest } from './common.js'; import { App, type MatchOptions } from './index.js'; +export { apply as applyPolyfills } from '../polyfill.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); diff --git a/packages/astro/src/core/app/ssrPipeline.ts b/packages/astro/src/core/app/ssrPipeline.ts new file mode 100644 index 000000000..5f135e42d --- /dev/null +++ b/packages/astro/src/core/app/ssrPipeline.ts @@ -0,0 +1,54 @@ +import type { Environment } from '../render'; +import type { EndpointCallResult } from '../endpoint/index.js'; +import mime from 'mime'; +import { attachCookiesToResponse } from '../cookies/index.js'; +import { Pipeline } from '../pipeline.js'; + +/** + * Thrown when an endpoint contains a response with the header "X-Astro-Response" === 'Not-Found' + */ +export class EndpointNotFoundError extends Error { + originalResponse: Response; + constructor(originalResponse: Response) { + super(); + this.originalResponse = originalResponse; + } +} + +export class SSRRoutePipeline extends Pipeline { + #encoder = new TextEncoder(); + + constructor(env: Environment) { + super(env); + this.setEndpointHandler(this.#ssrEndpointHandler); + } + + // This function is responsible for handling the result coming from an endpoint. + async #ssrEndpointHandler(request: Request, response: EndpointCallResult): Promise<Response> { + if (response.type === 'response') { + if (response.response.headers.get('X-Astro-Response') === 'Not-Found') { + throw new EndpointNotFoundError(response.response); + } + return response.response; + } else { + const url = new URL(request.url); + const headers = new Headers(); + const mimeType = mime.getType(url.pathname); + if (mimeType) { + headers.set('Content-Type', `${mimeType};charset=utf-8`); + } else { + headers.set('Content-Type', 'text/plain;charset=utf-8'); + } + const bytes = + response.encoding !== 'binary' ? this.#encoder.encode(response.body) : response.body; + headers.set('Content-Length', bytes.byteLength.toString()); + + const newResponse = new Response(bytes, { + status: 200, + headers, + }); + attachCookiesToResponse(newResponse, response.cookies); + return newResponse; + } + } +} diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 67d16d457..8812d2c44 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -1,4 +1,3 @@ -import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark'; import type { RouteData, SerializedRouteData, @@ -40,7 +39,6 @@ export type SSRManifest = { base: string; compressHTML: boolean; assetsPrefix?: string; - markdown: MarkdownRenderingOptions; renderers: SSRLoadedRenderer[]; /** * Map of directive name (e.g. `load`) to the directive script code diff --git a/packages/astro/src/core/build/buildPipeline.ts b/packages/astro/src/core/build/buildPipeline.ts new file mode 100644 index 000000000..4ebf48a9a --- /dev/null +++ b/packages/astro/src/core/build/buildPipeline.ts @@ -0,0 +1,211 @@ +import { Pipeline } from '../pipeline.js'; +import type { BuildInternals } from './internal'; +import type { PageBuildData, StaticBuildOptions } from './types'; +import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; +import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js'; +import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; +import type { SSRManifest } from '../app/types'; +import type { AstroConfig, AstroSettings, RouteType, SSRLoadedRenderer } from '../../@types/astro'; +import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js'; +import type { EndpointCallResult } from '../endpoint'; +import { createEnvironment } from '../render/index.js'; +import { BEFORE_HYDRATION_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; +import { createAssetLink } from '../render/ssr-element.js'; +import type { BufferEncoding } from 'vfile'; + +/** + * This pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files. + */ +export class BuildPipeline extends Pipeline { + #internals: BuildInternals; + #staticBuildOptions: StaticBuildOptions; + #manifest: SSRManifest; + #currentEndpointBody?: { + body: string | Uint8Array; + encoding: BufferEncoding; + }; + + constructor( + staticBuildOptions: StaticBuildOptions, + internals: BuildInternals, + manifest: SSRManifest + ) { + const ssr = isServerLikeOutput(staticBuildOptions.settings.config); + super( + createEnvironment({ + adapterName: manifest.adapterName, + logging: staticBuildOptions.logging, + mode: staticBuildOptions.mode, + renderers: manifest.renderers, + clientDirectives: manifest.clientDirectives, + compressHTML: manifest.compressHTML, + async resolve(specifier: string) { + const hashedFilePath = manifest.entryModules[specifier]; + if (typeof hashedFilePath !== 'string') { + // If no "astro:scripts/before-hydration.js" script exists in the build, + // then we can assume that no before-hydration scripts are needed. + if (specifier === BEFORE_HYDRATION_SCRIPT_ID) { + return ''; + } + throw new Error(`Cannot find the built path for ${specifier}`); + } + return createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix); + }, + routeCache: staticBuildOptions.routeCache, + site: manifest.site, + ssr, + streaming: true, + }) + ); + this.#internals = internals; + this.#staticBuildOptions = staticBuildOptions; + this.#manifest = manifest; + this.setEndpointHandler(this.#handleEndpointResult); + } + + getInternals(): Readonly<BuildInternals> { + return this.#internals; + } + + getSettings(): Readonly<AstroSettings> { + return this.#staticBuildOptions.settings; + } + + getStaticBuildOptions(): Readonly<StaticBuildOptions> { + return this.#staticBuildOptions; + } + + getConfig(): AstroConfig { + return this.#staticBuildOptions.settings.config; + } + + getManifest(): SSRManifest { + return this.#manifest; + } + + /** + * The SSR build emits two important files: + * - dist/server/manifest.mjs + * - dist/renderers.mjs + * + * These two files, put together, will be used to generate the pages. + * + * ## Errors + * + * It will throw errors if the previous files can't be found in the file system. + * + * @param staticBuildOptions + */ + static async retrieveManifest( + staticBuildOptions: StaticBuildOptions, + internals: BuildInternals + ): Promise<SSRManifest> { + const config = staticBuildOptions.settings.config; + const baseDirectory = getOutputDirectory(config); + const manifestEntryUrl = new URL( + `${internals.manifestFileName}?time=${Date.now()}`, + baseDirectory + ); + const { manifest } = await import(manifestEntryUrl.toString()); + if (!manifest) { + throw new Error( + "Astro couldn't find the emitted manifest. This is an internal error, please file an issue." + ); + } + + const renderersEntryUrl = new URL(`renderers.mjs?time=${Date.now()}`, baseDirectory); + const renderers = await import(renderersEntryUrl.toString()); + if (!renderers) { + throw new Error( + "Astro couldn't find the emitted renderers. This is an internal error, please file an issue." + ); + } + return { + ...manifest, + renderers: renderers.renderers as SSRLoadedRenderer[], + }; + } + + /** + * It collects the routes to generate during the build. + * + * It returns a map of page information and their relative entry point as a string. + */ + retrieveRoutesToGenerate(): Map<PageBuildData, string> { + const pages = new Map<PageBuildData, string>(); + + for (const [entryPoint, filePath] of this.#internals.entrySpecifierToBundleMap) { + // virtual pages can be emitted with different prefixes: + // - the classic way are pages emitted with prefix ASTRO_PAGE_RESOLVED_MODULE_ID -> plugin-pages + // - pages emitted using `build.split`, in this case pages are emitted with prefix RESOLVED_SPLIT_MODULE_ID + if ( + entryPoint.includes(ASTRO_PAGE_RESOLVED_MODULE_ID) || + entryPoint.includes(RESOLVED_SPLIT_MODULE_ID) + ) { + const [, pageName] = entryPoint.split(':'); + const pageData = this.#internals.pagesByComponent.get( + `${pageName.replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.')}` + ); + if (!pageData) { + throw new Error( + "Build failed. Astro couldn't find the emitted page from " + pageName + ' pattern' + ); + } + + pages.set(pageData, filePath); + } + } + for (const [path, pageData] of this.#internals.pagesByComponent.entries()) { + if (pageData.route.type === 'redirect') { + pages.set(pageData, path); + } + } + return pages; + } + + async #handleEndpointResult(request: Request, response: EndpointCallResult): Promise<Response> { + if (response.type === 'response') { + if (!response.response.body) { + return new Response(null); + } + const ab = await response.response.arrayBuffer(); + const body = new Uint8Array(ab); + this.#currentEndpointBody = { + body: body, + encoding: 'utf-8', + }; + return response.response; + } else { + if (response.encoding) { + this.#currentEndpointBody = { + body: response.body, + encoding: response.encoding, + }; + const headers = new Headers(); + headers.set('X-Astro-Encoding', response.encoding); + return new Response(response.body, { + headers, + }); + } else { + return new Response(response.body); + } + } + } + + async computeBodyAndEncoding( + routeType: RouteType, + response: Response + ): Promise<{ + body: string | Uint8Array; + encoding: BufferEncoding; + }> { + const encoding = response.headers.get('X-Astro-Encoding') ?? 'utf-8'; + if (this.#currentEndpointBody) { + const currentEndpointBody = this.#currentEndpointBody; + this.#currentEndpointBody = undefined; + return currentEndpointBody; + } else { + return { body: await response.text(), encoding: encoding as BufferEncoding }; + } + } +} diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index a78a46883..4e89dfb61 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -9,7 +9,7 @@ import type { ComponentInstance, GetStaticPathsItem, ImageTransform, - MiddlewareHandler, + MiddlewareEndpointHandler, RouteData, RouteType, SSRError, @@ -20,26 +20,21 @@ import { generateImage as generateImageInternal, getStaticImageList, } from '../../assets/build/generate.js'; -import { - eachPageDataFromEntryPoint, - eachRedirectPageData, - hasPrerenderedPages, - type BuildInternals, -} from '../../core/build/internal.js'; +import { hasPrerenderedPages, type BuildInternals } from '../../core/build/internal.js'; import { isRelativePath, + joinPaths, prependForwardSlash, removeLeadingForwardSlash, removeTrailingForwardSlash, } from '../../core/path.js'; import { runHookBuildGenerated } from '../../integrations/index.js'; -import { isServerLikeOutput } from '../../prerender/utils.js'; -import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; +import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js'; +import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; -import { debug, info } from '../logger/core.js'; +import { debug, info, Logger } from '../logger/core.js'; import { RedirectSinglePageBuiltModule, getRedirectLocationOrThrow } from '../redirects/index.js'; -import { isEndpointResult } from '../render/core.js'; -import { createEnvironment, createRenderContext, tryRenderRoute } from '../render/index.js'; +import { createRenderContext } from '../render/index.js'; import { callGetStaticPaths } from '../render/route-cache.js'; import { createAssetLink, @@ -63,6 +58,8 @@ import type { StylesheetAsset, } from './types'; import { getTimeStat } from './util.js'; +import { BuildPipeline } from './buildPipeline.js'; +import type { BufferEncoding } from 'vfile'; function createEntryURL(filePath: string, outFolder: URL) { return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); @@ -124,8 +121,23 @@ export function chunkIsPage( } export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) { + const logger = new Logger(opts.logging); const timer = performance.now(); const ssr = isServerLikeOutput(opts.settings.config); + let manifest: SSRManifest; + if (ssr) { + manifest = await BuildPipeline.retrieveManifest(opts, internals); + } else { + const baseDirectory = getOutputDirectory(opts.settings.config); + const renderersEntryUrl = new URL('renderers.mjs', baseDirectory); + const renderers = await import(renderersEntryUrl.toString()); + manifest = createBuildManifest( + opts.settings, + internals, + renderers.renderers as SSRLoadedRenderer[] + ); + } + const buildPipeline = new BuildPipeline(opts, internals, manifest); const outFolder = ssr ? opts.settings.config.build.server : getOutDirWithinCwd(opts.settings.config.outDir); @@ -139,20 +151,18 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn const verb = ssr ? 'prerendering' : 'generating'; info(opts.logging, null, `\n${bgGreen(black(` ${verb} static routes `))}`); - const builtPaths = new Set<string>(); - + const pagesToGenerate = buildPipeline.retrieveRoutesToGenerate(); if (ssr) { - for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) { + for (const [pageData, filePath] of pagesToGenerate) { if (pageData.route.prerender) { const ssrEntryURLPage = createEntryURL(filePath, outFolder); const ssrEntryPage = await import(ssrEntryURLPage.toString()); if (opts.settings.config.build.split) { // forcing to use undefined, so we fail in an expected way if the module is not even there. - const manifest: SSRManifest | undefined = ssrEntryPage.manifest; - const ssrEntry = manifest?.pageModule; + const ssrEntry = ssrEntryPage?.manifest?.pageModule; if (ssrEntry) { - await generatePage(opts, internals, pageData, ssrEntry, builtPaths, manifest); + await generatePage(pageData, ssrEntry, builtPaths, buildPipeline, logger); } else { throw new Error( `Unable to find the manifest for the module ${ssrEntryURLPage.toString()}. This is unexpected and likely a bug in Astro, please report.` @@ -160,40 +170,35 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn } } else { const ssrEntry = ssrEntryPage as SinglePageBuiltModule; - const manifest = createBuildManifest(opts.settings, internals, ssrEntry.renderers); - await generatePage(opts, internals, pageData, ssrEntry, builtPaths, manifest); + await generatePage(pageData, ssrEntry, builtPaths, buildPipeline, logger); } } - } - for (const pageData of eachRedirectPageData(internals)) { - const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); - const manifest = createBuildManifest(opts.settings, internals, entry.renderers); - await generatePage(opts, internals, pageData, entry, builtPaths, manifest); + if (pageData.route.type === 'redirect') { + const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); + await generatePage(pageData, entry, builtPaths, buildPipeline, logger); + } } } else { - for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) { - const ssrEntryURLPage = createEntryURL(filePath, outFolder); - const entry: SinglePageBuiltModule = await import(ssrEntryURLPage.toString()); - const manifest = createBuildManifest(opts.settings, internals, entry.renderers); + for (const [pageData, filePath] of pagesToGenerate) { + if (pageData.route.type === 'redirect') { + const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); + await generatePage(pageData, entry, builtPaths, buildPipeline, logger); + } else { + const ssrEntryURLPage = createEntryURL(filePath, outFolder); + const entry: SinglePageBuiltModule = await import(ssrEntryURLPage.toString()); - await generatePage(opts, internals, pageData, entry, builtPaths, manifest); - } - for (const pageData of eachRedirectPageData(internals)) { - const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); - const manifest = createBuildManifest(opts.settings, internals, entry.renderers); - await generatePage(opts, internals, pageData, entry, builtPaths, manifest); + await generatePage(pageData, entry, builtPaths, buildPipeline, logger); + } } } - if (opts.settings.config.experimental.assets) { - info(opts.logging, null, `\n${bgGreen(black(` generating optimized images `))}`); - for (const imageData of getStaticImageList()) { - await generateImage(opts, imageData[1].options, imageData[1].path); - } - - delete globalThis?.astroAsset?.addStaticImage; + info(opts.logging, null, `\n${bgGreen(black(` generating optimized images `))}`); + for (const imageData of getStaticImageList()) { + await generateImage(opts, imageData[1].options, imageData[1].path); } + delete globalThis?.astroAsset?.addStaticImage; + await runHookBuildGenerated({ config: opts.settings.config, logging: opts.logging, @@ -220,16 +225,15 @@ async function generateImage(opts: StaticBuildOptions, transform: ImageTransform } async function generatePage( - opts: StaticBuildOptions, - internals: BuildInternals, pageData: PageBuildData, ssrEntry: SinglePageBuiltModule, builtPaths: Set<string>, - manifest: SSRManifest + pipeline: BuildPipeline, + logger: Logger ) { let timeStart = performance.now(); - const pageInfo = getPageDataByComponent(internals, pageData.route.component); + const pageInfo = getPageDataByComponent(pipeline.getInternals(), pageData.route.component); // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. const linkIds: [] = []; @@ -241,6 +245,9 @@ async function generatePage( const pageModulePromise = ssrEntry.page; const onRequest = ssrEntry.onRequest; + if (onRequest) { + pipeline.setMiddlewareFunction(onRequest as MiddlewareEndpointHandler); + } if (!pageModulePromise) { throw new Error( @@ -248,14 +255,13 @@ async function generatePage( ); } const pageModule = await pageModulePromise(); - if (shouldSkipDraft(pageModule, opts.settings)) { - info(opts.logging, null, `${magenta('⚠️')} Skipping draft ${pageData.route.component}`); + if (shouldSkipDraft(pageModule, pipeline.getSettings())) { + logger.info(null, `${magenta('⚠️')} Skipping draft ${pageData.route.component}`); return; } const generationOptions: Readonly<GeneratePathOptions> = { pageData, - internals, linkIds, scripts, styles, @@ -264,23 +270,28 @@ async function generatePage( const icon = pageData.route.type === 'page' ? green('▶') : magenta('λ'); if (isRelativePath(pageData.route.component)) { - info(opts.logging, null, `${icon} ${pageData.route.route}`); + logger.info(null, `${icon} ${pageData.route.route}`); } else { - info(opts.logging, null, `${icon} ${pageData.route.component}`); + logger.info(null, `${icon} ${pageData.route.component}`); } // Get paths for the route, calling getStaticPaths if needed. - const paths = await getPathsForRoute(pageData, pageModule, opts, builtPaths); + const paths = await getPathsForRoute( + pageData, + pageModule, + pipeline.getStaticBuildOptions(), + builtPaths + ); for (let i = 0; i < paths.length; i++) { const path = paths[i]; - await generatePath(path, opts, generationOptions, manifest, onRequest); + await generatePath(path, generationOptions, pipeline); const timeEnd = performance.now(); const timeChange = getTimeStat(timeStart, timeEnd); const timeIncrease = `(+${timeChange})`; - const filePath = getOutputFilename(opts.settings.config, path, pageData.route.type); + const filePath = getOutputFilename(pipeline.getConfig(), path, pageData.route.type); const lineIcon = i === paths.length - 1 ? '└─' : '├─'; - info(opts.logging, null, ` ${cyan(lineIcon)} ${dim(filePath)} ${dim(timeIncrease)}`); + logger.info(null, ` ${cyan(lineIcon)} ${dim(filePath)} ${dim(timeIncrease)}`); } } @@ -300,7 +311,6 @@ async function getPathsForRoute( mod, route, routeCache: opts.routeCache, - isValidate: false, logging: opts.logging, ssr: isServerLikeOutput(opts.settings.config), }).catch((err) => { @@ -384,7 +394,6 @@ function getInvalidRouteSegmentError( interface GeneratePathOptions { pageData: PageBuildData; - internals: BuildInternals; linkIds: string[]; scripts: { type: 'inline' | 'external'; value: string } | null; styles: StylesheetAsset[]; @@ -438,29 +447,23 @@ function getUrlForPath( buildPathname = base; } else if (routeType === 'endpoint') { const buildPathRelative = removeLeadingForwardSlash(pathname); - buildPathname = base + buildPathRelative; + buildPathname = joinPaths(base, buildPathRelative); } else { const buildPathRelative = removeTrailingForwardSlash(removeLeadingForwardSlash(pathname)) + ending; - buildPathname = base + buildPathRelative; + buildPathname = joinPaths(base, buildPathRelative); } const url = new URL(buildPathname, origin); return url; } -async function generatePath( - pathname: string, - opts: StaticBuildOptions, - gopts: GeneratePathOptions, - manifest: SSRManifest, - onRequest?: MiddlewareHandler<unknown> -) { - const { settings, logging, origin, routeCache } = opts; - const { mod, internals, scripts: hoistedScripts, styles: _styles, pageData } = gopts; +async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeline: BuildPipeline) { + const manifest = pipeline.getManifest(); + const { mod, scripts: hoistedScripts, styles: _styles, pageData } = gopts; // This adds the page name to the array so it can be shown as part of stats. if (pageData.route.type === 'page') { - addPageName(pathname, opts); + addPageName(pathname, pipeline.getStaticBuildOptions()); } debug('build', `Generating: ${pathname}`); @@ -474,8 +477,8 @@ async function generatePath( ); const styles = createStylesheetElementSet(_styles, manifest.base, manifest.assetsPrefix); - if (settings.scripts.some((script) => script.stage === 'page')) { - const hashedFilePath = internals.entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID); + if (pipeline.getSettings().scripts.some((script) => script.stage === 'page')) { + const hashedFilePath = pipeline.getInternals().entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID); if (typeof hashedFilePath !== 'string') { throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`); } @@ -487,7 +490,7 @@ async function generatePath( } // Add all injected scripts to the page. - for (const script of settings.scripts) { + for (const script of pipeline.getSettings().scripts) { if (script.stage === 'head-inline') { scripts.add({ props: {}, @@ -496,59 +499,38 @@ async function generatePath( } } - const ssr = isServerLikeOutput(settings.config); + const ssr = isServerLikeOutput(pipeline.getConfig()); const url = getUrlForPath( pathname, - opts.settings.config.base, - origin, - opts.settings.config.build.format, + pipeline.getConfig().base, + pipeline.getStaticBuildOptions().origin, + pipeline.getConfig().build.format, pageData.route.type ); - const env = createEnvironment({ - adapterName: manifest.adapterName, - logging, - markdown: manifest.markdown, - mode: opts.mode, - renderers: manifest.renderers, - clientDirectives: manifest.clientDirectives, - compressHTML: manifest.compressHTML, - async resolve(specifier: string) { - const hashedFilePath = manifest.entryModules[specifier]; - if (typeof hashedFilePath !== 'string') { - // If no "astro:scripts/before-hydration.js" script exists in the build, - // then we can assume that no before-hydration scripts are needed. - if (specifier === BEFORE_HYDRATION_SCRIPT_ID) { - return ''; - } - throw new Error(`Cannot find the built path for ${specifier}`); - } - return createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix); - }, - routeCache, - site: manifest.site, - ssr, - streaming: true, - }); - const renderContext = await createRenderContext({ pathname, - request: createRequest({ url, headers: new Headers(), logging, ssr }), + request: createRequest({ + url, + headers: new Headers(), + logging: pipeline.getStaticBuildOptions().logging, + ssr, + }), componentMetadata: manifest.componentMetadata, scripts, styles, links, route: pageData.route, - env, + env: pipeline.getEnvironment(), mod, }); let body: string | Uint8Array; let encoding: BufferEncoding | undefined; - let response; + let response: Response; try { - response = await tryRenderRoute(pageData.route.type, renderContext, env, mod, onRequest); + response = await pipeline.renderRoute(renderContext, mod); } catch (err) { if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') { (err as SSRError).id = pageData.component; @@ -556,28 +538,17 @@ async function generatePath( throw err; } - if (isEndpointResult(response, pageData.route.type)) { - if (response.type === 'response') { - // If there's no body, do nothing - if (!response.response.body) return; - const ab = await response.response.arrayBuffer(); - body = new Uint8Array(ab); - } else { - body = response.body; - encoding = response.encoding; + if (response.status >= 300 && response.status < 400) { + // If redirects is set to false, don't output the HTML + if (!pipeline.getConfig().build.redirects) { + return; } - } else { - if (response.status >= 300 && response.status < 400) { - // If redirects is set to false, don't output the HTML - if (!opts.settings.config.build.redirects) { - return; - } - const location = getRedirectLocationOrThrow(response.headers); - const fromPath = new URL(renderContext.request.url).pathname; - // A short delay causes Google to interpret the redirect as temporary. - // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh - const delay = response.status === 302 ? 2 : 0; - body = `<!doctype html> + const location = getRedirectLocationOrThrow(response.headers); + const fromPath = new URL(renderContext.request.url).pathname; + // A short delay causes Google to interpret the redirect as temporary. + // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh + const delay = response.status === 302 ? 2 : 0; + body = `<!doctype html> <title>Redirecting to: ${location}</title> <meta http-equiv="refresh" content="${delay};url=${location}"> <meta name="robots" content="noindex"> @@ -585,20 +556,25 @@ async function generatePath( <body> <a href="${location}">Redirecting from <code>${fromPath}</code> to <code>${location}</code></a> </body>`; - // A dynamic redirect, set the location so that integrations know about it. - if (pageData.route.type !== 'redirect') { - pageData.route.redirect = location; - } - } else { - // If there's no body, do nothing - if (!response.body) return; - body = await response.text(); + // A dynamic redirect, set the location so that integrations know about it. + if (pageData.route.type !== 'redirect') { + pageData.route.redirect = location; } + } else { + // If there's no body, do nothing + if (!response.body) return; + const result = await pipeline.computeBodyAndEncoding(renderContext.route.type, response); + body = result.body; + encoding = result.encoding; } - const outFolder = getOutFolder(settings.config, pathname, pageData.route.type); - const outFile = getOutFile(settings.config, outFolder, pathname, pageData.route.type); + const outFolder = getOutFolder(pipeline.getConfig(), pathname, pageData.route.type); + const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, pageData.route.type); pageData.route.distURL = outFile; + const possibleEncoding = response.headers.get('X-Astro-Encoding'); + if (possibleEncoding) { + encoding = possibleEncoding as BufferEncoding; + } await fs.promises.mkdir(outFolder, { recursive: true }); await fs.promises.writeFile(outFile, body, encoding ?? 'utf-8'); } @@ -620,7 +596,6 @@ export function createBuildManifest( entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()), routes: [], adapterName: '', - markdown: settings.config.markdown, clientDirectives: settings.clientDirectives, compressHTML: settings.config.compressHTML, renderers, diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 5b1ecf404..07b9b2f7c 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -102,9 +102,7 @@ class AstroBuilder { logging, }); - // HACK: Since we only inject the endpoint if `experimental.assets` is on and it's possible for an integration to - // add that flag, we need to only check and inject the endpoint after running the config setup hook. - if (this.settings.config.experimental.assets && isServerLikeOutput(this.settings.config)) { + if (isServerLikeOutput(this.settings.config)) { this.settings = injectImageEndpoint(this.settings); } diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 5dff6f3dd..c1123e36b 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -85,6 +85,9 @@ export interface BuildInternals { staticFiles: Set<string>; // The SSR entry chunk. Kept in internals to share between ssr/client build steps ssrEntryChunk?: Rollup.OutputChunk; + // The SSR manifest entry chunk. + manifestEntryChunk?: Rollup.OutputChunk; + manifestFileName?: string; entryPoints: Map<RouteData, URL>; ssrSplitEntryChunks: Map<string, Rollup.OutputChunk>; componentMetadata: SSRResult['componentMetadata']; @@ -227,14 +230,6 @@ export function* eachPageData(internals: BuildInternals) { yield* internals.pagesByComponent.values(); } -export function* eachRedirectPageData(internals: BuildInternals) { - for (const pageData of eachPageData(internals)) { - if (pageData.route.type === 'redirect') { - yield pageData; - } - } -} - export function* eachPageDataFromEntryPoint( internals: BuildInternals ): Generator<[PageBuildData, string]> { diff --git a/packages/astro/src/core/build/plugins/README.md b/packages/astro/src/core/build/plugins/README.md index 145158163..ef73b9e50 100644 --- a/packages/astro/src/core/build/plugins/README.md +++ b/packages/astro/src/core/build/plugins/README.md @@ -125,10 +125,13 @@ will look like this: Of course, all these files will be deleted by Astro at the end build. -## `plugin-ssr` (WIP) +## `plugin-ssr` -This plugin is responsible to create a single `entry.mjs` file that will be used -in SSR. +This plugin is responsible to create the JS files that will be executed in SSR. + +### Classic mode + +The plugin will emit a single entry point called `entry.mjs`. This plugin **will emit code** only when building an **SSR** site. @@ -146,4 +149,24 @@ const pageMap = new Map([ ``` It will also import the [`renderers`](#plugin-renderers) virtual module -and the [`middleware`](#plugin-middleware) virtual module. +and the [`manifest`](#plugin-manifest) virtual module. + +### Split mode + +The plugin will emit various entry points. Each route will be an entry point. + +Each entry point will contain the necessary code to **render one single route**. + +Each entry point will also import the [`renderers`](#plugin-renderers) virtual module +and the [`manifest`](#plugin-manifest) virtual module. + +## `plugin-manifest` + +This plugin is responsible to create a file called `manifest.mjs`. In SSG, the file is saved +in `config.outDir`, in SSR the file is saved in `config.build.server`. + +This file is important to do two things: +- generate the pages during the SSG; +- render the pages in SSR; + +The file contains all the information needed to Astro to accomplish the operations mentioned above. diff --git a/packages/astro/src/core/build/plugins/index.ts b/packages/astro/src/core/build/plugins/index.ts index decfefd04..19c952660 100644 --- a/packages/astro/src/core/build/plugins/index.ts +++ b/packages/astro/src/core/build/plugins/index.ts @@ -12,12 +12,14 @@ import { pluginPages } from './plugin-pages.js'; import { pluginPrerender } from './plugin-prerender.js'; import { pluginRenderers } from './plugin-renderers.js'; import { pluginSSR, pluginSSRSplit } from './plugin-ssr.js'; +import { pluginManifest } from './plugin-manifest.js'; export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) { register(pluginComponentEntry(internals)); register(pluginAliasResolve(internals)); register(pluginAnalyzer(options, internals)); register(pluginInternals(internals)); + register(pluginManifest(options, internals)); register(pluginRenderers(options)); register(pluginMiddleware(options, internals)); register(pluginPages(options, internals)); diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts new file mode 100644 index 000000000..2c2ceb7e1 --- /dev/null +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -0,0 +1,251 @@ +import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js'; +import type { AstroBuildPlugin } from '../plugin'; +import { type Plugin as VitePlugin } from 'vite'; +import { runHookBuildSsr } from '../../../integrations/index.js'; +import { addRollupInput } from '../add-rollup-input.js'; +import glob from 'fast-glob'; +import { fileURLToPath } from 'node:url'; +import type { OutputChunk } from 'rollup'; +import { getOutFile, getOutFolder } from '../common.js'; +import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; +import { joinPaths, prependForwardSlash } from '../../path.js'; +import { serializeRouteData } from '../../routing/index.js'; +import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types'; +import type { StaticBuildOptions } from '../types'; + +const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; +const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); + +export const SSR_MANIFEST_VIRTUAL_MODULE_ID = '@astrojs-manifest'; +export const RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID = '\0' + SSR_MANIFEST_VIRTUAL_MODULE_ID; + +function vitePluginManifest(options: StaticBuildOptions, internals: BuildInternals): VitePlugin { + return { + name: '@astro/plugin-build-manifest', + enforce: 'post', + options(opts) { + return addRollupInput(opts, [SSR_MANIFEST_VIRTUAL_MODULE_ID]); + }, + resolveId(id) { + if (id === SSR_MANIFEST_VIRTUAL_MODULE_ID) { + return RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID; + } + }, + augmentChunkHash(chunkInfo) { + if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) { + return Date.now().toString(); + } + }, + async load(id) { + if (id === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) { + const imports = []; + const contents = []; + const exports = []; + imports.push( + `import { deserializeManifest as _deserializeManifest } from 'astro/app'`, + `import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'` + ); + + contents.push(` +const manifest = _deserializeManifest('${manifestReplace}'); +_privateSetManifestDontUseThis(manifest); +`); + + exports.push('export { manifest }'); + + return `${imports.join('\n')}${contents.join('\n')}${exports.join('\n')}`; + } + }, + + async generateBundle(_opts, bundle) { + for (const [chunkName, chunk] of Object.entries(bundle)) { + if (chunk.type === 'asset') { + continue; + } + if (chunk.modules[RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID]) { + internals.manifestEntryChunk = chunk; + delete bundle[chunkName]; + } + if (chunkName.startsWith('manifest')) { + internals.manifestFileName = chunkName; + } + } + }, + }; +} + +export function pluginManifest( + options: StaticBuildOptions, + internals: BuildInternals +): AstroBuildPlugin { + return { + build: 'ssr', + hooks: { + 'build:before': () => { + return { + vitePlugin: vitePluginManifest(options, internals), + }; + }, + + 'build:post': async ({ mutate }) => { + if (!internals.manifestEntryChunk) { + throw new Error(`Did not generate an entry chunk for SSR`); + } + + const manifest = await createManifest(options, internals); + await runHookBuildSsr({ + config: options.settings.config, + manifest, + logging: options.logging, + entryPoints: internals.entryPoints, + middlewareEntryPoint: internals.middlewareEntryPoint, + }); + // TODO: use the manifest entry chunk instead + const code = injectManifest(manifest, internals.manifestEntryChunk); + mutate(internals.manifestEntryChunk, 'server', code); + }, + }, + }; +} + +export async function createManifest( + buildOpts: StaticBuildOptions, + internals: BuildInternals +): Promise<SerializedSSRManifest> { + if (!internals.manifestEntryChunk) { + throw new Error(`Did not generate an entry chunk for SSR`); + } + + // Add assets from the client build. + const clientStatics = new Set( + await glob('**/*', { + cwd: fileURLToPath(buildOpts.settings.config.build.client), + }) + ); + for (const file of clientStatics) { + internals.staticFiles.add(file); + } + + const staticFiles = internals.staticFiles; + return buildManifest(buildOpts, internals, Array.from(staticFiles)); +} + +/** + * It injects the manifest in the given output rollup chunk. It returns the new emitted code + * @param buildOpts + * @param internals + * @param chunk + */ +export function injectManifest(manifest: SerializedSSRManifest, chunk: Readonly<OutputChunk>) { + const code = chunk.code; + + return code.replace(replaceExp, () => { + return JSON.stringify(manifest); + }); +} + +function buildManifest( + opts: StaticBuildOptions, + internals: BuildInternals, + staticFiles: string[] +): SerializedSSRManifest { + const { settings } = opts; + + const routes: SerializedRouteInfo[] = []; + const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); + if (settings.scripts.some((script) => script.stage === 'page')) { + staticFiles.push(entryModules[PAGE_SCRIPT_ID]); + } + + const prefixAssetPath = (pth: string) => { + if (settings.config.build.assetsPrefix) { + return joinPaths(settings.config.build.assetsPrefix, pth); + } else { + return prependForwardSlash(joinPaths(settings.config.base, pth)); + } + }; + + for (const route of opts.manifest.routes) { + if (!route.prerender) continue; + if (!route.pathname) continue; + + const outFolder = getOutFolder(opts.settings.config, route.pathname, route.type); + const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route.type); + const file = outFile.toString().replace(opts.settings.config.build.client.toString(), ''); + routes.push({ + file, + links: [], + scripts: [], + styles: [], + routeData: serializeRouteData(route, settings.config.trailingSlash), + }); + staticFiles.push(file); + } + + for (const route of opts.manifest.routes) { + const pageData = internals.pagesByComponent.get(route.component); + if (route.prerender || !pageData) continue; + const scripts: SerializedRouteInfo['scripts'] = []; + if (pageData.hoistedScript) { + const hoistedValue = pageData.hoistedScript.value; + const value = hoistedValue.endsWith('.js') ? prefixAssetPath(hoistedValue) : hoistedValue; + scripts.unshift( + Object.assign({}, pageData.hoistedScript, { + value, + }) + ); + } + if (settings.scripts.some((script) => script.stage === 'page')) { + const src = entryModules[PAGE_SCRIPT_ID]; + + scripts.push({ + type: 'external', + value: prefixAssetPath(src), + }); + } + + // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. + const links: [] = []; + + const styles = pageData.styles + .sort(cssOrder) + .map(({ sheet }) => sheet) + .map((s) => (s.type === 'external' ? { ...s, src: prefixAssetPath(s.src) } : s)) + .reduce(mergeInlineCss, []); + + routes.push({ + file: '', + links, + scripts: [ + ...scripts, + ...settings.scripts + .filter((script) => script.stage === 'head-inline') + .map(({ stage, content }) => ({ stage, children: content })), + ], + styles, + routeData: serializeRouteData(route, settings.config.trailingSlash), + }); + } + + // HACK! Patch this special one. + if (!(BEFORE_HYDRATION_SCRIPT_ID in entryModules)) { + // Set this to an empty string so that the runtime knows not to try and load this. + entryModules[BEFORE_HYDRATION_SCRIPT_ID] = ''; + } + + const ssrManifest: SerializedSSRManifest = { + adapterName: opts.settings.adapter?.name ?? '', + routes, + site: settings.config.site, + base: settings.config.base, + compressHTML: settings.config.compressHTML, + assetsPrefix: settings.config.build.assetsPrefix, + componentMetadata: Array.from(internals.componentMetadata), + renderers: [], + clientDirectives: Array.from(settings.clientDirectives), + entryModules, + assets: staticFiles.map(prefixAssetPath), + }; + + return ssrManifest; +} diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts index 2ee438a6a..ff63acd74 100644 --- a/packages/astro/src/core/build/plugins/plugin-pages.ts +++ b/packages/astro/src/core/build/plugins/plugin-pages.ts @@ -8,6 +8,7 @@ import type { StaticBuildOptions } from '../types'; import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js'; import { RENDERERS_MODULE_ID } from './plugin-renderers.js'; import { ASTRO_PAGE_EXTENSION_POST_PATTERN, getPathFromVirtualModulePageName } from './util.js'; +import type { AstroSettings } from '../../../@types/astro'; export const ASTRO_PAGE_MODULE_ID = '@astro-page:'; export const ASTRO_PAGE_RESOLVED_MODULE_ID = '\0' + ASTRO_PAGE_MODULE_ID; @@ -74,7 +75,7 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V exports.push(`export { renderers };`); // The middleware should not be imported by the pages - if (!opts.settings.config.build.excludeMiddleware) { + if (shouldBundleMiddleware(opts.settings)) { const middlewareModule = await this.resolve(MIDDLEWARE_MODULE_ID); if (middlewareModule) { imports.push(`import { onRequest } from "${middlewareModule.id}";`); @@ -90,6 +91,17 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V }; } +export function shouldBundleMiddleware(settings: AstroSettings) { + // TODO: Remove in Astro 4.0 + if (settings.config.build.excludeMiddleware === true) { + return false; + } + if (settings.adapter?.adapterFeatures?.edgeMiddleware === true) { + return false; + } + return true; +} + export function pluginPages(opts: StaticBuildOptions, internals: BuildInternals): AstroBuildPlugin { return { build: 'ssr', diff --git a/packages/astro/src/core/build/plugins/plugin-prerender.ts b/packages/astro/src/core/build/plugins/plugin-prerender.ts index a0d6a9c7b..402264c6e 100644 --- a/packages/astro/src/core/build/plugins/plugin-prerender.ts +++ b/packages/astro/src/core/build/plugins/plugin-prerender.ts @@ -14,7 +14,7 @@ function vitePluginPrerender(opts: StaticBuildOptions, internals: BuildInternals extendManualChunks(outputOptions, { after(id, meta) { // Split the Astro runtime into a separate chunk for readability - if (id.includes('astro/dist')) { + if (id.includes('astro/dist/runtime')) { return 'astro'; } const pageInfo = internals.pagesByViteID.get(id); diff --git a/packages/astro/src/core/build/plugins/plugin-renderers.ts b/packages/astro/src/core/build/plugins/plugin-renderers.ts index 912df4241..f0cdf8983 100644 --- a/packages/astro/src/core/build/plugins/plugin-renderers.ts +++ b/packages/astro/src/core/build/plugins/plugin-renderers.ts @@ -38,6 +38,8 @@ export function vitePluginRenderers(opts: StaticBuildOptions): VitePlugin { exports.push(`export const renderers = [${rendererItems}];`); return `${imports.join('\n')}\n${exports.join('\n')}`; + } else { + return `export const renderers = [];`; } } }, diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index 514fe2409..098b9dee8 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -1,28 +1,21 @@ -import glob from 'fast-glob'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import type { Plugin as VitePlugin } from 'vite'; import type { AstroAdapter, AstroConfig } from '../../../@types/astro'; -import { runHookBuildSsr } from '../../../integrations/index.js'; +import { isFunctionPerRouteEnabled } from '../../../integrations/index.js'; import { isServerLikeOutput } from '../../../prerender/utils.js'; -import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; -import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types'; -import { joinPaths, prependForwardSlash } from '../../path.js'; import { routeIsRedirect } from '../../redirects/index.js'; -import { serializeRouteData } from '../../routing/index.js'; import { addRollupInput } from '../add-rollup-input.js'; -import { getOutFile, getOutFolder } from '../common.js'; -import { cssOrder, mergeInlineCss, type BuildInternals } from '../internal.js'; +import type { BuildInternals } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin'; -import type { OutputChunk, StaticBuildOptions } from '../types'; +import type { StaticBuildOptions } from '../types'; import { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js'; import { RENDERERS_MODULE_ID } from './plugin-renderers.js'; import { getPathFromVirtualModulePageName, getVirtualModulePageNameFromPath } from './util.js'; +import { SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugin-manifest.js'; export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry'; -const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID; -const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; -const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); +export const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID; function vitePluginSSR( internals: BuildInternals, @@ -85,13 +78,12 @@ function vitePluginSSR( } } - for (const [chunkName, chunk] of Object.entries(bundle)) { + for (const [, chunk] of Object.entries(bundle)) { if (chunk.type === 'asset') { continue; } if (chunk.modules[RESOLVED_SSR_VIRTUAL_MODULE_ID]) { internals.ssrEntryChunk = chunk; - delete bundle[chunkName]; } } }, @@ -103,12 +95,16 @@ export function pluginSSR( internals: BuildInternals ): AstroBuildPlugin { const ssr = isServerLikeOutput(options.settings.config); + const functionPerRouteEnabled = isFunctionPerRouteEnabled(options.settings.adapter); return { build: 'ssr', hooks: { 'build:before': () => { let vitePlugin = - ssr && !options.settings.config.build.split + ssr && + // TODO: Remove in Astro 4.0 + options.settings.config.build.split === false && + functionPerRouteEnabled === false ? vitePluginSSR(internals, options.settings.adapter!, options) : undefined; @@ -117,12 +113,12 @@ export function pluginSSR( vitePlugin, }; }, - 'build:post': async ({ mutate }) => { + 'build:post': async () => { if (!ssr) { return; } - if (options.settings.config.build.split) { + if (options.settings.config.build.split || functionPerRouteEnabled) { return; } @@ -131,17 +127,6 @@ export function pluginSSR( } // Mutate the filename internals.ssrEntryChunk.fileName = options.settings.config.build.serverEntry; - - const manifest = await createManifest(options, internals); - await runHookBuildSsr({ - config: options.settings.config, - manifest, - logging: options.logging, - entryPoints: internals.entryPoints, - middlewareEntryPoint: internals.middlewareEntryPoint, - }); - const code = injectManifest(manifest, internals.ssrEntryChunk); - mutate(internals.ssrEntryChunk, 'server', code); }, }, }; @@ -155,11 +140,12 @@ function vitePluginSSRSplit( adapter: AstroAdapter, options: StaticBuildOptions ): VitePlugin { + const functionPerRouteEnabled = isFunctionPerRouteEnabled(options.settings.adapter); return { name: '@astrojs/vite-plugin-astro-ssr-split', enforce: 'post', options(opts) { - if (options.settings.config.build.split) { + if (options.settings.config.build.split || functionPerRouteEnabled) { const inputs = new Set<string>(); for (const path of Object.keys(options.allPages)) { @@ -204,21 +190,16 @@ function vitePluginSSRSplit( } } - for (const [chunkName, chunk] of Object.entries(bundle)) { + for (const [, chunk] of Object.entries(bundle)) { if (chunk.type === 'asset') { continue; } - let shouldDeleteBundle = false; for (const moduleKey of Object.keys(chunk.modules)) { if (moduleKey.startsWith(RESOLVED_SPLIT_MODULE_ID)) { internals.ssrSplitEntryChunks.set(moduleKey, chunk); storeEntryPoint(moduleKey, options, internals, chunk.fileName); - shouldDeleteBundle = true; } } - if (shouldDeleteBundle) { - delete bundle[chunkName]; - } } }, }; @@ -229,12 +210,14 @@ export function pluginSSRSplit( internals: BuildInternals ): AstroBuildPlugin { const ssr = isServerLikeOutput(options.settings.config); + const functionPerRouteEnabled = isFunctionPerRouteEnabled(options.settings.adapter); + return { build: 'ssr', hooks: { 'build:before': () => { let vitePlugin = - ssr && options.settings.config.build.split + ssr && (options.settings.config.build.split || functionPerRouteEnabled) ? vitePluginSSRSplit(internals, options.settings.adapter!, options) : undefined; @@ -243,31 +226,6 @@ export function pluginSSRSplit( vitePlugin, }; }, - 'build:post': async ({ mutate }) => { - if (!ssr) { - return; - } - if (!options.settings.config.build.split) { - return; - } - - if (internals.ssrSplitEntryChunks.size === 0) { - throw new Error(`Did not generate an entry chunk for SSR serverless`); - } - - const manifest = await createManifest(options, internals); - await runHookBuildSsr({ - config: options.settings.config, - manifest, - logging: options.logging, - entryPoints: internals.entryPoints, - middlewareEntryPoint: internals.middlewareEntryPoint, - }); - for (const [, chunk] of internals.ssrSplitEntryChunks) { - const code = injectManifest(manifest, chunk); - mutate(chunk, 'server', code); - } - }, }, }; } @@ -276,7 +234,7 @@ function generateSSRCode(config: AstroConfig, adapter: AstroAdapter) { const imports: string[] = []; const contents: string[] = []; let pageMap; - if (config.build.split) { + if (config.build.split || isFunctionPerRouteEnabled(adapter)) { pageMap = 'pageModule'; } else { pageMap = 'pageMap'; @@ -284,13 +242,11 @@ function generateSSRCode(config: AstroConfig, adapter: AstroAdapter) { contents.push(`import * as adapter from '${adapter.serverEntrypoint}'; import { renderers } from '${RENDERERS_MODULE_ID}'; -import { deserializeManifest as _deserializeManifest } from 'astro/app'; -import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'; -const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), { +import { manifest as defaultManifest} from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}'; +const _manifest = Object.assign(defaultManifest, { ${pageMap}, renderers, }); -_privateSetManifestDontUseThis(_manifest); const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'}; ${ @@ -320,48 +276,6 @@ if(_start in adapter) { } /** - * It injects the manifest in the given output rollup chunk. It returns the new emitted code - * @param buildOpts - * @param internals - * @param chunk - */ -export function injectManifest(manifest: SerializedSSRManifest, chunk: Readonly<OutputChunk>) { - const code = chunk.code; - - return code.replace(replaceExp, () => { - return JSON.stringify(manifest); - }); -} - -export async function createManifest( - buildOpts: StaticBuildOptions, - internals: BuildInternals -): Promise<SerializedSSRManifest> { - if (buildOpts.settings.config.build.split) { - if (internals.ssrSplitEntryChunks.size === 0) { - throw new Error(`Did not generate an entry chunk for SSR in serverless mode`); - } - } else { - if (!internals.ssrEntryChunk) { - throw new Error(`Did not generate an entry chunk for SSR`); - } - } - - // Add assets from the client build. - const clientStatics = new Set( - await glob('**/*', { - cwd: fileURLToPath(buildOpts.settings.config.build.client), - }) - ); - for (const file of clientStatics) { - internals.staticFiles.add(file); - } - - const staticFiles = internals.staticFiles; - return buildManifest(buildOpts, internals, Array.from(staticFiles)); -} - -/** * Because we delete the bundle from rollup at the end of this function, * we can't use `writeBundle` hook to get the final file name of the entry point written on disk. * We use this hook instead. @@ -382,110 +296,3 @@ function storeEntryPoint( } } } - -function buildManifest( - opts: StaticBuildOptions, - internals: BuildInternals, - staticFiles: string[] -): SerializedSSRManifest { - const { settings } = opts; - - const routes: SerializedRouteInfo[] = []; - const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); - if (settings.scripts.some((script) => script.stage === 'page')) { - staticFiles.push(entryModules[PAGE_SCRIPT_ID]); - } - - const prefixAssetPath = (pth: string) => { - if (settings.config.build.assetsPrefix) { - return joinPaths(settings.config.build.assetsPrefix, pth); - } else { - return prependForwardSlash(joinPaths(settings.config.base, pth)); - } - }; - - for (const route of opts.manifest.routes) { - if (!route.prerender) continue; - if (!route.pathname) continue; - - const outFolder = getOutFolder(opts.settings.config, route.pathname, route.type); - const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route.type); - const file = outFile.toString().replace(opts.settings.config.build.client.toString(), ''); - routes.push({ - file, - links: [], - scripts: [], - styles: [], - routeData: serializeRouteData(route, settings.config.trailingSlash), - }); - staticFiles.push(file); - } - - for (const route of opts.manifest.routes) { - const pageData = internals.pagesByComponent.get(route.component); - if (route.prerender || !pageData) continue; - const scripts: SerializedRouteInfo['scripts'] = []; - if (pageData.hoistedScript) { - const hoistedValue = pageData.hoistedScript.value; - const value = hoistedValue.endsWith('.js') ? prefixAssetPath(hoistedValue) : hoistedValue; - scripts.unshift( - Object.assign({}, pageData.hoistedScript, { - value, - }) - ); - } - if (settings.scripts.some((script) => script.stage === 'page')) { - const src = entryModules[PAGE_SCRIPT_ID]; - - scripts.push({ - type: 'external', - value: prefixAssetPath(src), - }); - } - - // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. - const links: [] = []; - - const styles = pageData.styles - .sort(cssOrder) - .map(({ sheet }) => sheet) - .map((s) => (s.type === 'external' ? { ...s, src: prefixAssetPath(s.src) } : s)) - .reduce(mergeInlineCss, []); - - routes.push({ - file: '', - links, - scripts: [ - ...scripts, - ...settings.scripts - .filter((script) => script.stage === 'head-inline') - .map(({ stage, content }) => ({ stage, children: content })), - ], - styles, - routeData: serializeRouteData(route, settings.config.trailingSlash), - }); - } - - // HACK! Patch this special one. - if (!(BEFORE_HYDRATION_SCRIPT_ID in entryModules)) { - // Set this to an empty string so that the runtime knows not to try and load this. - entryModules[BEFORE_HYDRATION_SCRIPT_ID] = ''; - } - - const ssrManifest: SerializedSSRManifest = { - adapterName: opts.settings.adapter!.name, - routes, - site: settings.config.site, - base: settings.config.base, - compressHTML: settings.config.compressHTML, - assetsPrefix: settings.config.build.assetsPrefix, - markdown: settings.config.markdown, - componentMetadata: Array.from(internals.componentMetadata), - renderers: [], - clientDirectives: Array.from(settings.clientDirectives), - entryModules, - assets: staticFiles.map(prefixAssetPath), - }; - - return ssrManifest; -} diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index fb16b433d..a1c7c3e56 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -16,7 +16,7 @@ import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js'; import { appendForwardSlash, prependForwardSlash } from '../../core/path.js'; import { isModeServerWithNoAdapter } from '../../core/util.js'; import { runHookBuildSetup } from '../../integrations/index.js'; -import { isServerLikeOutput } from '../../prerender/utils.js'; +import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js'; import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { info } from '../logger/core.js'; @@ -28,10 +28,11 @@ import { createPluginContainer, type AstroBuildPluginContainer } from './plugin. import { registerAllPlugins } from './plugins/index.js'; import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js'; -import { RESOLVED_SPLIT_MODULE_ID, SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js'; +import { RESOLVED_SPLIT_MODULE_ID, RESOLVED_SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js'; import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; import type { PageBuildData, StaticBuildOptions } from './types'; import { getTimeStat } from './util.js'; +import { RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugins/plugin-manifest.js'; export async function viteBuild(opts: StaticBuildOptions) { const { allPages, settings } = opts; @@ -147,7 +148,7 @@ async function ssrBuild( ) { const { allPages, settings, viteConfig } = opts; const ssr = isServerLikeOutput(settings.config); - const out = ssr ? settings.config.build.server : getOutDirWithinCwd(settings.config.outDir); + const out = getOutputDirectory(settings.config); const routes = Object.values(allPages).map((pd) => pd.route); const { lastVitePlugins, vitePlugins } = container.runBeforeHook('ssr', input); @@ -184,10 +185,12 @@ async function ssrBuild( ); } else if (chunkInfo.facadeModuleId?.startsWith(RESOLVED_SPLIT_MODULE_ID)) { return makeSplitEntryPointFileName(chunkInfo.facadeModuleId, routes); - } else if (chunkInfo.facadeModuleId === SSR_VIRTUAL_MODULE_ID) { + } else if (chunkInfo.facadeModuleId === RESOLVED_SSR_VIRTUAL_MODULE_ID) { return opts.settings.config.build.serverEntry; } else if (chunkInfo.facadeModuleId === RESOLVED_RENDERERS_MODULE_ID) { return 'renderers.mjs'; + } else if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) { + return 'manifest.[hash].mjs'; } else { return '[name].mjs'; } diff --git a/packages/astro/src/core/compile/compile.ts b/packages/astro/src/core/compile/compile.ts index f266c0b16..bd069611d 100644 --- a/packages/astro/src/core/compile/compile.ts +++ b/packages/astro/src/core/compile/compile.ts @@ -41,7 +41,7 @@ export async function compile({ filename, normalizedFilename: normalizeFilename(filename, astroConfig.root), sourcemap: 'both', - internalURL: 'astro/server/index.js', + internalURL: 'astro/compiler-runtime', astroGlobalArgs: JSON.stringify(astroConfig.site), scopedStyleStrategy: astroConfig.scopedStyleStrategy, resultScopedSlot: true, diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts index 29b0bb23a..ba089c9a7 100644 --- a/packages/astro/src/core/config/config.ts +++ b/packages/astro/src/core/config/config.ts @@ -124,8 +124,6 @@ export function resolveFlags(flags: Partial<Flags>): CLIFlags { host: typeof flags.host === 'string' || typeof flags.host === 'boolean' ? flags.host : undefined, drafts: typeof flags.drafts === 'boolean' ? flags.drafts : undefined, - experimentalAssets: - typeof flags.experimentalAssets === 'boolean' ? flags.experimentalAssets : undefined, }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 87ff7ba9f..bff55b392 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { BUNDLED_THEMES } from 'shiki'; import { z } from 'zod'; -import { appendForwardSlash, prependForwardSlash, trimSlashes } from '../path.js'; +import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../path.js'; const ASTRO_CONFIG_DEFAULTS = { root: '.', @@ -29,10 +29,10 @@ const ASTRO_CONFIG_DEFAULTS = { split: false, excludeMiddleware: false, }, - compressHTML: false, + compressHTML: true, server: { host: false, - port: 3000, + port: 4321, open: false, }, integrations: [], @@ -44,7 +44,6 @@ const ASTRO_CONFIG_DEFAULTS = { legacy: {}, redirects: {}, experimental: { - assets: false, viewTransitions: false, optimizeHoistedScript: false, }, @@ -88,9 +87,9 @@ export const AstroConfigSchema = z.object({ .optional() .default('static'), scopedStyleStrategy: z - .union([z.literal('where'), z.literal('class')]) + .union([z.literal('where'), z.literal('class'), z.literal('attribute')]) .optional() - .default('where'), + .default('attribute'), adapter: z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) }).optional(), integrations: z.preprocess( // preprocess @@ -125,7 +124,15 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets), + /** + * @deprecated + * Use the adapter feature instead + */ split: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.split), + /** + * @deprecated + * Use the adapter feature instead + */ excludeMiddleware: z .boolean() .optional() @@ -208,9 +215,7 @@ export const AstroConfigSchema = z.object({ .default([]), }) .default({ - service: { entrypoint: 'astro/assets/services/squoosh', config: {} }, - domains: [], - remotePatterns: [], + service: { entrypoint: 'astro/assets/services/sharp', config: {} }, }), markdown: z .object({ @@ -259,7 +264,6 @@ export const AstroConfigSchema = z.object({ .default(ASTRO_CONFIG_DEFAULTS.vite), experimental: z .object({ - assets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.assets), viewTransitions: z .boolean() .optional() @@ -391,22 +395,14 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) { ) { config.build.client = new URL('./dist/client/', config.outDir); } - const trimmedBase = trimSlashes(config.base); - // If there is no base but there is a base for site config, warn. - const sitePathname = config.site && new URL(config.site).pathname; - if (!trimmedBase.length && sitePathname && sitePathname !== '/') { - config.base = sitePathname; - /* eslint-disable no-console */ - console.warn(`The site configuration value includes a pathname of ${sitePathname} but there is no base configuration. - -A future version of Astro will stop using the site pathname when producing <link> and <script> tags. Set your site's base with the base configuration.`); - } - - if (trimmedBase.length && config.trailingSlash === 'never') { - config.base = prependForwardSlash(trimmedBase); + // Handle `base` trailing slash based on `trailingSlash` config + if (config.trailingSlash === 'never') { + config.base = prependForwardSlash(removeTrailingForwardSlash(config.base)); + } else if (config.trailingSlash === 'always') { + config.base = prependForwardSlash(appendForwardSlash(config.base)); } else { - config.base = prependForwardSlash(appendForwardSlash(trimmedBase)); + config.base = prependForwardSlash(config.base); } return config; diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts index c0274f602..30ca7c4c2 100644 --- a/packages/astro/src/core/config/settings.ts +++ b/packages/astro/src/core/config/settings.ts @@ -3,7 +3,6 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import type { AstroConfig, AstroSettings } from '../../@types/astro'; import { getContentPaths } from '../../content/index.js'; -import jsxRenderer from '../../jsx/renderer.js'; import { markdownContentEntryType } from '../../vite-plugin-markdown/content-entry-type.js'; import { getDefaultClientDirectives } from '../client-directive/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; @@ -18,7 +17,6 @@ export function createBaseSettings(config: AstroConfig): AstroSettings { config, tsConfig: undefined, tsConfigPath: undefined, - adapter: undefined, injectedRoutes: [], resolvedInjectedRoutes: [], @@ -96,7 +94,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings { }, }, ], - renderers: [jsxRenderer], + renderers: [], scripts: [], clientDirectives: getDefaultClientDirectives(), watchFiles: [], diff --git a/packages/astro/src/core/cookies/cookies.ts b/packages/astro/src/core/cookies/cookies.ts index 013357f32..604f30e63 100644 --- a/packages/astro/src/core/cookies/cookies.ts +++ b/packages/astro/src/core/cookies/cookies.ts @@ -15,14 +15,14 @@ interface AstroCookieSetOptions { type AstroCookieDeleteOptions = Pick<AstroCookieSetOptions, 'domain' | 'path'>; interface AstroCookieInterface { - value: string | undefined; + value: string; json(): Record<string, any>; number(): number; boolean(): boolean; } interface AstroCookiesInterface { - get(key: string): AstroCookieInterface; + get(key: string): AstroCookieInterface | undefined; has(key: string): boolean; set( key: string, @@ -37,7 +37,7 @@ const DELETED_VALUE = 'deleted'; const responseSentSymbol = Symbol.for('astro.responseSent'); class AstroCookie implements AstroCookieInterface { - constructor(public value: string | undefined) {} + constructor(public value: string) {} json() { if (this.value === undefined) { throw new Error(`Cannot convert undefined to an object.`); @@ -97,20 +97,22 @@ class AstroCookies implements AstroCookiesInterface { * @param key The cookie to get. * @returns An object containing the cookie value as well as convenience methods for converting its value. */ - get(key: string): AstroCookie { + get(key: string): AstroCookie | undefined { // Check for outgoing Set-Cookie values first if (this.#outgoing?.has(key)) { let [serializedValue, , isSetValue] = this.#outgoing.get(key)!; if (isSetValue) { return new AstroCookie(serializedValue); } else { - return new AstroCookie(undefined); + return undefined; } } const values = this.#ensureParsed(); - const value = values[key]; - return new AstroCookie(value); + if (key in values) { + const value = values[key]; + return new AstroCookie(value); + } } /** diff --git a/packages/astro/src/core/cookies/index.ts b/packages/astro/src/core/cookies/index.ts index 1b0c6b7a0..f3c7b6d61 100644 --- a/packages/astro/src/core/cookies/index.ts +++ b/packages/astro/src/core/cookies/index.ts @@ -1,2 +1,2 @@ export { AstroCookies } from './cookies.js'; -export { attachToResponse, getSetCookiesFromResponse } from './response.js'; +export { attachCookiesToResponse, getSetCookiesFromResponse } from './response.js'; diff --git a/packages/astro/src/core/cookies/response.ts b/packages/astro/src/core/cookies/response.ts index 18d72ab1c..668bd265f 100644 --- a/packages/astro/src/core/cookies/response.ts +++ b/packages/astro/src/core/cookies/response.ts @@ -2,7 +2,7 @@ import type { AstroCookies } from './cookies'; const astroCookiesSymbol = Symbol.for('astro.cookies'); -export function attachToResponse(response: Response, cookies: AstroCookies) { +export function attachCookiesToResponse(response: Response, cookies: AstroCookies) { Reflect.set(response, astroCookiesSymbol, cookies); } diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 0fb64ef69..5b2ebfa21 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -19,9 +19,9 @@ import configAliasVitePlugin from '../vite-plugin-config-alias/index.js'; import envVitePlugin from '../vite-plugin-env/index.js'; import astroHeadPlugin from '../vite-plugin-head/index.js'; import htmlVitePlugin from '../vite-plugin-html/index.js'; +import mdxVitePlugin from '../vite-plugin-mdx/index.js'; import { astroInjectEnvTsPlugin } from '../vite-plugin-inject-env-ts/index.js'; import astroIntegrationsContainerPlugin from '../vite-plugin-integrations-container/index.js'; -import jsxVitePlugin from '../vite-plugin-jsx/index.js'; import astroLoadFallbackPlugin from '../vite-plugin-load-fallback/index.js'; import markdownVitePlugin from '../vite-plugin-markdown/index.js'; import astroScannerPlugin from '../vite-plugin-scanner/index.js'; @@ -121,7 +121,7 @@ export async function createVite( envVitePlugin({ settings }), markdownVitePlugin({ settings, logging }), htmlVitePlugin(), - jsxVitePlugin({ settings, logging }), + mdxVitePlugin({ settings, logging }), astroPostprocessVitePlugin(), astroIntegrationsContainerPlugin({ settings, logging }), astroScriptsPageSSRPlugin({ settings }), @@ -132,7 +132,7 @@ export async function createVite( astroContentImportPlugin({ fs, settings }), astroContentAssetPropagationPlugin({ mode, settings }), vitePluginSSRManifest(), - settings.config.experimental.assets ? [astroAssetsPlugin({ settings, logging, mode })] : [], + astroAssetsPlugin({ settings, logging, mode }), astroTransitions({ config: settings.config }), ], publicDir: fileURLToPath(settings.config.publicDir), @@ -234,37 +234,12 @@ export async function createVite( result = vite.mergeConfig(result, settings.config.vite || {}); } result = vite.mergeConfig(result, commandConfig); - if (result.plugins) { - sortPlugins(result.plugins); - } result.customLogger = vite.createLogger(result.logLevel ?? 'warn'); return result; } -function isVitePlugin(plugin: vite.PluginOption): plugin is vite.Plugin { - return Boolean(plugin?.hasOwnProperty('name')); -} - -function findPluginIndexByName(pluginOptions: vite.PluginOption[], name: string): number { - return pluginOptions.findIndex(function (pluginOption) { - // Use isVitePlugin to ignore nulls, booleans, promises, and arrays - // CAUTION: could be a problem if a plugin we're searching for becomes async! - return isVitePlugin(pluginOption) && pluginOption.name === name; - }); -} - -function sortPlugins(pluginOptions: vite.PluginOption[]) { - // HACK: move mdxPlugin to top because it needs to run before internal JSX plugin - const mdxPluginIndex = findPluginIndexByName(pluginOptions, '@mdx-js/rollup'); - if (mdxPluginIndex === -1) return; - const jsxPluginIndex = findPluginIndexByName(pluginOptions, 'astro:jsx'); - const mdxPlugin = pluginOptions[mdxPluginIndex]; - pluginOptions.splice(mdxPluginIndex, 1); - pluginOptions.splice(jsxPluginIndex, 0, mdxPlugin); -} - const COMMON_DEPENDENCIES_NOT_ASTRO = [ 'autoprefixer', 'react', diff --git a/packages/astro/src/core/dev/container.ts b/packages/astro/src/core/dev/container.ts index d4e41e96d..4aeb35f3a 100644 --- a/packages/astro/src/core/dev/container.ts +++ b/packages/astro/src/core/dev/container.ts @@ -50,11 +50,7 @@ export async function createContainer({ isRestart, }); - // HACK: Since we only inject the endpoint if `experimental.assets` is on and it's possible for an integration to - // add that flag, we need to only check and inject the endpoint after running the config setup hook. - if (settings.config.experimental.assets) { - settings = injectImageEndpoint(settings); - } + settings = injectImageEndpoint(settings); const { host, headers, open } = settings.config.server; diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index 485190e47..9298e7cbe 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -7,13 +7,13 @@ import type { Params, } from '../../@types/astro'; import type { Environment, RenderContext } from '../render/index'; - import { renderEndpoint } from '../../runtime/server/index.js'; import { ASTRO_VERSION } from '../constants.js'; -import { AstroCookies, attachToResponse } from '../cookies/index.js'; +import { AstroCookies, attachCookiesToResponse } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { warn } from '../logger/core.js'; import { callMiddleware } from '../middleware/callMiddleware.js'; + const clientAddressSymbol = Symbol.for('astro.clientAddress'); const clientLocalsSymbol = Symbol.for('astro.locals'); @@ -117,15 +117,15 @@ export async function callEndpoint<MiddlewareResult = Response | EndpointOutput> onRequest as MiddlewareEndpointHandler, context, async () => { - return await renderEndpoint(mod, context, env.ssr); + return await renderEndpoint(mod, context, env.ssr, env.logging); } ); } else { - response = await renderEndpoint(mod, context, env.ssr); + response = await renderEndpoint(mod, context, env.ssr, env.logging); } if (response instanceof Response) { - attachToResponse(response, context.cookies); + attachCookiesToResponse(response, context.cookies); return { type: 'response', response, diff --git a/packages/astro/src/core/errors/errors.ts b/packages/astro/src/core/errors/errors.ts index 1960bac4a..faf365686 100644 --- a/packages/astro/src/core/errors/errors.ts +++ b/packages/astro/src/core/errors/errors.ts @@ -19,6 +19,7 @@ export interface ErrorLocation { type ErrorTypes = | 'AstroError' + | 'AstroUserError' | 'CompilerError' | 'CSSError' | 'MarkdownError' @@ -171,3 +172,25 @@ export interface ErrorWithMetadata { }; cause?: any; } + +/** + * Special error that is exposed to users. + * Compared to AstroError, it contains a subset of information. + */ +export class AstroUserError extends Error { + type: ErrorTypes = 'AstroUserError'; + /** + * A message that explains to the user how they can fix the error. + */ + hint: string | undefined; + name = 'AstroUserError'; + constructor(message: string, hint?: string) { + super(); + this.message = message; + this.hint = hint; + } + + static is(err: unknown): err is AstroUserError { + return (err as AstroUserError).type === 'AstroUserError'; + } +} diff --git a/packages/astro/src/core/errors/index.ts b/packages/astro/src/core/errors/index.ts index e09225af4..5a796a0b3 100644 --- a/packages/astro/src/core/errors/index.ts +++ b/packages/astro/src/core/errors/index.ts @@ -7,6 +7,7 @@ export { CompilerError, MarkdownError, isAstroError, + AstroUserError, } from './errors.js'; export { codeFrame } from './printer.js'; export { createSafeError, positionAt } from './utils.js'; diff --git a/packages/astro/src/core/errors/userError.ts b/packages/astro/src/core/errors/userError.ts new file mode 100644 index 000000000..663549314 --- /dev/null +++ b/packages/astro/src/core/errors/userError.ts @@ -0,0 +1 @@ +export { AstroUserError as AstroError } from './errors.js'; diff --git a/packages/astro/src/core/logger/console.ts b/packages/astro/src/core/logger/console.ts index dfe732bd7..f39f6b74d 100644 --- a/packages/astro/src/core/logger/console.ts +++ b/packages/astro/src/core/logger/console.ts @@ -15,7 +15,7 @@ export const consoleLogDestination = { function getPrefix() { let prefix = ''; - let type = event.type; + let type = event.label; if (type) { // hide timestamp when type is undefined prefix += dim(dateTimeFormat.format(new Date()) + ' '); diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts index 4f0c281e0..c92cdbb24 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -6,7 +6,6 @@ interface LogWritable<T> { } export type LoggerLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; // same as Pino -export type LoggerEvent = 'info' | 'warn' | 'error'; export interface LogOptions { dest: LogWritable<LogMessage>; @@ -29,7 +28,7 @@ export const dateTimeFormat = new Intl.DateTimeFormat([], { }); export interface LogMessage { - type: string | null; + label: string | null; level: LoggerLevel; message: string; } @@ -43,11 +42,11 @@ export const levels: Record<LoggerLevel, number> = { }; /** Full logging API */ -export function log(opts: LogOptions, level: LoggerLevel, type: string | null, message: string) { +export function log(opts: LogOptions, level: LoggerLevel, label: string | null, message: string) { const logLevel = opts.level; const dest = opts.dest; const event: LogMessage = { - type, + label, level, message, }; @@ -61,18 +60,18 @@ export function log(opts: LogOptions, level: LoggerLevel, type: string | null, m } /** Emit a user-facing message. Useful for UI and other console messages. */ -export function info(opts: LogOptions, type: string | null, message: string) { - return log(opts, 'info', type, message); +export function info(opts: LogOptions, label: string | null, message: string) { + return log(opts, 'info', label, message); } /** Emit a warning message. Useful for high-priority messages that aren't necessarily errors. */ -export function warn(opts: LogOptions, type: string | null, message: string) { - return log(opts, 'warn', type, message); +export function warn(opts: LogOptions, label: string | null, message: string) { + return log(opts, 'warn', label, message); } /** Emit a error message, Useful when Astro can't recover from some error. */ -export function error(opts: LogOptions, type: string | null, message: string) { - return log(opts, 'error', type, message); +export function error(opts: LogOptions, label: string | null, message: string) { + return log(opts, 'error', label, message); } type LogFn = typeof info | typeof warn | typeof error; @@ -127,3 +126,53 @@ export function timerMessage(message: string, startTime: number = Date.now()) { timeDiff < 750 ? `${Math.round(timeDiff)}ms` : `${(timeDiff / 1000).toFixed(1)}s`; return `${message} ${dim(timeDisplay)}`; } + +export class Logger { + options: LogOptions; + constructor(options: LogOptions) { + this.options = options; + } + + info(label: string | null, message: string) { + info(this.options, label, message); + } + warn(label: string | null, message: string) { + warn(this.options, label, message); + } + error(label: string | null, message: string) { + error(this.options, label, message); + } + debug(label: string | null, message: string) { + debug(this.options, label, message); + } +} + +export class AstroIntegrationLogger { + options: LogOptions; + label: string; + + constructor(logging: LogOptions, label: string) { + this.options = logging; + this.label = label; + } + + /** + * Creates a new logger instance with a new label, but the same log options. + */ + fork(label: string): AstroIntegrationLogger { + return new AstroIntegrationLogger(this.options, label); + } + + info(message: string) { + info(this.options, this.label, message); + } + warn(message: string) { + warn(this.options, this.label, message); + } + error(message: string) { + error(this.options, this.label, message); + } + debug(message: string) { + debug(this.options, this.label, message); + } +} diff --git a/packages/astro/src/core/logger/node.ts b/packages/astro/src/core/logger/node.ts index 513ba257e..aeef4bd84 100644 --- a/packages/astro/src/core/logger/node.ts +++ b/packages/astro/src/core/logger/node.ts @@ -21,19 +21,19 @@ export const nodeLogDestination = new Writable({ function getPrefix() { let prefix = ''; - let type = event.type; - if (type) { + let label = event.label; + if (label) { // hide timestamp when type is undefined prefix += dim(dateTimeFormat.format(new Date()) + ' '); if (event.level === 'info') { - type = bold(cyan(`[${type}]`)); + label = bold(cyan(`[${label}]`)); } else if (event.level === 'warn') { - type = bold(yellow(`[${type}]`)); + label = bold(yellow(`[${label}]`)); } else if (event.level === 'error') { - type = bold(red(`[${type}]`)); + label = bold(red(`[${label}]`)); } - prefix += `${type} `; + prefix += `${label} `; } return reset(prefix); } @@ -87,7 +87,7 @@ export const nodeLogOptions: Required<LogOptions> = { }; export interface LogMessage { - type: string | null; + label: string | null; level: LoggerLevel; message: string; } diff --git a/packages/astro/src/core/messages.ts b/packages/astro/src/core/messages.ts index 51ec39ad9..4fc3ca02a 100644 --- a/packages/astro/src/core/messages.ts +++ b/packages/astro/src/core/messages.ts @@ -17,7 +17,12 @@ import { import type { ResolvedServerUrls } from 'vite'; import type { ZodError } from 'zod'; import { renderErrorMarkdown } from './errors/dev/utils.js'; -import { AstroError, CompilerError, type ErrorWithMetadata } from './errors/index.js'; +import { + AstroError, + CompilerError, + type ErrorWithMetadata, + AstroUserError, +} from './errors/index.js'; import { emoji, padMultilineString } from './util.js'; const PREFIX_PADDING = 6; @@ -198,7 +203,7 @@ export function formatConfigErrorMessage(err: ZodError) { } export function formatErrorMessage(err: ErrorWithMetadata, args: string[] = []): string { - const isOurError = AstroError.is(err) || CompilerError.is(err); + const isOurError = AstroError.is(err) || CompilerError.is(err) || AstroUserError.is(err); args.push( `${bgRed(black(` error `))}${red( diff --git a/packages/astro/src/core/pipeline.ts b/packages/astro/src/core/pipeline.ts new file mode 100644 index 000000000..b5c66517a --- /dev/null +++ b/packages/astro/src/core/pipeline.ts @@ -0,0 +1,165 @@ +import { type RenderContext, type Environment } from './render/index.js'; +import { type EndpointCallResult, callEndpoint, createAPIContext } from './endpoint/index.js'; +import type { + MiddlewareHandler, + MiddlewareResponseHandler, + ComponentInstance, + MiddlewareEndpointHandler, + RouteType, + EndpointHandler, +} from '../@types/astro'; +import { callMiddleware } from './middleware/callMiddleware.js'; +import { renderPage } from './render/core.js'; + +type EndpointResultHandler = ( + originalRequest: Request, + result: EndpointCallResult +) => Promise<Response> | Response; + +/** + * This is the basic class of a pipeline. + * + * Check the {@link ./README.md|README} for more information about the pipeline. + */ +export class Pipeline { + env: Environment; + #onRequest?: MiddlewareEndpointHandler; + /** + * The handler accepts the *original* `Request` and result returned by the endpoint. + * It must return a `Response`. + */ + #endpointHandler?: EndpointResultHandler; + + /** + * When creating a pipeline, an environment is mandatory. + * The environment won't change for the whole lifetime of the pipeline. + */ + constructor(env: Environment) { + this.env = env; + } + + setEnvironment() {} + + /** + * When rendering a route, an "endpoint" will a type that needs to be handled and transformed into a `Response`. + * + * Each consumer might have different needs; use this function to set up the handler. + */ + setEndpointHandler(handler: EndpointResultHandler) { + this.#endpointHandler = handler; + } + + /** + * A middleware function that will be called before each request. + */ + setMiddlewareFunction(onRequest: MiddlewareEndpointHandler) { + this.#onRequest = onRequest; + } + + /** + * Returns the current environment + */ + getEnvironment() { + return this.env; + } + + /** + * The main function of the pipeline. Use this function to render any route known to Astro; + */ + async renderRoute( + renderContext: RenderContext, + componentInstance: ComponentInstance + ): Promise<Response> { + const result = await this.#tryRenderRoute( + renderContext, + this.env, + componentInstance, + this.#onRequest + ); + if (Pipeline.isEndpointResult(result, renderContext.route.type)) { + if (!this.#endpointHandler) { + throw new Error( + 'You created a pipeline that does not know how to handle the result coming from an endpoint.' + ); + } + return this.#endpointHandler(renderContext.request, result); + } else { + return result; + } + } + + /** + * It attempts to render a route. A route can be a: + * - page + * - redirect + * - endpoint + * + * ## Errors + * + * It throws an error if the page can't be rendered. + */ + async #tryRenderRoute<MiddlewareReturnType = Response>( + renderContext: Readonly<RenderContext>, + env: Readonly<Environment>, + mod: Readonly<ComponentInstance>, + onRequest?: MiddlewareHandler<MiddlewareReturnType> + ): Promise<Response | EndpointCallResult> { + const apiContext = createAPIContext({ + request: renderContext.request, + params: renderContext.params, + props: renderContext.props, + site: env.site, + adapterName: env.adapterName, + }); + + switch (renderContext.route.type) { + case 'page': + case 'redirect': { + if (onRequest) { + return await callMiddleware<Response>( + env.logging, + onRequest as MiddlewareResponseHandler, + apiContext, + () => { + return renderPage({ + mod, + renderContext, + env, + cookies: apiContext.cookies, + }); + } + ); + } else { + return await renderPage({ + mod, + renderContext, + env, + cookies: apiContext.cookies, + }); + } + } + case 'endpoint': { + const result = await callEndpoint( + mod as any as EndpointHandler, + env, + renderContext, + onRequest + ); + return result; + } + default: + throw new Error(`Couldn't find route of type [${renderContext.route.type}]`); + } + } + + /** + * Use this function + */ + static isEndpointResult(result: any, routeType: RouteType): result is EndpointCallResult { + return !(result instanceof Response) && routeType === 'endpoint'; + } + + static isResponse(result: any, routeType: RouteType): result is Response { + return result instanceof Response && (routeType === 'page' || routeType === 'redirect'); + } +} diff --git a/packages/astro/src/core/polyfill.ts b/packages/astro/src/core/polyfill.ts index 99e0d5cc5..daceb53e2 100644 --- a/packages/astro/src/core/polyfill.ts +++ b/packages/astro/src/core/polyfill.ts @@ -1,8 +1,21 @@ -import { polyfill } from '@astrojs/webapi'; +import { File } from 'node:buffer'; +import crypto from 'node:crypto'; + +// NOTE: This file does not intend to polyfill everything that exists, its main goal is to make life easier +// for users deploying to runtime that do support these features. In the future, we hope for this file to disappear. export function apply() { - // polyfill WebAPIs for Node.js runtime - polyfill(globalThis, { - exclude: 'window document', - }); + // Remove when Node 18 is dropped for Node 20 + if (!globalThis.crypto) { + Object.defineProperty(globalThis, 'crypto', { + value: crypto.webcrypto, + }); + } + + // Remove when Node 18 is dropped for Node 20 + if (!globalThis.File) { + Object.defineProperty(globalThis, 'File', { + value: File, + }); + } } diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index 5b26eda18..d767d7910 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -22,7 +22,7 @@ export interface RenderContext { links?: Set<SSRElement>; styles?: Set<SSRElement>; componentMetadata?: SSRResult['componentMetadata']; - route?: RouteData; + route: RouteData; status?: number; params: Params; props: Props; @@ -32,6 +32,7 @@ export interface RenderContext { export type CreateRenderContextArgs = Partial< Omit<RenderContext, 'params' | 'props' | 'locals'> > & { + route: RouteData; request: RenderContext['request']; mod: ComponentInstance; env: Environment; diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index d6228fbbe..9de046278 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -7,7 +7,7 @@ import type { RouteType, } from '../../@types/astro'; import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js'; -import { attachToResponse } from '../cookies/index.js'; +import { attachCookiesToResponse } from '../cookies/index.js'; import { callEndpoint, createAPIContext, type EndpointCallResult } from '../endpoint/index.js'; import { callMiddleware } from '../middleware/callMiddleware.js'; import { redirectRouteGenerate, redirectRouteStatus, routeIsRedirect } from '../redirects/index.js'; @@ -22,7 +22,7 @@ export type RenderPage = { cookies: AstroCookies; }; -async function renderPage({ mod, renderContext, env, cookies }: RenderPage) { +export async function renderPage({ mod, renderContext, env, cookies }: RenderPage) { if (routeIsRedirect(renderContext.route)) { return new Response(null, { status: redirectRouteStatus(renderContext.route, renderContext.request.method), @@ -42,7 +42,6 @@ async function renderPage({ mod, renderContext, env, cookies }: RenderPage) { links: renderContext.links, styles: renderContext.styles, logging: env.logging, - markdown: env.markdown, params: renderContext.params, pathname: renderContext.pathname, componentMetadata: renderContext.componentMetadata, @@ -59,12 +58,7 @@ async function renderPage({ mod, renderContext, env, cookies }: RenderPage) { locals: renderContext.locals ?? {}, }); - // Support `export const components` for `MDX` pages - if (typeof (mod as any).components === 'object') { - Object.assign(renderContext.props, { components: (mod as any).components }); - } - - let response = await runtimeRenderPage( + const response = await runtimeRenderPage( result, Component, renderContext.props, @@ -76,7 +70,7 @@ async function renderPage({ mod, renderContext, env, cookies }: RenderPage) { // If there is an Astro.cookies instance, attach it to the response so that // adapters can grab the Set-Cookie headers. if (result.cookies) { - attachToResponse(response, result.cookies); + attachCookiesToResponse(response, result.cookies); } return response; @@ -91,9 +85,9 @@ async function renderPage({ mod, renderContext, env, cookies }: RenderPage) { * ## Errors * * It throws an error if the page can't be rendered. + * @deprecated Use the pipeline instead */ export async function tryRenderRoute<MiddlewareReturnType = Response>( - routeType: RouteType, renderContext: Readonly<RenderContext>, env: Readonly<Environment>, mod: Readonly<ComponentInstance>, @@ -107,7 +101,7 @@ export async function tryRenderRoute<MiddlewareReturnType = Response>( adapterName: env.adapterName, }); - switch (routeType) { + switch (renderContext.route.type) { case 'page': case 'redirect': { if (onRequest) { @@ -143,7 +137,7 @@ export async function tryRenderRoute<MiddlewareReturnType = Response>( return result; } default: - throw new Error(`Couldn't find route of type [${routeType}]`); + throw new Error(`Couldn't find route of type [${renderContext.route.type}]`); } } diff --git a/packages/astro/src/core/render/environment.ts b/packages/astro/src/core/render/environment.ts index de7cbe6a8..32dfb454b 100644 --- a/packages/astro/src/core/render/environment.ts +++ b/packages/astro/src/core/render/environment.ts @@ -1,4 +1,3 @@ -import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark'; import type { AstroSettings, RuntimeMode, SSRLoadedRenderer } from '../../@types/astro'; import type { LogOptions } from '../logger/core.js'; import type { ModuleLoader } from '../module-loader'; @@ -16,10 +15,6 @@ export interface Environment { adapterName?: string; /** logging options */ logging: LogOptions; - /** - * Used to support `Astro.__renderMarkdown` for legacy `<Markdown />` component - */ - markdown: MarkdownRenderingOptions; /** "development" or "production" */ mode: RuntimeMode; compressHTML: boolean; diff --git a/packages/astro/src/core/render/index.ts b/packages/astro/src/core/render/index.ts index a82c5699e..20b964fa7 100644 --- a/packages/astro/src/core/render/index.ts +++ b/packages/astro/src/core/render/index.ts @@ -22,7 +22,7 @@ export interface SSROptions { /** Request */ request: Request; /** optional, in case we need to render something outside of a dev server */ - route?: RouteData; + route: RouteData; /** * Optional middlewares */ diff --git a/packages/astro/src/core/render/params-and-props.ts b/packages/astro/src/core/render/params-and-props.ts index a5e4fa222..fc08c495e 100644 --- a/packages/astro/src/core/render/params-and-props.ts +++ b/packages/astro/src/core/render/params-and-props.ts @@ -33,7 +33,6 @@ export async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise mod, route, routeCache, - isValidate: true, logging, ssr, }); diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 968b232d4..72fa4ddcf 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -1,4 +1,3 @@ -import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark'; import type { AstroGlobal, AstroGlobalPartial, @@ -27,10 +26,6 @@ export interface CreateResultArgs { */ ssr: boolean; logging: LogOptions; - /** - * Used to support `Astro.__renderMarkdown` for legacy `<Markdown />` component - */ - markdown: MarkdownRenderingOptions; params: Params; pathname: string; renderers: SSRLoadedRenderer[]; @@ -128,10 +123,8 @@ class Slots { } } -let renderMarkdown: any = null; - export function createResult(args: CreateResultArgs): SSRResult { - const { markdown, params, request, resolve, locals } = args; + const { params, request, resolve, locals } = args; const url = new URL(request.url); const headers = new Headers(); @@ -222,31 +215,6 @@ export function createResult(args: CreateResultArgs): SSRResult { slots: astroSlots as unknown as AstroGlobal['slots'], }; - Object.defineProperty(Astro, '__renderMarkdown', { - // Ensure this API is not exposed to users - enumerable: false, - writable: false, - // TODO: Remove this hole "Deno" logic once our plugin gets Deno support - value: async function (content: string, opts: MarkdownRenderingOptions) { - // @ts-expect-error - if (typeof Deno !== 'undefined') { - throw new Error('Markdown is not supported in Deno SSR'); - } - - if (!renderMarkdown) { - // The package is saved in this variable because Vite is too smart - // and will try to inline it in buildtime - let astroRemark = '@astrojs/'; - astroRemark += 'markdown-remark'; - - renderMarkdown = (await import(astroRemark)).renderMarkdown; - } - - const { code } = await renderMarkdown(content, { ...markdown, ...(opts ?? {}) }); - return code; - }, - }); - return Astro; }, resolve, diff --git a/packages/astro/src/core/render/route-cache.ts b/packages/astro/src/core/render/route-cache.ts index 7ad247ef8..804f09183 100644 --- a/packages/astro/src/core/render/route-cache.ts +++ b/packages/astro/src/core/render/route-cache.ts @@ -18,7 +18,6 @@ interface CallGetStaticPathsOptions { mod: ComponentInstance; route: RouteData; routeCache: RouteCache; - isValidate: boolean; logging: LogOptions; ssr: boolean; } @@ -27,7 +26,6 @@ export async function callGetStaticPaths({ mod, route, routeCache, - isValidate, logging, ssr, }: CallGetStaticPathsOptions): Promise<GetStaticPathsResultKeyed> { @@ -58,14 +56,7 @@ export async function callGetStaticPaths({ }, }); - // Flatten the array before validating the content, otherwise users using `.map` will run into errors - if (Array.isArray(staticPaths)) { - staticPaths = staticPaths.flat(); - } - - if (isValidate) { - validateGetStaticPathsResult(staticPaths, logging, route); - } + validateGetStaticPathsResult(staticPaths, logging, route); const keyedStaticPaths = staticPaths as GetStaticPathsResultKeyed; keyedStaticPaths.keyed = new Map<string, GetStaticPathsItem>(); diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index e669f293b..124d870d9 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -185,7 +185,12 @@ function injectedRouteToItem( { config, cwd }: { config: AstroConfig; cwd?: string }, { pattern, entryPoint }: InjectedRoute ): Item { - const resolved = require.resolve(entryPoint, { paths: [cwd || fileURLToPath(config.root)] }); + let resolved: string; + try { + resolved = require.resolve(entryPoint, { paths: [cwd || fileURLToPath(config.root)] }); + } catch (e) { + resolved = fileURLToPath(new URL(entryPoint, config.root)); + } const ext = path.extname(pattern); diff --git a/packages/astro/src/core/routing/validation.ts b/packages/astro/src/core/routing/validation.ts index 9a562c044..b5c29b16e 100644 --- a/packages/astro/src/core/routing/validation.ts +++ b/packages/astro/src/core/routing/validation.ts @@ -54,6 +54,15 @@ export function validateGetStaticPathsResult( } result.forEach((pathObject) => { + if ((typeof pathObject === 'object' && Array.isArray(pathObject)) || pathObject === null) { + throw new AstroError({ + ...AstroErrorData.InvalidGetStaticPathsEntry, + message: AstroErrorData.InvalidGetStaticPathsEntry.message( + Array.isArray(pathObject) ? 'array' : typeof pathObject + ), + }); + } + if ( pathObject.params === undefined || pathObject.params === null || @@ -67,16 +76,6 @@ export function validateGetStaticPathsResult( }); } - if (typeof pathObject.params !== 'object') { - throw new AstroError({ - ...AstroErrorData.InvalidGetStaticPathParam, - message: AstroErrorData.InvalidGetStaticPathParam.message(typeof pathObject.params), - location: { - file: route.component, - }, - }); - } - // TODO: Replace those with errors? They technically don't crash the build, but users might miss the warning. - erika, 2022-11-07 for (const [key, val] of Object.entries(pathObject.params)) { if (!(typeof val === 'undefined' || typeof val === 'string' || typeof val === 'number')) { diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index ff41a5ed6..d442e5811 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -189,7 +189,6 @@ export function emoji(char: string, fallback: string) { * through a script tag or a dynamic import as-is. */ // NOTE: `/@id/` should only be used when the id is fully resolved -// TODO: Export a helper util from Vite export async function resolveIdToUrl(loader: ModuleLoader, id: string, root?: URL) { let resultId = await loader.resolveId(id, undefined); // Try resolve jsx to tsx diff --git a/packages/astro/src/integrations/astroFeaturesValidation.ts b/packages/astro/src/integrations/astroFeaturesValidation.ts new file mode 100644 index 000000000..c494b35f4 --- /dev/null +++ b/packages/astro/src/integrations/astroFeaturesValidation.ts @@ -0,0 +1,157 @@ +import type { + AstroAssetsFeature, + AstroConfig, + AstroFeatureMap, + SupportsKind, +} from '../@types/astro'; +import { error, warn, type LogOptions } from '../core/logger/core.js'; + +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 image service "Sharp".` + ); + return false; + } + + if (config?.image?.service?.entrypoint === SQUOOSH_SERVICE && !isSquooshCompatible) { + error( + logging, + 'astro', + `The currently selected adapter \`${adapterName}\` is not compatible with the image service "Squoosh".` + ); + return false; + } + + return validateSupportKind(supportKind, adapterName, logging, 'assets', () => true); +} diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index cf50df0e1..71c5a5e63 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -4,7 +4,9 @@ import type { AddressInfo } from 'node:net'; import { fileURLToPath } from 'node:url'; import type { InlineConfig, ViteDevServer } from 'vite'; import type { + AstroAdapter, AstroConfig, + AstroIntegration, AstroRenderer, AstroSettings, ContentEntryType, @@ -16,8 +18,9 @@ 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 { AstroIntegrationLogger, error, info, warn, type LogOptions } from '../core/logger/core.js'; import { isServerLikeOutput } from '../prerender/utils.js'; +import { validateSupportedFeatures } from './astroFeaturesValidation.js'; async function withTakingALongTimeMsg<T>({ name, @@ -38,6 +41,19 @@ async function withTakingALongTimeMsg<T>({ return result; } +// Used internally to store instances of loggers. +const Loggers = new WeakMap<AstroIntegration, AstroIntegrationLogger>(); + +function getLogger(integration: AstroIntegration, logging: LogOptions) { + if (Loggers.has(integration)) { + // SAFETY: we check the existence in the if block + return Loggers.get(integration)!; + } + const logger = new AstroIntegrationLogger(logging, integration.name); + Loggers.set(integration, logger); + return logger; +} + export async function runHookConfigSetup({ settings, command, @@ -72,6 +88,8 @@ export async function runHookConfigSetup({ * ``` */ if (integration.hooks?.['astro:config:setup']) { + const logger = getLogger(integration, logging); + const hooks: HookParameters<'astro:config:setup'> = { config: updatedConfig, command, @@ -107,6 +125,7 @@ export async function runHookConfigSetup({ } addedClientDirectives.set(name, buildClientDirectiveEntrypoint(name, entrypoint)); }, + logger, }; // --- @@ -167,6 +186,7 @@ export async function runHookConfigDone({ logging: LogOptions; }) { for (const integration of settings.config.integrations) { + const logger = getLogger(integration, logging); if (integration?.hooks?.['astro:config:done']) { await withTakingALongTimeMsg({ name: integration.name, @@ -178,8 +198,44 @@ export async function runHookConfigDone({ `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.` + ); + } + } + if (!validationResult.assets) { + info( + logging, + 'astro', + `The selected adapter ${adapter.name} does not support Sharp or Squoosh for image processing. To ensure your project is still able to build, image processing has been disabled.` + ); + settings.config.image.service = { + entrypoint: 'astro/assets/services/noop', + config: {}, + }; + } + } settings.adapter = adapter; }, + logger, }), logging, }); @@ -198,9 +254,10 @@ export async function runHookServerSetup({ }) { for (const integration of config.integrations) { if (integration?.hooks?.['astro:server:setup']) { + const logger = getLogger(integration, logging); await withTakingALongTimeMsg({ name: integration.name, - hookResult: integration.hooks['astro:server:setup']({ server }), + hookResult: integration.hooks['astro:server:setup']({ server, logger }), logging, }); } @@ -217,10 +274,12 @@ export async function runHookServerStart({ logging: LogOptions; }) { for (const integration of config.integrations) { + const logger = getLogger(integration, logging); + if (integration?.hooks?.['astro:server:start']) { await withTakingALongTimeMsg({ name: integration.name, - hookResult: integration.hooks['astro:server:start']({ address }), + hookResult: integration.hooks['astro:server:start']({ address, logger }), logging, }); } @@ -235,10 +294,12 @@ export async function runHookServerDone({ logging: LogOptions; }) { for (const integration of config.integrations) { + const logger = getLogger(integration, logging); + if (integration?.hooks?.['astro:server:done']) { await withTakingALongTimeMsg({ name: integration.name, - hookResult: integration.hooks['astro:server:done'](), + hookResult: integration.hooks['astro:server:done']({ logger }), logging, }); } @@ -254,9 +315,11 @@ export async function runHookBuildStart({ }) { for (const integration of config.integrations) { if (integration?.hooks?.['astro:build:start']) { + const logger = getLogger(integration, logging); + await withTakingALongTimeMsg({ name: integration.name, - hookResult: integration.hooks['astro:build:start'](), + hookResult: integration.hooks['astro:build:start']({ logger }), logging, }); } @@ -280,6 +343,8 @@ export async function runHookBuildSetup({ for (const integration of config.integrations) { if (integration?.hooks?.['astro:build:setup']) { + const logger = getLogger(integration, logging); + await withTakingALongTimeMsg({ name: integration.name, hookResult: integration.hooks['astro:build:setup']({ @@ -289,6 +354,7 @@ export async function runHookBuildSetup({ updateConfig: (newConfig) => { updatedConfig = mergeConfig(updatedConfig, newConfig); }, + logger, }), logging, }); @@ -315,12 +381,15 @@ export async function runHookBuildSsr({ }: RunHookBuildSsr) { for (const integration of config.integrations) { if (integration?.hooks?.['astro:build:ssr']) { + const logger = getLogger(integration, logging); + await withTakingALongTimeMsg({ name: integration.name, hookResult: integration.hooks['astro:build:ssr']({ manifest, entryPoints, middlewareEntryPoint, + logger, }), logging, }); @@ -338,10 +407,12 @@ export async function runHookBuildGenerated({ const dir = isServerLikeOutput(config) ? config.build.client : config.outDir; for (const integration of config.integrations) { + const logger = getLogger(integration, logging); + if (integration?.hooks?.['astro:build:generated']) { await withTakingALongTimeMsg({ name: integration.name, - hookResult: integration.hooks['astro:build:generated']({ dir }), + hookResult: integration.hooks['astro:build:generated']({ dir, logger }), logging, }); } @@ -361,15 +432,34 @@ export async function runHookBuildDone({ config, pages, routes, logging }: RunHo for (const integration of config.integrations) { if (integration?.hooks?.['astro:build:done']) { + const logger = getLogger(integration, logging); + await withTakingALongTimeMsg({ name: integration.name, hookResult: integration.hooks['astro:build:done']({ pages: pages.map((p) => ({ pathname: p })), dir, routes, + logger, }), logging, }); } } } + +export function isFunctionPerRouteEnabled(adapter: AstroAdapter | undefined): boolean { + if (adapter?.adapterFeatures?.functionPerRoute === true) { + return true; + } else { + return false; + } +} + +export function isEdgeMiddlewareEnabled(adapter: AstroAdapter | undefined): boolean { + if (adapter?.adapterFeatures?.edgeMiddleware === true) { + return true; + } else { + return false; + } +} diff --git a/packages/astro/src/jsx/server.ts b/packages/astro/src/jsx/server.ts index 6374b1ebd..d445ee3a5 100644 --- a/packages/astro/src/jsx/server.ts +++ b/packages/astro/src/jsx/server.ts @@ -1,3 +1,4 @@ +import { AstroError } from '../core/errors/errors.js'; import { AstroJSX, jsx } from '../jsx-runtime/index.js'; import { renderJSX } from '../runtime/server/jsx.js'; @@ -22,7 +23,7 @@ export async function check( // if the exception is from an mdx component // throw an error if (Component[Symbol.for('mdx-component')]) { - throw createFormattedError({ + throw new AstroError({ message: error.message, title: error.name, hint: `This issue often occurs when your MDX component encounters runtime errors.`, @@ -51,23 +52,6 @@ export async function renderToStaticMarkup( return { html }; } -type FormatErrorOptions = { - message: string; - name: string; - stack?: string; - hint: string; - title: string; -}; -// TODO: Remove this function and use `AstroError` when we refactor it to be usable without error codes -function createFormattedError({ message, name, stack, hint }: FormatErrorOptions) { - const error = new Error(message); - error.name = name; - error.stack = stack; - // @ts-expect-error - hint is not part of the Error interface but it will be picked up by the error overlay - error.hint = hint; - return error; -} - export default { check, renderToStaticMarkup, diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts index bd6e367ad..a3655eead 100644 --- a/packages/astro/src/prerender/utils.ts +++ b/packages/astro/src/prerender/utils.ts @@ -1,4 +1,5 @@ import type { AstroConfig } from '../@types/astro'; +import { getOutDirWithinCwd } from '../core/build/common.js'; export function isServerLikeOutput(config: AstroConfig) { return config.output === 'server' || config.output === 'hybrid'; @@ -7,3 +8,15 @@ export function isServerLikeOutput(config: AstroConfig) { export function getPrerenderDefault(config: AstroConfig) { return config.output === 'hybrid'; } + +/** + * Returns the correct output directory of hte SSR build based on the configuration + */ +export function getOutputDirectory(config: AstroConfig): URL { + const ssr = isServerLikeOutput(config); + if (ssr) { + return config.build.server; + } else { + return getOutDirWithinCwd(config.outDir); + } +} diff --git a/packages/astro/src/runtime/README.md b/packages/astro/src/runtime/README.md index a11a98d8c..68225fed1 100644 --- a/packages/astro/src/runtime/README.md +++ b/packages/astro/src/runtime/README.md @@ -4,5 +4,6 @@ Code that executes within isolated contexts: - `client/`: executes within the browser. Astro’s client-side partial hydration code lives here, and only browser-compatible code can be used. - `server/`: executes inside Vite SSR. Though also a Node context, this is isolated from code in `core/`. +- `compiler/`: same as `server/`, but only used by the Astro compiler `internalURL` option. [See CONTRIBUTING.md](../../../../CONTRIBUTING.md) for a code overview. diff --git a/packages/astro/src/runtime/compiler/index.ts b/packages/astro/src/runtime/compiler/index.ts new file mode 100644 index 000000000..a5c238b68 --- /dev/null +++ b/packages/astro/src/runtime/compiler/index.ts @@ -0,0 +1,20 @@ +// NOTE: Although this entrypoint is exported, it is internal API and may change at any time. + +export { + Fragment, + render, + createAstro, + createComponent, + renderComponent, + renderHead, + maybeRenderHead, + unescapeHTML, + renderSlot, + mergeSlots, + addAttribute, + renderTransition, + createTransitionScope, + spreadAttributes, + defineStyleVars, + defineScriptVars, +} from '../server/index.js'; diff --git a/packages/astro/src/runtime/server/endpoint.ts b/packages/astro/src/runtime/server/endpoint.ts index c56ab7646..89c35957c 100644 --- a/packages/astro/src/runtime/server/endpoint.ts +++ b/packages/astro/src/runtime/server/endpoint.ts @@ -1,28 +1,56 @@ import type { APIContext, EndpointHandler, Params } from '../../@types/astro'; +import { type LogOptions, warn } from '../../core/logger/core.js'; -function getHandlerFromModule(mod: EndpointHandler, method: string) { +function getHandlerFromModule(mod: EndpointHandler, method: string, logging: LogOptions) { + const lowerCaseMethod = method.toLowerCase(); + + // TODO: remove in Astro 4.0 + if (mod[lowerCaseMethod]) { + warn( + logging, + 'astro', + `Lower case endpoint names are deprecated and will not be supported in Astro 4.0. Rename the endpoint ${lowerCaseMethod} to ${method}.` + ); + } // If there was an exact match on `method`, return that function. if (mod[method]) { return mod[method]; } + + // TODO: remove in Astro 4.0 + if (mod[lowerCaseMethod]) { + return mod[lowerCaseMethod]; + } + // TODO: remove in Astro 4.0 // Handle `del` instead of `delete`, since `delete` is a reserved word in JS. if (method === 'delete' && mod['del']) { return mod['del']; } + // TODO: remove in Astro 4.0 // If a single `all` handler was used, return that function. if (mod['all']) { return mod['all']; } + if (mod['ALL']) { + return mod['ALL']; + } // Otherwise, no handler found. return undefined; } /** Renders an endpoint request to completion, returning the body. */ -export async function renderEndpoint(mod: EndpointHandler, context: APIContext, ssr: boolean) { - const { request, params } = context; - const chosenMethod = request.method?.toLowerCase(); - const handler = getHandlerFromModule(mod, chosenMethod); - if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'get') { +export async function renderEndpoint( + mod: EndpointHandler, + context: APIContext, + ssr: boolean, + logging: LogOptions +) { + const { request } = context; + + const chosenMethod = request.method?.toUpperCase(); + const handler = getHandlerFromModule(mod, chosenMethod, logging); + // TODO: remove the 'get' check in Astro 4.0 + if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'GET' && chosenMethod !== 'get') { // eslint-disable-next-line no-console console.warn(` ${chosenMethod} requests are not available when building a static site. Update your config to \`output: 'server'\` or \`output: 'hybrid'\` with an \`export const prerender = false\` to handle ${chosenMethod} requests.`); @@ -40,35 +68,10 @@ ${chosenMethod} requests are not available when building a static site. Update y return response; } - // TODO: Remove support for old API in Astro 3.0 - if (handler.length > 1) { - // eslint-disable-next-line no-console - console.warn(` -API routes with 2 arguments have been deprecated. Instead they take a single argument in the form of: - -export function get({ params, request }) { - //... -} - -Update your code to remove this warning.`); - } - const proxy = new Proxy(context, { get(target, prop) { if (prop in target) { return Reflect.get(target, prop); - } else if (prop in params) { - // TODO: Remove support for old API in Astro 3.0 - // eslint-disable-next-line no-console - console.warn(` -API routes no longer pass params as the first argument. Instead an object containing a params property is provided in the form of: - -export function get({ params }) { - // ... -} - -Update your code to remove this warning.`); - return Reflect.get(params, prop); } else { return undefined; } diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 5d4697bc7..81d05987a 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -1,3 +1,5 @@ +// NOTE: Although this entrypoint is exported, it is internal API and may change at any time. + export { createComponent } from './astro-component.js'; export { createAstro } from './astro-global.js'; export { renderEndpoint } from './endpoint.js'; diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts index cabbe8dae..74e8a45b7 100644 --- a/packages/astro/src/runtime/server/render/page.ts +++ b/packages/astro/src/runtime/server/render/page.ts @@ -2,7 +2,6 @@ import type { RouteData, SSRResult } from '../../../@types/astro'; import { renderComponentToString, type NonAstroPageComponent } from './component.js'; import type { AstroComponentFactory } from './index'; -import { createResponse } from '../response.js'; import { isAstroComponentFactory } from './astro/index.js'; import { renderToReadableStream, renderToString } from './astro/render.js'; import { encoder } from './common.js'; @@ -64,6 +63,6 @@ export async function renderPage( body = encoder.encode(body); headers.set('Content-Length', body.byteLength.toString()); } - const response = createResponse(body, { ...init, headers }); + const response = new Response(body, { ...init, headers }); return response; } diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts index e007fe6f1..f6a3f4191 100644 --- a/packages/astro/src/runtime/server/render/util.ts +++ b/packages/astro/src/runtime/server/render/util.ts @@ -29,10 +29,6 @@ const toStyleString = (obj: Record<string, any>) => Object.entries(obj) .map(([k, v]) => { if (k[0] !== '-' && k[1] !== '-') return `${kebab(k)}:${v}`; - // TODO: Remove in v3! See #6264 - // We need to emit --kebab-case AND --camelCase for backwards-compat in v2, - // but we should be able to remove this workaround in v3. - if (kebab(k) !== k) return `${kebab(k)}:var(${k});${k}:${v}`; return `${k}:${v}`; }) .join(';'); diff --git a/packages/astro/src/runtime/server/response.ts b/packages/astro/src/runtime/server/response.ts deleted file mode 100644 index bcfda19aa..000000000 --- a/packages/astro/src/runtime/server/response.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { streamAsyncIterator } from './util.js'; - -const isNodeJS = - typeof process === 'object' && Object.prototype.toString.call(process) === '[object process]'; - -let StreamingCompatibleResponse: typeof Response | undefined; - -function createResponseClass() { - StreamingCompatibleResponse = class extends Response { - #isStream: boolean; - #body: any; - constructor(body?: BodyInit | null, init?: ResponseInit) { - let isStream = body instanceof ReadableStream; - super(isStream ? null : body, init); - this.#isStream = isStream; - this.#body = body; - } - - get body() { - return this.#body; - } - - async text(): Promise<string> { - if (this.#isStream && isNodeJS) { - let decoder = new TextDecoder(); - let body = this.#body; - let out = ''; - for await (let chunk of streamAsyncIterator(body)) { - out += decoder.decode(chunk); - } - return out; - } - return super.text(); - } - - async arrayBuffer(): Promise<ArrayBuffer> { - if (this.#isStream && isNodeJS) { - let body = this.#body; - let chunks: Uint8Array[] = []; - let len = 0; - for await (let chunk of streamAsyncIterator(body)) { - chunks.push(chunk); - len += chunk.length; - } - let ab = new Uint8Array(len); - let offset = 0; - for (const chunk of chunks) { - ab.set(chunk, offset); - offset += chunk.length; - } - return ab; - } - return super.arrayBuffer(); - } - - clone() { - return new StreamingCompatibleResponse!(this.#body, { - status: this.status, - statusText: this.statusText, - headers: this.headers, - }); - } - }; - - return StreamingCompatibleResponse; -} - -type CreateResponseFn = (body?: BodyInit | null, init?: ResponseInit) => Response; - -export const createResponse: CreateResponseFn = isNodeJS - ? (body, init) => { - if (typeof body === 'string' || ArrayBuffer.isView(body)) { - return new Response(body, init); - } - if (typeof StreamingCompatibleResponse === 'undefined') { - return new (createResponseClass())(body, init); - } - return new StreamingCompatibleResponse(body, init); - } - : (body, init) => new Response(body, init); diff --git a/packages/astro/src/vite-plugin-astro-postprocess/index.ts b/packages/astro/src/vite-plugin-astro-postprocess/index.ts index 9a2e185af..39acd000c 100644 --- a/packages/astro/src/vite-plugin-astro-postprocess/index.ts +++ b/packages/astro/src/vite-plugin-astro-postprocess/index.ts @@ -1,4 +1,5 @@ import { parse } from 'acorn'; +import type { Node as ESTreeNode } from 'estree-walker'; import { walk } from 'estree-walker'; import MagicString from 'magic-string'; import type { Plugin } from 'vite'; @@ -28,7 +29,7 @@ export default function astro(): Plugin { sourceType: 'module', }); - walk(ast, { + walk(ast as ESTreeNode, { enter(node: any) { // Transform `Astro.glob("./pages/*.astro")` to `Astro.glob(import.meta.glob("./pages/*.astro"), () => "./pages/*.astro")` // Also handle for `Astro2.glob()` diff --git a/packages/astro/src/vite-plugin-astro-server/environment.ts b/packages/astro/src/vite-plugin-astro-server/environment.ts index bcf783bf2..ce7b92662 100644 --- a/packages/astro/src/vite-plugin-astro-server/environment.ts +++ b/packages/astro/src/vite-plugin-astro-server/environment.ts @@ -17,7 +17,6 @@ export function createDevelopmentEnvironment( let env = createEnvironment({ adapterName: manifest.adapterName, logging, - markdown: manifest.markdown, mode, // This will be overridden in the dev server renderers: [], diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 8f74bd47a..dfaf976bf 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -91,7 +91,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest entryModules: {}, routes: [], adapterName: '', - markdown: settings.config.markdown, clientDirectives: settings.clientDirectives, renderers: [], base: settings.config.base, diff --git a/packages/astro/src/vite-plugin-astro-server/request.ts b/packages/astro/src/vite-plugin-astro-server/request.ts index b641503a6..ae476f9be 100644 --- a/packages/astro/src/vite-plugin-astro-server/request.ts +++ b/packages/astro/src/vite-plugin-astro-server/request.ts @@ -48,7 +48,7 @@ export async function handleRequest({ // Add config.base back to url before passing it to SSR url.pathname = removeTrailingForwardSlash(config.base) + url.pathname; - // HACK! @astrojs/image uses query params for the injected route in `dev` + // HACK! astro:assets uses query params for the injected route in `dev` if (!buildingToSSR && pathname !== '/_image') { // Prevent user from depending on search params when not doing SSR. // NOTE: Create an array copy here because deleting-while-iterating diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index f58d248a3..0bbaacbe2 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -8,7 +8,7 @@ import type { SSRElement, SSRManifest, } from '../@types/astro'; -import { attachToResponse } from '../core/cookies/index.js'; +import { attachCookiesToResponse } from '../core/cookies/index.js'; import { AstroErrorData, isAstroError } from '../core/errors/index.js'; import { warn } from '../core/logger/core.js'; import { loadMiddleware } from '../core/middleware/loadMiddleware.js'; @@ -49,18 +49,18 @@ export interface MatchedRoute { mod: ComponentInstance; } -function getCustom404Route(manifest: ManifestData): RouteData | undefined { +function getCustom404Route(manifestData: ManifestData): RouteData | undefined { const route404 = /^\/404\/?$/; - return manifest.routes.find((r) => route404.test(r.route)); + return manifestData.routes.find((r) => route404.test(r.route)); } export async function matchRoute( pathname: string, env: DevelopmentEnvironment, - manifest: ManifestData + manifestData: ManifestData ): Promise<MatchedRoute | undefined> { const { logging, settings, routeCache } = env; - const matches = matchAllRoutes(pathname, manifest); + const matches = matchAllRoutes(pathname, manifestData); const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings }); for await (const { preloadedComponent, route: maybeRoute, filePath } of preloadedMatches) { @@ -96,7 +96,7 @@ export async function matchRoute( // build formats, and is necessary based on how the manifest tracks build targets. const altPathname = pathname.replace(/(index)?\.html$/, ''); if (altPathname !== pathname) { - return await matchRoute(altPathname, env, manifest); + return await matchRoute(altPathname, env, manifestData); } if (matches.length) { @@ -112,7 +112,7 @@ export async function matchRoute( } log404(logging, pathname); - const custom404 = getCustom404Route(manifest); + const custom404 = getCustom404Route(manifestData); if (custom404) { const filePath = new URL(`./${custom404.component}`, settings.config.root); @@ -216,7 +216,7 @@ export async function handleRoute({ }); const onRequest = options.middleware?.onRequest as MiddlewareResponseHandler | undefined; - const result = await tryRenderRoute(route.type, renderContext, env, mod, onRequest); + const result = await tryRenderRoute(renderContext, env, mod, onRequest); if (isEndpointResult(result, route.type)) { if (result.type === 'response') { if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { @@ -255,7 +255,7 @@ export async function handleRoute({ }, } ); - attachToResponse(response, result.cookies); + attachCookiesToResponse(response, result.cookies); await writeWebResponse(incomingResponse, response); } } else { diff --git a/packages/astro/src/vite-plugin-head/index.ts b/packages/astro/src/vite-plugin-head/index.ts index 9cfdc739f..ca95a334e 100644 --- a/packages/astro/src/vite-plugin-head/index.ts +++ b/packages/astro/src/vite-plugin-head/index.ts @@ -16,7 +16,7 @@ export default function configHeadVitePlugin(): vite.Plugin { function propagateMetadata< P extends keyof PluginMetadata['astro'], - V extends PluginMetadata['astro'][P] + V extends PluginMetadata['astro'][P], >( this: { getModuleInfo(id: string): ModuleInfo | null }, id: string, diff --git a/packages/astro/src/vite-plugin-inject-env-ts/index.ts b/packages/astro/src/vite-plugin-inject-env-ts/index.ts index 9c2874fb9..0f0fbb86d 100644 --- a/packages/astro/src/vite-plugin-inject-env-ts/index.ts +++ b/packages/astro/src/vite-plugin-inject-env-ts/index.ts @@ -50,26 +50,6 @@ export async function setUpEnvTs({ if (fs.existsSync(envTsPath)) { let typesEnvContents = await fs.promises.readFile(envTsPath, 'utf-8'); - // TODO: Remove this logic in 3.0, as `astro/client-image` will be merged into `astro/client` - if (settings.config.experimental.assets && typesEnvContents.includes('types="astro/client"')) { - typesEnvContents = typesEnvContents.replace( - 'types="astro/client"', - 'types="astro/client-image"' - ); - await fs.promises.writeFile(envTsPath, typesEnvContents, 'utf-8'); - info(logging, 'assets', `Added ${bold(envTsPathRelativetoRoot)} types`); - } else if ( - !settings.config.experimental.assets && - typesEnvContents.includes('types="astro/client-image"') - ) { - typesEnvContents = typesEnvContents.replace( - 'types="astro/client-image"', - 'types="astro/client"' - ); - await fs.promises.writeFile(envTsPath, typesEnvContents, 'utf-8'); - info(logging, 'assets', `Removed ${bold(envTsPathRelativetoRoot)} types`); - } - if (!fs.existsSync(dotAstroDir)) // Add `.astro` types reference if none exists return; @@ -83,13 +63,7 @@ export async function setUpEnvTs({ } else { // Otherwise, inject the `env.d.ts` file let referenceDefs: string[] = []; - if (settings.config.experimental.assets) { - referenceDefs.push('/// <reference types="astro/client-image" />'); - } else if (settings.config.integrations.find((i) => i.name === '@astrojs/image')) { - referenceDefs.push('/// <reference types="@astrojs/image/client" />'); - } else { - referenceDefs.push('/// <reference types="astro/client" />'); - } + referenceDefs.push('/// <reference types="astro/client" />'); if (fs.existsSync(dotAstroDir)) { referenceDefs.push(dotAstroTypeReference); diff --git a/packages/astro/src/vite-plugin-jsx/index.ts b/packages/astro/src/vite-plugin-jsx/index.ts deleted file mode 100644 index 7aa7e7b16..000000000 --- a/packages/astro/src/vite-plugin-jsx/index.ts +++ /dev/null @@ -1,251 +0,0 @@ -import type { TransformResult } from 'rollup'; -import { - transformWithEsbuild, - type EsbuildTransformOptions, - type Plugin, - type ResolvedConfig, -} from 'vite'; -import type { AstroRenderer, AstroSettings } from '../@types/astro'; -import type { LogOptions } from '../core/logger/core.js'; -import type { PluginMetadata } from '../vite-plugin-astro/types'; - -import babel from '@babel/core'; -import * as colors from 'kleur/colors'; -import path from 'node:path'; -import { CONTENT_FLAG, PROPAGATED_ASSET_FLAG } from '../content/index.js'; -import { astroEntryPrefix } from '../core/build/plugins/plugin-component-entry.js'; -import { error } from '../core/logger/core.js'; -import { removeQueryString } from '../core/path.js'; -import { detectImportSource } from './import-source.js'; -import tagExportsPlugin from './tag.js'; - -const JSX_EXTENSIONS = new Set(['.jsx', '.tsx', '.mdx']); -const IMPORT_STATEMENTS: Record<string, string> = { - react: "import React from 'react'", - preact: "import { h } from 'preact'", - 'solid-js': "import 'solid-js'", - astro: "import 'astro/jsx-runtime'", -}; - -function getEsbuildLoader(filePath: string): EsbuildTransformOptions['loader'] { - const fileExt = path.extname(filePath); - if (fileExt === '.mdx') return 'jsx'; - return fileExt.slice(1) as EsbuildTransformOptions['loader']; -} - -function collectJSXRenderers(renderers: AstroRenderer[]): Map<string, AstroRenderer> { - const renderersWithJSXSupport = renderers.filter((r) => r.jsxImportSource); - return new Map( - renderersWithJSXSupport.map((r) => [r.jsxImportSource, r] as [string, AstroRenderer]) - ); -} - -interface TransformJSXOptions { - code: string; - id: string; - mode: string; - renderer: AstroRenderer; - ssr: boolean; - root: URL; -} - -async function transformJSX({ - code, - mode, - id, - ssr, - renderer, - root, -}: TransformJSXOptions): Promise<TransformResult> { - const { jsxTransformOptions } = renderer; - const options = await jsxTransformOptions!({ mode, ssr }); - const plugins = [...(options.plugins || [])]; - if (ssr) { - plugins.push(await tagExportsPlugin({ rendererName: renderer.name, root })); - } - const result = await babel.transformAsync(code, { - presets: options.presets, - plugins, - cwd: process.cwd(), - filename: id, - ast: false, - compact: false, - sourceMaps: true, - configFile: false, - babelrc: false, - inputSourceMap: options.inputSourceMap, - }); - // TODO: Be more strict about bad return values here. - // Should we throw an error instead? Should we never return `{code: ""}`? - if (!result) return null; - - if (renderer.name === 'astro:jsx') { - const { astro } = result.metadata as unknown as PluginMetadata; - return { - code: result.code || '', - map: result.map, - meta: { - astro, - vite: { - // Setting this vite metadata to `ts` causes Vite to resolve .js - // extensions to .ts files. - lang: 'ts', - }, - }, - }; - } - - return { - code: result.code || '', - map: result.map, - }; -} - -interface AstroPluginJSXOptions { - settings: AstroSettings; - logging: LogOptions; -} - -// Format inspired by https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L54 -const SPECIAL_QUERY_REGEX = new RegExp( - `[?&](?:worker|sharedworker|raw|url|${CONTENT_FLAG}|${PROPAGATED_ASSET_FLAG})\\b` -); - -/** Use Astro config to allow for alternate or multiple JSX renderers (by default Vite will assume React) */ -export default function jsx({ settings, logging }: AstroPluginJSXOptions): Plugin { - let viteConfig: ResolvedConfig; - const jsxRenderers = new Map<string, AstroRenderer>(); - const jsxRenderersIntegrationOnly = new Map<string, AstroRenderer>(); - // A reference to Astro's internal JSX renderer. - let astroJSXRenderer: AstroRenderer; - // The first JSX renderer provided is considered the default renderer. - // This is a useful reference for when the user only gives a single render. - let defaultJSXRendererEntry: [string, AstroRenderer] | undefined; - - return { - name: 'astro:jsx', - enforce: 'pre', // run transforms before other plugins - async configResolved(resolvedConfig) { - viteConfig = resolvedConfig; - const possibleRenderers = collectJSXRenderers(settings.renderers); - for (const [importSource, renderer] of possibleRenderers) { - jsxRenderers.set(importSource, renderer); - if (importSource === 'astro') { - astroJSXRenderer = renderer; - } else { - jsxRenderersIntegrationOnly.set(importSource, renderer); - } - } - defaultJSXRendererEntry = [...jsxRenderersIntegrationOnly.entries()][0]; - }, - async transform(code, id, opts) { - const ssr = Boolean(opts?.ssr); - // Skip special queries and astro entries. We skip astro entries here as we know it doesn't contain - // JSX code, and also because we can't detect the import source to apply JSX transforms. - if (SPECIAL_QUERY_REGEX.test(id) || id.startsWith(astroEntryPrefix)) { - return null; - } - id = removeQueryString(id); - if (!JSX_EXTENSIONS.has(path.extname(id))) { - return null; - } - - const { mode } = viteConfig; - // Shortcut: only use Astro renderer for MD and MDX files - if (id.endsWith('.mdx')) { - const { code: jsxCode } = await transformWithEsbuild(code, id, { - loader: getEsbuildLoader(id), - jsx: 'preserve', - sourcemap: 'inline', - tsconfigRaw: { - compilerOptions: { - // Ensure client:only imports are treeshaken - verbatimModuleSyntax: false, - importsNotUsedAsValues: 'remove', - }, - }, - }); - return transformJSX({ - code: jsxCode, - id, - renderer: astroJSXRenderer, - mode, - ssr, - root: settings.config.root, - }); - } - if (defaultJSXRendererEntry && jsxRenderersIntegrationOnly.size === 1) { - // downlevel any non-standard syntax, but preserve JSX - const { code: jsxCode } = await transformWithEsbuild(code, id, { - loader: getEsbuildLoader(id), - jsx: 'preserve', - sourcemap: 'inline', - }); - return transformJSX({ - code: jsxCode, - id, - renderer: defaultJSXRendererEntry[1], - mode, - ssr, - root: settings.config.root, - }); - } - - const importSource = await detectImportSource(code, jsxRenderers, settings.tsConfig); - - // if we still can’t tell the import source, now is the time to throw an error. - if (!importSource && defaultJSXRendererEntry) { - const [defaultRendererName] = defaultJSXRendererEntry; - error( - logging, - 'renderer', - `${colors.yellow(id)} -Unable to resolve a renderer that handles this file! With more than one renderer enabled, you should include an import or use a pragma comment. -Add ${colors.cyan( - IMPORT_STATEMENTS[defaultRendererName] || `import '${defaultRendererName}';` - )} or ${colors.cyan(`/** @jsxImportSource: ${defaultRendererName} */`)} to this file. -` - ); - return null; - } else if (!importSource) { - error( - logging, - 'renderer', - `${colors.yellow(id)} -Unable to find a renderer for JSX. Do you have one configured in your Astro config? See this page to learn how: -https://docs.astro.build/en/core-concepts/framework-components/#installing-integrations -` - ); - return null; - } - - const selectedJsxRenderer = jsxRenderers.get(importSource); - // if the renderer is not installed for this JSX source, throw error - if (!selectedJsxRenderer) { - error( - logging, - 'renderer', - `${colors.yellow( - id - )} No renderer installed for ${importSource}. Try adding \`@astrojs/${importSource}\` to your project.` - ); - return null; - } - - // downlevel any non-standard syntax, but preserve JSX - const { code: jsxCode } = await transformWithEsbuild(code, id, { - loader: getEsbuildLoader(id), - jsx: 'preserve', - sourcemap: 'inline', - }); - return await transformJSX({ - code: jsxCode, - id, - renderer: selectedJsxRenderer, - mode, - ssr, - root: settings.config.root, - }); - }, - }; -} diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index dd2cbcd85..ae26bfb42 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -49,11 +49,6 @@ function safeMatter(source: string, id: string) { } } -// absolute path of "astro/jsx-runtime" -const astroJsxRuntimeModulePath = normalizePath( - fileURLToPath(new URL('../jsx-runtime/index.js', import.meta.url)) -); - const astroServerRuntimeModulePath = normalizePath( fileURLToPath(new URL('../runtime/server/index.js', import.meta.url)) ); @@ -80,7 +75,6 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu ...settings.config.markdown, fileURL: new URL(`file://${fileId}`), frontmatter: raw.data, - experimentalAssets: settings.config.experimental.assets, }); let html = renderResult.code; @@ -88,7 +82,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu // Resolve all the extracted images from the content let imagePaths: { raw: string; resolved: string }[] = []; - if (settings.config.experimental.assets && renderResult.vfile.data.imagePaths) { + if (renderResult.vfile.data.imagePaths) { for (let imagePath of renderResult.vfile.data.imagePaths.values()) { imagePaths.push({ raw: imagePath, @@ -115,12 +109,13 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu } const code = escapeViteEnvReferences(` - import { Fragment, jsx as h } from ${JSON.stringify(astroJsxRuntimeModulePath)}; - import { spreadAttributes } from ${JSON.stringify(astroServerRuntimeModulePath)}; + import { unescapeHTML, spreadAttributes, createComponent, render, renderComponent } from ${JSON.stringify( + astroServerRuntimeModulePath + )}; import { AstroError, AstroErrorData } from ${JSON.stringify(astroErrorModulePath)}; ${layout ? `import Layout from ${JSON.stringify(layout)};` : ''} - ${settings.config.experimental.assets ? 'import { getImage } from "astro:assets";' : ''} + import { getImage } from "astro:assets"; export const images = { ${imagePaths.map( @@ -167,27 +162,29 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu export function getHeadings() { return ${JSON.stringify(headings)}; } - export async function Content() { + + export const Content = createComponent((result, _props, slots) => { const { layout, ...content } = frontmatter; content.file = file; content.url = url; - const contentFragment = h(Fragment, { 'set:html': html }); + return ${ layout - ? `h(Layout, { - file, - url, - content, - frontmatter: content, - headings: getHeadings(), - rawContent, - compiledContent, - 'server:root': true, - children: contentFragment - })` - : `contentFragment` - }; - } + ? `render\`\${renderComponent(result, 'Layout', Layout, { + file, + url, + content, + frontmatter: content, + headings: getHeadings(), + rawContent, + compiledContent, + 'server:root': true, + }, { + 'default': () => render\`\${unescapeHTML(html)}\` + })}\`;` + : `render\`\${unescapeHTML(html)}\`;` + } + }); Content[Symbol.for('astro.needsHeadRendering')] = ${layout ? 'false' : 'true'}; export default Content; `); diff --git a/packages/astro/src/vite-plugin-jsx/README.md b/packages/astro/src/vite-plugin-mdx/README.md index 554651869..554651869 100644 --- a/packages/astro/src/vite-plugin-jsx/README.md +++ b/packages/astro/src/vite-plugin-mdx/README.md diff --git a/packages/astro/src/vite-plugin-jsx/import-source.ts b/packages/astro/src/vite-plugin-mdx/import-source.ts index c1f9ea6dc..c1f9ea6dc 100644 --- a/packages/astro/src/vite-plugin-jsx/import-source.ts +++ b/packages/astro/src/vite-plugin-mdx/import-source.ts diff --git a/packages/astro/src/vite-plugin-mdx/index.ts b/packages/astro/src/vite-plugin-mdx/index.ts new file mode 100644 index 000000000..f2b068068 --- /dev/null +++ b/packages/astro/src/vite-plugin-mdx/index.ts @@ -0,0 +1,130 @@ +import type { TransformResult } from 'rollup'; +import { transformWithEsbuild, type Plugin, type ResolvedConfig } from 'vite'; +import type { AstroRenderer, AstroSettings } from '../@types/astro'; +import type { LogOptions } from '../core/logger/core.js'; +import type { PluginMetadata } from '../vite-plugin-astro/types'; + +import babel from '@babel/core'; +import { CONTENT_FLAG, PROPAGATED_ASSET_FLAG } from '../content/index.js'; +import { astroEntryPrefix } from '../core/build/plugins/plugin-component-entry.js'; +import { removeQueryString } from '../core/path.js'; +import tagExportsPlugin from './tag.js'; + +interface TransformJSXOptions { + code: string; + id: string; + mode: string; + renderer: AstroRenderer; + ssr: boolean; + root: URL; +} + +async function transformJSX({ + code, + mode, + id, + ssr, + renderer, + root, +}: TransformJSXOptions): Promise<TransformResult> { + const { jsxTransformOptions } = renderer; + const options = await jsxTransformOptions!({ mode, ssr }); + const plugins = [...(options.plugins || [])]; + if (ssr) { + plugins.push(await tagExportsPlugin({ rendererName: renderer.name, root })); + } + const result = await babel.transformAsync(code, { + presets: options.presets, + plugins, + cwd: process.cwd(), + filename: id, + ast: false, + compact: false, + sourceMaps: true, + configFile: false, + babelrc: false, + inputSourceMap: options.inputSourceMap, + }); + // TODO: Be more strict about bad return values here. + // Should we throw an error instead? Should we never return `{code: ""}`? + if (!result) return null; + + if (renderer.name === 'astro:jsx') { + const { astro } = result.metadata as unknown as PluginMetadata; + return { + code: result.code || '', + map: result.map, + meta: { + astro, + vite: { + // Setting this vite metadata to `ts` causes Vite to resolve .js + // extensions to .ts files. + lang: 'ts', + }, + }, + }; + } + + return { + code: result.code || '', + map: result.map, + }; +} + +interface AstroPluginJSXOptions { + settings: AstroSettings; + logging: LogOptions; +} + +// Format inspired by https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L54 +const SPECIAL_QUERY_REGEX = new RegExp( + `[?&](?:worker|sharedworker|raw|url|${CONTENT_FLAG}|${PROPAGATED_ASSET_FLAG})\\b` +); + +/** Use Astro config to allow for alternate or multiple JSX renderers (by default Vite will assume React) */ +export default function mdxVitePlugin({ settings }: AstroPluginJSXOptions): Plugin { + let viteConfig: ResolvedConfig; + // A reference to Astro's internal JSX renderer. + let astroJSXRenderer: AstroRenderer; + + return { + name: 'astro:jsx', + enforce: 'pre', // run transforms before other plugins + async configResolved(resolvedConfig) { + viteConfig = resolvedConfig; + astroJSXRenderer = settings.renderers.find((r) => r.jsxImportSource === 'astro')!; + }, + async transform(code, id, opts) { + // Skip special queries and astro entries. We skip astro entries here as we know it doesn't contain + // JSX code, and also because we can't detect the import source to apply JSX transforms. + if (SPECIAL_QUERY_REGEX.test(id) || id.startsWith(astroEntryPrefix)) { + return null; + } + id = removeQueryString(id); + // Shortcut: only use Astro renderer for MD and MDX files + if (!id.endsWith('.mdx')) { + return null; + } + const { code: jsxCode } = await transformWithEsbuild(code, id, { + loader: 'jsx', + jsx: 'preserve', + sourcemap: 'inline', + tsconfigRaw: { + compilerOptions: { + // Ensure client:only imports are treeshaken + verbatimModuleSyntax: false, + importsNotUsedAsValues: 'remove', + }, + }, + }); + return transformJSX({ + code: jsxCode, + id, + renderer: astroJSXRenderer, + mode: viteConfig.mode, + ssr: Boolean(opts?.ssr), + root: settings.config.root, + }); + }, + }; +} diff --git a/packages/astro/src/vite-plugin-jsx/tag.ts b/packages/astro/src/vite-plugin-mdx/tag.ts index 5efc4c41f..b7ae1f2c4 100644 --- a/packages/astro/src/vite-plugin-jsx/tag.ts +++ b/packages/astro/src/vite-plugin-mdx/tag.ts @@ -18,7 +18,7 @@ export default async function tagExportsWithRenderer({ return { visitor: { Program: { - // Inject `import { __astro_tag_component__ } from 'astro/server/index.js'` + // Inject `import { __astro_tag_component__ } from 'astro/runtime/server/index.js'` enter(path) { path.node.body.splice( 0, @@ -30,7 +30,7 @@ export default async function tagExportsWithRenderer({ t.identifier('__astro_tag_component__') ), ], - t.stringLiteral('astro/server/index.js') + t.stringLiteral('astro/runtime/server/index.js') ) ); }, |