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/packages/astro/package.json b/packages/astro/package.json index 929a762f4def..1f3eaa379f5e 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -82,6 +82,7 @@ "./errors": "./dist/core/errors/userError.js", "./middleware": "./dist/core/middleware/index.js", "./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", 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/container/index.ts b/packages/astro/src/container/index.ts index f3efcbbde476..d4a5f81b4194 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -134,7 +134,6 @@ function createManifest( onRequest: middleware ?? NOOP_MIDDLEWARE_FN, }; } - const root = new URL(import.meta.url); return { rootDir: root, @@ -181,6 +180,7 @@ function createManifest( experimentalQueuedRendering: manifest?.experimentalQueuedRendering ?? { enabled: false, }, + experimentalLogger: manifest?.experimentalLogger ?? undefined, }; } @@ -273,6 +273,7 @@ type AstroContainerManifest = Pick< | 'assetsDir' | 'image' | 'experimentalQueuedRendering' + | 'experimentalLogger' >; type AstroContainerConstructor = { diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 1ab84635077d..6a205727040c 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -26,6 +26,7 @@ import type { BaseSessionConfig, SessionDriverFactory } from '../session/types.j 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; @@ -158,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 = { diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 4506a01042ea..4ead3a4515f0 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -28,6 +28,7 @@ import type { CompiledCacheRoute } from './cache/runtime/route-matching.js'; 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 { loadLogger } from './logger/load.js'; /** * The `Pipeline` represents the static parts of rendering that do not change between requests. @@ -46,7 +47,7 @@ export abstract class Pipeline { nodePool: NodePool | undefined; htmlStringCache: HTMLStringCache | undefined; - readonly logger: AstroLogger; + logger: AstroLogger; readonly manifest: SSRManifest; /** * "development" or "production" only @@ -230,11 +231,8 @@ export abstract class Pipeline { return this.logger; } this.resolvedLogger = true; - if (this.manifest.logger) { - const mod = await this.manifest.logger(); - if (mod?.default) { - this.logger.setDestination(mod.default); - } + if (this.manifest.experimentalLogger) { + this.logger = await loadLogger(this.manifest.experimentalLogger); } return this.logger; } diff --git a/packages/astro/src/core/build/plugins/index.ts b/packages/astro/src/core/build/plugins/index.ts index 2a6a535d0427..e48f93106596 100644 --- a/packages/astro/src/core/build/plugins/index.ts +++ b/packages/astro/src/core/build/plugins/index.ts @@ -7,7 +7,6 @@ import { pluginAnalyzer } from './plugin-analyzer.js'; import { pluginComponentEntry } from './plugin-component-entry.js'; import { pluginCSS } from './plugin-css.js'; import { pluginInternals } from './plugin-internals.js'; -import { pluginLogger } from './plugin-logger.js'; import { pluginMiddleware } from './plugin-middleware.js'; import { pluginPrerender } from './plugin-prerender.js'; import { pluginScripts } from './plugin-scripts.js'; @@ -24,9 +23,6 @@ export function getAllBuildPlugins( pluginComponentEntry(internals), pluginAnalyzer(internals), pluginInternals(options, internals), - options.settings.config.experimental.logger - ? pluginLogger(options.settings.config.experimental.logger, options, internals) - : undefined, pluginMiddleware(options, internals), vitePluginActionsBuild(options, internals), ...pluginCSS(options, internals), diff --git a/packages/astro/src/core/build/plugins/plugin-logger.ts b/packages/astro/src/core/build/plugins/plugin-logger.ts deleted file mode 100644 index 24093844a9ce..000000000000 --- a/packages/astro/src/core/build/plugins/plugin-logger.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Plugin as VitePlugin } from 'vite'; -import type { BuildInternals } from '../internal.js'; -import type { StaticBuildOptions } from '../types.js'; -import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js'; -import { addRollupInput } from '../add-rollup-input.js'; -import { getServerOutputDirectory } from '../../../prerender/utils.js'; -import { - generateLoggerCode, - LOGGER_MODULE_ID, - RESOLVED_LOGGER_MODULE_ID, -} from '../../logger/shared.js'; -import type { LoggerHandlerConfig } from '../../logger/config.js'; - -/** - * Vite plugin that resolves `virtual:astro:logger` and optionally bundles it - * as a separate chunk for SSR builds. - * - * When called with only a `LoggerHandlerConfig`, it acts as a pure resolution plugin - * (used by `loadLogger` to load the logger in a throwaway Vite server). - * - * When called with `StaticBuildOptions` and `BuildInternals`, it additionally - * registers the virtual module as a Rollup input and records the output chunk path - * (used during the SSR build). - */ -export function pluginLogger( - config: LoggerHandlerConfig, - buildOptions?: StaticBuildOptions, - internals?: BuildInternals, -): VitePlugin { - return { - name: LOGGER_MODULE_ID, - applyToEnvironment(environment) { - return ( - environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr || - environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.astro || - environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender - ); - }, - - resolveId: { - filter: { - id: new RegExp(`^${LOGGER_MODULE_ID}$`), - }, - handler() { - return RESOLVED_LOGGER_MODULE_ID; - }, - }, - - load: { - filter: { - id: new RegExp(`^${RESOLVED_LOGGER_MODULE_ID}$`), - }, - handler() { - return { - code: generateLoggerCode(config), - }; - }, - }, - - options(opts) { - if (buildOptions) { - return addRollupInput(opts, [LOGGER_MODULE_ID]); - } - }, - - writeBundle(_, bundle) { - if (buildOptions && internals) { - for (const [chunkName, chunk] of Object.entries(bundle)) { - if (chunk.type !== 'asset' && chunk.facadeModuleId === RESOLVED_LOGGER_MODULE_ID) { - const outputDirectory = getServerOutputDirectory(buildOptions.settings); - internals.loggerEntryPoint = new URL(chunkName, outputDirectory); - } - } - } - }, - }; -} 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/logger/core.ts b/packages/astro/src/core/logger/core.ts index 73f1a64564d0..526d6ac7507a 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -5,14 +5,19 @@ export interface AstroLoggerDestination { * It receives a message and writes it into a destination */ write: (chunk: T) => void; - // NOTE: will document once we actually use it + /** + * It dumps logs without closing the connection to the destination. + * Method that can be used by specialized loggers. + */ flush?: () => Promise | void; - // NOTE: will document once we actually use it + /** + * 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 - /** * The level of logging. Priority is the following: * 1. debug diff --git a/packages/astro/src/core/logger/handlers.ts b/packages/astro/src/core/logger/handlers.ts index bac794690366..f26c3b4ef3c6 100644 --- a/packages/astro/src/core/logger/handlers.ts +++ b/packages/astro/src/core/logger/handlers.ts @@ -1,24 +1,78 @@ import type { LoggerHandlerConfig } from './config.js'; -import type { JonsHandlerConfig } from './impls/json.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 = { - json(config?: JonsHandlerConfig): LoggerHandlerConfig { + /** + * 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, }; }, - node(): LoggerHandlerConfig { + /** + * 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, }; }, - console(): LoggerHandlerConfig { + /** + * 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', diff --git a/packages/astro/src/core/logger/impls/compose.ts b/packages/astro/src/core/logger/impls/compose.ts index baa67ed632c8..c3ad72300602 100644 --- a/packages/astro/src/core/logger/impls/compose.ts +++ b/packages/astro/src/core/logger/impls/compose.ts @@ -1,10 +1,8 @@ import type { AstroLoggerDestination } from '../core.js'; -export function compose( - ...destinations: AstroLoggerDestination[] -): AstroLoggerDestination { +export default function compose(destinations: AstroLoggerDestination[]): AstroLoggerDestination { return { - write(chunk: unknown) { + write(chunk) { for (const logger of destinations) { logger.write(chunk); } diff --git a/packages/astro/src/core/logger/impls/console.ts b/packages/astro/src/core/logger/impls/console.ts index 51da376e1d27..b3333c5c86f2 100644 --- a/packages/astro/src/core/logger/impls/console.ts +++ b/packages/astro/src/core/logger/impls/console.ts @@ -6,10 +6,17 @@ import { type AstroLoggerLevel, AstroLogger, } from '../core.js'; +import type { NodeHandlerConfig } from './node.js'; +import { matchesLevel } from '../public.js'; + +export type ConsoleHandlerConfig = { + level?: AstroLoggerLevel; +}; function consoleLogDestination( - level: AstroLoggerLevel = 'info', + config: ConsoleHandlerConfig = {}, ): AstroLoggerDestination { + const { level = 'info' } = config; return { write(event: AstroLoggerMessage) { let dest = console.error; @@ -17,7 +24,7 @@ function consoleLogDestination( dest = console.info; } - if (levels[event.level] < levels[level]) { + if (!matchesLevel(event.level, level)) { return; } @@ -37,9 +44,6 @@ export function createConsoleLogger({ level }: { level: AstroLoggerLevel }): Ast }); } -type Options = { - level?: AstroLoggerLevel; -}; -export default function (options?: Options): AstroLoggerDestination { - return consoleLogDestination(options?.level ?? 'info'); +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 index 6d4b2ac068b0..6f8b0a7f3217 100644 --- a/packages/astro/src/core/logger/impls/json.ts +++ b/packages/astro/src/core/logger/impls/json.ts @@ -7,9 +7,16 @@ import { } from '../core.js'; import type { Writable } from 'node:stream'; import type { AstroInlineConfig } from '../../../types/public/index.js'; +import { matchesLevel } from '../public.js'; -export type JonsHandlerConfig = { +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; }; @@ -20,7 +27,7 @@ type ConsoleStream = Writable & { export const SGR_REGEX = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g'); export default function jsonLoggerDestination( - config: JonsHandlerConfig = {}, + config: JsonHandlerConfig = {}, ): AstroLoggerDestination { const { pretty = false, level = 'info' } = config; return { @@ -30,7 +37,7 @@ export default function jsonLoggerDestination( dest = process.stdout; } - if (levels[event.level] < levels[level]) { + if (!matchesLevel(event.level, level)) { return; } diff --git a/packages/astro/src/core/logger/impls/node.ts b/packages/astro/src/core/logger/impls/node.ts index c61dcc27e2ce..462c676feda1 100644 --- a/packages/astro/src/core/logger/impls/node.ts +++ b/packages/astro/src/core/logger/impls/node.ts @@ -8,14 +8,20 @@ import { } 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( - level: AstroLoggerLevel = 'info', + config: NodeHandlerConfig = {}, ): AstroLoggerDestination { + const { level = 'info' } = config; return { write(event: AstroLoggerMessage) { let dest: ConsoleStream = process.stderr; @@ -23,7 +29,7 @@ function nodeLogDestination( dest = process.stdout; } - if (levels[event.level] < levels[level]) { + if (!matchesLevel(event.level, level)) { return; } @@ -37,11 +43,8 @@ function nodeLogDestination( }; } -type Options = { - level?: AstroLoggerLevel; -}; -export default function (options?: Options): AstroLoggerDestination { - return nodeLogDestination(options?.level ?? 'info'); +export default function (options?: NodeHandlerConfig): AstroLoggerDestination { + return nodeLogDestination(options); } export function createNodeLoggerFromFlags(inlineConfig: AstroInlineConfig): AstroLogger { diff --git a/packages/astro/src/core/logger/load.ts b/packages/astro/src/core/logger/load.ts index 30247ffc0bf3..3b916327cea8 100644 --- a/packages/astro/src/core/logger/load.ts +++ b/packages/astro/src/core/logger/load.ts @@ -1,47 +1,68 @@ import { AstroLogger, type AstroLoggerDestination, type AstroLoggerLevel } from './core.js'; -import loadFallbackPlugin from '../../vite-plugin-load-fallback/index.js'; -import { createMinimalViteDevServer } from '../createMinimalViteDevServer.js'; -import { isRunnableDevEnvironment, type RunnableDevEnvironment, type ViteDevServer } from 'vite'; -import fsMod from 'node:fs'; -import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.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 { pluginLogger } from '../build/plugins/plugin-logger.js'; -import { createNodeLoggerFromFlags } from './impls/node.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'; -async function loadLogger( +export async function loadLogger( config: LoggerHandlerConfig, - root: AstroConfig['root'], level: AstroLoggerLevel = 'info', - fs: typeof fsMod = fsMod, ): Promise { - let server: ViteDevServer | undefined = undefined; let cause: Error | undefined = undefined; try { - const plugins = [...loadFallbackPlugin({ fs, root }), pluginLogger(config)]; - server = await createMinimalViteDevServer(plugins); + 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; + }), + ); + } - if (isRunnableDevEnvironment(server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr])) { - const environment = server.environments[ - ASTRO_VITE_ENVIRONMENT_NAMES.ssr - ] as RunnableDevEnvironment; - const mod = await environment.runner.import('virtual:astro:logger'); - return new AstroLogger({ - destination: mod.default as AstroLoggerDestination, - level, - }); + 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; } - } finally { - if (server) { - await server.close(); - } } const error = new AstroError({ @@ -66,11 +87,7 @@ export async function loadOrCreateNodeLogger( ) { try { if (astroConfig.experimental.logger) { - return await loadLogger( - astroConfig.experimental.logger, - astroConfig.root, - inlineAstroConfig.logLevel, - ); + return await loadLogger(astroConfig.experimental.logger, inlineAstroConfig.logLevel); } else { return createNodeLoggerFromFlags(inlineAstroConfig); } 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/logger/shared.ts b/packages/astro/src/core/logger/shared.ts deleted file mode 100644 index 389f7d09fda5..000000000000 --- a/packages/astro/src/core/logger/shared.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { AstroError } from '../errors/errors.js'; -import { LoggerConfigurationNotSerializable } from '../errors/errors-data.js'; -import type { LoggerHandlerConfig } from './config.js'; - -export const LOGGER_MODULE_ID = 'virtual:astro:logger'; -export const RESOLVED_LOGGER_MODULE_ID = '\0' + LOGGER_MODULE_ID; - -/** - * Generates virtual module code for a logger factory given a `LoggerHandlerConfig`. - * Handles built-in loggers (node, console, json), the compose meta-logger, - * and arbitrary user-provided entrypoints. - */ -export function generateLoggerCode(config: LoggerHandlerConfig): string { - switch (config.entrypoint) { - case 'astro/logger/node': - case 'astro/logger/console': - case 'astro/logger/json': { - return createSimpleLoggerCode(config.entrypoint, config.config); - } - case 'astro/logger/compose': { - return createComposeCode(config.config?.loggers); - } - default: { - return createSimpleLoggerCode(config.entrypoint, config.config); - } - } -} - -function createSimpleLoggerCode(factory: string, config: Record = {}): string { - try { - const serializedConfig = JSON.stringify(config, null, 2); - return `import { default as factory } from '${factory}';\nexport default factory(${serializedConfig});\n`; - } catch { - throw new AstroError(LoggerConfigurationNotSerializable); - } -} - -function createComposeCode(loggers: LoggerHandlerConfig[]): string { - try { - const imports = loggers - .map((logger, i) => `import factory${i} from '${logger.entrypoint}';`) - .join('\n'); - const args = loggers - .map((logger, i) => { - const serializedConfig = JSON.stringify(logger.config ?? {}); - return `factory${i}(${serializedConfig})`; - }) - .join(', '); - return [ - imports, - `import { compose } from 'astro/logger/compose';`, - `export default compose(${args});`, - ].join('\n'); - } catch { - throw new AstroError(LoggerConfigurationNotSerializable); - } -} 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/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/manifest/serialized.ts b/packages/astro/src/manifest/serialized.ts index a2cd633dd107..b134a35df849 100644 --- a/packages/astro/src/manifest/serialized.ts +++ b/packages/astro/src/manifest/serialized.ts @@ -27,7 +27,6 @@ import { ASTRO_ROUTES_MODULE_ID } from '../vite-plugin-routes/index.js'; import { cacheConfigToManifest } from '../core/cache/utils.js'; import { sessionConfigToManifest } from '../core/session/utils.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; -import { LOGGER_MODULE_ID } from '../core/logger/shared.js'; import { resolveMiddlewareMode } from '../integrations/adapter-utils.js'; // This is used by Cloudflare optimizeDeps config @@ -110,8 +109,6 @@ export function serializedManifestPlugin({ const cacheProviderLine = hasCacheConfig ? `cacheProvider: () => import('${VIRTUAL_CACHE_PROVIDER_ID}'),` : ''; - const hasLoggerConfig = !!settings.config.experimental?.logger; - const loggerLine = hasLoggerConfig ? `logger: () => import('${LOGGER_MODULE_ID}'),` : ''; const code = ` import { deserializeManifest as _deserializeManifest } from 'astro/app'; import { renderers } from '${ASTRO_RENDERERS_MODULE_ID}'; @@ -132,7 +129,6 @@ export function serializedManifestPlugin({ middleware: () => import('${MIDDLEWARE_MODULE_ID}'), sessionDriver: () => import('${VIRTUAL_SESSION_DRIVER_ID}'), ${cacheProviderLine} - ${loggerLine} serverIslandMappings: () => import('${SERVER_ISLAND_MANIFEST}'), routes: manifestRoutes, pageMap, @@ -176,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(), @@ -237,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 5d8a4b6e873a..6bd06ad46169 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -3064,9 +3064,26 @@ export interface AstroUserConfig< * @name experimental.logger * @type {{ entrypoint: string; config?: Record }} * @default `undefined` - * @version 6.0.0 + * @version 6.2.0 * @description - * Allows to customise how Astro logger behaves + * + * 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/test/units/app/logger.test.ts b/packages/astro/test/units/app/logger.test.ts index 64468b6077a4..a485b79e8ff9 100644 --- a/packages/astro/test/units/app/logger.test.ts +++ b/packages/astro/test/units/app/logger.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { describe, it, before } from 'node:test'; import { App } from '../../../dist/core/app/app.js'; -import type { AstroLoggerDestination, AstroLoggerMessage } from '../../../dist/core/logger/core.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

`; @@ -32,44 +32,27 @@ const pageMap = new Map([ ], ]); -function createSpyDestination(overrides: Partial> = {}) { - const calls: string[] = []; - const destination: AstroLoggerDestination = { - write() {}, - flush() { - calls.push('flush'); - }, - close() { - calls.push('close'); - }, - ...overrides, - }; - return { destination, calls }; -} - -function createAppWithLogger( - logger?: () => { default: AstroLoggerDestination }, -) { +function createAppWithLogger(experimentalLogger?: LoggerHandlerConfig) { return new App( createManifest({ routes: [createRouteInfo(indexRoute)], pageMap, - logger, + experimentalLogger, }), ); } describe('SSR Logger', () => { it('resolves a custom logger destination from the manifest on first request', async () => { - const { destination } = createSpyDestination(); - const app = createAppWithLogger(() => ({ default: destination })); + const app = createAppWithLogger({ entrypoint: 'astro/logger/json' }); await app.render(new Request('http://example.com/')); - assert.equal( - app.logger.options.destination, - destination, - 'Logger destination should be the custom one after first request', + 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', ); }); @@ -80,30 +63,25 @@ describe('SSR Logger', () => { assert.equal(response.status, 200); }); - it('calls flush on the logger destination after each response', async () => { - const { destination, calls } = createSpyDestination(); - const app = createAppWithLogger(() => ({ default: destination })); + it('flush does not throw when destination has no flush method', async () => { + const app = createAppWithLogger({ entrypoint: 'astro/logger/json' }); - await app.render(new Request('http://example.com/')); - const flushCountAfterFirst = calls.filter((c) => c === 'flush').length; - assert.ok(flushCountAfterFirst > 0, 'flush() should have been called'); + // 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); - await app.render(new Request('http://example.com/')); - const flushCountAfterSecond = calls.filter((c) => c === 'flush').length; - assert.ok( - flushCountAfterSecond > flushCountAfterFirst, - 'flush() should be called on subsequent requests too', - ); + // Explicit flush should also be a safe no-op + assert.doesNotThrow(() => app.logger.flush()); }); - it('calls close on the logger destination', async () => { - const { destination, calls } = createSpyDestination(); - const app = createAppWithLogger(() => ({ default: destination })); + 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/')); - app.logger.close(); - assert.ok(calls.includes('close'), 'close() should have been called on the destination'); + // Explicit close should be a safe no-op when the destination doesn't define it + assert.doesNotThrow(() => app.logger.close()); }); describe('build', () => { diff --git a/packages/astro/test/units/app/test-helpers.ts b/packages/astro/test/units/app/test-helpers.ts index e366a6bdc89f..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,25 +13,26 @@ export function createManifest({ base = '/', trailingSlash = 'ignore', middleware = undefined, - logger = 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']; - logger?: SSRManifest['logger']; + 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/'); @@ -58,7 +60,7 @@ export function createManifest({ key: Promise.resolve({} as CryptoKey), i18n, middleware, - logger, + experimentalLogger, actions, sessionDriver: undefined, checkOrigin: false, @@ -88,6 +90,7 @@ export function createManifest({ experimentalQueuedRendering: { enabled: false, }, + ...overrides, } as SSRManifest; } 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 7a7e733596dc..02d429ea6b73 100644 --- a/packages/astro/test/units/render/render-context.test.ts +++ b/packages/astro/test/units/render/render-context.test.ts @@ -7,7 +7,8 @@ import { 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'; // The public types for maybeRenderHead declare zero params, // but the runtime implementation accepts a result argument. @@ -136,4 +137,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/test-utils.ts b/packages/astro/test/units/test-utils.ts index f82e500cc2db..9b4e28bed921 100644 --- a/packages/astro/test/units/test-utils.ts +++ b/packages/astro/test/units/test-utils.ts @@ -19,6 +19,7 @@ import type { HeadElements, TryRewriteResult } from '../../dist/core/base-pipeli 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 const defaultLogger: AstroLogger = new AstroLogger({ destination: nodeLoggerFactory(), @@ -145,8 +146,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; @@ -164,13 +165,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)),