diff --git a/.changeset/silver-berries-send.md b/.changeset/silver-berries-send.md new file mode 100644 index 000000000000..97c2984227d5 --- /dev/null +++ b/.changeset/silver-berries-send.md @@ -0,0 +1,65 @@ +--- +'astro': minor +--- + +Adds experimental support for configurable log handlers. + +This experimental feature provides better control over Astro's logging infrastructure by allowing users to replace the default console output with custom logging implementations (e.g., structured JSON). This is particularly useful for users using on-demand rendering and wishing to connect their log aggregation services such as Kibana, Logstash, CloudWatch, Grafana, or Loki. + +By default, Astro provides three built-in log handlers (`json`, `node` and `console`), but you can also create your own. +#### JSON logging + +JSON logging can be enabled via the CLI for the `build`, `dev`, and `sync` commands using the `experimentalJson` flag: + + +```js +// astro.config.mjs +import { defineConfig, logHandlers } from "astro/config"; + +export default defineConfig({ + experimental: { + logger: logHandlers.json({ + pretty: true, + level: 'warn' + }) + } +}) +``` + +#### Custom logger + +You can also create your own custom logger by implementing the correct interface: + +```js +// astro.config.mjs +import { defineConfig } from "astro/config"; + +export default defineConfig({ + experimental: { + logger: { + entrypoint: "@org/custom-logger" + } + } +}) +``` + +```ts +// @org/custom-logger.js +import type { AstroLoggerDestination, AstroLoggerMessage } from "astro"; +import { matchesLevel } from "astor/logger"; + +function customLogger(level = 'info'): AstroLoggerDestination { + return { + write(message: AstroLoggerMessage) { + if (matchesLevel(message.level, level)) { + // write message somewhere + } + } + } +} +export default customLogger +``` + +For more information on enabling and using this feature in your project, see the [Experimental Logger docs](https://docs.astro.build/en/reference/experimental-flags/logger/). + +For a complete overview, and to give feedback on this experimental API, see the [Custom logger RFC](https://github.com/withastro/roadmap/blob/logger/proposals/0059-custom-logger.md). diff --git a/.gitignore b/.gitignore index fe331c16ad03..4d0275899e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ /triage/ /.compiler/ dist/ +dist-**/ temp/ *.tsbuildinfo .DS_Store diff --git a/packages/astro/package.json b/packages/astro/package.json index f264f2f69645..05198ec014cd 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -81,7 +81,12 @@ "./zod": "./dist/zod.js", "./errors": "./dist/core/errors/userError.js", "./middleware": "./dist/core/middleware/index.js", - "./virtual-modules/*": "./dist/virtual-modules/*" + "./virtual-modules/*": "./dist/virtual-modules/*", + "./logger": "./dist/core/logger/public.js", + "./logger/json": "./dist/core/logger/impls/json.js", + "./logger/node": "./dist/core/logger/impls/node.js", + "./logger/compose": "./dist/core/logger/impls/compose.js", + "./logger/console": "./dist/core/logger/impls/console.js" }, "bin": { "astro": "./bin/astro.mjs" diff --git a/packages/astro/src/actions/runtime/types.ts b/packages/astro/src/actions/runtime/types.ts index 5078e3ad24fe..35f226e4c63e 100644 --- a/packages/astro/src/actions/runtime/types.ts +++ b/packages/astro/src/actions/runtime/types.ts @@ -105,6 +105,7 @@ export type ActionAPIContext = Pick< | 'session' | 'cache' | 'csp' + | 'logger' >; export type MaybePromise = T | Promise; diff --git a/packages/astro/src/assets/index.ts b/packages/astro/src/assets/index.ts index ae8cf46316ba..e87d03499a1d 100644 --- a/packages/astro/src/assets/index.ts +++ b/packages/astro/src/assets/index.ts @@ -1,4 +1,4 @@ -export { getConfiguredImageService, getImage } from './internal.js'; +export { getConfiguredImageService, getImage, verifyOptions } from './internal.js'; export { baseService, isLocalService } from './services/service.js'; export { hashTransform, propsToFilename } from './utils/hash.js'; export type { LocalImageProps, RemoteImageProps } from './types.js'; diff --git a/packages/astro/src/cli/flags.ts b/packages/astro/src/cli/flags.ts index ba91134961e7..58d09ad3cf65 100644 --- a/packages/astro/src/cli/flags.ts +++ b/packages/astro/src/cli/flags.ts @@ -1,6 +1,7 @@ import type { Arguments } from 'yargs-parser'; -import type { AstroLogger, AstroLogOptions } from '../core/logger/core.js'; -import { createNodeLogger, nodeLogDestination } from '../core/logger/node.js'; +import type { AstroLogger } from '../core/logger/core.js'; +import { createNodeLoggerFromFlags } from '../core/logger/impls/node.js'; +import { createJsonLoggerFromFlags } from '../core/logger/impls/json.js'; import type { AstroInlineConfig } from '../types/public/config.js'; // Alias for now, but allows easier migration to node's `parseArgs` in the future. @@ -8,7 +9,7 @@ export type Flags = Arguments; /** @deprecated Use AstroConfigResolver instead */ export function flagsToAstroInlineConfig(flags: Flags): AstroInlineConfig { - return { + const inlineConfig: AstroInlineConfig = { // Inline-only configs configFile: typeof flags.config === 'string' ? flags.config : undefined, mode: typeof flags.mode === 'string' ? flags.mode : undefined, @@ -34,6 +35,16 @@ export function flagsToAstroInlineConfig(flags: Flags): AstroInlineConfig { : [], }, }; + + if (flags.experimentalJson) { + inlineConfig.experimental = { + logger: { + entrypoint: 'astro/logger/json', + }, + }; + } + + return inlineConfig; } /** @@ -41,16 +52,16 @@ export function flagsToAstroInlineConfig(flags: Flags): AstroInlineConfig { * doesn't read the AstroConfig directly, so we create a `logging` object from the CLI flags instead. */ export function createLoggerFromFlags(flags: Flags): AstroLogger { - const logging: AstroLogOptions = { - destination: nodeLogDestination, - level: 'info', - }; + let logLevel = flags.level; if (flags.verbose) { - logging.level = 'debug'; + logLevel = 'debug'; } else if (flags.silent) { - logging.level = 'silent'; + logLevel = 'silent'; + } + if (flags.experimentalJson) { + return createJsonLoggerFromFlags({ logLevel }); + } else { + return createNodeLoggerFromFlags({ logLevel }); } - - return createNodeLogger({ logLevel: logging.level }); } diff --git a/packages/astro/src/cli/help/index.ts b/packages/astro/src/cli/help/index.ts index 0e27b6874603..c244df3a1e1c 100644 --- a/packages/astro/src/cli/help/index.ts +++ b/packages/astro/src/cli/help/index.ts @@ -28,6 +28,7 @@ export const DEFAULT_HELP_PAYLOAD: HelpPayload = { ['--silent', 'Disable all logging.'], ['--version', 'Show the version number and exit.'], ['--help', 'Show this help message.'], + ['--experimental-json', 'Enables JSON logging.'], ], }, }; diff --git a/packages/astro/src/cli/preferences/index.ts b/packages/astro/src/cli/preferences/index.ts index 74f420bbd73e..9195980327ea 100644 --- a/packages/astro/src/cli/preferences/index.ts +++ b/packages/astro/src/cli/preferences/index.ts @@ -10,7 +10,8 @@ import { DEFAULT_PREFERENCES } from '../../preferences/defaults.js'; import dlv from '../../preferences/dlv.js'; import { coerce, isValidKey, type PreferenceKey } from '../../preferences/index.js'; import type { AstroSettings } from '../../types/astro.js'; -import { createLoggerFromFlags, type Flags, flagsToAstroInlineConfig } from '../flags.js'; +import { type Flags, flagsToAstroInlineConfig } from '../flags.js'; +import { loadOrCreateNodeLogger } from '../../core/logger/load.js'; const { bgGreen, black, bold, dim, yellow } = colors; @@ -68,8 +69,8 @@ export async function preferences( } const inlineConfig = flagsToAstroInlineConfig(flags); - const logger = createLoggerFromFlags(flags); const { astroConfig } = await resolveConfig(inlineConfig ?? {}, 'dev'); + const logger = await loadOrCreateNodeLogger(astroConfig, inlineConfig ?? {}); const settings = await createSettings( astroConfig, inlineConfig.logLevel, diff --git a/packages/astro/src/config/entrypoint.ts b/packages/astro/src/config/entrypoint.ts index 60283f128670..5d9292cff366 100644 --- a/packages/astro/src/config/entrypoint.ts +++ b/packages/astro/src/config/entrypoint.ts @@ -12,6 +12,7 @@ export { validateConfig } from '../core/config/validate.js'; export { envField } from '../env/config.js'; export { defineConfig, getViteConfig } from './index.js'; export { sessionDrivers } from '../core/session/drivers.js'; +export { logHandlers } from '../core/logger/handlers.js'; /** * Return the configuration needed to use the Sharp-based image service diff --git a/packages/astro/src/config/index.ts b/packages/astro/src/config/index.ts index a6d880969beb..34011b28d6d8 100644 --- a/packages/astro/src/config/index.ts +++ b/packages/astro/src/config/index.ts @@ -1,7 +1,6 @@ +// Keep this imports free of possible runtime imports import type { UserConfig as ViteUserConfig, UserConfigFn as ViteUserConfigFn } from 'vite'; import type { FontProvider } from '../assets/fonts/types.js'; -import { createRoutesList } from '../core/routing/create-manifest.js'; -import { getPrerenderDefault } from '../prerender/utils.js'; import type { SessionDriverConfig, SessionDriverName } from '../core/session/types.js'; import type { AstroInlineConfig, AstroUserConfig, Locales } from '../types/public/config.js'; @@ -32,19 +31,23 @@ export function getViteConfig( // Use dynamic import to avoid pulling in deps unless used const [ { mergeConfig }, - { createNodeLogger }, + { loadOrCreateNodeLogger }, { resolveConfig, createSettings }, { createVite }, { runHookConfigSetup, runHookConfigDone }, + { createRoutesList }, + { getPrerenderDefault }, ] = await Promise.all([ import('vite'), - import('../core/logger/node.js'), + import('../core/logger/load.js'), import('../core/config/index.js'), import('../core/create-vite.js'), import('../integrations/hooks.js'), + import('../core/routing/create-manifest.js'), + import('../prerender/utils.js'), ]); - const logger = createNodeLogger(inlineAstroConfig); const { astroConfig: config } = await resolveConfig(inlineAstroConfig, cmd); + const logger = await loadOrCreateNodeLogger(config, inlineAstroConfig); let settings = await createSettings(config, inlineAstroConfig.logLevel, userViteConfig.root); settings = await runHookConfigSetup({ settings, command: cmd, logger }); const routesList = await createRoutesList( diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index caed810a1876..d4a5f81b4194 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -3,8 +3,6 @@ import { getDefaultClientDirectives } from '../core/client-directive/index.js'; import { ASTRO_CONFIG_DEFAULTS } from '../core/config/schemas/index.js'; import { validateConfig } from '../core/config/validate.js'; import { createKey } from '../core/encryption.js'; -import { AstroLogger } from '../core/logger/core.js'; -import { nodeLogDestination } from '../core/logger/node.js'; import { NOOP_MIDDLEWARE_FN } from '../core/middleware/noop-middleware.js'; import { removeLeadingForwardSlash } from '../core/path.js'; import { RenderContext } from '../core/render-context.js'; @@ -26,6 +24,7 @@ import type { SSRResult, } from '../types/public/internal.js'; import { ContainerPipeline } from './pipeline.js'; +import { createConsoleLogger } from '../core/logger/impls/console.js'; /** * Public type, used for integrations to define a renderer for the container API @@ -135,7 +134,6 @@ function createManifest( onRequest: middleware ?? NOOP_MIDDLEWARE_FN, }; } - const root = new URL(import.meta.url); return { rootDir: root, @@ -182,6 +180,7 @@ function createManifest( experimentalQueuedRendering: manifest?.experimentalQueuedRendering ?? { enabled: false, }, + experimentalLogger: manifest?.experimentalLogger ?? undefined, }; } @@ -274,6 +273,7 @@ type AstroContainerManifest = Pick< | 'assetsDir' | 'image' | 'experimentalQueuedRendering' + | 'experimentalLogger' >; type AstroContainerConstructor = { @@ -301,10 +301,7 @@ export class experimental_AstroContainer { astroConfig, }: AstroContainerConstructor) { this.#pipeline = ContainerPipeline.create({ - logger: new AstroLogger({ - level: 'info', - destination: nodeLogDestination, - }), + logger: createConsoleLogger({ level: 'error' }), manifest: createManifest(manifest, renderers), streaming, renderers: renderers ?? manifest?.renderers ?? [], diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index e115b26ff1b8..92ea7fc976e6 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -30,8 +30,7 @@ import { } from '../cookies/index.js'; import { getCookiesFromResponse } from '../cookies/response.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; -import { consoleLogDestination } from '../logger/console.js'; -import { AstroIntegrationLogger, AstroLogger } from '../logger/core.js'; +import { AstroIntegrationLogger, type AstroLogger } from '../logger/core.js'; import { type CreateRenderContext, RenderContext } from '../render-context.js'; import { redirectTemplate } from '../routing/3xx.js'; import { ensure404Route } from '../routing/astro-designed-error-pages.js'; @@ -129,20 +128,29 @@ export abstract class BaseApp

{ manifest: SSRManifest; manifestData: RoutesList; pipeline: P; - adapterLogger: AstroIntegrationLogger; + #adapterLogger: AstroIntegrationLogger | undefined; baseWithoutTrailingSlash: string; - logger: AstroLogger; #router: Router; + + get logger(): AstroLogger { + return this.pipeline.logger; + } + + get adapterLogger(): AstroIntegrationLogger { + if (!this.#adapterLogger) { + this.#adapterLogger = new AstroIntegrationLogger( + this.logger.options, + this.manifest.adapterName, + ); + } + return this.#adapterLogger; + } + constructor(manifest: SSRManifest, streaming = true, ...args: any[]) { this.manifest = manifest; this.manifestData = { routes: manifest.routes.map((route) => route.routeData) }; this.baseWithoutTrailingSlash = removeTrailingForwardSlash(manifest.base); this.pipeline = this.createPipeline(streaming, manifest, ...args); - this.logger = new AstroLogger({ - destination: consoleLogDestination, - level: manifest.logLevel, - }); - this.adapterLogger = new AstroIntegrationLogger(this.logger.options, manifest.adapterName); // This is necessary to allow running middlewares for 404 in SSR. There's special handling // to return the host 404 if the user doesn't provide a custom 404 ensure404Route(this.manifestData); @@ -159,6 +167,14 @@ export abstract class BaseApp

{ return this.adapterLogger; } + /** + * Resets the cached adapter logger so it picks up a new logger instance. + * Used by BuildApp when the logger is replaced via setOptions(). + */ + protected resetAdapterLogger(): void { + this.#adapterLogger = undefined; + } + getAllowedDomains() { return this.manifest.allowedDomains; } @@ -390,6 +406,10 @@ export abstract class BaseApp

{ routeData, }: RenderOptions = {}, ): Promise { + // Lazily resolve the logger destination from the manifest on the first request. + // This swaps the user-configured logger destination (if any) into the shared + // AstroLogger instance before any logging occurs. + await this.pipeline.getLogger(); const timeStart = performance.now(); const url = new URL(request.url); const redirect = this.redirectTrailingSlash(url.pathname); @@ -590,6 +610,7 @@ export abstract class BaseApp

{ } } + this.logger.flush(); Reflect.set(response, responseSentSymbol, true); } diff --git a/packages/astro/src/core/app/dev/app.ts b/packages/astro/src/core/app/dev/app.ts index b1a373671b6a..7925dc21799d 100644 --- a/packages/astro/src/core/app/dev/app.ts +++ b/packages/astro/src/core/app/dev/app.ts @@ -18,10 +18,8 @@ import type { RoutesList } from '../../../types/astro.js'; import { req } from '../../messages/runtime.js'; export class DevApp extends BaseApp { - logger: AstroLogger; constructor(manifest: SSRManifest, streaming = true, logger: AstroLogger) { super(manifest, streaming, logger); - this.logger = logger; } createPipeline( diff --git a/packages/astro/src/core/app/entrypoints/index.ts b/packages/astro/src/core/app/entrypoints/index.ts index 2730ce8395f6..3133731c2806 100644 --- a/packages/astro/src/core/app/entrypoints/index.ts +++ b/packages/astro/src/core/app/entrypoints/index.ts @@ -7,7 +7,7 @@ export { type LogRequestPayload, } from '../base.js'; export { fromRoutingStrategy, toRoutingStrategy } from '../common.js'; -export { createConsoleLogger } from '../logging.js'; +export { createConsoleLogger } from '../../logger/impls/console.js'; export { deserializeManifest, deserializeRouteData, diff --git a/packages/astro/src/core/app/entrypoints/virtual/dev.ts b/packages/astro/src/core/app/entrypoints/virtual/dev.ts index 2ad43f34d71d..ab60d619b469 100644 --- a/packages/astro/src/core/app/entrypoints/virtual/dev.ts +++ b/packages/astro/src/core/app/entrypoints/virtual/dev.ts @@ -1,8 +1,8 @@ import { manifest } from 'virtual:astro:manifest'; import { DevApp } from '../../dev/app.js'; -import { createConsoleLogger } from '../../logging.js'; import type { CreateApp, RouteInfo } from '../../types.js'; import type { RoutesList } from '../../../../types/astro.js'; +import { createConsoleLogger } from '../../../logger/impls/console.js'; let currentDevApp: DevApp | null = null; diff --git a/packages/astro/src/core/app/logging.ts b/packages/astro/src/core/app/logging.ts deleted file mode 100644 index 36b229a042d5..000000000000 --- a/packages/astro/src/core/app/logging.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AstroInlineConfig } from '../../types/public/index.js'; -import { consoleLogDestination } from '../logger/console.js'; -import { AstroLogger } from '../logger/core.js'; - -export function createConsoleLogger(level: AstroInlineConfig['logLevel']): AstroLogger { - return new AstroLogger({ - destination: consoleLogDestination, - level: level ?? 'info', - }); -} diff --git a/packages/astro/src/core/app/manifest.ts b/packages/astro/src/core/app/manifest.ts index b279d1a81a6f..feb2f4a025df 100644 --- a/packages/astro/src/core/app/manifest.ts +++ b/packages/astro/src/core/app/manifest.ts @@ -50,6 +50,7 @@ export function deserializeManifest( middleware() { return { onRequest: NOOP_MIDDLEWARE_FN }; }, + ...serializedManifest, rootDir: new URL(serializedManifest.rootDir), srcDir: new URL(serializedManifest.srcDir), diff --git a/packages/astro/src/core/app/pipeline.ts b/packages/astro/src/core/app/pipeline.ts index 459a96e409c6..59cdcdee44b9 100644 --- a/packages/astro/src/core/app/pipeline.ts +++ b/packages/astro/src/core/app/pipeline.ts @@ -11,7 +11,7 @@ import { } from '../render/ssr-element.js'; import { getFallbackRoute, routeIsFallback, routeIsRedirect } from '../routing/helpers.js'; import { findRouteToRewrite } from '../routing/rewrite.js'; -import { createConsoleLogger } from './logging.js'; +import { createConsoleLogger } from '../logger/impls/console.js'; export class AppPipeline extends Pipeline { getName(): string { return 'AppPipeline'; @@ -29,7 +29,9 @@ export class AppPipeline extends Pipeline { return createAssetLink(bundlePath, manifest.base, manifest.assetsPrefix); } }; - const logger = createConsoleLogger(manifest.logLevel); + // Start with console logger synchronously; the custom logger destination + // (if configured) is lazily resolved via pipeline.getLogger() on first request. + const logger = createConsoleLogger({ level: manifest.logLevel }); const pipeline = new AppPipeline( logger, manifest, diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 7641ff1f3ca1..6a205727040c 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -15,13 +15,18 @@ import type { } from '../../types/public/internal.js'; import type { SinglePageBuiltModule } from '../build/types.js'; import type { CspDirective } from '../csp/config.js'; -import type { AstroLoggerLevel } from '../logger/core.js'; +import type { + AstroLoggerDestination, + AstroLoggerLevel, + AstroLoggerMessage, +} from '../logger/core.js'; import type { RoutingStrategies } from './common.js'; import type { CacheProviderFactory, SSRManifestCache } from '../cache/types.js'; import type { BaseSessionConfig, SessionDriverFactory } from '../session/types.js'; import type { DevToolbarPlacement } from '../../types/public/toolbar.js'; import type { MiddlewareMode } from '../../types/public/integrations.js'; import type { BaseApp } from './base.js'; +import type { LoggerHandlerConfig } from '../logger/config.js'; type ComponentPath = string; @@ -111,6 +116,9 @@ export type SSRManifest = { key: Promise; i18n: SSRManifestI18n | undefined; middleware?: () => Promise | AstroMiddlewareInstance; + logger?: () => + | Promise<{ default: AstroLoggerDestination }> + | { default: AstroLoggerDestination }; actions?: () => Promise | SSRActions; sessionDriver?: () => Promise<{ default: SessionDriverFactory | null }>; cacheProvider?: () => Promise<{ default: CacheProviderFactory | null }>; @@ -151,6 +159,8 @@ export type SSRManifest = { }; internalFetchHeaders?: Record; logLevel: AstroLoggerLevel; + // Configure that tells us how to load the logger + experimentalLogger: LoggerHandlerConfig | undefined; }; export type SSRActions = { @@ -187,6 +197,7 @@ export interface SSRManifestSession extends BaseSessionConfig { export type SerializedSSRManifest = Omit< SSRManifest, | 'middleware' + | 'logger' | 'routes' | 'assets' | 'componentMetadata' diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index bf6524eb990c..1dd304eb9bcf 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -29,6 +29,7 @@ import type { SessionDriverFactory } from './session/types.js'; import { NodePool } from '../runtime/server/render/queue/pool.js'; import { HTMLStringCache } from '../runtime/server/html-string-cache.js'; import { FORBIDDEN_PATH_KEYS } from '@astrojs/internal-helpers/object'; +import { loadLogger } from './logger/load.js'; /** * The `Pipeline` represents the static parts of rendering that do not change between requests. @@ -39,6 +40,7 @@ import { FORBIDDEN_PATH_KEYS } from '@astrojs/internal-helpers/object'; export abstract class Pipeline { readonly internalMiddleware: MiddlewareHandler[]; resolvedMiddleware: MiddlewareHandler | undefined = undefined; + resolvedLogger = false; resolvedActions: SSRActions | undefined = undefined; resolvedSessionDriver: SessionDriverFactory | null | undefined = undefined; resolvedCacheProvider: CacheProvider | null | undefined = undefined; @@ -46,7 +48,7 @@ export abstract class Pipeline { nodePool: NodePool | undefined; htmlStringCache: HTMLStringCache | undefined; - readonly logger: AstroLogger; + logger: AstroLogger; readonly manifest: SSRManifest; /** * "development" or "production" only @@ -220,6 +222,22 @@ export abstract class Pipeline { this.resolvedMiddleware = undefined; } + /** + * Resolves the logger destination from the manifest and updates the pipeline logger. + * If the user configured `experimental.logger`, the bundled logger factory is loaded + * and replaces the default console destination. This is lazy and only resolves once. + */ + async getLogger(): Promise { + if (this.resolvedLogger) { + return this.logger; + } + this.resolvedLogger = true; + if (this.manifest.experimentalLogger) { + this.logger = await loadLogger(this.manifest.experimentalLogger); + } + return this.logger; + } + async getActions(): Promise { if (this.resolvedActions) { return this.resolvedActions; diff --git a/packages/astro/src/core/build/app.ts b/packages/astro/src/core/build/app.ts index 80e0b6ef47c2..dc2ff9f84bb0 100644 --- a/packages/astro/src/core/build/app.ts +++ b/packages/astro/src/core/build/app.ts @@ -30,7 +30,8 @@ export class BuildApp extends BaseApp { public setOptions(options: StaticBuildOptions) { this.pipeline.setOptions(options); - this.logger = options.logger; + this.logger.setDestination(options.logger.options.destination); + this.resetAdapterLogger(); } public getOptions() { diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index d96f736efcaf..b2df9803cf85 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -14,7 +14,7 @@ import { import type { AstroSettings, RoutesList } from '../../types/astro.js'; import type { AstroInlineConfig, RuntimeMode } from '../../types/public/config.js'; import { resolveConfig } from '../config/config.js'; -import { createNodeLogger } from '../logger/node.js'; +import { loadOrCreateNodeLogger } from '../logger/load.js'; import { createSettings } from '../config/settings.js'; import { createVite } from '../create-vite.js'; import { createKey, getEnvironmentKey, hasEnvironmentKey } from '../encryption.js'; @@ -62,8 +62,8 @@ export default async function build( options: BuildOptions = {}, ): Promise { ensureProcessNodeEnv(options.devOutput ? 'development' : 'production'); - const logger = createNodeLogger(inlineConfig); const { userConfig, astroConfig } = await resolveConfig(inlineConfig, 'build'); + const logger = await loadOrCreateNodeLogger(astroConfig, inlineConfig ?? {}); telemetry.record(eventCliSession('build', userConfig)); warnIfCspWithShiki(astroConfig, logger); diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 7f5df2ef1b38..9a0845940cc3 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -95,6 +95,7 @@ export interface BuildInternals { prerenderEntryFileName?: string; componentMetadata: SSRResult['componentMetadata']; middlewareEntryPoint: URL | undefined; + loggerEntryPoint: URL | undefined; astroActionsEntryPoint: URL | undefined; /** @@ -139,6 +140,7 @@ export function createBuildInternals(): BuildInternals { componentMetadata: new Map(), astroActionsEntryPoint: undefined, middlewareEntryPoint: undefined, + loggerEntryPoint: undefined, clientChunksAndAssets: new Set(), ssrAssetsPerEnvironment: new Map(), }; diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index 21ece179a594..01a1f55c37ba 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -81,7 +81,9 @@ export class BuildPipeline extends Pipeline { resolveCache.set(specifier, assetLink); return assetLink; } - const logger = createConsoleLogger(manifest.logLevel); + // Start with console logger synchronously; the custom logger destination + // (if configured) is lazily resolved via pipeline.getLogger() on first use. + const logger = createConsoleLogger({ level: manifest.logLevel }); // We can skip streaming in SSG for performance as writing as strings are faster super(logger, manifest, 'production', manifest.renderers, resolve, manifest.serverLike); this.manifest = manifest; diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 7cc031504580..81dca1782dc3 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -324,6 +324,11 @@ async function buildManifest( const middlewareMode = resolveMiddlewareMode(opts.settings.adapter?.adapterFeatures); + let experimentalLogger = undefined; + if (settings.config.experimental.logger) { + experimentalLogger = settings.config.experimental.logger; + } + return { rootDir: opts.settings.config.root.toString(), cacheDir: opts.settings.config.cacheDir.toString(), @@ -388,5 +393,6 @@ async function buildManifest( internalFetchHeaders, logLevel: settings.logLevel, shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config.security.csp), + experimentalLogger, }; } diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index f9cc154f3f1e..59d35af83b0d 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -114,8 +114,13 @@ export const ASTRO_CONFIG_DEFAULTS = { queuedRendering: { enabled: false, }, + logger: { + entrypoint: 'astro/logger/node', + }, }, -} satisfies AstroUserConfig & { server: { open: boolean } }; +} satisfies AstroUserConfig & { + server: { open: boolean }; +}; const highlighterTypesSchema = z .union([z.literal('shiki'), z.literal('prism')]) @@ -548,6 +553,12 @@ export const AstroConfigSchema = z.object({ }) .optional() .prefault(ASTRO_CONFIG_DEFAULTS.experimental.queuedRendering), + logger: z + .object({ + entrypoint: z.string(), + config: z.record(z.string(), z.any()).optional(), + }) + .optional(), }) .prefault({}), legacy: z diff --git a/packages/astro/src/core/config/vite-load.ts b/packages/astro/src/core/config/vite-load.ts index 5abacfa56ef6..91540a09475e 100644 --- a/packages/astro/src/core/config/vite-load.ts +++ b/packages/astro/src/core/config/vite-load.ts @@ -1,28 +1,10 @@ import type fsType from 'node:fs'; import { pathToFileURL } from 'node:url'; -import { - createServer, - isRunnableDevEnvironment, - type RunnableDevEnvironment, - type ViteDevServer, -} from 'vite'; +import { isRunnableDevEnvironment, type RunnableDevEnvironment, type ViteDevServer } from 'vite'; import loadFallbackPlugin from '../../vite-plugin-load-fallback/index.js'; import { debug } from '../logger/core.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.js'; - -async function createViteServer(root: string, fs: typeof fsType): Promise { - const viteServer = await createServer({ - configFile: false, - server: { middlewareMode: true, hmr: false, watch: null, ws: false }, - optimizeDeps: { noDiscovery: true }, - clearScreen: false, - appType: 'custom', - ssr: { external: true }, - plugins: [loadFallbackPlugin({ fs, root: pathToFileURL(root) })], - }); - - return viteServer; -} +import { createMinimalViteDevServer } from '../createMinimalViteDevServer.js'; interface LoadConfigWithViteOptions { root: string; @@ -55,7 +37,8 @@ export async function loadConfigWithVite({ // Try Loading with Vite let server: ViteDevServer | undefined; try { - server = await createViteServer(root, fs); + const plugins = loadFallbackPlugin({ fs, root: pathToFileURL(root) }); + server = await createMinimalViteDevServer(plugins); if (isRunnableDevEnvironment(server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr])) { const environment = server.environments[ ASTRO_VITE_ENVIRONMENT_NAMES.ssr diff --git a/packages/astro/src/core/createMinimalViteDevServer.ts b/packages/astro/src/core/createMinimalViteDevServer.ts new file mode 100644 index 000000000000..a8cf0b350d48 --- /dev/null +++ b/packages/astro/src/core/createMinimalViteDevServer.ts @@ -0,0 +1,19 @@ +import { createServer, type ViteDevServer, type Plugin } from 'vite'; + +/** + * Creates a minimal dev server with a list of plugins. Use this instance for a one-shot usage. + * + * NOTE: This is intentionally in its own module to avoid pulling `vite`'s heavy `createServer` + * (and transitively Rollup) into every file that imports from `viteUtils.ts`. + */ +export async function createMinimalViteDevServer(plugins: Plugin[] = []): Promise { + return await createServer({ + configFile: false, + server: { middlewareMode: true, hmr: false, watch: null, ws: false }, + optimizeDeps: { noDiscovery: true }, + clearScreen: false, + appType: 'custom', + ssr: { external: true }, + plugins, + }); +} diff --git a/packages/astro/src/core/dev/restart.ts b/packages/astro/src/core/dev/restart.ts index 4f1bc53fbc0b..7960c1323bea 100644 --- a/packages/astro/src/core/dev/restart.ts +++ b/packages/astro/src/core/dev/restart.ts @@ -14,7 +14,7 @@ import { clearCrawlCache, createVite } from '../create-vite.js'; import { collectErrorMetadata } from '../errors/dev/utils.js'; import { isAstroConfigZodError } from '../errors/errors.js'; import { createSafeError } from '../errors/index.js'; -import { createNodeLogger } from '../logger/node.js'; +import { loadOrCreateNodeLogger } from '../logger/load.js'; import { formatErrorMessage, warnIfCspWithShiki } from '../messages/runtime.js'; import { createRoutesList } from '../routing/create-manifest.js'; import type { Container } from './container.js'; @@ -146,8 +146,10 @@ export async function createContainerWithAutomaticRestart({ inlineConfig, fs, }: CreateContainerWithAutomaticRestart): Promise { - const logger = createNodeLogger(inlineConfig ?? {}); const { userConfig, astroConfig } = await resolveConfig(inlineConfig ?? {}, 'dev', fs); + // For now, we override only when no inline config has been provided. This won't break tests + const logger = await loadOrCreateNodeLogger(astroConfig, inlineConfig ?? {}); + warnIfCspWithShiki(astroConfig, logger); telemetry.record(eventCliSession('dev', userConfig)); diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index a9070f1b3cc7..44bdc464a93a 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1489,6 +1489,30 @@ export const UnavailableAstroGlobal = { `The Astro global is not available in this scope. Please remove "Astro.${name}" from your getStaticPaths() function.`, } satisfies ErrorData; +/** + * @docs + * @description + * Unable to load the logger. + * @message + * Couldn't load the logger at the given path. + */ +export const UnableToLoadLogger = { + name: 'UnableToLoadLogger', + title: 'Unable to load the logger.', + message: (path: string) => `Couldn't load the logger at given path "${path}".`, +} satisfies ErrorData; + +/** + * @docs + * @description + * The configuration of the logger is not serializable. + * @message + * The configuration of the logger is not serializable. + */ +export const LoggerConfigurationNotSerializable = { + name: 'LoggerConfigurationNotSerializable', + title: 'The configuration of the logger is not serializable', +} satisfies ErrorData; /** * @docs * @kind heading diff --git a/packages/astro/src/core/logger/config.ts b/packages/astro/src/core/logger/config.ts new file mode 100644 index 000000000000..75f8b0affd6a --- /dev/null +++ b/packages/astro/src/core/logger/config.ts @@ -0,0 +1,6 @@ +export interface LoggerHandlerConfig { + /** Serializable options used by the driver implementation */ + config?: Record | undefined; + /** URL or package import */ + entrypoint: string; +} diff --git a/packages/astro/src/core/logger/console.ts b/packages/astro/src/core/logger/console.ts deleted file mode 100644 index a20e108f9704..000000000000 --- a/packages/astro/src/core/logger/console.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - getEventPrefix, - type AstroLogMessage, - type AstroLoggerDestination, - levels, -} from './core.js'; - -export const consoleLogDestination: AstroLoggerDestination = { - write(event: AstroLogMessage) { - let dest = console.error; - if (levels[event.level] < levels['error']) { - dest = console.info; - } - if (event.label === 'SKIP_FORMAT') { - dest(event.message); - } else { - dest(getEventPrefix(event) + ' ' + event.message); - } - return true; - }, -}; diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts index a3c527851728..526d6ac7507a 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -1,19 +1,23 @@ import colors from 'piccolore'; -export interface AstroLoggerDestination { - write: (chunk: T) => boolean; +export interface AstroLoggerDestination { + /** + * It receives a message and writes it into a destination + */ + write: (chunk: T) => void; + /** + * It dumps logs without closing the connection to the destination. + * Method that can be used by specialized loggers. + */ + flush?: () => Promise | void; + /** + * It dumps logs and closes the connection to the destination. + * Method that can be used by specialized loggers. + */ + close?: () => Promise | void; } // NOTE: this is a public type -/** - * How the log should be formatted - * - 'default': how Astro usually format the logs - * - 'json': logs are formatted in JSON format - */ -export type AstroLoggerFormat = 'default' | 'json'; - -// NOTE: this is a public type - /** * The level of logging. Priority is the following: * 1. debug @@ -29,42 +33,46 @@ export type AstroLoggerLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; / * rather than specific to a single command, function, use, etc. The label will be * shown in the log message to the user, so it should be relevant. */ -type AstroLoggerLabel = - | 'add' - | 'build' - | 'check' - | 'config' - | 'content' - | 'crypto' - | 'deprecated' - | 'markdown' - | 'router' - | 'types' - | 'vite' - | 'watch' - | 'middleware' - | 'preferences' - | 'redirects' - | 'sync' - | 'session' - | 'toolbar' - | 'assets' - | 'env' - | 'update' - | 'adapter' - | 'islands' - | 'cache' - | 'csp' +const AstroLoggerLabels = [ + 'add', + 'build', + 'check', + 'config', + 'content', + 'crypto', + 'deprecated', + 'markdown', + 'router', + 'types', + 'vite', + 'watch', + 'middleware', + 'preferences', + 'redirects', + 'sync', + 'session', + 'toolbar', + 'assets', + 'env', + 'update', + 'adapter', + 'islands', + 'cache', + 'csp', // SKIP_FORMAT: A special label that tells the logger not to apply any formatting. // Useful for messages that are already formatted, like the server start message. - | 'SKIP_FORMAT'; + 'SKIP_FORMAT', +] as const; +type AstroLoggerLabel = (typeof AstroLoggerLabels)[number]; export interface AstroLogOptions { - destination: AstroLoggerDestination; + destination: AstroLoggerDestination; level: AstroLoggerLevel; - // Intentionally optional so we don't leak to public code. It will be public and non-optional - // once we expose to users - _format?: AstroLoggerFormat; + + /** + * Optional configuration for the logger destination + */ + config?: Record | undefined; } // Hey, locales are pretty complicated! Be careful modifying this logic... @@ -85,7 +93,7 @@ export const dateTimeFormat = new Intl.DateTimeFormat([], { }); // NOTE: this is a public type now -export interface AstroLogMessage { +export interface AstroLoggerMessage { /** * Label associated to the message. Used by Astro for pretty logging */ @@ -102,12 +110,6 @@ export interface AstroLogMessage { * Whether a newline should be appended to the end of the message i.e. message + '\n' */ newLine: boolean; - - /** - * @internal - * How the log should be formatted when printed inside the destination - */ - _format?: AstroLoggerFormat; } export const levels: Record = { @@ -128,12 +130,11 @@ function log( ) { const logLevel = opts.level; const dest = opts.destination; - const event: AstroLogMessage = { + const event: AstroLoggerMessage = { label, level, message, newLine, - _format: opts._format, }; // test if this level is enabled or not @@ -174,7 +175,7 @@ export function debug(...args: any[]) { * This includes the timestamp, log level, and label all properly formatted * with colors. This is shared across different loggers, so it's defined here. */ -export function getEventPrefix({ level, label }: AstroLogMessage) { +export function getEventPrefix({ level, label }: AstroLoggerMessage) { const timestamp = `${dateTimeFormat.format(new Date())}`; const prefix = []; if (level === 'error' || level === 'warn') { @@ -209,9 +210,6 @@ export function timerMessage(message: string, startTime: number = Date.now()) { export class AstroLogger { options: AstroLogOptions; constructor(options: AstroLogOptions) { - if (!options._format) { - options._format = 'default'; - } this.options = options; } @@ -235,6 +233,28 @@ export class AstroLogger { forkIntegrationLogger(label: string) { return new AstroIntegrationLogger(this.options, label); } + + setDestination(destination: AstroLoggerDestination) { + this.options.destination = destination; + } + + /** + * It calls the `close` function of the provided destination, if it exists. + */ + close() { + if (this.options.destination.close) { + this.options.destination.close(); + } + } + + /** + * It calls the `flush` function of the provided destinatin, if it exists. + */ + flush() { + if (this.options.destination.flush) { + this.options.destination.flush(); + } + } } export class AstroIntegrationLogger { diff --git a/packages/astro/src/core/logger/handlers.ts b/packages/astro/src/core/logger/handlers.ts new file mode 100644 index 000000000000..ec50fb500c13 --- /dev/null +++ b/packages/astro/src/core/logger/handlers.ts @@ -0,0 +1,84 @@ +import type { LoggerHandlerConfig } from './config.js'; +import type { JsonHandlerConfig } from './impls/json.js'; +import type { NodeHandlerConfig } from './impls/node.js'; +import type { ConsoleHandlerConfig } from './impls/console.js'; + +export const logHandlers = { + /** + * It uses the built-in Astro JSON logger. + * @example + * ```js + * export default defineConfig({ + * experimental: { + * logger: logHandlers.json({ pretty: true }) + * } + * }) + * ``` + */ + json(config?: JsonHandlerConfig): LoggerHandlerConfig { + return { + entrypoint: 'astro/logger/json', + config, + }; + }, + /** + * It uses the built-in Astro Node.js logger. + * + * @example + * ```js + * export default defineConfig({ + * experimental: { + * logger: logHandlers.node({ pretty: true }) + * } + * }) + * ``` + */ + node(config?: NodeHandlerConfig): LoggerHandlerConfig { + return { + entrypoint: 'astro/logger/node', + config, + }; + }, + /** + * It uses the built-in Astro console logger. + * + * @example + * ```js + * export default defineConfig({ + * experimental: { + * logger: logHandlers.console({ pretty: true }) + * } + * }) + * ``` + */ + console(config?: ConsoleHandlerConfig): LoggerHandlerConfig { + return { + entrypoint: 'astro/logger/console', + config, + }; + }, + + /** + * It allows composing different loggers + * + * @example + * ```js + * export default defineConfig({ + * experimental: { + * logger: logHandlers.compose( + * logHandlers.console(), + * logHandlers.json(), + * ) + * } + * }) + * ``` + */ + compose(...loggers: LoggerHandlerConfig[]): LoggerHandlerConfig { + return { + entrypoint: 'astro/logger/compose', + config: { + loggers, + }, + }; + }, +}; diff --git a/packages/astro/src/core/logger/impls/compose.ts b/packages/astro/src/core/logger/impls/compose.ts new file mode 100644 index 000000000000..c3ad72300602 --- /dev/null +++ b/packages/astro/src/core/logger/impls/compose.ts @@ -0,0 +1,25 @@ +import type { AstroLoggerDestination } from '../core.js'; + +export default function compose(destinations: AstroLoggerDestination[]): AstroLoggerDestination { + return { + write(chunk) { + for (const logger of destinations) { + logger.write(chunk); + } + }, + flush() { + for (const logger of destinations) { + if (logger.flush) { + logger.flush(); + } + } + }, + close() { + for (const logger of destinations) { + if (logger.close) { + logger.close(); + } + } + }, + }; +} diff --git a/packages/astro/src/core/logger/impls/console.ts b/packages/astro/src/core/logger/impls/console.ts new file mode 100644 index 000000000000..b3333c5c86f2 --- /dev/null +++ b/packages/astro/src/core/logger/impls/console.ts @@ -0,0 +1,49 @@ +import { + getEventPrefix, + type AstroLoggerMessage, + type AstroLoggerDestination, + levels, + type AstroLoggerLevel, + AstroLogger, +} from '../core.js'; +import type { NodeHandlerConfig } from './node.js'; +import { matchesLevel } from '../public.js'; + +export type ConsoleHandlerConfig = { + level?: AstroLoggerLevel; +}; + +function consoleLogDestination( + config: ConsoleHandlerConfig = {}, +): AstroLoggerDestination { + const { level = 'info' } = config; + return { + write(event: AstroLoggerMessage) { + let dest = console.error; + if (levels[event.level] < levels['error']) { + dest = console.info; + } + + if (!matchesLevel(event.level, level)) { + return; + } + + if (event.label === 'SKIP_FORMAT') { + dest(event.message); + } else { + dest(getEventPrefix(event) + ' ' + event.message); + } + }, + }; +} + +export function createConsoleLogger({ level }: { level: AstroLoggerLevel }): AstroLogger { + return new AstroLogger({ + level, + destination: consoleLogDestination(), + }); +} + +export default function (options?: NodeHandlerConfig): AstroLoggerDestination { + return consoleLogDestination(options); +} diff --git a/packages/astro/src/core/logger/impls/json.ts b/packages/astro/src/core/logger/impls/json.ts new file mode 100644 index 000000000000..6f8b0a7f3217 --- /dev/null +++ b/packages/astro/src/core/logger/impls/json.ts @@ -0,0 +1,65 @@ +import { + AstroLogger, + type AstroLoggerDestination, + type AstroLoggerLevel, + type AstroLoggerMessage, + levels, +} from '../core.js'; +import type { Writable } from 'node:stream'; +import type { AstroInlineConfig } from '../../../types/public/index.js'; +import { matchesLevel } from '../public.js'; + +export type JsonHandlerConfig = { + /** + * Whether the JSON line should format on multiple lines + */ + pretty?: boolean; + /** + * The level of logs that should be printed by the logger. + */ + level?: AstroLoggerLevel; +}; + +type ConsoleStream = Writable & { + fd: 1 | 2; +}; + +export const SGR_REGEX = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g'); + +export default function jsonLoggerDestination( + config: JsonHandlerConfig = {}, +): AstroLoggerDestination { + const { pretty = false, level = 'info' } = config; + return { + write(event) { + let dest: ConsoleStream = process.stderr; + if (levels[event.level] < levels['error']) { + dest = process.stdout; + } + + if (!matchesLevel(event.level, level)) { + return; + } + + let trailingLine = event.newLine ? '\n' : ''; + const message = event.message.replace(SGR_REGEX, ''); + if (pretty) { + dest.write( + JSON.stringify({ message, label: event.label, level: event.level }, null, 2) + + trailingLine, + ); + } else { + dest.write( + JSON.stringify({ message, label: event.label, level: event.level }) + trailingLine, + ); + } + }, + }; +} + +export function createJsonLoggerFromFlags(config: AstroInlineConfig) { + return new AstroLogger({ + destination: jsonLoggerDestination({ pretty: false }), + level: config.logLevel ?? 'info', + }); +} diff --git a/packages/astro/src/core/logger/impls/node.ts b/packages/astro/src/core/logger/impls/node.ts new file mode 100644 index 000000000000..462c676feda1 --- /dev/null +++ b/packages/astro/src/core/logger/impls/node.ts @@ -0,0 +1,57 @@ +import { + AstroLogger, + type AstroLoggerDestination, + type AstroLoggerLevel, + type AstroLoggerMessage, + getEventPrefix, + levels, +} from '../core.js'; +import type { Writable } from 'node:stream'; +import type { AstroInlineConfig } from '../../../types/public/index.js'; +import { matchesLevel } from '../public.js'; + +type ConsoleStream = Writable & { + fd: 1 | 2; +}; + +export type NodeHandlerConfig = { + level?: AstroLoggerLevel; +}; + +function nodeLogDestination( + config: NodeHandlerConfig = {}, +): AstroLoggerDestination { + const { level = 'info' } = config; + return { + write(event: AstroLoggerMessage) { + let dest: ConsoleStream = process.stderr; + if (levels[event.level] < levels['error']) { + dest = process.stdout; + } + + if (!matchesLevel(event.level, level)) { + return; + } + + let trailingLine = event.newLine ? '\n' : ''; + if (event.label === 'SKIP_FORMAT') { + dest.write(event.message + trailingLine); + } else { + dest.write(getEventPrefix(event) + ' ' + event.message + trailingLine); + } + }, + }; +} + +export default function (options?: NodeHandlerConfig): AstroLoggerDestination { + return nodeLogDestination(options); +} + +export function createNodeLoggerFromFlags(inlineConfig: AstroInlineConfig): AstroLogger { + if (inlineConfig.logger) return inlineConfig.logger; + + return new AstroLogger({ + destination: nodeLogDestination(), + level: inlineConfig.logLevel ?? 'info', + }); +} diff --git a/packages/astro/src/core/logger/load.ts b/packages/astro/src/core/logger/load.ts new file mode 100644 index 000000000000..3b916327cea8 --- /dev/null +++ b/packages/astro/src/core/logger/load.ts @@ -0,0 +1,97 @@ +import { AstroLogger, type AstroLoggerDestination, type AstroLoggerLevel } from './core.js'; +import { AstroError } from '../errors/index.js'; +import { UnableToLoadLogger } from '../errors/errors-data.js'; +import type { LoggerHandlerConfig } from './config.js'; +import type { AstroConfig, AstroInlineConfig } from '../../types/public/index.js'; +import { default as nodeLoggerCreator, createNodeLoggerFromFlags } from './impls/node.js'; +import { default as consoleLoggerCreator } from './impls/console.js'; +import { default as jsonLoggerCreator } from './impls/json.js'; +import { default as composeLoggerCreator } from './impls/compose.js'; + +export async function loadLogger( + config: LoggerHandlerConfig, + level: AstroLoggerLevel = 'info', +): Promise { + let cause: Error | undefined = undefined; + + try { + switch (config.entrypoint) { + case 'astro/logger/node': { + return new AstroLogger({ + destination: nodeLoggerCreator(config.config), + level, + }); + } + case 'astro/logger/console': { + return new AstroLogger({ + destination: consoleLoggerCreator(config.config), + level, + }); + } + case 'astro/logger/json': { + return new AstroLogger({ + destination: jsonLoggerCreator(config.config), + level, + }); + } + case 'astro/logger/compose': { + let destinations: AstroLoggerDestination[] = []; + if (config.config?.loggers) { + const loggers: LoggerHandlerConfig[] = config.config?.loggers; + destinations = await Promise.all( + loggers.map(async (loggerConfig) => { + const logger = await import(/* @vite-ignore */ loggerConfig.entrypoint); + return logger.default(loggerConfig.config) as AstroLoggerDestination; + }), + ); + } + + return new AstroLogger({ + destination: composeLoggerCreator(destinations), + level, + }); + } + default: { + const nodeLogger = await import(/* @vite-ignore */ config.entrypoint); + return new AstroLogger({ + destination: nodeLogger.default(config.config), + level, + }); + } + } + } catch (e: unknown) { + if (e instanceof Error) { + cause = e; + } + } + + const error = new AstroError({ + ...UnableToLoadLogger, + message: UnableToLoadLogger.message(config.entrypoint), + }); + if (cause) { + error.cause = cause; + } + throw error; +} + +/** + * It attempts to load a logger from the entrypoint. + * If not provided, it creates a new logger instance on the fly. + * @param astroConfig + * @param inlineAstroConfig + */ +export async function loadOrCreateNodeLogger( + astroConfig: AstroConfig, + inlineAstroConfig: AstroInlineConfig, +) { + try { + if (astroConfig.experimental.logger) { + return await loadLogger(astroConfig.experimental.logger, inlineAstroConfig.logLevel); + } else { + return createNodeLoggerFromFlags(inlineAstroConfig); + } + } catch { + return createNodeLoggerFromFlags(inlineAstroConfig); + } +} diff --git a/packages/astro/src/core/logger/node.ts b/packages/astro/src/core/logger/node.ts index abd75da80fca..841c01679ee3 100644 --- a/packages/astro/src/core/logger/node.ts +++ b/packages/astro/src/core/logger/node.ts @@ -1,44 +1,4 @@ -import type { Writable } from 'node:stream'; import { createDebug, enable as obugEnable } from 'obug'; -import type { AstroInlineConfig } from '../../types/public/config.js'; -import { AstroLogger } from './core.js'; -import { - getEventPrefix, - type AstroLogMessage, - type AstroLoggerDestination, - levels, -} from './core.js'; - -type ConsoleStream = Writable & { - fd: 1 | 2; -}; - -export const nodeLogDestination: AstroLoggerDestination = { - write(event: AstroLogMessage) { - let dest: ConsoleStream = process.stderr; - if (levels[event.level] < levels['error']) { - dest = process.stdout; - } - - let format = event._format ?? 'default'; - - let trailingLine = event.newLine ? '\n' : ''; - switch (format) { - case 'json': { - dest.write(JSON.stringify({ message: event.message, label: event.label }) + trailingLine); - return true; - } - case 'default': { - if (event.label === 'SKIP_FORMAT') { - dest.write(event.message + trailingLine); - } else { - dest.write(getEventPrefix(event) + ' ' + event.message + trailingLine); - } - return true; - } - } - }, -}; const debuggers: Record> = {}; @@ -67,12 +27,3 @@ export function enableVerboseLogging() { 'Tip: Set the DEBUG env variable directly for more control. Example: "DEBUG=astro:*,vite:* astro build".', ); } - -export function createNodeLogger(inlineConfig: AstroInlineConfig): AstroLogger { - if (inlineConfig.logger) return inlineConfig.logger; - - return new AstroLogger({ - destination: nodeLogDestination, - level: inlineConfig.logLevel ?? 'info', - }); -} diff --git a/packages/astro/src/core/logger/public.ts b/packages/astro/src/core/logger/public.ts new file mode 100644 index 000000000000..3366b6b42232 --- /dev/null +++ b/packages/astro/src/core/logger/public.ts @@ -0,0 +1,25 @@ +// This is public module with functions exported to the user + +import { type AstroLoggerLevel, levels } from './core.js'; + +/** + * Returns `true` if `messageLevel` has a level equals or higher than `configuredLevel`. As a golden rule, + * the first argument should be level of the incoming message, and the second argument should be the + * configured level of the logger. + * + * @param messageLevel The level of the incoming message + * @param configuredLevel The level the logger is configured with + * + * @example + * + * ```js + * matchesLevel('error', 'info') // true, because 'error' has higher priority than 'info' + * matchesLevel('info', 'error') // false, because 'info' has lower priority than 'error' + * ``` + */ +export function matchesLevel( + messageLevel: AstroLoggerLevel, + configuredLevel: AstroLoggerLevel, +): boolean { + return levels[messageLevel] >= levels[configuredLevel]; +} diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index d992e6dbea3f..bedf2042695d 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -121,6 +121,7 @@ function createContext({ session: undefined, cache: new DisabledAstroCache(), csp: undefined, + logger: undefined, }; return Object.assign(context, { getActionResult: createGetActionResult(context.locals), diff --git a/packages/astro/src/core/preview/index.ts b/packages/astro/src/core/preview/index.ts index 1a40977e84b0..66125ecfc48d 100644 --- a/packages/astro/src/core/preview/index.ts +++ b/packages/astro/src/core/preview/index.ts @@ -1,14 +1,14 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { AstroIntegrationLogger } from '../../core/logger/core.js'; +import { AstroIntegrationLogger } from '../logger/core.js'; import { telemetry } from '../../events/index.js'; import { eventCliSession } from '../../events/session.js'; import { runHookConfigDone, runHookConfigSetup } from '../../integrations/hooks.js'; import type { AstroInlineConfig } from '../../types/public/config.js'; import type { PreviewModule, PreviewServer } from '../../types/public/preview.js'; import { resolveConfig } from '../config/config.js'; -import { createNodeLogger } from '../logger/node.js'; +import { loadOrCreateNodeLogger } from '../logger/load.js'; import { createSettings } from '../config/settings.js'; import { createRoutesList } from '../routing/create-manifest.js'; import { getPrerenderDefault } from '../../prerender/utils.js'; @@ -24,8 +24,8 @@ import { getResolvedHostForHttpServer } from './util.js'; */ export default async function preview(inlineConfig: AstroInlineConfig): Promise { ensureProcessNodeEnv('production'); - const logger = createNodeLogger(inlineConfig); const { userConfig, astroConfig } = await resolveConfig(inlineConfig ?? {}, 'preview'); + const logger = await loadOrCreateNodeLogger(astroConfig, inlineConfig ?? {}); telemetry.record(eventCliSession('preview', userConfig)); const _settings = await createSettings( diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 3ce124e61374..65917e527540 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -599,6 +599,27 @@ export class RenderContext { }, }; }, + + get logger(): APIContext['logger'] { + if (!pipeline.manifest.experimentalLogger) { + pipeline.logger.warn( + null, + 'The Astro.logger is available only when experimental.logger is defined.', + ); + return undefined; + } + return { + info(msg: string) { + pipeline.logger.info(null, msg); + }, + warn(msg: string) { + pipeline.logger.warn(null, msg); + }, + error(msg: string) { + pipeline.logger.error(null, msg); + }, + }; + }, }; } @@ -866,6 +887,19 @@ export class RenderContext { }, }; }, + get logger(): APIContext['logger'] { + return { + info(msg: string) { + pipeline.logger.info(null, msg); + }, + warn(msg: string) { + pipeline.logger.warn(null, msg); + }, + error(msg: string) { + pipeline.logger.error(null, msg); + }, + }; + }, }; } diff --git a/packages/astro/src/core/sync/index.ts b/packages/astro/src/core/sync/index.ts index cfcf14852c26..bf04261585f1 100644 --- a/packages/astro/src/core/sync/index.ts +++ b/packages/astro/src/core/sync/index.ts @@ -19,7 +19,7 @@ import type { AstroSettings } from '../../types/astro.js'; import type { AstroInlineConfig } from '../../types/public/config.js'; import { getTimeStat } from '../build/util.js'; import { resolveConfig } from '../config/config.js'; -import { createNodeLogger } from '../logger/node.js'; +import { loadOrCreateNodeLogger } from '../logger/load.js'; import { createSettings } from '../config/settings.js'; import { createVite } from '../create-vite.js'; import { @@ -59,8 +59,8 @@ export default async function sync( { fs, telemetry: _telemetry = false }: { fs?: typeof fsMod; telemetry?: boolean } = {}, ) { ensureProcessNodeEnv('production'); - const logger = createNodeLogger(inlineConfig); const { astroConfig, userConfig } = await resolveConfig(inlineConfig ?? {}, 'sync'); + const logger = await loadOrCreateNodeLogger(astroConfig, inlineConfig ?? {}); if (_telemetry) { telemetry.record(eventCliSession('sync', userConfig)); } diff --git a/packages/astro/src/manifest/serialized.ts b/packages/astro/src/manifest/serialized.ts index 65bf28ff61d3..b134a35df849 100644 --- a/packages/astro/src/manifest/serialized.ts +++ b/packages/astro/src/manifest/serialized.ts @@ -172,6 +172,11 @@ async function createSerializedManifest( }; } + let experimentalLogger = undefined; + if (settings.config.experimental.logger) { + experimentalLogger = settings.config.experimental.logger; + } + return { rootDir: settings.config.root.toString(), srcDir: settings.config.srcDir.toString(), @@ -233,5 +238,6 @@ async function createSerializedManifest( logLevel: settings.logLevel, shouldInjectCspMetaTags: false, experimentalQueuedRendering: settings.config.experimental?.queuedRendering, + experimentalLogger, }; } diff --git a/packages/astro/src/runtime/server/astro-global.ts b/packages/astro/src/runtime/server/astro-global.ts index 8c7950a5946f..3a93dcdb5081 100644 --- a/packages/astro/src/runtime/server/astro-global.ts +++ b/packages/astro/src/runtime/server/astro-global.ts @@ -98,5 +98,8 @@ export function createAstro(site: string | undefined): AstroGlobal { get cache(): any { throw createError('cache'); }, + get logger(): any { + throw createError('logger'); + }, }; } diff --git a/packages/astro/src/types/public/common.ts b/packages/astro/src/types/public/common.ts index 734008e1789c..9f70d6644449 100644 --- a/packages/astro/src/types/public/common.ts +++ b/packages/astro/src/types/public/common.ts @@ -184,3 +184,9 @@ export type Params = Record; export type Props = Record; export type CodeLanguage = BundledLanguage | LanguageRegistration | SpecialLanguage; + +export type { + AstroLoggerDestination, + AstroLoggerLevel, + AstroLoggerMessage, +} from '../../core/logger/core.js'; diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 57029eaf177c..6bd06ad46169 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -25,6 +25,7 @@ import type { } from '../../core/session/types.js'; import type { EnvSchema } from '../../env/schema.js'; import type { AstroIntegration } from './integrations.js'; +import type { LoggerHandlerConfig } from '../../core/logger/config.js'; export type Locales = (string | { codes: [string, ...string[]]; path: string })[]; @@ -3059,6 +3060,32 @@ export interface AstroUserConfig< */ contentCache?: boolean; }; + /** + * @name experimental.logger + * @type {{ entrypoint: string; config?: Record }} + * @default `undefined` + * @version 6.2.0 + * @description + * + * Configure a custom logger by defining its entrypoint and, optionally, providing a serializable configuration: + * + * ```js + * // astro.config.mjs + * import { defineConfig } from 'astro/config'; + * + * export default defineConfig({ + * experimental: { + * logger: { + * entrypoint: "@org/astro-logger", + * config: { + * level: "error" + * } + * } + * } + * }); + * ``` + */ + logger?: LoggerHandlerConfig; }; } diff --git a/packages/astro/src/types/public/context.ts b/packages/astro/src/types/public/context.ts index b40b59d6ea21..98ffd1724114 100644 --- a/packages/astro/src/types/public/context.ts +++ b/packages/astro/src/types/public/context.ts @@ -576,6 +576,26 @@ export interface APIContext< } | undefined; + /** + * It exposes utilities for logging messages. + */ + logger: + | { + /** + * Logs a message with `info` level. + */ + info: (msg: string) => void; + /** + * Logs a message with `warn` level. + */ + warn: (msg: string) => void; + /** + * Logs a message with `error` level. + */ + error: (msg: string) => void; + } + | undefined; + /** * The route currently rendered. It's stripped of the `srcDir` and the `pages` folder, and it doesn't contain the extension. * diff --git a/packages/astro/src/types/public/index.ts b/packages/astro/src/types/public/index.ts index 03f2134b16ee..654ac53ef797 100644 --- a/packages/astro/src/types/public/index.ts +++ b/packages/astro/src/types/public/index.ts @@ -57,3 +57,8 @@ export type * from './manifest.js'; export type * from './preview.js'; export type * from './toolbar.js'; export type * from './view-transitions.js'; +export type { + AstroLoggerDestination, + AstroLoggerMessage, + AstroLoggerLevel, +} from '../../core/logger/core.js'; diff --git a/packages/astro/src/vite-plugin-app/app.ts b/packages/astro/src/vite-plugin-app/app.ts index ac958214e9d6..cd99578db0d4 100644 --- a/packages/astro/src/vite-plugin-app/app.ts +++ b/packages/astro/src/vite-plugin-app/app.ts @@ -28,7 +28,6 @@ import { req } from '../core/messages/runtime.js'; export class AstroServerApp extends BaseApp { settings: AstroSettings; - logger: AstroLogger; loader: ModuleLoader; manifestData: RoutesList; currentRenderContext: RenderContext | undefined = undefined; @@ -43,7 +42,6 @@ export class AstroServerApp extends BaseApp { ) { super(manifest, streaming, settings, logger, loader, manifestData, getDebugInfo); this.settings = settings; - this.logger = logger; this.loader = loader; this.manifestData = manifestData; } diff --git a/packages/astro/src/vite-plugin-app/createAstroServerApp.ts b/packages/astro/src/vite-plugin-app/createAstroServerApp.ts index 47811591cc15..29b6fa499429 100644 --- a/packages/astro/src/vite-plugin-app/createAstroServerApp.ts +++ b/packages/astro/src/vite-plugin-app/createAstroServerApp.ts @@ -11,12 +11,12 @@ import { PassthroughTextStyler } from '../cli/infra/passthrough-text-styler.js'; import { ProcessOperatingSystemProvider } from '../cli/infra/process-operating-system-provider.js'; import { TinyexecCommandExecutor } from '../cli/infra/tinyexec-command-executor.js'; import type { RouteInfo } from '../core/app/types.js'; -import { AstroLogger } from '../core/logger/core.js'; -import { nodeLogDestination } from '../core/logger/node.js'; +import type { AstroLogger } from '../core/logger/core.js'; import type { ModuleLoader } from '../core/module-loader/index.js'; import type { AstroSettings, RoutesList } from '../types/astro.js'; import type { DevServerController } from '../vite-plugin-astro-server/controller.js'; import { AstroServerApp } from './app.js'; +import { createNodeLoggerFromFlags } from '../core/logger/impls/node.js'; export default async function createAstroServerApp( controller: DevServerController, @@ -24,12 +24,8 @@ export default async function createAstroServerApp( loader: ModuleLoader, logger?: AstroLogger, ) { - const actualLogger = - logger ?? - new AstroLogger({ - destination: nodeLogDestination, - level: settings.logLevel, - }); + const actualLogger = logger ?? createNodeLoggerFromFlags({}); + const routesList: RoutesList = { routes: routes.map((r: RouteInfo) => r.routeData) }; const debugInfoProvider = new DevDebugInfoProvider({ diff --git a/packages/astro/src/vite-plugin-load-fallback/index.ts b/packages/astro/src/vite-plugin-load-fallback/index.ts index 89073dd7a584..95bf93d68724 100644 --- a/packages/astro/src/vite-plugin-load-fallback/index.ts +++ b/packages/astro/src/vite-plugin-load-fallback/index.ts @@ -13,16 +13,13 @@ interface LoadFallbackPluginParams { const FALLBACK_FLAG = 'astroFallbackFlag'; -export default function loadFallbackPlugin({ - fs, - root, -}: LoadFallbackPluginParams): vite.Plugin[] | false { +export default function loadFallbackPlugin({ fs, root }: LoadFallbackPluginParams): vite.Plugin[] { // Only add this plugin if a custom fs implementation is provided. // Also check for `fs.default` because `import * as fs from 'node:fs'` will // export as so, which only it's `.default` would === `nodeFs`. // @ts-expect-error check default if (!fs || fs === nodeFs || fs.default === nodeFs) { - return false; + return []; } const tryLoadModule = async (id: string) => { diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index 9874ab6528c3..5fc54734eb7d 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -80,7 +80,6 @@ export default function testAdapter({ createPipeline(streaming) { return AppPipeline.create({ manifest: this.manifest, - logger: this.logger, streaming }) } @@ -217,7 +216,6 @@ export function selfTestAdapter({ createPipeline(streaming) { return AppPipeline.create({ manifest: this.manifest, - logger: this.logger, streaming }) } diff --git a/packages/astro/test/units/app/logger.test.ts b/packages/astro/test/units/app/logger.test.ts new file mode 100644 index 000000000000..a485b79e8ff9 --- /dev/null +++ b/packages/astro/test/units/app/logger.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { describe, it, before } from 'node:test'; +import { App } from '../../../dist/core/app/app.js'; +import { createComponent, render } from '../../../dist/runtime/server/index.js'; +import { createManifest, createRouteInfo } from './test-helpers.ts'; +import { makeRoute, staticPart } from '../routing/test-helpers.ts'; +import { loadFixture, type Fixture } from '../../test-utils.js'; +import testAdapter from '../../test-adapter.js'; +import type { LoggerHandlerConfig } from '../../../dist/core/logger/config.js'; + +const okPage = createComponent(() => { + return render`

Ok

`; +}); + +const indexRoute = makeRoute({ + route: '/', + pathname: '/', + segments: [[staticPart('')]], + trailingSlash: 'ignore', + isIndex: true, + component: 'src/pages/index.astro', +}); + +const pageMap = new Map([ + [ + indexRoute.component, + async () => ({ + page: async () => ({ + default: okPage, + }), + }), + ], +]); + +function createAppWithLogger(experimentalLogger?: LoggerHandlerConfig) { + return new App( + createManifest({ + routes: [createRouteInfo(indexRoute)], + pageMap, + experimentalLogger, + }), + ); +} + +describe('SSR Logger', () => { + it('resolves a custom logger destination from the manifest on first request', async () => { + const app = createAppWithLogger({ entrypoint: 'astro/logger/json' }); + + await app.render(new Request('http://example.com/')); + + const destination = app.logger.options.destination; + assert.ok(destination, 'Logger destination should exist'); + assert.ok( + typeof destination.write === 'function', + 'Logger destination should have a write method', + ); + }); + + it('falls back to console logger when no custom logger is configured', async () => { + const app = createAppWithLogger(); + + const response = await app.render(new Request('http://example.com/')); + assert.equal(response.status, 200); + }); + + it('flush does not throw when destination has no flush method', async () => { + const app = createAppWithLogger({ entrypoint: 'astro/logger/json' }); + + // The json logger destination does not define flush/close. + // Verify that rendering (which calls flush internally) completes without error. + const response = await app.render(new Request('http://example.com/')); + assert.equal(response.status, 200); + + // Explicit flush should also be a safe no-op + assert.doesNotThrow(() => app.logger.flush()); + }); + + it('close does not throw when destination has no close method', async () => { + const app = createAppWithLogger({ entrypoint: 'astro/logger/json' }); + + await app.render(new Request('http://example.com/')); + + // Explicit close should be a safe no-op when the destination doesn't define it + assert.doesNotThrow(() => app.logger.close()); + }); + + describe('build', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-assets/', + outDir: './dist/ssr-logger/', + output: 'server', + adapter: testAdapter(), + build: { inlineStylesheets: 'never' }, + experimental: { + logger: { + entrypoint: 'astro/logger/json', + }, + }, + }); + await fixture.build(); + }); + + it('bundles the custom logger and resolves it at runtime', async () => { + const app = await fixture.loadTestAdapterApp(); + const response = await app.render(new Request('http://example.com/')); + assert.equal(response.status, 200); + + const destination = app.logger.options.destination; + assert.ok(destination, 'Logger destination should exist'); + assert.ok(destination.write, 'Logger destination should have a write method'); + }); + }); +}); diff --git a/packages/astro/test/units/app/test-helpers.ts b/packages/astro/test/units/app/test-helpers.ts index bb7b83c2f5b3..dada7dc815d1 100644 --- a/packages/astro/test/units/app/test-helpers.ts +++ b/packages/astro/test/units/app/test-helpers.ts @@ -4,6 +4,7 @@ import type { SSRManifestCSP, RouteInfo, } from '../../../dist/core/app/types.js'; +import type { LoggerHandlerConfig } from '../../../dist/core/logger/config.js'; import type { RouteData } from '../../../dist/types/public/internal.js'; export function createManifest({ @@ -12,23 +13,26 @@ export function createManifest({ base = '/', trailingSlash = 'ignore', middleware = undefined, + experimentalLogger = undefined, actions = undefined, - actionBodySizeLimit = 0, + actionBodySizeLimit = 1024 * 1024, i18n = undefined, csp = undefined, serverLike = true, + ...overrides }: { routes?: RouteInfo[]; pageMap?: SSRManifest['pageMap']; base?: string; trailingSlash?: 'always' | 'never' | 'ignore'; middleware?: SSRManifest['middleware']; + experimentalLogger?: LoggerHandlerConfig; actions?: SSRManifest['actions']; actionBodySizeLimit?: number; i18n?: SSRManifestI18n; csp?: SSRManifestCSP; serverLike?: boolean; -} = {}): SSRManifest { +} & Partial = {}): SSRManifest { const rootDir = new URL('file:///astro-test/'); const buildDir = new URL('file:///astro-test/dist/'); @@ -56,6 +60,7 @@ export function createManifest({ key: Promise.resolve({} as CryptoKey), i18n, middleware, + experimentalLogger, actions, sessionDriver: undefined, checkOrigin: false, @@ -85,6 +90,7 @@ export function createManifest({ experimentalQueuedRendering: { enabled: false, }, + ...overrides, } as SSRManifest; } diff --git a/packages/astro/test/units/assets/fonts/e2e.test.ts b/packages/astro/test/units/assets/fonts/e2e.test.ts index 6f2d272d7aeb..69143b618501 100644 --- a/packages/astro/test/units/assets/fonts/e2e.test.ts +++ b/packages/astro/test/units/assets/fonts/e2e.test.ts @@ -26,19 +26,15 @@ import { UnifontFontResolver } from '../../../../dist/assets/fonts/infra/unifont import { UnstorageFsStorage } from '../../../../dist/assets/fonts/infra/unstorage-fs-storage.js'; import { XxhashHasher } from '../../../../dist/assets/fonts/infra/xxhash-hasher.js'; import { fontProviders } from '../../../../dist/assets/fonts/providers/index.js'; +import { createNodeLoggerFromFlags } from '../../../../dist/core/logger/impls/node.js'; import type { FontFamily } from '../../../../dist/assets/fonts/types.js'; -import { AstroLogger } from '../../../../dist/core/logger/core.js'; -import { nodeLogDestination } from '../../../../dist/core/logger/node.js'; async function run({ fonts: _fonts }: { fonts: Array }) { const hasher = await XxhashHasher.create(); const resolvedFamilies = _fonts.map((family) => resolveFamily({ family, hasher })); const defaults = DEFAULTS; const { bold } = colors; - const logger = new AstroLogger({ - level: 'silent', - destination: nodeLogDestination, - }); + const logger = createNodeLoggerFromFlags({ logLevel: 'silent' }); const stringMatcher = new LevenshteinStringMatcher(); const base = new URL('./data/cache/', import.meta.url); // Clear cache diff --git a/packages/astro/test/units/cli/create-key.test.ts b/packages/astro/test/units/cli/create-key.test.ts index 7419bc50c732..09b83657de65 100644 --- a/packages/astro/test/units/cli/create-key.test.ts +++ b/packages/astro/test/units/cli/create-key.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { createKeyCommand } from '../../../dist/cli/create-key/core/create-key.js'; -import { FakeKeyGenerator, PassthroughCommandRunner, SpyLogger } from './utils.ts'; +import { SpyLogger } from '../test-utils.ts'; +import { FakeKeyGenerator, PassthroughCommandRunner } from './utils.ts'; describe('CLI create-key', () => { describe('core', () => { diff --git a/packages/astro/test/units/cli/docs.test.ts b/packages/astro/test/units/cli/docs.test.ts index 791b14906dff..8c03e865ed77 100644 --- a/packages/astro/test/units/cli/docs.test.ts +++ b/packages/astro/test/units/cli/docs.test.ts @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { openDocsCommand } from '../../../dist/cli/docs/core/open-docs.js'; import { ProcessCloudIdeProvider } from '../../../dist/cli/docs/infra/process-cloud-ide-provider.js'; +import { SpyLogger } from '../test-utils.ts'; import { - SpyLogger, FakeCloudIdeProvider, FakeOperatingSystemProvider, PassthroughCommandRunner, diff --git a/packages/astro/test/units/cli/index.test.ts b/packages/astro/test/units/cli/index.test.ts index 21b84aa71638..79d8449f8939 100644 --- a/packages/astro/test/units/cli/index.test.ts +++ b/packages/astro/test/units/cli/index.test.ts @@ -6,7 +6,8 @@ import { LoggerHelpDisplay } from '../../../dist/cli/infra/logger-help-display.j import { PassthroughTextStyler } from '../../../dist/cli/infra/passthrough-text-styler.js'; import { ProcessOperatingSystemProvider } from '../../../dist/cli/infra/process-operating-system-provider.js'; import packageJson from '../../../package.json' with { type: 'json' }; -import { FakeAstroVersionProvider, SpyHelpDisplay, SpyLogger } from './utils.ts'; +import { SpyLogger } from '../test-utils.ts'; +import { FakeAstroVersionProvider, SpyHelpDisplay } from './utils.ts'; describe('CLI shared', () => { describe('infra', () => { diff --git a/packages/astro/test/units/cli/info.test.ts b/packages/astro/test/units/cli/info.test.ts index dba95778748a..23f6c259b621 100644 --- a/packages/astro/test/units/cli/info.test.ts +++ b/packages/astro/test/units/cli/info.test.ts @@ -7,8 +7,8 @@ import { DevDebugInfoProvider } from '../../../dist/cli/info/infra/dev-debug-inf import { ProcessNodeVersionProvider } from '../../../dist/cli/info/infra/process-node-version-provider.js'; import { ProcessPackageManagerUserAgentProvider } from '../../../dist/cli/info/infra/process-package-manager-user-agent-provider.js'; import { TinyclipClipboard } from '../../../dist/cli/info/infra/tinyclip-clipboard.js'; +import { SpyLogger } from '../test-utils.ts'; import { - SpyLogger, FakeAstroVersionProvider, FakeDebugInfoProvider, FakeNodeVersionProvider, diff --git a/packages/astro/test/units/cli/utils.ts b/packages/astro/test/units/cli/utils.ts index 201d4a0301fb..3370938c5913 100644 --- a/packages/astro/test/units/cli/utils.ts +++ b/packages/astro/test/units/cli/utils.ts @@ -1,5 +1,3 @@ -import { AstroIntegrationLogger } from '../../../dist/core/logger/core.js'; -import type { AstroLogOptions } from '../../../dist/core/logger/core.js'; import type { CloudIde } from '../../../dist/cli/docs/domain/cloud-ide.js'; import type { CloudIdeProvider } from '../../../dist/cli/docs/definitions.js'; import type { AnyCommand } from '../../../dist/cli/domain/command.js'; @@ -184,43 +182,6 @@ export class FakePrompt implements Prompt { } } -export class SpyLogger { - readonly #logs: Array<{ type: string; label: string | null; message: string }> = []; - - get logs(): Array<{ type: string; label: string | null; message: string }> { - return this.#logs; - } - - debug(label: string, ...messages: string[]): void { - this.#logs.push(...messages.map((message) => ({ type: 'debug', label, message }))); - } - - error(label: string | null, message: string): void { - this.#logs.push({ type: 'error', label, message }); - } - - info(label: string | null, message: string): void { - this.#logs.push({ type: 'info', label, message }); - } - - warn(label: string | null, message: string): void { - this.#logs.push({ type: 'warn', label, message }); - } - - options: AstroLogOptions = { - destination: { write: () => true }, - level: 'silent', - }; - - level(): 'silent' { - return this.options.level as 'silent'; - } - - forkIntegrationLogger(label: string): AstroIntegrationLogger { - return new AstroIntegrationLogger(this.options, label); - } -} - export class FakeNodeVersionProvider implements NodeVersionProvider { readonly #version: string; diff --git a/packages/astro/test/units/compile/css-base-path.test.ts b/packages/astro/test/units/compile/css-base-path.test.ts index 5cb41c56d66f..4de937b8fd1f 100644 --- a/packages/astro/test/units/compile/css-base-path.test.ts +++ b/packages/astro/test/units/compile/css-base-path.test.ts @@ -5,10 +5,9 @@ import { resolveConfig } from 'vite'; import { compileAstro } from '../../../dist/vite-plugin-astro/compile.js'; import type { AstroConfig } from '../../../dist/types/public/config.js'; import type { CompileProps } from '../../../dist/core/compile/compile.js'; -import { AstroLogger } from '../../../dist/core/logger/core.js'; -import { nodeLogDestination } from '../../../dist/core/logger/node.js'; +import { createNodeLoggerFromFlags } from '../../../dist/core/logger/impls/node.js'; -const logger = new AstroLogger({ destination: nodeLogDestination, level: 'silent' }); +const logger = createNodeLoggerFromFlags({ logLevel: 'silent' }); /** Compile Astro source with a given base path. */ async function compileWithBase(source: string, base = '/') { diff --git a/packages/astro/test/units/logger/destination.test.ts b/packages/astro/test/units/logger/destination.test.ts index f5bd057bbfc8..5f09a9c1fb09 100644 --- a/packages/astro/test/units/logger/destination.test.ts +++ b/packages/astro/test/units/logger/destination.test.ts @@ -1,38 +1,26 @@ import * as assert from 'node:assert/strict'; -import { beforeEach, describe, it } from 'node:test'; -import type { AstroLogMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import type { AstroLoggerMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; import { AstroLogger } from '../../../dist/core/logger/core.js'; +import jsonFactory, { SGR_REGEX } from '../../../dist/core/logger/impls/json.js'; -let logs: AstroLogMessage[] = []; -let jsonLogs: string[] = []; +let logs: AstroLoggerMessage[] = []; -const testDestination: AstroLoggerDestination = { - write(event: AstroLogMessage) { +const testDestination: AstroLoggerDestination = { + write(event: AstroLoggerMessage) { logs.push(event); - return true; - }, -}; - -const jsonDestination: AstroLoggerDestination = { - write(event: AstroLogMessage) { - if ((event as any)._format === 'json') { - jsonLogs.push(JSON.stringify({ message: event.message, label: event.label })); - } - return true; }, }; describe('log destination', () => { beforeEach(() => { logs = []; - jsonLogs = []; }); describe('event shape', () => { const logger = new AstroLogger({ destination: testDestination, level: 'info', - _format: 'default', }); it('info() pushes an event with level info', () => { @@ -70,68 +58,11 @@ describe('log destination', () => { }); }); - describe('format propagation', () => { - it('propagates default format to events', () => { - const logger = new AstroLogger({ - destination: testDestination, - level: 'info', - _format: 'default', - }); - logger.info('build', 'test'); - assert.equal((logs[0] as any)._format, 'default'); - }); - - it('propagates json format to events', () => { - const logger = new AstroLogger({ - destination: testDestination, - level: 'info', - _format: 'json', - }); - logger.info('build', 'test'); - assert.equal((logs[0] as any)._format, 'json'); - }); - }); - - describe('json formatting', () => { - const logger = new AstroLogger({ - destination: jsonDestination, - level: 'info', - _format: 'json', - }); - - it('serializes message and label as JSON', () => { - logger.info('build', 'compiled successfully'); - assert.equal(jsonLogs.length, 1); - assert.equal(jsonLogs[0], '{"message":"compiled successfully","label":"build"}'); - }); - - it('serializes null label', () => { - logger.info(null, 'no label message'); - assert.equal(jsonLogs[0], '{"message":"no label message","label":null}'); - }); - - it('only includes message and label', () => { - logger.warn('build', 'a warning'); - assert.equal(jsonLogs[0], '{"message":"a warning","label":"build"}'); - }); - - it('does not write when format is not json', () => { - const defaultLogger = new AstroLogger({ - destination: jsonDestination, - level: 'info', - _format: 'default', - }); - defaultLogger.info('build', 'should not appear'); - assert.equal(jsonLogs.length, 0); - }); - }); - describe('level filtering', () => { it('filters out info when level is warn', () => { const logger = new AstroLogger({ destination: testDestination, level: 'warn', - _format: 'default', }); logger.info('build', 'should be filtered'); assert.equal(logs.length, 0); @@ -141,7 +72,6 @@ describe('log destination', () => { const logger = new AstroLogger({ destination: testDestination, level: 'warn', - _format: 'default', }); logger.warn('build', 'should pass'); assert.equal(logs.length, 1); @@ -151,7 +81,6 @@ describe('log destination', () => { const logger = new AstroLogger({ destination: testDestination, level: 'warn', - _format: 'default', }); logger.error('build', 'should pass'); assert.equal(logs.length, 1); @@ -161,7 +90,6 @@ describe('log destination', () => { const logger = new AstroLogger({ destination: testDestination, level: 'silent', - _format: 'default', }); logger.info('build', 'nope'); logger.warn('build', 'nope'); @@ -170,3 +98,142 @@ describe('log destination', () => { }); }); }); + +describe('SGR_REGEX', () => { + it('strips a single SGR sequence', () => { + assert.equal('hello'.replace(SGR_REGEX, ''), 'hello'); + assert.equal('\x1b[31mhello\x1b[0m'.replace(SGR_REGEX, ''), 'hello'); + }); + + it('strips bold, dim, italic, and other multi-param sequences', () => { + assert.equal('\x1b[1;31mbold red\x1b[0m'.replace(SGR_REGEX, ''), 'bold red'); + assert.equal('\x1b[2mfaded\x1b[22m'.replace(SGR_REGEX, ''), 'faded'); + }); + + it('strips reset-only sequence', () => { + assert.equal('\x1b[0m'.replace(SGR_REGEX, ''), ''); + assert.equal('\x1b[m'.replace(SGR_REGEX, ''), ''); + }); + + it('strips multiple SGR sequences in one string', () => { + const input = '\x1b[32mgreen\x1b[39m and \x1b[34mblue\x1b[39m'; + assert.equal(input.replace(SGR_REGEX, ''), 'green and blue'); + }); + + it('leaves plain text untouched', () => { + assert.equal('no codes here'.replace(SGR_REGEX, ''), 'no codes here'); + assert.equal(''.replace(SGR_REGEX, ''), ''); + }); + + it('does not strip non-SGR escape sequences', () => { + // Cursor movement (CSI H) is not an SGR (does not end with 'm') + const cursorMove = '\x1b[2J'; + assert.equal(cursorMove.replace(SGR_REGEX, ''), cursorMove); + }); + + it('is stateless across calls (global flag resets lastIndex)', () => { + SGR_REGEX.lastIndex = 0; + const a = '\x1b[31mred\x1b[0m'.replace(SGR_REGEX, ''); + const b = '\x1b[32mgreen\x1b[0m'.replace(SGR_REGEX, ''); + assert.equal(a, 'red'); + assert.equal(b, 'green'); + }); +}); + +describe('json handler', () => { + let stdoutWrites: string[]; + let stderrWrites: string[]; + let originalStdoutWrite: typeof process.stdout.write; + let originalStderrWrite: typeof process.stderr.write; + + beforeEach(() => { + stdoutWrites = []; + stderrWrites = []; + originalStdoutWrite = process.stdout.write; + originalStderrWrite = process.stderr.write; + process.stdout.write = ((chunk: string) => { + stdoutWrites.push(chunk); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string) => { + stderrWrites.push(chunk); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + }); + + describe('output format', () => { + const destination = jsonFactory({ pretty: false }); + const logger = new AstroLogger({ + destination, + level: 'info', + }); + + it('writes JSON with message and label', () => { + logger.info('build', 'compiled successfully'); + assert.equal(stdoutWrites.length, 1); + assert.equal(stdoutWrites[0], '{"message":"compiled successfully","label":"build","level":"info"}\n'); + }); + + it('writes JSON with null label', () => { + logger.info(null, 'no label message'); + assert.equal(stdoutWrites[0], '{"message":"no label message","label":null,"level":"info"}\n'); + }); + + it('includes message, label and level in output', () => { + logger.warn('build', 'a warning'); + assert.equal(stdoutWrites[0], '{"message":"a warning","label":"build","level":"warn"}\n'); + }); + + it('strips ANSI codes from messages', () => { + logger.info('build', '\x1b[32mgreen text\x1b[39m'); + assert.equal(stdoutWrites[0], '{"message":"green text","label":"build","level":"info"}\n'); + }); + }); + + describe('pretty mode', () => { + const destination = jsonFactory({ pretty: true }); + const logger = new AstroLogger({ + destination, + level: 'info', + }); + + it('writes indented JSON when pretty is true', () => { + logger.info('build', 'test'); + const parsed = JSON.parse(stdoutWrites[0]); + assert.equal(parsed.message, 'test'); + assert.equal(parsed.label, 'build'); + assert.ok(stdoutWrites[0].includes('\n '), 'output should be indented'); + }); + }); + + describe('stream routing', () => { + const destination = jsonFactory({ pretty: false }); + const logger = new AstroLogger({ + destination, + level: 'info', + }); + + it('routes info to stdout', () => { + logger.info('build', 'test'); + assert.equal(stdoutWrites.length, 1); + assert.equal(stderrWrites.length, 0); + }); + + it('routes warn to stdout', () => { + logger.warn('build', 'test'); + assert.equal(stdoutWrites.length, 1); + assert.equal(stderrWrites.length, 0); + }); + + it('routes error to stderr', () => { + logger.error('build', 'test'); + assert.equal(stdoutWrites.length, 0); + assert.equal(stderrWrites.length, 1); + }); + }); +}); diff --git a/packages/astro/test/units/logger/logger.test.ts b/packages/astro/test/units/logger/logger.test.ts new file mode 100644 index 000000000000..c546aa956949 --- /dev/null +++ b/packages/astro/test/units/logger/logger.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { AstroLoggerMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; + +describe('AstroLogger', () => { + function createSpyDestination() { + const calls: { method: string }[] = []; + const destination: AstroLoggerDestination = { + write: () => {}, + flush: () => { + calls.push({ method: 'flush' }); + }, + close: () => { + calls.push({ method: 'close' }); + }, + }; + return { destination, calls }; + } + + describe('flush', () => { + it('calls destination.flush when present', () => { + const { destination, calls } = createSpyDestination(); + const logger = new AstroLogger({ destination, level: 'info' }); + + logger.flush(); + + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'flush'); + }); + + it('does not throw when destination has no flush', () => { + const destination: AstroLoggerDestination = { + write: () => {}, + }; + const logger = new AstroLogger({ destination, level: 'info' }); + + assert.doesNotThrow(() => logger.flush()); + }); + }); + + describe('close', () => { + it('calls destination.close when present', () => { + const { destination, calls } = createSpyDestination(); + const logger = new AstroLogger({ destination, level: 'info' }); + + logger.close(); + + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'close'); + }); + + it('does not throw when destination has no close', () => { + const destination: AstroLoggerDestination = { + write: () => {}, + }; + const logger = new AstroLogger({ destination, level: 'info' }); + + assert.doesNotThrow(() => logger.close()); + }); + }); + + describe('setDestination', () => { + it('replaces the destination', () => { + const writes: string[] = []; + const originalDestination: AstroLoggerDestination = { + write: (msg) => { + writes.push('original:' + msg.message); + }, + }; + const newDestination: AstroLoggerDestination = { + write: (msg) => { + writes.push('new:' + msg.message); + }, + }; + const logger = new AstroLogger({ destination: originalDestination, level: 'info' }); + + logger.info(null, 'before'); + logger.setDestination(newDestination); + logger.info(null, 'after'); + + assert.equal(writes.length, 2); + assert.match(writes[0], /^original:/); + assert.match(writes[1], /^new:/); + }); + }); +}); diff --git a/packages/astro/test/units/logger/matches-level.test.ts b/packages/astro/test/units/logger/matches-level.test.ts new file mode 100644 index 000000000000..6a2536bae9fe --- /dev/null +++ b/packages/astro/test/units/logger/matches-level.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { matchesLevel } from '../../../dist/core/logger/public.js'; + +describe('matchesLevel', () => { + it('returns true when message level equals configured level', () => { + assert.equal(matchesLevel('info', 'info'), true); + assert.equal(matchesLevel('warn', 'warn'), true); + assert.equal(matchesLevel('error', 'error'), true); + assert.equal(matchesLevel('debug', 'debug'), true); + assert.equal(matchesLevel('silent', 'silent'), true); + }); + + it('returns true when message level is higher than configured level', () => { + assert.equal(matchesLevel('error', 'info'), true); + assert.equal(matchesLevel('warn', 'info'), true); + assert.equal(matchesLevel('error', 'warn'), true); + assert.equal(matchesLevel('silent', 'debug'), true); + }); + + it('returns false when message level is lower than configured level', () => { + assert.equal(matchesLevel('info', 'warn'), false); + assert.equal(matchesLevel('info', 'error'), false); + assert.equal(matchesLevel('warn', 'error'), false); + assert.equal(matchesLevel('debug', 'info'), false); + }); + + it('debug configured level allows all non-silent levels', () => { + assert.equal(matchesLevel('debug', 'debug'), true); + assert.equal(matchesLevel('info', 'debug'), true); + assert.equal(matchesLevel('warn', 'debug'), true); + assert.equal(matchesLevel('error', 'debug'), true); + }); + + it('silent configured level only matches silent', () => { + assert.equal(matchesLevel('debug', 'silent'), false); + assert.equal(matchesLevel('info', 'silent'), false); + assert.equal(matchesLevel('warn', 'silent'), false); + assert.equal(matchesLevel('error', 'silent'), false); + assert.equal(matchesLevel('silent', 'silent'), true); + }); + + it('error message level is only suppressed by silent', () => { + assert.equal(matchesLevel('error', 'debug'), true); + assert.equal(matchesLevel('error', 'info'), true); + assert.equal(matchesLevel('error', 'warn'), true); + assert.equal(matchesLevel('error', 'error'), true); + assert.equal(matchesLevel('error', 'silent'), false); + }); +}); diff --git a/packages/astro/test/units/mocks.ts b/packages/astro/test/units/mocks.ts index 870e022a2a5b..0a2a763dc6bf 100644 --- a/packages/astro/test/units/mocks.ts +++ b/packages/astro/test/units/mocks.ts @@ -1,7 +1,8 @@ -import { createBasicPipeline } from './test-utils.ts'; +import { createBasicPipeline, type SpyLogger } from './test-utils.ts'; import { makeRoute, staticPart } from './routing/test-helpers.ts'; import { AstroCookies } from '../../dist/core/cookies/index.js'; import { App } from '../../dist/core/app/app.js'; +import { RenderContext } from '../../dist/core/render-context.js'; import { baseService } from '../../dist/assets/services/service.js'; import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; import { @@ -11,7 +12,7 @@ import { spreadAttributes, } from '../../dist/runtime/server/index.js'; import { createManifest, createRouteInfo } from './app/test-helpers.ts'; - +import type { AstroLogger } from '../../dist/core/logger/core.js'; import type { Pipeline } from '../../dist/core/render/index.js'; import type { RouteData, RoutePart, RouteType } from '../../dist/types/public/internal.js'; import type { APIContext } from '../../dist/types/public/context.js'; @@ -27,7 +28,7 @@ import type { ImageTransform } from '../../dist/assets/types.js'; * in their respective directories. */ -interface MockRenderContextOverrides { +interface LightMockRenderContextOverrides { request?: Request; routeData?: Partial; params?: Record; @@ -36,21 +37,16 @@ interface MockRenderContextOverrides { } /** - * Creates a minimal RenderContext mock for unit testing redirect functions. + * Creates a lightweight RenderContext-shaped plain object. * - * This is a lightweight mock that provides only what renderRedirect() needs, - * without the overhead of creating a full RenderContext instance. + * Use this when the code under test only reads a few fields + * (e.g. `renderRedirect` which only needs `request`, `routeData`, + * `params` and `pipeline`). */ -export function createMockRenderContext(overrides: MockRenderContextOverrides = {}) { +export function createLightRenderContext(overrides: LightMockRenderContextOverrides = {}) { const pipeline = overrides.pipeline || - createBasicPipeline({ - manifest: { - rootDir: new URL(import.meta.url), - experimentalQueuedRendering: { enabled: true }, - trailingSlash: 'never', - } as unknown as SSRManifest, - }); + createBasicPipeline({ manifest: { trailingSlash: 'never' } }); return { request: overrides.request || new Request('http://localhost/'), @@ -61,6 +57,54 @@ export function createMockRenderContext(overrides: MockRenderContextOverrides = }; } +interface MockRenderContextOverrides { + request?: Request; + route?: string; + routeData?: Partial; + pipeline?: Pipeline; + logger?: AstroLogger | SpyLogger; + manifest?: Partial; + status?: number; + skipMiddleware?: boolean; + clientAddress?: string; +} + +/** + * Creates a real `RenderContext` instance for unit testing. + * + * Pass `logger` and/or `manifest` to customise the pipeline without + * having to build one manually. When a full `pipeline` override is + * provided, `logger` and `manifest` are ignored. + * + * Uses `createRouteData` internally so a simple `route: '/'` string + * is enough — no need to construct a full `RouteData` object. + */ +export async function createMockRenderContext( + overrides: MockRenderContextOverrides = {}, +): Promise { + const pipeline = + overrides.pipeline || + createBasicPipeline({ + logger: overrides.logger, + manifest: { trailingSlash: 'never', ...overrides.manifest }, + }); + + const routeData = createRouteData({ + route: overrides.route ?? '/', + ...overrides.routeData, + }); + + return RenderContext.create({ + pipeline, + request: overrides.request || new Request('http://localhost/'), + routeData, + pathname: routeData.pathname ?? '/', + clientAddress: overrides.clientAddress, + status: overrides.status, + skipMiddleware: overrides.skipMiddleware, + }); +} + interface MockAPIContextOverrides extends Partial> { url?: string | URL; } diff --git a/packages/astro/test/units/redirects/render.test.ts b/packages/astro/test/units/redirects/render.test.ts index 6ef6eff75c75..1d24932206f4 100644 --- a/packages/astro/test/units/redirects/render.test.ts +++ b/packages/astro/test/units/redirects/render.test.ts @@ -6,7 +6,7 @@ import { renderRedirect, resolveRedirectTarget, } from '../../../dist/core/redirects/render.js'; -import { createMockRenderContext } from '../mocks.ts'; +import { createLightRenderContext } from '../mocks.ts'; import type { RenderContext } from '../../../dist/core/render-context.js'; import type { RouteData } from '../../../dist/types/public/internal.js'; @@ -43,7 +43,7 @@ describe('redirects/render', () => { describe('renderRedirect', () => { it('returns 301 for GET requests', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ request: new Request('http://localhost/source'), routeData: { type: 'redirect', @@ -58,7 +58,7 @@ describe('redirects/render', () => { }); it('returns 308 for non-GET requests', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ request: new Request('http://localhost/source', { method: 'POST' }), routeData: { type: 'redirect', @@ -73,7 +73,7 @@ describe('redirects/render', () => { }); it('handles redirect object with custom status', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ routeData: { type: 'redirect', redirect: { destination: '/target', status: 302 }, @@ -89,7 +89,7 @@ describe('redirects/render', () => { }); it('encodes URIs properly', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ routeData: { type: 'redirect', redirect: '/target with spaces', @@ -102,7 +102,7 @@ describe('redirects/render', () => { }); it('handles external redirects', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ routeData: { type: 'redirect', redirect: 'https://example.com', @@ -117,7 +117,7 @@ describe('redirects/render', () => { }); it('substitutes single dynamic parameter', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ routeData: { type: 'redirect', redirect: '/articles/[slug]', @@ -131,7 +131,7 @@ describe('redirects/render', () => { }); it('substitutes multiple dynamic parameters', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ routeData: { type: 'redirect', redirect: '/new/[param1]/[param2]', @@ -145,7 +145,7 @@ describe('redirects/render', () => { }); it('substitutes spread parameters', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ routeData: { type: 'redirect', redirect: '/new/[...rest]', @@ -159,7 +159,7 @@ describe('redirects/render', () => { }); it('encodes special characters in parameters', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ routeData: { type: 'redirect', redirect: '/new/[city]', @@ -173,7 +173,7 @@ describe('redirects/render', () => { }); it('uses redirectRoute when available', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ routeData: { type: 'redirect', redirect: '/not-used', @@ -190,7 +190,7 @@ describe('redirects/render', () => { }); it('falls back to "/" when no redirect is defined', async () => { - const renderContext = createMockRenderContext({ + const renderContext = createLightRenderContext({ routeData: { type: 'redirect', redirect: undefined, diff --git a/packages/astro/test/units/render/render-context.test.ts b/packages/astro/test/units/render/render-context.test.ts index 14baf931241e..31db88c7bde2 100644 --- a/packages/astro/test/units/render/render-context.test.ts +++ b/packages/astro/test/units/render/render-context.test.ts @@ -3,7 +3,8 @@ import { describe, it } from 'node:test'; import { RenderContext } from '../../../dist/core/render-context.js'; import { createComponent, maybeRenderHead, render } from '../../../dist/runtime/server/index.js'; import type { AstroComponentFactory } from '../../../dist/runtime/server/render/index.js'; -import { createBasicPipeline } from '../test-utils.ts'; +import { createBasicPipeline, SpyLogger } from '../test-utils.ts'; +import { createMockRenderContext } from '../mocks.ts'; const createAstroModule = (AstroComponent: AstroComponentFactory) => ({ default: AstroComponent }); @@ -128,4 +129,111 @@ describe('RenderContext', () => { ); }); }); + + describe('context.logger (APIContext)', () => { + it('warns when context.logger is accessed without experimentalLogger enabled', async () => { + const spyLogger = new SpyLogger(); + const renderContext = await createMockRenderContext({ logger: spyLogger }); + + renderContext.createActionAPIContext().logger; + + assert.equal(spyLogger.writeCount(), 1); + assert.equal(spyLogger.logs[0].type, 'warn'); + assert.match(spyLogger.logs[0].message, /experimental\.logger/i); + }); + + it('provides info/warn/error methods when experimentalLogger is enabled', async () => { + const spyLogger = new SpyLogger(); + const renderContext = await createMockRenderContext({ + logger: spyLogger, + manifest: { + experimentalLogger: { + entrypoint: 'astro/logger/node', + }, + }, + }); + + const { logger } = renderContext.createActionAPIContext(); + assert.ok(logger); + assert.equal(typeof logger.info, 'function'); + assert.equal(typeof logger.warn, 'function'); + assert.equal(typeof logger.error, 'function'); + }); + + it('context.logger delegates to the pipeline logger', async () => { + const spyLogger = new SpyLogger(); + const renderContext = await createMockRenderContext({ + logger: spyLogger, + manifest: { + experimentalLogger: { + entrypoint: 'astro/logger/node', + }, + }, + }); + + const ctx = renderContext.createActionAPIContext(); + ctx.logger!.info('info message'); + ctx.logger!.warn('warn message'); + ctx.logger!.error('error message'); + + assert.equal(spyLogger.writeCount(), 3); + assert.deepStrictEqual(spyLogger.logs, [ + { type: 'info', label: null, message: 'info message' }, + { type: 'warn', label: null, message: 'warn message' }, + { type: 'error', label: null, message: 'error message' }, + ]); + }); + }); + + describe('Astro.logger (page rendering)', () => { + it('Astro.logger is always available on the page global', async () => { + const spyLogger = new SpyLogger(); + const renderContext = await createMockRenderContext({ logger: spyLogger }); + + const LoggingPage = createComponent((result: any, _props: any, _slots: any) => { + const Astro = result.createAstro({}, null); + Astro.logger.info('page info'); + Astro.logger.warn('page warn'); + Astro.logger.error('page error'); + return render`${maybeRenderHead(result)}

Logged

`; + }); + + const response = await renderContext.render(createAstroModule(LoggingPage)); + assert.equal(response.status, 200); + + const userLogs = spyLogger.logs.filter((l) => l.label === null); + assert.equal(userLogs.length, 3); + assert.deepStrictEqual(userLogs, [ + { type: 'info', label: null, message: 'page info' }, + { type: 'warn', label: null, message: 'page warn' }, + { type: 'error', label: null, message: 'page error' }, + ]); + }); + + it('Astro.logger delegates to the pipeline logger', async () => { + const spyLogger = new SpyLogger(); + const renderContext = await createMockRenderContext({ + logger: spyLogger, + manifest: { + experimentalLogger: { + entrypoint: 'astro/logger/node', + }, + }, + }); + + const LoggingPage = createComponent((result: any, _props: any, _slots: any) => { + const Astro = result.createAstro({}, null); + Astro.logger.info('hello from page'); + return render`${maybeRenderHead(result)}

OK

`; + }); + + const response = await renderContext.render(createAstroModule(LoggingPage)); + assert.equal(response.status, 200); + + const userLogs = spyLogger.logs.filter((l) => l.label === null); + assert.equal(userLogs.length, 1); + assert.equal(userLogs[0].type, 'info'); + assert.equal(userLogs[0].message, 'hello from page'); + }); + }); }); diff --git a/packages/astro/test/units/routing/getstaticpaths-cache.test.ts b/packages/astro/test/units/routing/getstaticpaths-cache.test.ts index b19befb175c0..ceabf030916a 100644 --- a/packages/astro/test/units/routing/getstaticpaths-cache.test.ts +++ b/packages/astro/test/units/routing/getstaticpaths-cache.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it, before, beforeEach } from 'node:test'; import type { ComponentInstance } from '../../../dist/types/astro.js'; -import type { AstroLogMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; +import type { AstroLoggerMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; import { AstroLogger } from '../../../dist/core/logger/core.js'; import { RouteCache, callGetStaticPaths } from '../../../dist/core/render/route-cache.js'; import { dynamicPart, makeRoute } from './test-helpers.ts'; @@ -15,7 +15,7 @@ describe('getStaticPaths caching behavior', () => { let logger: AstroLogger; let callCount: number; - const destination: AstroLoggerDestination = { + const destination: AstroLoggerDestination = { write: () => true, }; diff --git a/packages/astro/test/units/routing/manifest.test.ts b/packages/astro/test/units/routing/manifest.test.ts index 8899c2555e7d..454cf2d6ec5b 100644 --- a/packages/astro/test/units/routing/manifest.test.ts +++ b/packages/astro/test/units/routing/manifest.test.ts @@ -1,6 +1,6 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import type { AstroLogMessage } from '../../../dist/core/logger/core.js'; +import type { AstroLoggerMessage } from '../../../dist/core/logger/core.js'; import { AstroLogger } from '../../../dist/core/logger/core.js'; import { createRoutesList } from '../../../dist/core/routing/create-manifest.js'; import type { RouteData } from '../../../dist/types/public/internal.js'; @@ -14,12 +14,12 @@ function getManifestRoutes(manifest: { routes: RouteData[] }) { } function getLogger() { - const logs: AstroLogMessage[] = []; + const logs: AstroLoggerMessage[] = []; return { logger: new AstroLogger({ destination: { - write(msg: AstroLogMessage) { + write(msg: AstroLoggerMessage) { logs.push(msg); return true; }, @@ -396,7 +396,6 @@ describe('routing - createRoutesList', () => { assert.deepEqual(logs, [ { - _format: 'default', label: 'router', level: 'warn', message: @@ -404,7 +403,6 @@ describe('routing - createRoutesList', () => { newLine: true, }, { - _format: 'default', label: 'router', level: 'warn', message: 'A collision will result in a hard error in following versions of Astro.', @@ -437,7 +435,6 @@ describe('routing - createRoutesList', () => { assert.deepEqual(logs, [ { - _format: 'default', label: 'router', level: 'warn', message: @@ -445,7 +442,6 @@ describe('routing - createRoutesList', () => { newLine: true, }, { - _format: 'default', label: 'router', level: 'warn', message: 'A collision will result in a hard error in following versions of Astro.', diff --git a/packages/astro/test/units/routing/params-validation.test.ts b/packages/astro/test/units/routing/params-validation.test.ts index 1257ce1e7cb9..db3bb579f21a 100644 --- a/packages/astro/test/units/routing/params-validation.test.ts +++ b/packages/astro/test/units/routing/params-validation.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it, before } from 'node:test'; import type { ComponentInstance } from '../../../dist/types/astro.js'; -import type { AstroLogMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; +import type { AstroLoggerMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; import { AstroLogger } from '../../../dist/core/logger/core.js'; import { RouteCache, callGetStaticPaths } from '../../../dist/core/render/route-cache.js'; import { makeRoute } from './test-helpers.ts'; @@ -14,7 +14,7 @@ describe('getStaticPaths param validation', () => { let routeCache: RouteCache; let logger: AstroLogger; - const destination: AstroLoggerDestination = { + const destination: AstroLoggerDestination = { write: () => true, }; diff --git a/packages/astro/test/units/routing/route-manifest.test.ts b/packages/astro/test/units/routing/route-manifest.test.ts index 75896bf2606e..d9e629783e48 100644 --- a/packages/astro/test/units/routing/route-manifest.test.ts +++ b/packages/astro/test/units/routing/route-manifest.test.ts @@ -2,11 +2,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { AstroConfig } from '../../../dist/types/public/config.js'; import type { RouteData } from '../../../dist/types/public/internal.js'; -import type { AstroLogMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; +import type { AstroLoggerMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; import { AstroLogger } from '../../../dist/core/logger/core.js'; import { createRoutesFromEntries } from '../../../dist/core/routing/create-manifest.js'; -const destination: AstroLoggerDestination = { write: () => true }; +const destination: AstroLoggerDestination = { write: () => true }; const logger = new AstroLogger({ destination, level: 'silent' }); type RoutingSettings = Parameters[1]; diff --git a/packages/astro/test/units/test-utils.ts b/packages/astro/test/units/test-utils.ts index 11cc086b6ae3..f660f8730e1d 100644 --- a/packages/astro/test/units/test-utils.ts +++ b/packages/astro/test/units/test-utils.ts @@ -7,7 +7,7 @@ import { getDefaultClientDirectives } from '../../dist/core/client-directive/ind import { resolveConfig } from '../../dist/core/config/index.js'; import { createBaseSettings } from '../../dist/core/config/settings.js'; import { AstroIntegrationLogger, AstroLogger } from '../../dist/core/logger/core.js'; -import { nodeLogDestination } from '../../dist/core/logger/node.js'; +import nodeLoggerFactory from '../../dist/core/logger/impls/node.js'; import { NOOP_MIDDLEWARE_FN } from '../../dist/core/middleware/noop-middleware.js'; import { Pipeline } from '../../dist/core/render/index.js'; import { RouteCache } from '../../dist/core/render/route-cache.js'; @@ -19,11 +19,13 @@ import type { RouteData, SSRLoadedRenderer, SSRResult } from '../../dist/types/p import type { HeadElements, TryRewriteResult } from '../../dist/core/base-pipeline.js'; import type { ComponentInstance } from '../../dist/types/astro.js'; import type { RewritePayload, MiddlewareHandler } from '../../dist/types/public/common.js'; +import type { AstroLoggerDestination } from '../../dist/core/logger/core.js'; +import { createManifest } from './app/test-helpers.ts'; export type { AstroSettings }; export const defaultLogger: AstroLogger = new AstroLogger({ - destination: nodeLogDestination, + destination: nodeLoggerFactory(), level: 'error', }); @@ -147,8 +149,8 @@ class TestPipeline extends Pipeline { */ export function createBasicPipeline( options: { - logger?: AstroLogger; - manifest?: SSRManifest; + logger?: AstroLogger | SpyLogger; + manifest?: Partial; mode?: RuntimeMode; renderers?: SSRLoadedRenderer[]; resolve?: (s: string) => Promise; @@ -166,13 +168,8 @@ export function createBasicPipeline( ): Pipeline { const mode = options.mode ?? 'development'; return new TestPipeline( - options.logger ?? defaultLogger, - (options.manifest ?? { - rootDir: import.meta.url, - experimentalQueuedRendering: { - enabled: true, - }, - }) as SSRManifest, + (options.logger ?? defaultLogger) as AstroLogger, + createManifest(options.manifest ?? {}), options.mode ?? 'development', options.renderers ?? [], options.resolve ?? ((s: string) => Promise.resolve(s)), @@ -204,28 +201,40 @@ interface LogEntry { message: string; } +const destination: AstroLoggerDestination = { + write: () => true as const, + flush: () => {}, + close: () => {}, +}; + export class SpyLogger { #logs: LogEntry[] = []; + #writeCount = 0; + #flushCount = 0; + #closeCount = 0; + get logs() { return this.#logs; } debug(label: string | null, ...messages: string[]) { this.#logs.push(...messages.map((message) => ({ type: 'debug', label, message }))); + this.#writeCount += messages.length; } error(label: string | null, message: string) { this.#logs.push({ type: 'error', label, message }); + this.#writeCount++; } info(label: string | null, message: string) { this.#logs.push({ type: 'info', label, message }); + this.#writeCount++; } warn(label: string | null, message: string) { this.#logs.push({ type: 'warn', label, message }); + this.#writeCount++; } options = { - destination: { - write: () => true as const, - }, + destination, level: 'silent' as AstroLoggerLevel, }; level() { @@ -234,6 +243,35 @@ export class SpyLogger { forkIntegrationLogger(label: string) { return new AstroIntegrationLogger(this.options, label); } + flush() { + this.#flushCount++; + if (this.options.destination.flush) { + this.options.destination.flush(); + } + } + + close() { + this.#closeCount++; + if (this.options.destination.close) { + this.options.destination.close(); + } + } + + writeCount() { + return this.#writeCount; + } + + flushCount() { + return this.#flushCount; + } + + closeCount() { + return this.#closeCount; + } + + setDestination(dest: AstroLoggerDestination) { + this.options.destination = dest; + } } /** diff --git a/packages/integrations/mdx/test/test-utils.ts b/packages/integrations/mdx/test/test-utils.ts index f0b6232c5535..d602807dbe87 100644 --- a/packages/integrations/mdx/test/test-utils.ts +++ b/packages/integrations/mdx/test/test-utils.ts @@ -4,7 +4,7 @@ import type * as mdast from 'mdast'; import type * as unified from 'unified'; import { AstroIntegrationLogger, - type AstroLogMessage, + type AstroLoggerMessage, } from '../../../astro/dist/core/logger/core.js'; export { @@ -30,10 +30,10 @@ export type RecmaPlugin = unified.Plugin >; export class SpyIntegrationLogger extends AstroIntegrationLogger { - readonly messages: AstroLogMessage[]; + readonly messages: AstroLoggerMessage[]; constructor() { - const messages: AstroLogMessage[] = []; + const messages: AstroLoggerMessage[] = []; super( { destination: { diff --git a/packages/integrations/netlify/src/image-service.ts b/packages/integrations/netlify/src/image-service.ts index f8e93a05abdc..33892b6e842e 100644 --- a/packages/integrations/netlify/src/image-service.ts +++ b/packages/integrations/netlify/src/image-service.ts @@ -1,6 +1,5 @@ import type { ExternalImageService } from 'astro'; -import { baseService } from 'astro/assets'; -import { verifyOptions } from '../../../astro/dist/assets/internal.js'; +import { baseService, verifyOptions } from 'astro/assets'; import { isESMImportedImage } from 'astro/assets/utils'; import { AstroError } from 'astro/errors'; diff --git a/packages/integrations/node/src/standalone.ts b/packages/integrations/node/src/standalone.ts index 35f366662995..50234024fdd3 100644 --- a/packages/integrations/node/src/standalone.ts +++ b/packages/integrations/node/src/standalone.ts @@ -34,6 +34,9 @@ export default function standalone( if (process.env.ASTRO_NODE_LOGGING !== 'disabled') { logListeningOn(app.getAdapterLogger(), server.server, host); } + server.server.on('close', () => { + app.logger.close(); + }); return { server, done: server.closed(),