diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 6ac96fcef..5dba2eb86 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -33,7 +33,7 @@ "build": "tsdown", "dev:prepare": "tsdown --watch", "prepack": "tsdown", - "test:dist": "node --experimental-strip-types ../../scripts/check-dist.ts && node --experimental-strip-types ../../scripts/check-youch.ts && node --experimental-strip-types ../../scripts/check-loading-page.ts" + "test:dist": "node --experimental-strip-types ../../scripts/check-dist.ts && node --experimental-strip-types ../../scripts/check-loading-page.ts" }, "peerDependencies": { "@nuxt/docs": "^4.0.0", @@ -73,6 +73,7 @@ "exsolve": "^1.1.1", "fuzzysort": "^4.0.2", "get-port-please": "^3.2.0", + "my-bad": "https://pkg.pr.new/my-bad@d9f855a", "obug": "^2.1.4", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", @@ -105,8 +106,6 @@ "tsdown": "^0.22.14", "typescript": "^6.0.3", "undici": "^8.10.0", - "vitest": "^4.1.11", - "youch": "^4.1.1", - "youch-core": "^0.3.3" + "vitest": "^4.1.11" } } diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index f8616e71a..1fda53ef4 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -245,7 +245,7 @@ const command = defineCommand({ throw error }) - const { listener, close, reload, onRestart, onReady, onLoading, onEachReady, onLog, onRequests, onRoutes, onBuilding, onFileChange } = started + const { listener, close, reload, onRestart, onReady, onLoading, onEachReady, onLog, onRequests, onRoutes, onBuilding, onReport, onReportClear, onFileChange } = started /** Feed the dev UI from the server running in this process. */ function attachDevUI(devUI: DevUIController): DevUIController { @@ -254,6 +254,8 @@ const command = defineCommand({ onBuilding(building => devUI.setStatus(building ? 'building' : 'ready')) onLog(log => devUI.pushServerLog(log)) onRequests(requests => devUI.pushRequests(requests)) + onReport(report => devUI.pushReport(report)) + onReportClear(id => devUI.clearReport(id)) onRoutes(payload => devUI.setRoutes(payload)) return devUI } @@ -355,6 +357,12 @@ const command = defineCommand({ else if (message.type === 'nuxt:internal:dev:requests') { devUI.pushRequests(message.requests) } + else if (message.type === 'nuxt:internal:dev:report') { + devUI.pushReport(message.report) + } + else if (message.type === 'nuxt:internal:dev:report:clear') { + devUI.clearReport(message.id) + } else if (message.type === 'nuxt:internal:dev:routes') { devUI.setRoutes(message.payload) } diff --git a/packages/nuxt-cli/src/dev/error-channel.ts b/packages/nuxt-cli/src/dev/error-channel.ts new file mode 100644 index 000000000..bb097af61 --- /dev/null +++ b/packages/nuxt-cli/src/dev/error-channel.ts @@ -0,0 +1,428 @@ +import type { CompileErrorInput, ErrorReport } from 'my-bad' +import type { BuildProgress, Channel, LogEntry, LogLevel } from 'my-bad/channel' +import type { IncomingMessage, ServerResponse } from 'node:http' + +import type { ProgressSnapshot } from '../utils/progress-snapshot' + +import { existsSync } from 'node:fs' + +import process from 'node:process' + +import { BroadcastChannel } from 'node:worker_threads' + +import { isAbsolute, join, normalize, relative } from 'pathe' + +import { debug } from '../utils/logger' +import { DEV_INTERNAL_PREFIX } from './progress' + +/** Base path of the live error channel, before `nuxt.config` is known. */ +export const DEFAULT_ERROR_CHANNEL: string = `${DEV_INTERNAL_PREFIX}error` + +/** Where the app reads the path the CLI mounted the channel on. */ +export const ERROR_CHANNEL_ENV = 'NUXT_DEV_ERROR_CHANNEL' + +/** Where the app forwards its reports when the CLI owns the channel. */ +export const ERROR_BROADCAST_CHANNEL = 'nuxt:dev:error' + +/** Reports the app forwards; anything else on the wire is ignored. */ +export type DevErrorMessage + = | { type: 'nuxt:dev:error:report', report: ErrorReport, requestId?: number, request?: string } + | { type: 'nuxt:dev:error:clear', id?: string } + | { type: 'nuxt:dev:error:warning', report: ErrorReport } + | { type: 'nuxt:dev:error:log', entry: LogEntry } + | { type: 'nuxt:dev:error:progress', progress: BuildProgress } + +/** Asks whoever holds a current report to post it again. */ +const SYNC_MESSAGE = { type: 'nuxt:dev:error:sync' } as const + +const THREAD_RUNNERS = new Set(['node-worker']) + +/** + * Whether `runner` (unset means Nitro's default) evaluates the app in a thread + * of this process, and so can reach the bridge. Anywhere else the app serves + * the channel itself. + */ +export function isThreadRunner(runner: string | undefined): boolean { + return !runner || THREAD_RUNNERS.has(runner) +} + +let channel: Promise | undefined + +export interface ErrorChannelOptions { + cwd?: string + /** Directory `errors.jsonl` is written to when a sink is asked for. */ + buildDir?: string +} + +/** + * The live error channel, created on first use. Owned by the process that owns + * the listener, so it outlives the app being rebuilt, crashing or not having + * started. + */ +export function useErrorChannel(options: ErrorChannelOptions = {}): Promise { + if (!channel) { + const pending = channel = import('my-bad/channel').then(async ({ createChannel }) => createChannel({ + open: true, + // Opening a file spawns the developer's editor, so the project is the + // whole of what a page may ask the CLI to open. `process.cwd()`, which + // the channel would confine to otherwise, is not where the project is + // when `nuxt dev` was pointed at another directory. + root: options.cwd || process.cwd(), + // Which hosts may address the channel is decided before a request reaches + // it, by the dev server's own `Host` check, which knows the hostnames the + // server is listening on and what `--host` allowed. + allowedHosts: true, + sink: await resolveSink(options), + })) + // A channel that failed to open is not cached, or every later error would + // be answered with the same rejection. + pending.catch(() => { + if (channel === pending) { + channel = undefined + } + }) + } + return channel +} + +/** Record every channel event as JSON lines, when `NUXT_DEV_ERROR_LOG` asks for it. */ +async function resolveSink(options: ErrorChannelOptions) { + const requested = process.env.NUXT_DEV_ERROR_LOG + if (!requested) { + return undefined + } + const path = requested === '1' || requested === 'true' + ? join(options.buildDir || join(options.cwd || process.cwd(), '.nuxt'), 'errors.jsonl') + : requested + const { fileSink } = await import('my-bad/sinks') + return fileSink(path) +} + +/** + * The channel path a config asks for, or nothing when it cannot be served + * there: a bare or trailing slash would intercept the app's own routes. + */ +export function resolveChannelPath(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const path = value.replace(/\/+$/, '') + return path.startsWith('/') && path.length > 1 ? path : undefined +} + +/** Whether `path` is served by the channel mounted at `base`. */ +export function isErrorChannelRequest(path: string, base: string): boolean { + return path === base || path.startsWith(`${base}/`) +} + +/** Answer a request under the mounted channel path. */ +export async function handleErrorChannelRequest(req: IncomingMessage, res: ServerResponse, options: ErrorChannelOptions = {}): Promise { + const instance = await useErrorChannel(options) + if (await instance.handler(req, res)) { + return + } + res.statusCode = 404 + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'no-store') + res.end('{}') +} + +export interface CreateReportOptions { + cwd?: string + /** Request the error was raised for, shown as the report's request section. */ + req?: IncomingMessage +} + +/** A position quoted at the end of a message, as `file:line:column`. */ +const TRAILING_POSITION_RE = /^\s*(?(?:[a-z]:)?[^\s:][^:]*):(?\d+):(?\d+)[\s)]*$/i + +/** A parser or loader that names itself before its message. */ +const ERROR_NAME_RE = /^(?[A-Z][A-Za-z]*(?:Error|Exception)): (?[\s\S]*)$/ + +/** + * Recast a syntax error as a compile error, so the report shows the source it + * failed on. A parser reports a position in its message rather than its stack, + * which points at the parser. + */ +export function toCompileInput(error: unknown): CompileErrorInput | undefined { + if (!(error instanceof Error) || !error.message.includes('\n')) { + return undefined + } + const lines = error.message.split('\n') + const position = TRAILING_POSITION_RE.exec(lines.at(-1)!) + // Forward slashes, since a report's snippet is only read for a path it + // recognises and a Windows path with backslashes is not one. + const file = position?.groups?.file && normalize(position.groups.file) + if (!file || !isAbsolute(file) || !existsSync(file)) { + return undefined + } + const described = lines.slice(0, -1).join('\n').trim() + const named = ERROR_NAME_RE.exec(described) + return { + name: named?.groups?.name ?? error.name, + message: named?.groups?.message?.trim() ?? described, + id: file, + loc: { file, line: Number(position.groups!.line), column: Number(position.groups!.column) }, + stack: error.stack, + } as CompileErrorInput +} + +/** + * Build a report for something the CLI itself failed at: the config, a module's + * setup, a build. Frames are mapped from disk; the app holds the sourcemaps of + * its own bundle and maps its own. + */ +export async function createCliReport(error: unknown, options: CreateReportOptions = {}): Promise { + const [{ createReport, fsLoader }, { nuxtPreset }] = await Promise.all([ + import('my-bad'), + import('my-bad/presets'), + ]) + return createReport(toCompileInput(error) ?? error, { + cwd: options.cwd || process.cwd(), + loaders: [fsLoader()], + presets: [nuxtPreset()], + context: options.req ? { req: options.req } : undefined, + }) +} + +/** Above the bar the label sits in the header row, between brand and actions. */ +const PROGRESS_LABEL_CSS = '.mb-progress-label { top: calc(100% + 6px); bottom: auto; }' + +/** Render `report` as a standalone page, subscribed to the live channel. */ +export async function renderErrorPage(report: ErrorReport, options: { cwd?: string, channel?: string, history?: Channel['history'] }): Promise { + const [{ renderPage }, { nuxtTheme }] = await Promise.all([ + import('my-bad'), + import('my-bad/presets'), + ]) + return renderPage(report, { + cwd: options.cwd, + channel: options.channel, + history: options.history, + theme: { ...nuxtTheme, css: [nuxtTheme.css, PROGRESS_LABEL_CSS].filter(Boolean).join('\n') }, + }) +} + +/** Drop causes that only repeat what their parent already says. */ +function withoutEchoingCauses(report: ErrorReport): ErrorReport { + const causes = report.causes + .filter(cause => cause.message !== report.message) + .map(cause => withoutEchoingCauses(cause)) + return causes.length === report.causes.length && causes.every((cause, index) => cause === report.causes[index]) + ? report + : { ...report, causes } +} + +/** Render `report` for the terminal, with its own marker and colours. */ +async function renderReportAnsi(report: ErrorReport, cwd?: string): Promise { + const { renderAnsi } = await import('my-bad') + return renderAnsi(withoutEchoingCauses(report), { cwd }) +} + +/** + * Whether the CLI's own startup or reload sequence is still running. The CLI + * publishes a coarse, monotonic sequence of phases; the app forwards updates + * for work the CLI cannot see, which would drag the bar backwards if the two + * interleaved, so forwarded progress is dropped until the CLI's own sequence + * settles. + */ +let cliProgressInFlight = false + +/** Progress for the bar an open error page draws; `100` retires it. */ +export function toBuildProgress(snapshot: ProgressSnapshot): BuildProgress { + cliProgressInFlight = snapshot.status === 'loading' + return { + phase: snapshot.phase, + percent: snapshot.status === 'error' ? undefined : Math.round(snapshot.progress * 100), + message: snapshot.message, + } +} + +/** Publish to the channel if one exists, without creating it. */ +export async function withErrorChannel(run: (channel: Channel) => void): Promise { + if (!channel) { + return + } + try { + run(await channel) + } + catch (error) { + debug('Could not publish to the error channel:', error) + } +} + +/** Whether `message` is a report forwarded by the app. */ +export function isDevErrorMessage(message: unknown): message is DevErrorMessage { + const candidate = message as { type?: unknown, request?: unknown } | undefined + if (candidate?.request !== undefined && typeof candidate.request !== 'string') { + return false + } + const type = candidate?.type + if (type === 'nuxt:dev:error:log') { + return isLogEntry((message as { entry?: unknown }).entry) + } + if (type === 'nuxt:dev:error:progress') { + return isBuildProgress((message as { progress?: unknown }).progress) + } + return type === 'nuxt:dev:error:report' || type === 'nuxt:dev:error:clear' || type === 'nuxt:dev:error:warning' +} + +const LOG_LEVELS = new Set(['trace', 'debug', 'info', 'log', 'warn', 'error', 'fatal'] satisfies LogLevel[]) + +/** Whether `entry` is a log the channel's drawer can show. */ +function isLogEntry(entry: unknown): entry is LogEntry { + if (typeof entry !== 'object' || entry === null) { + return false + } + const candidate = entry as { level?: unknown, text?: unknown } + return typeof candidate.text === 'string' && typeof candidate.level === 'string' && LOG_LEVELS.has(candidate.level) +} + +/** Whether `progress` is an update the error page's bar can draw. */ +function isBuildProgress(progress: unknown): progress is BuildProgress { + if (typeof progress !== 'object' || progress === null) { + return false + } + const candidate = progress as { phase?: unknown, percent?: unknown, message?: unknown } + if (typeof candidate.phase !== 'string') { + return false + } + if (candidate.percent !== undefined && (typeof candidate.percent !== 'number' || !Number.isFinite(candidate.percent))) { + return false + } + return candidate.message === undefined || typeof candidate.message === 'string' +} + +/** + * A report as it crosses to the supervisor: the rendering to show it with, plus + * enough to summarise it on a status line. The report itself stays in the + * channel, which serves it from `/history/`. + */ +export interface DevReportSummary { + id: string + name: string + message: string + /** Where the topmost frame of the project's own code points, if anywhere. */ + file?: string + line?: number + /** That position as `file:line:column`, relative to the project. */ + location?: string + /** The request the report was raised for, shared with the logs attributed to it. */ + requestId?: number + /** That request as `METHOD /path`, when the app raised this while serving one. */ + request?: string + /** The report rendered for a terminal. */ + ansi: string +} + +/** The compile error a report was caused by, when there is one. */ +function findCompileReport(report: ErrorReport): ErrorReport | undefined { + if (report.kind === 'compile') { + return report + } + for (const cause of report.causes) { + const compile = findCompileReport(cause) + if (compile) { + return compile + } + } +} + +/** What the fork knows about a report beyond the report itself. */ +export interface ReportContext { + requestId?: number + request?: string +} + +/** Everything the supervisor needs to present `report`, rendered for a terminal. */ +export async function summariseReport(report: ErrorReport, context: ReportContext = {}, cwd: string = process.cwd()): Promise { + // A request that hit a compile error is described by the compile error. + const named = findCompileReport(report) ?? report + const frames = named.frames.some(frame => frame.file) ? named.frames : report.frames + const frame = frames.find(frame => frame.type === 'app' && frame.file) ?? frames.find(frame => frame.file) + return { + id: report.id, + name: named.name, + message: named.message, + file: frame?.file, + line: frame?.line, + location: frame?.file && formatLocation(frame.file, frame.line, frame.column, cwd), + requestId: context.requestId, + request: context.request, + ansi: await renderReportAnsi(report, cwd), + } +} + +/** `file:line:column`, relative to the project where it sits inside it. */ +function formatLocation(file: string, line: number | undefined, column: number | undefined, cwd: string): string { + const relativePath = relative(cwd, file) + const path = !relativePath || relativePath.startsWith('..') || isAbsolute(relativePath) ? file : `./${relativePath}` + return [path, line, column].filter(part => part !== undefined).join(':') +} + +/** The request that hit `report`, then its rendering. */ +export function formatReportForTerminal(report: DevReportSummary): string { + const body = report.ansi + if (!report.request) { + return body + } + const separator = report.request.indexOf(' ') + const method = separator === -1 ? report.request : report.request.slice(0, separator) + const path = separator === -1 ? '' : ` ${report.request.slice(separator + 1)}` + return `[request error] [${method}]${path}\n\n ${body.replaceAll('\n', '\n ')}` +} + +export interface ErrorBridgeHandlers { + onReport?: (report: ErrorReport, context: ReportContext) => void + onClear?: (id?: string) => void +} + +/** + * Receive the reports the app forwards, publishing them on the CLI's channel, + * until the returned function is called. + */ +export function openErrorBridge(handlers: ErrorBridgeHandlers = {}, options: ErrorChannelOptions = {}): () => void { + const broadcast = new BroadcastChannel(ERROR_BROADCAST_CHANNEL) + broadcast.unref() + broadcast.postMessage(SYNC_MESSAGE) + broadcast.onmessage = (event: { data: unknown }) => { + const message = event.data + if (!isDevErrorMessage(message)) { + return + } + void useErrorChannel(options).then((instance) => { + switch (message.type) { + case 'nuxt:dev:error:report': { + instance.setError(message.report) + handlers.onReport?.(message.report, { requestId: message.requestId, request: message.request }) + break + } + case 'nuxt:dev:error:log': { + instance.log(message.entry) + break + } + case 'nuxt:dev:error:progress': { + if (!cliProgressInFlight) { + instance.progress(message.progress) + } + break + } + case 'nuxt:dev:error:warning': { + instance.warn(message.report) + break + } + case 'nuxt:dev:error:clear': { + instance.clearError(message.id) + handlers.onClear?.(message.id) + break + } + } + }).catch(error => debug('Could not handle a forwarded error report:', error)) + } + return () => broadcast.close() +} + +export async function closeErrorChannel(): Promise { + const instance = channel + channel = undefined + await instance?.then(open => open.close()).catch(() => {}) +} diff --git a/packages/nuxt-cli/src/dev/error-lazy.ts b/packages/nuxt-cli/src/dev/error-lazy.ts deleted file mode 100644 index bbc32bdb0..000000000 --- a/packages/nuxt-cli/src/dev/error-lazy.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'node:http' -import type { RenderErrorOptions } from './error' - -export async function renderError(req: IncomingMessage, res: ServerResponse, error: unknown, options: RenderErrorOptions = {}): Promise { - const { renderError } = await import('./error') - return renderError(req, res, error, options) -} - -export async function renderErrorAnsi(error: unknown): Promise { - const { renderErrorAnsi } = await import('./error') - return renderErrorAnsi(error) -} diff --git a/packages/nuxt-cli/src/dev/error-response.ts b/packages/nuxt-cli/src/dev/error-response.ts new file mode 100644 index 000000000..622f7f482 --- /dev/null +++ b/packages/nuxt-cli/src/dev/error-response.ts @@ -0,0 +1,61 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' + +export interface ErrorResponseOptions { + /** Markup appended to the rendered page, used to make the error page live. */ + inject?: string +} + +const ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', +} + +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, character => ESCAPES[character]!) +} + +/** + * Answer a request with the little that can be said without a report: the + * message, and the stack where there is one. + */ +export async function sendErrorResponse(req: IncomingMessage, res: ServerResponse, error: unknown, options: ErrorResponseOptions = {}): Promise { + if (res.headersSent) { + if (!res.writableEnded) { + res.end() + } + return + } + + const err = error as Partial & { data?: unknown } + const useJSON = !req.headers.accept?.includes('text/html') + + res.statusCode = 500 + res.setHeader('Content-Type', useJSON ? 'application/json' : 'text/html') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('Referrer-Policy', 'no-referrer') + if (!options.inject) { + res.setHeader('Refresh', '3') + } + + if (useJSON) { + res.end(JSON.stringify({ + error: true, + url: req.url, + status: 500, + message: err?.message || 'Unknown error', + data: err?.data, + stack: err?.stack?.split('\n').map(line => line.trim()), + }, null, 2)) + return + } + + const message = escapeHtml(err?.message || 'Unknown error') + const stack = err?.stack ? `
${escapeHtml(err.stack)}
` : '' + res.end(`${message}` + + `

${message}

${stack}${options.inject ?? ''}`) +} diff --git a/packages/nuxt-cli/src/dev/error.ts b/packages/nuxt-cli/src/dev/error.ts deleted file mode 100644 index afa98e5f5..000000000 --- a/packages/nuxt-cli/src/dev/error.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'node:http' -import type { SourceLoader, StackFrame } from 'youch-core/types' - -import { readFile } from 'node:fs/promises' -import { SourceMap } from 'node:module' -import process from 'node:process' - -import { dirname, normalize, resolve } from 'pathe' -import { Youch } from 'youch' -import { ErrorParser } from 'youch-core' - -import { debug } from '../utils/logger' - -export interface RenderErrorOptions { - /** Markup appended to the rendered page, used to make the error page live. */ - inject?: string -} - -export async function renderError(req: IncomingMessage, res: ServerResponse, error: unknown, options: RenderErrorOptions = {}) { - if (res.headersSent) { - if (!res.writableEnded) { - res.end() - } - return - } - - await loadStackTrace(error).catch(err => debug('Failed to load stack trace:', err)) - - const useJSON = !req.headers.accept?.includes('text/html') - - res.statusCode = 500 - res.setHeader('Content-Type', useJSON ? 'application/json' : 'text/html') - res.setHeader('Cache-Control', 'no-store') - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('X-Frame-Options', 'DENY') - res.setHeader('Referrer-Policy', 'no-referrer') - if (!options.inject) { - res.setHeader('Refresh', '3') - } - - if (useJSON) { - const err = error as Partial & { data?: unknown } - res.end(JSON.stringify({ - error: true, - url: req.url, - status: 500, - message: err?.message || 'Unknown error', - data: err?.data, - stack: err?.stack?.split('\n').map(line => line.trim()), - }, null, 2)) - return - } - - const youch = new Youch() - const html = await youch.toHTML(error, { - request: { - url: req.url, - method: req.method, - }, - }) - res.end(options.inject ? html + options.inject : html) -} - -/** Render the error with source-mapped frames as ANSI for terminal output. */ -export async function renderErrorAnsi(error: unknown): Promise { - await loadStackTrace(error).catch(err => debug('Failed to load stack trace:', err)) - const ansi = await new Youch().toANSI(error) - return stripCwd(ansi) -} - -/** - * Replace the working directory in rendered output with `.`. - * - * Frame filenames are normalised to forward slashes, so on Windows the native - * `process.cwd()` spelling never matches and both forms have to be stripped. - */ -export function stripCwd(text: string, cwd = process.cwd()): string { - return text.replaceAll(cwd, '.').replaceAll(normalize(cwd), '.') -} - -const sourceLoader: SourceLoader = async (frame) => { - if (!frame.fileName || frame.fileType !== 'fs' || frame.type === 'native') { - return - } - if (frame.type === 'app') { - await applySourceMap(frame).catch(error => debug(`Failed to source-map \`${frame.fileName}\`:`, error)) - } - const contents = await readFile(frame.fileName, 'utf8').catch(() => undefined) - return contents ? { contents } : undefined -} - -/** - * Rewrite a frame to its original position. Isolated per frame so a malformed - * `.map` costs only that frame's mapping rather than the whole stack. - */ -export async function applySourceMap(frame: StackFrame): Promise { - const rawSourceMap = await readFile(`${frame.fileName}.map`, 'utf8').catch(() => undefined) - if (!rawSourceMap) { - return - } - const payload = JSON.parse(rawSourceMap) - const entry = new SourceMap(payload).findEntry(frame.lineNumber! - 1, frame.columnNumber!) - if ('originalSource' in entry && entry.originalSource !== undefined && entry.originalLine !== undefined) { - const source = payload.sourceRoot ? `${payload.sourceRoot.replace(/\/?$/, '/')}${entry.originalSource}` : entry.originalSource - frame.fileName = resolve(dirname(frame.fileName!), source) - frame.lineNumber = entry.originalLine + 1 - frame.columnNumber = entry.originalColumn || 0 - } -} - -/** Rewrite the error stack (and causes) with source-mapped file names and positions. */ -async function loadStackTrace(error: unknown): Promise { - if (!(error instanceof Error)) { - return - } - const parsed = await new ErrorParser().defineSourceLoader(sourceLoader).parse(error) - const stack = `${error.message}\n${parsed.frames.map(frame => fmtFrame(frame)).join('\n')}` - Object.defineProperty(error, 'stack', { value: stack }) - if (error.cause) { - await loadStackTrace(error.cause).catch(err => debug('Failed to load stack trace of cause:', err)) - } -} - -function fmtFrame(frame: StackFrame): string { - if (frame.type === 'native') { - return frame.raw ?? '' - } - const src = `${frame.fileName || ''}:${frame.lineNumber}:${frame.columnNumber}` - return frame.functionName ? ` at ${frame.functionName} (${src})` : ` at ${src}` -} diff --git a/packages/nuxt-cli/src/dev/index.ts b/packages/nuxt-cli/src/dev/index.ts index bbcf8c37f..c4f72d25c 100644 --- a/packages/nuxt-cli/src/dev/index.ts +++ b/packages/nuxt-cli/src/dev/index.ts @@ -3,6 +3,7 @@ import type { NuxtConfig } from '@nuxt/schema' import type { DevListenOverrides, Listener, ListenURL } from './listen' import type { ProgressSnapshot } from '../utils/progress-snapshot' import type { DevRestartReason } from './reason' +import type { DevReportSummary } from './error-channel' import type { ServerLogEvent } from './log-channel' import type { DevRequestEvent, DevRoutes, NuxtDevContext, NuxtDevIPCMessage, NuxtParentIPCMessage } from './utils' @@ -18,8 +19,10 @@ import { configureProjectConsola } from '../utils/console' import { overrideEnv } from '../utils/env.ts' import { isRemotePeerError, KEEPS_PROCESS_ALIVE } from '../utils/errors' import { debug } from '../utils/logger' +import { blankLineBefore, writeDirect } from '../utils/stdout' import { startCpuProfile, stopCpuProfile } from '../utils/profile.ts' import { openInspector } from './inspect' +import { closeErrorChannel, formatReportForTerminal } from './error-channel' import { currentRequest, isServingRequest } from './serving-state' import { createPhaseReporter } from '../utils/phase-reporter' import { NuxtDevServer } from './utils' @@ -30,6 +33,7 @@ const REQUEST_FLUSH_MS = 100 const REQUEST_BATCH_LIMIT = 200 const PENDING_REQUEST_BATCHES = 20 const PENDING_LOG_LIMIT = 500 +const PENDING_REPORTS = 20 /** * How often a piped startup repeats the phase it is on, and the shortest gap @@ -221,6 +225,10 @@ interface InitializeReturn { onLog: (callback: (log: ServerLogEvent) => void) => void /** Called with batches of served requests. */ onRequests: (callback: (requests: DevRequestEvent[]) => void) => void + /** Called with reports the app forwarded, rendered for a terminal. */ + onReport: (callback: (report: DevReportSummary) => void) => void + /** Called when the app reports that its error has gone. */ + onReportClear: (callback: (id?: string) => void) => void /** Called when a server-side rebuild starts and finishes. */ onBuilding: (callback: (building: boolean) => void) => void /** Called whenever the app's routes are (re)discovered. */ @@ -300,6 +308,31 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti const requests = createFeed(PENDING_REQUEST_BATCHES) const routes = createFeed(1) const building = createFeed(0) + const reports = createFeed(PENDING_REPORTS) + const reportsCleared = createFeed(0) + + // The app stops printing once the CLI owns the channel, so exactly one of + // these shows the report. + devServer.on('report', (report) => { + if (ipc.enabled) { + ipc.send({ type: 'nuxt:internal:dev:report', report }) + } + else if (captureUIEvents) { + reports.emit(report) + } + // Verbatim: the rendering carries its own marker and colours. + if (!captureUIEvents) { + writeDirect(`${blankLineBefore()}${formatReportForTerminal(report)}\n`) + } + }) + devServer.on('report:clear', (id) => { + if (ipc.enabled) { + ipc.send({ type: 'nuxt:internal:dev:report:clear', id }) + } + else if (captureUIEvents) { + reportsCleared.emit(id) + } + }) let closeLogChannel: (() => void) | undefined if (captureUIEvents) { @@ -446,6 +479,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti } finally { devServer.progress.close() + await closeErrorChannel() devServer.releaseLock() } })() @@ -475,6 +509,8 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti onLog: logs.subscribe, onRequests: requests.subscribe, onBuilding: building.subscribe, + onReport: reports.subscribe, + onReportClear: reportsCleared.subscribe, onRoutes: routes.subscribe, onFileChange: (callback: () => void) => { devServer.once('change', callback) diff --git a/packages/nuxt-cli/src/dev/tui/controller.ts b/packages/nuxt-cli/src/dev/tui/controller.ts index f05853fbf..ecbea4aec 100644 --- a/packages/nuxt-cli/src/dev/tui/controller.ts +++ b/packages/nuxt-cli/src/dev/tui/controller.ts @@ -1,4 +1,5 @@ import type { PendingRender } from '../../utils/progress-snapshot' +import type { DevReportSummary } from '../error-channel' import type { ServerLogEvent } from '../log-channel' import type { ShortcutContext } from '../shortcuts' import type { DevRequestEvent, DevRoutes } from '../utils' @@ -23,6 +24,10 @@ export interface DevUIController { pushServerLog: (log: ForwardedLog) => void /** Record a batch of served requests for the traffic ticker. */ pushRequests: (requests: DevRequestEvent[]) => void + /** Record a report the app raised, for the log view and the status line. */ + pushReport: (report: DevReportSummary) => void + /** Drop a report the status line is still naming. */ + clearReport: (id?: string) => void /** Replace the routes shown in the route view. */ setRoutes: (routes: DevRoutes) => void /** @@ -40,6 +45,8 @@ export const NOOP_CONTROLLER: DevUIController = { settleRestart: () => {}, pushServerLog: () => {}, pushRequests: () => {}, + pushReport: () => {}, + clearReport: () => {}, setRoutes: () => {}, setRendering: () => {}, } diff --git a/packages/nuxt-cli/src/dev/tui/events.ts b/packages/nuxt-cli/src/dev/tui/events.ts index e8c35961c..f5b504628 100644 --- a/packages/nuxt-cli/src/dev/tui/events.ts +++ b/packages/nuxt-cli/src/dev/tui/events.ts @@ -12,6 +12,8 @@ export interface DevLogEvent { message: string /** The formatted output as it would have been printed, colour and all. */ rendered?: string + /** The message carries its own colours, so severity styling must not be applied. */ + styled?: boolean /** Recovered from printed output rather than reported by a logger. */ raw?: boolean /** Already paired with the other route the same log arrived by. */ diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index 3530e6fac..77ffaba8a 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -134,6 +134,8 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) let animation: NodeJS.Timeout | undefined let animationInterval = LOGO_FRAME_MS let activityTimer: NodeJS.Timeout | undefined + /** Report currently named on the status line. */ + let reported: string | undefined let noticeTimer: NodeJS.Timeout | undefined /** * The load in flight raised an error, so the server never came up. Held apart @@ -627,6 +629,31 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) activityTimer = setTimeout(clearActivity, ACTIVITY_MS) activityTimer.unref?.() }, + pushReport: (report) => { + // Set before the event, which would otherwise paint the badge's standing + // description in between. + reported = report.id + update({ status: 'error', note: `${report.message} · press l to read it` }) + // The rendering is the message, so the log view shows it in full. + events.push({ + time: Date.now(), + level: 0, + type: 'error', + message: report.ansi, + rendered: report.ansi, + styled: true, + source: 'runtime', + request: report.request, + requestId: report.requestId, + }) + }, + clearReport: (id) => { + if (id !== undefined && id !== reported) { + return + } + reported = undefined + update({ note: undefined }) + }, setRoutes: payload => routeOverlay.setRoutes(payload), setRendering: (pending, awaiting) => { update({ diff --git a/packages/nuxt-cli/src/dev/tui/overlay.ts b/packages/nuxt-cli/src/dev/tui/overlay.ts index 9c64aaeb4..3daf6c9ea 100644 --- a/packages/nuxt-cli/src/dev/tui/overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/overlay.ts @@ -181,7 +181,7 @@ export function formatEvent(event: DevLogEvent, columns: number, timeWidth: numb /** Colour a message by its level, unless it already carries its own colour. */ function colorBySeverity(line: string, event: DevLogEvent): string { - if (line.includes('\u001B[')) { + if (event.styled || line.includes('\u001B[')) { return line } if (event.level <= 0) { diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index b42478ac3..be4c8d8f9 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -1,11 +1,13 @@ import type { Nuxt, NuxtConfig, NuxtOptions, ViteConfig } from '@nuxt/schema' +import type { ErrorReport } from 'my-bad' import type { createDevServer } from 'nitro/builder' import type { NitroDevServer } from 'nitropack' import type { FSWatcher, Stats } from 'node:fs' import type { Server as HttpServer, IncomingMessage, RequestListener, ServerResponse } from 'node:http' - import type { PendingRender } from '../utils/progress-snapshot' + import type { ResolvedCertificate } from './cert' +import type { DevReportSummary } from './error-channel' import type { InspectOptions } from './inspect' import type { BoundServer, DevListenOverrides, Listener, ListenOptions, ListenURL } from './listen' import type { DevRestartReason } from './reason' @@ -24,7 +26,7 @@ import { toNodeListener } from 'h3' import { join, resolve } from 'pathe' import { debounce } from 'perfect-debounce' import { toNodeHandler } from 'srvx/node' -import { provider } from 'std-env' +import { isCI, provider } from 'std-env' import { showBanner } from '../utils/banner' import { loadDevServerHint, saveDevServerHint } from '../utils/dev-hint' @@ -35,7 +37,8 @@ import { acquireLock, formatLockError, getTakeoverPid, updateLock } from '../uti import { debug, logger, writeNotice } from '../utils/logger' import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt' import { resolveServerBuild } from '../utils/server-build' -import { renderError, renderErrorAnsi } from './error-lazy' +import { createCliReport, DEFAULT_ERROR_CHANNEL, ERROR_CHANNEL_ENV, handleErrorChannelRequest, isErrorChannelRequest, isThreadRunner, openErrorBridge, renderErrorPage, resolveChannelPath, summariseReport, toBuildProgress, useErrorChannel, withErrorChannel } from './error-channel' +import { sendErrorResponse } from './error-response' import { isAllowedHost } from './host-check' import { bindListener, createListener, matchesBoundTarget, openBrowser, resolveOpenURL } from './listen' import { RECOVERY_SCRIPT, withProgress } from './loading-page' @@ -98,6 +101,8 @@ export type NuxtDevIPCMessage | { type: 'nuxt:internal:dev:routes', payload: DevRoutes } | { type: 'nuxt:internal:dev:building', building: boolean } | { type: 'nuxt:internal:dev:rendering', pending?: PendingRender, awaiting?: boolean } + | { type: 'nuxt:internal:dev:report', report: DevReportSummary } + | { type: 'nuxt:internal:dev:report:clear', id?: string } export interface NuxtDevContext { cwd: string @@ -383,10 +388,18 @@ interface DevServerEventMap { 'request': [event: DevRequestEvent] 'routes': [payload: DevRoutes] 'building': [building: boolean] + /** A report the app forwarded, rendered for a terminal. */ + 'report': [report: DevReportSummary] + 'report:clear': [id?: string] } export class NuxtDevServer extends EventEmitter { #handler?: RequestListener + /** Whether the app can reach this process, and so whether the CLI serves the channel. */ + #ownsChannel = isThreadRunner(process.env.NITRO_DEV_RUNNER) + #errorChannel = DEFAULT_ERROR_CHANNEL + #closeErrorBridge?: () => void + #loadingReport?: ErrorReport #distWatcher?: FSWatcher #configWatcher?: () => void #currentNuxt?: NuxtWithServer @@ -434,6 +447,11 @@ export class NuxtDevServer extends EventEmitter { this.#cwd = options.cwd + this.#announceErrorChannel() + this.#progress.onUpdate((snapshot) => { + void withErrorChannel(channel => channel.progress(toBuildProgress(snapshot))) + }) + this.handler = async (req, res) => { // Only the CLI's own dispatch may set the request-attribution header; // anything arriving on the wire is stripped so an external client cannot @@ -442,7 +460,8 @@ export class NuxtDevServer extends EventEmitter { // Internal endpoints answer before Nuxt exists, so they are matched ahead // of anything that waits on the first successful load, and they stay out // of the request feed. - if ((req.url || '').split('?')[0]?.startsWith(DEV_INTERNAL_PREFIX)) { + const path = (req.url || '').split('?')[0] || '/' + if (path.startsWith(DEV_INTERNAL_PREFIX)) { if (this.#rejectDisallowedHost(req, res)) { return } @@ -450,6 +469,23 @@ export class NuxtDevServer extends EventEmitter { return } } + // Never passed on to the app. The default path answers alongside a + // configured one, for pages served before the config was known. + if (this.#ownsChannel && (isErrorChannelRequest(path, this.#errorChannel) || isErrorChannelRequest(path, DEFAULT_ERROR_CHANNEL))) { + if (this.#rejectDisallowedHost(req, res)) { + return + } + if (options.captureUIEvents) { + this.#internalResponses.add(res) + } + await handleErrorChannelRequest(req, res, this.#errorChannelOptions()).catch((error) => { + debug('Could not answer an error channel request:', error) + if (!res.writableEnded) { + res.end() + } + }) + return + } if (!options.captureUIEvents) { return this.#serve(req, res) } @@ -529,11 +565,14 @@ export class NuxtDevServer extends EventEmitter { if (this.#rejectDisallowedHost(req, res)) { return } + if (this.#loadingReport && await this.#renderReport(req, res, this.#loadingReport)) { + return + } // The error page answers a request the client made, so it stays in the // request feed rather than counting as one the CLI answered itself. // The recovery script makes the page reload itself once the next load // starts, so a fixed file shows up without the reader touching anything. - await renderError(req, res, this.#loadingError, { inject: RECOVERY_SCRIPT }) + await sendErrorResponse(req, res, this.#loadingError, { inject: RECOVERY_SCRIPT }) return } if (!this.#handler) { @@ -579,6 +618,84 @@ export class NuxtDevServer extends EventEmitter { this.#handler(req, res) } + /** Root the project's own paths are written relative to. */ + #rootDir(): string { + return this.#currentNuxt?.options.rootDir || this.#cwd + } + + #errorChannelOptions(): { cwd: string, buildDir?: string } { + return { cwd: this.#cwd, buildDir: this.#currentNuxt?.options.buildDir } + } + + /** + * Tell the app that the CLI owns the channel, before Nuxt loads: the dev + * worker inherits this process's environment as it is when the worker starts. + */ + #announceErrorChannel(): void { + if (this.#ownsChannel) { + process.env[ERROR_CHANNEL_ENV] = this.#errorChannel + } + } + + /** Start receiving the reports the app forwards. */ + #openErrorBridge(): void { + if (!this.#ownsChannel) { + return + } + this.#closeErrorBridge ??= openErrorBridge({ + onReport: (report, context) => { + void summariseReport(report, context, this.#rootDir()) + .then(summary => this.emit('report', summary)) + .catch(error => debug('Could not summarise a forwarded report:', error)) + }, + onClear: id => this.emit('report:clear', id), + }, this.#errorChannelOptions()) + } + + /** Move the channel to the path the config asks for, before the app is built. */ + #resolveErrorChannel(): void { + if (!this.#ownsChannel || !this.#currentNuxt) { + return + } + const devServer = this.#currentNuxt.options.devServer as { errorChannel?: unknown } + const runner = (this.#currentNuxt.options.nitro?.devServer as { runner?: unknown } | undefined)?.runner + if (!isThreadRunner(typeof runner === 'string' ? runner : undefined)) { + this.#ownsChannel = false + delete process.env[ERROR_CHANNEL_ENV] + return + } + this.#errorChannel = resolveChannelPath(devServer.errorChannel) ?? DEFAULT_ERROR_CHANNEL + process.env[ERROR_CHANNEL_ENV] = this.#errorChannel + } + + /** + * Serve `report` as a live error page, or `false` when it could not be + * rendered. The page dismisses itself once the channel clears the error. + */ + async #renderReport(req: IncomingMessage, res: ServerResponse, report: ErrorReport): Promise { + if (!String(req.headers.accept || '').includes('text/html')) { + return false + } + try { + // Without a channel of our own there is nothing to subscribe to. + const channel = this.#ownsChannel ? await useErrorChannel(this.#errorChannelOptions()) : undefined + const html = await renderErrorPage(report, { + cwd: this.#rootDir(), + channel: channel && this.#errorChannel, + history: channel?.history, + }) + res.statusCode = 500 + res.setHeader('Content-Type', 'text/html') + res.setHeader('Cache-Control', 'no-store') + res.end(html) + return true + } + catch (error) { + debug('Could not render the error page:', error) + return false + } + } + async #renderLoadingScreen(req: IncomingMessage, res: ServerResponse): Promise { if (res.headersSent) { if (!res.writableEnded) { @@ -652,8 +769,25 @@ export class NuxtDevServer extends EventEmitter { this.#progress.start(this.#loadingMessage) this.emit('loading', this.#loadingMessage) + this.#openErrorBridge() await this.#bindEagerListener() + try { + await this.#startNuxt() + } + catch (error) { + // A config that cannot be loaded is fixed in the editor, so the socket is + // kept and the error served. Without a listener, or with nobody watching, + // there is nothing to serve it to. + if (!this.#bound || isCI || !isInteractive()) { + throw error + } + await this.#reportLoadFailure(error, false) + } + this.#watchConfig() + } + + async #startNuxt(): Promise { await this.#loadNuxtInstance(this.#bound && this.listener.getURLs().map(({ url }) => url)) // Acquire lock before serving so parallel agent invocations @@ -666,7 +800,6 @@ export class NuxtDevServer extends EventEmitter { await this.#createListener() await this.#initializeNuxt(false) - this.#watchConfig() } closeWatchers(): void { @@ -690,19 +823,34 @@ export class NuxtDevServer extends EventEmitter { await this.#load(reload, reason) this.#loadingError = undefined + this.#loadingReport = undefined } catch (error) { + await this.#reportLoadFailure(error, !!reload) + } + this.#watchConfig() + } + + /** Serve and report a load that failed, in place of the app it would have served. */ + async #reportLoadFailure(error: unknown, reload: boolean): Promise { + this.#handler = undefined + this.#loadingError = error as Error + this.#loadingMessage = 'Error while loading Nuxt. Please check console and fix errors.' + this.#progress.setError(error as Error) + const report = await this.#publishError(error) + // Reported rather than printed, so whoever owns the terminal renders it. + const summary = report && await summariseReport(report, {}, this.#rootDir()) + .catch(reportError => void debug('Could not summarise the report:', reportError)) + if (summary) { + this.emit('report', summary) + } + else { console.error( `Cannot ${reload ? 'restart' : 'start'} nuxt: `, - await renderErrorAnsi(error).catch(() => error), + (error as Error)?.stack ?? String(error), ) - this.#handler = undefined - this.#loadingError = error as Error - this.#loadingMessage = 'Error while loading Nuxt. Please check console and fix errors.' - this.#progress.setError(error as Error) - this.emit('loading:error', error as Error) } - this.#watchConfig() + this.emit('loading:error', error as Error) } #createLoadOptions(urls?: string[]): LoadNuxtOptionsWithConfigDiff { @@ -1066,6 +1214,8 @@ export class NuxtDevServer extends EventEmitter { throw new Error('Nuxt must be loaded before configuration') } + this.#resolveErrorChannel() + this.#progress.attachNuxt(this.#currentNuxt.hooks, { installedModules: () => this.#currentNuxt?.options._installedModules?.length ?? 0, }) @@ -1223,6 +1373,7 @@ export class NuxtDevServer extends EventEmitter { }) this.#progress.setReady() + void withErrorChannel(channel => channel.clearError()) this.emit('ready', serverUrl) } @@ -1232,6 +1383,30 @@ export class NuxtDevServer extends EventEmitter { } } + /** Publish a startup or build failure to the channel. */ + async #publishError(error: unknown): Promise { + let report: ErrorReport + try { + report = await createCliReport(error, { cwd: this.#rootDir() }) + } + catch (reportError) { + debug('Could not build a report for the error:', reportError) + return undefined + } + this.#loadingReport = report + if (this.#ownsChannel) { + // The terminal and the page render from the report, not from the channel. + try { + const channel = await useErrorChannel(this.#errorChannelOptions()) + channel.setError(report) + } + catch (channelError) { + debug('Could not publish the error to the channel:', channelError) + } + } + return report + } + /** Release the lock file. Call only on final shutdown, not during reloads. */ releaseLock(): void { const takenOverBy = this.#lockedBuildDir && getTakeoverPid(this.#lockedBuildDir) @@ -1348,6 +1523,11 @@ function stripRequestHeader(req: IncomingMessage): void { } } +/** Whether anyone is watching this terminal, directly or through the panel. */ +function isInteractive(): boolean { + return !!process.stdout.isTTY || !!process.env.__NUXT_DEV_PIPED_TTY__ +} + function getAddressURL(addr: { address: string, port: number }, https: boolean) { const proto = https ? 'https' : 'http' let host = addr.address.includes(':') ? `[${addr.address}]` : addr.address diff --git a/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts b/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts index 123a69401..db2166293 100644 --- a/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts @@ -106,6 +106,8 @@ beforeEach(() => { onRequests: vi.fn(), onRoutes: vi.fn(), onBuilding: vi.fn(), + onReport: vi.fn(), + onReportClear: vi.fn(), })) exit = vi.spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit') diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index ca8021a81..41404ca76 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -960,6 +960,13 @@ describe('log overlay', () => { ...overrides, }) + it('should leave a message that carries its own colours alone', () => { + const coloured = `${'\u001B[31m'}✖${'\u001B[39m'} ParseError` + const lines = formatEvent(event({ message: coloured, rendered: coloured, level: 0, type: 'error', styled: true }), 80, 8) + + expect(lines.join('\n')).toContain(coloured) + }) + it('opens focused on the newest error', () => { const events = new DevEventLog() events.push(event({ message: 'all fine' })) @@ -2388,6 +2395,40 @@ describe('request failures on the panel', () => { }) }) + it('should name a forwarded report on the status line and count it once', async () => { + await withPanel(async (ui, settle) => { + ui.setStatus('ready') + ui.pushReport({ id: 'abc', name: 'TypeError', message: 'x is not a function', ansi: 'TypeError: x is not a function\n at app.vue:3:1', requestId: 1 }) + const frames = await settle() + + expect(frames).toContain('x is not a function · press l to read it') + expect(frames).toContain('1 error') + expect(frames).not.toContain('2 errors') + }) + }) + + it('should drop a report from the status line once the app recovers', async () => { + await withPanel(async (ui, settle) => { + ui.setStatus('ready') + ui.pushReport({ id: 'abc', name: 'TypeError', message: 'x is not a function', ansi: 'TypeError: x is not a function' }) + await settle() + + ui.clearReport('abc') + expect(await settle()).toContain('an error was logged') + }) + }) + + it('should ignore a clear for a report it is not showing', async () => { + await withPanel(async (ui, settle) => { + ui.setStatus('ready') + ui.pushReport({ id: 'abc', name: 'TypeError', message: 'x is not a function', ansi: 'TypeError: x is not a function' }) + await settle() + + ui.clearReport('older') + expect(await settle()).not.toContain('an error was logged') + }) + }) + it('should report a failed app request', async () => { await withPanel(async (ui, settle) => { ui.setStatus('ready') diff --git a/packages/nuxt-cli/test/unit/dev/responses.spec.ts b/packages/nuxt-cli/test/unit/dev/responses.spec.ts index bdb90a8df..beee96b9c 100644 --- a/packages/nuxt-cli/test/unit/dev/responses.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/responses.spec.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import { describe, expect, it } from 'vitest' -import { renderError } from '../../../src/dev/error' +import { sendErrorResponse } from '../../../src/dev/error-response' import { NuxtDevServer } from '../../../src/dev/utils' interface FakeResponse { @@ -51,10 +51,10 @@ function createResponse(): FakeResponse { }) as unknown as FakeResponse } -describe('renderError', () => { +describe('sendErrorResponse', () => { it('should escape an error message in the html error page', async () => { const res = createResponse() - await renderError(createRequest('text/html'), res.response, new Error('')) + await sendErrorResponse(createRequest('text/html'), res.response, new Error('')) expect(res.statusCode).toBe(500) expect(res.headers['content-type']).toBe('text/html') @@ -64,14 +64,14 @@ describe('renderError', () => { it('should escape a reflected request url in the html error page', async () => { const res = createResponse() - await renderError(createRequest('text/html', '/'), res.response, new Error('boom')) + await sendErrorResponse(createRequest('text/html', '/'), res.response, new Error('boom')) expect(res.body).not.toContain('') }) it('should answer a non-html client with json', async () => { const res = createResponse() - await renderError(createRequest('application/json'), res.response, new Error('boom')) + await sendErrorResponse(createRequest('application/json'), res.response, new Error('boom')) expect(res.headers['content-type']).toBe('application/json') expect(JSON.parse(res.body)).toMatchObject({ error: true, status: 500, message: 'boom' }) @@ -79,7 +79,7 @@ describe('renderError', () => { it('should send hardening headers with the error page', async () => { const res = createResponse() - await renderError(createRequest('text/html'), res.response, new Error('boom')) + await sendErrorResponse(createRequest('text/html'), res.response, new Error('boom')) expect(res.headers).toMatchObject({ 'cache-control': 'no-store', @@ -92,14 +92,14 @@ describe('renderError', () => { it('should not write a body once headers have been sent', async () => { const res = createResponse() res.headersSent = true - await renderError(createRequest('text/html'), res.response, new Error('boom')) + await sendErrorResponse(createRequest('text/html'), res.response, new Error('boom')) expect(res.body).toBe('') }) it('should render a non-error rejection value', async () => { const res = createResponse() - await renderError(createRequest('application/json'), res.response, 'just a string') + await sendErrorResponse(createRequest('application/json'), res.response, 'just a string') expect(JSON.parse(res.body)).toMatchObject({ status: 500, message: 'Unknown error' }) }) diff --git a/packages/nuxt-cli/test/unit/error-channel.spec.ts b/packages/nuxt-cli/test/unit/error-channel.spec.ts new file mode 100644 index 000000000..f0bc3a640 --- /dev/null +++ b/packages/nuxt-cli/test/unit/error-channel.spec.ts @@ -0,0 +1,483 @@ +import type { ErrorReport } from 'my-bad' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { ReportContext } from '../../src/dev/error-channel' + +import { existsSync } from 'node:fs' +import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { Readable } from 'node:stream' +import { BroadcastChannel } from 'node:worker_threads' + +import { normalize } from 'pathe' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { closeErrorChannel, createCliReport, DEFAULT_ERROR_CHANNEL, ERROR_BROADCAST_CHANNEL, formatReportForTerminal, isDevErrorMessage, isErrorChannelRequest, openErrorBridge, renderErrorPage, resolveChannelPath, summariseReport, toBuildProgress, toCompileInput, useErrorChannel } from '../../src/dev/error-channel' +import { NuxtDevServer } from '../../src/dev/utils' + +function createResponse() { + const chunks: string[] = [] + const listeners = new Map void>() + const res = { + writableEnded: false, + headersSent: false, + statusCode: 200, + headers: {} as Record, + setHeader(key: string, value: string) { + this.headers[key.toLowerCase()] = value + }, + writeHead(status: number, headers?: Record) { + this.statusCode = status + for (const [key, value] of Object.entries(headers ?? {})) { + this.headers[key.toLowerCase()] = value + } + return this + }, + flushHeaders() {}, + write(chunk: string) { + chunks.push(chunk) + return true + }, + end(chunk?: string) { + if (chunk) { + chunks.push(chunk) + } + this.writableEnded = true + return this + }, + once(event: string, listener: () => void) { + listeners.set(event, listener) + return this + }, + } + return { res: res as unknown as ServerResponse, headers: res.headers, chunks, statusOf: () => res.statusCode } +} + +function request(url: string) { + return { + url, + method: 'GET', + headers: { accept: 'text/html' }, + rawHeaders: [], + on: () => {}, + } as unknown as IncomingMessage +} + +function openRequest(headers: Record, file = '/etc/passwd') { + return Object.assign(Readable.from([JSON.stringify({ file })]), { + url: `${DEFAULT_ERROR_CHANNEL}/open`, + method: 'POST', + headers: { 'host': 'localhost:3000', 'content-type': 'application/json', ...headers }, + rawHeaders: [], + }) as unknown as IncomingMessage +} + +/** A project of one file, with an editor that records what it was asked to open. */ +async function createProject() { + const dir = await mkdtemp(join(tmpdir(), 'nuxi-open-')) + const file = join(dir, 'app.vue') + const opened = join(dir, 'opened.txt') + const editor = join(dir, 'editor.sh') + await writeFile(file, '') + await writeFile(editor, `#!/bin/sh\necho "$@" >> ${JSON.stringify(opened)}\n`) + await chmod(editor, 0o755) + vi.stubEnv('LAUNCH_EDITOR', editor) + return { dir, file, opened } +} + +function createServer() { + return new NuxtDevServer({ cwd: process.cwd(), dotenv: {}, overrides: {} }) +} + +afterEach(async () => { + vi.unstubAllEnvs() + delete process.env.NUXT_DEV_ERROR_CHANNEL + await closeErrorChannel() +}) + +describe('resolveChannelPath', () => { + it('should refuse a path that would swallow the app\'s own routes', () => { + expect(resolveChannelPath('/__nuxt_dev__/error')).toBe('/__nuxt_dev__/error') + expect(resolveChannelPath('/__nuxt_dev__/error/')).toBe('/__nuxt_dev__/error') + expect(resolveChannelPath('/')).toBeUndefined() + expect(resolveChannelPath('__nuxt_dev__/error')).toBeUndefined() + expect(resolveChannelPath(42)).toBeUndefined() + }) +}) + +describe('isErrorChannelRequest', () => { + it('should match the channel and nothing else', () => { + expect(isErrorChannelRequest(DEFAULT_ERROR_CHANNEL, DEFAULT_ERROR_CHANNEL)).toBe(true) + expect(isErrorChannelRequest(`${DEFAULT_ERROR_CHANNEL}/events`, DEFAULT_ERROR_CHANNEL)).toBe(true) + expect(isErrorChannelRequest(`${DEFAULT_ERROR_CHANNEL}s`, DEFAULT_ERROR_CHANNEL)).toBe(false) + expect(isErrorChannelRequest('/__nuxt_dev__/progress', DEFAULT_ERROR_CHANNEL)).toBe(false) + }) +}) + +describe('the forwarding protocol', () => { + it('should only accept the messages the app forwards', () => { + expect(isDevErrorMessage({ type: 'nuxt:dev:error:report', report: {} })).toBe(true) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:clear' })).toBe(true) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:warning', report: {} })).toBe(true) + expect(isDevErrorMessage({ type: 'nuxt:internal:dev:log' })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:sync' })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:report', report: {}, request: 'GET /ok' })).toBe(true) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:report', report: {}, request: 7 })).toBe(false) + expect(isDevErrorMessage(undefined)).toBe(false) + }) + + it('should only accept a log entry the drawer can show', () => { + expect(isDevErrorMessage({ type: 'nuxt:dev:error:log', entry: { level: 'info', text: 'ready', timestamp: 1 } })).toBe(true) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:log', entry: { level: 'shout', text: 'ready' } })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:log', entry: { level: 'info', text: 42 } })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:log', entry: null })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:log' })).toBe(false) + }) + + it('should only accept a progress update the bar can draw', () => { + expect(isDevErrorMessage({ type: 'nuxt:dev:error:progress', progress: { phase: 'transform' } })).toBe(true) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:progress', progress: { phase: 'transform', percent: 100, message: 'Rebuilding' } })).toBe(true) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:progress', progress: { phase: 7 } })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:progress', progress: { phase: 'transform', percent: Number.NaN } })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:progress', progress: { phase: 'transform', percent: '50' } })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:progress', progress: { phase: 'transform', message: 3 } })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:progress', progress: null })).toBe(false) + expect(isDevErrorMessage({ type: 'nuxt:dev:error:progress' })).toBe(false) + }) +}) + +const idleSnapshot = { + status: 'ready' as const, + phase: 'ready', + message: 'Ready', + index: 6, + total: 6, + progress: 1, + elapsed: 0, + phaseElapsed: 0, + reload: false, + serving: true, + timings: [], +} + +describe('toBuildProgress', () => { + const snapshot = { + status: 'loading' as const, + phase: 'bundle', + message: 'Bundling app', + index: 4, + total: 6, + progress: 0.666, + elapsed: 0, + phaseElapsed: 0, + reload: false, + serving: false, + timings: [], + } + + it('should leave the bar indeterminate once the load has failed', () => { + expect(toBuildProgress(snapshot)).toEqual({ phase: 'bundle', percent: 67, message: 'Bundling app' }) + expect(toBuildProgress({ ...snapshot, status: 'error' }).percent).toBeUndefined() + }) +}) + +function compileReport(file: string, line: number, column: number) { + return { + id: `${file}:${line}`, + kind: 'error', + name: 'Error', + message: 'failed to load', + frames: [], + sections: [], + timestamp: Date.now(), + causes: [{ + id: 'compile', + kind: 'compile', + name: 'CompileError', + message: 'unexpected token', + frames: [{ file, line, column, type: 'app' }], + sections: [], + causes: [], + timestamp: Date.now(), + }], + } as unknown as ErrorReport +} + +describe('toCompileInput', () => { + it('should lift the position a syntax error quotes in its message', async () => { + const dir = await mkdtemp(join(tmpdir(), 'nuxi-config-')) + const file = join(dir, 'nuxt.config.ts') + await writeFile(file, 'export default defineNuxtConfig({\n a: 1\n b: 2\n})\n') + const error = new Error(`ParseError: Unexpected token, expected "," \n ${file}:3:2`) + + const input = toCompileInput(error) + + // Handed on with forward slashes, which is the only form a report reads. + expect(input).toMatchObject({ + name: 'ParseError', + message: 'Unexpected token, expected ","', + id: normalize(file), + loc: { file: normalize(file), line: 3, column: 2 }, + }) + }) + + it('should render the source the error failed on', async () => { + const dir = await mkdtemp(join(tmpdir(), 'nuxi-config-')) + const file = join(dir, 'nuxt.config.ts') + await writeFile(file, 'export default defineNuxtConfig({\n a: 1\n b: 2\n})\n') + const report = await createCliReport(new Error(`ParseError: Unexpected token\n ${file}:3:2`), { cwd: dir }) + + expect(report.kind).toBe('compile') + expect(report.frames[0]?.snippet?.lines.join('\n')).toContain('b: 2') + }) + + it('should leave an error whose position it cannot trust alone', () => { + expect(toCompileInput(new Error('boom'))).toBeUndefined() + expect(toCompileInput(new Error('ParseError: boom\n /nowhere/nuxt.config.ts:3:2'))).toBeUndefined() + expect(toCompileInput('not an error')).toBeUndefined() + }) +}) + +describe('renderErrorPage', () => { + it('should render a page that subscribes to the channel it is served from', async () => { + const report = await createCliReport(new Error('rendered'), { cwd: process.cwd() }) + + const html = await renderErrorPage(report, { channel: DEFAULT_ERROR_CHANNEL }) + + expect(html).toContain('rendered') + expect(html).toContain(DEFAULT_ERROR_CHANNEL) + }) +}) + +describe('formatReportForTerminal', () => { + const report = { id: 'a', name: 'SyntaxError', message: 'Illegal \'/\' in tags.', location: 'app/app.vue:16:6', ansi: 'the frame' } + + it('should head a report with the request that hit it', () => { + expect(formatReportForTerminal({ ...report, request: 'GET /ok?a=1' })).toBe('[request error] [GET] /ok?a=1\n\n the frame') + }) + + it('should print a report raised outside a request as it is', () => { + expect(formatReportForTerminal(report)).toBe('the frame') + }) +}) + +describe('summariseReport', () => { + it('should carry the rendering and the topmost frame of the project', async () => { + const error = new Error('summarise me') + const report = await createCliReport(error, { cwd: process.cwd() }) + const summary = await summariseReport(report, { requestId: 7 }) + + expect(summary).toMatchObject({ id: report.id, name: 'Error', message: 'summarise me', requestId: 7 }) + expect(summary.file).toContain('error-channel.spec.ts') + expect(summary.location).toMatch(/^\.\/packages\/nuxt-cli\/test\/unit\/error-channel\.spec\.ts:\d+:\d+$/) + expect(summary.ansi).toContain('summarise me') + }) +}) + +describe('the CLI-owned error channel', () => { + it('should answer the channel stream before nuxt exists', async () => { + const server = createServer() + const { res, chunks, headers } = createResponse() + + await Promise.race([ + server.handler(request(`${DEFAULT_ERROR_CHANNEL}/events`), res), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error('request hung')), 1000)), + ]) + + expect(headers['content-type']).toBe('text/event-stream') + expect(chunks.join('')).toContain('event: hello') + }) + + it('should announce the path it mounted to the app', () => { + createServer() + + expect(process.env.NUXT_DEV_ERROR_CHANNEL).toBe(DEFAULT_ERROR_CHANNEL) + }) + + it('should leave the channel to the app when it runs outside this process', async () => { + vi.stubEnv('NITRO_DEV_RUNNER', 'node-process') + const server = createServer() + const { res, statusOf } = createResponse() + + await server.handler(request(`${DEFAULT_ERROR_CHANNEL}/events`), res) + + expect(process.env.NUXT_DEV_ERROR_CHANNEL).toBeUndefined() + expect(statusOf()).toBe(503) + }) + + it('should publish and serve a report the app forwards', async () => { + const server = createServer() + const report = await createCliReport(new Error('forwarded from the app'), { cwd: process.cwd() }) + const reports: Array<{ report: ErrorReport, context: ReportContext }> = [] + const close = openErrorBridge({ onReport: (report, context) => reports.push({ report, context }) }) + + const app = new BroadcastChannel(ERROR_BROADCAST_CHANNEL) + app.postMessage({ type: 'nuxt:dev:error:report', report, requestId: 3 }) + app.close() + + await vi.waitUntil(() => reports.length === 1) + close() + expect(reports[0]!.report.message).toBe('forwarded from the app') + expect(reports[0]!.context.requestId).toBe(3) + + const { res, chunks } = createResponse() + await server.handler(request(`${DEFAULT_ERROR_CHANNEL}/history/${report.id}`), res) + expect(chunks.join('')).toContain('forwarded from the app') + }) + + it('should publish a log entry the app forwards, without telling the supervisor', async () => { + createServer() + const instance = await useErrorChannel() + const log = vi.spyOn(instance, 'log') + const reports: ErrorReport[] = [] + const cleared: Array = [] + const close = openErrorBridge({ onReport: report => reports.push(report), onClear: id => cleared.push(id) }) + + const app = new BroadcastChannel(ERROR_BROADCAST_CHANNEL) + app.postMessage({ type: 'nuxt:dev:error:log', entry: { level: 'warn', text: 'slow route', timestamp: 5 } }) + app.postMessage({ type: 'nuxt:dev:error:log', entry: { level: 'nope', text: 'dropped' } }) + app.close() + + await vi.waitUntil(() => log.mock.calls.length === 1) + close() + expect(log).toHaveBeenCalledWith({ level: 'warn', text: 'slow route', timestamp: 5 }) + expect(reports).toHaveLength(0) + expect(cleared).toHaveLength(0) + }) + + it('should publish a progress update the app forwards, without telling the supervisor', async () => { + createServer() + toBuildProgress({ ...idleSnapshot, status: 'ready' }) + const instance = await useErrorChannel() + const progress = vi.spyOn(instance, 'progress') + const reports: ErrorReport[] = [] + const cleared: Array = [] + const close = openErrorBridge({ onReport: report => reports.push(report), onClear: id => cleared.push(id) }) + + const app = new BroadcastChannel(ERROR_BROADCAST_CHANNEL) + app.postMessage({ type: 'nuxt:dev:error:progress', progress: { phase: 'transform', message: 'Rebuilding' } }) + app.postMessage({ type: 'nuxt:dev:error:progress', progress: { phase: 'transform', percent: 'done' } }) + app.close() + + await vi.waitUntil(() => progress.mock.calls.length === 1) + close() + expect(progress).toHaveBeenCalledWith({ phase: 'transform', message: 'Rebuilding' }) + expect(reports).toHaveLength(0) + expect(cleared).toHaveLength(0) + }) + + it('should ignore forwarded progress while the CLI has a load of its own in flight', async () => { + createServer() + toBuildProgress({ ...idleSnapshot, status: 'loading' }) + const instance = await useErrorChannel() + const progress = vi.spyOn(instance, 'progress') + const log = vi.spyOn(instance, 'log') + const close = openErrorBridge() + + const app = new BroadcastChannel(ERROR_BROADCAST_CHANNEL) + app.postMessage({ type: 'nuxt:dev:error:progress', progress: { phase: 'transform', message: 'Rebuilding' } }) + app.postMessage({ type: 'nuxt:dev:error:log', entry: { level: 'info', text: 'after' } }) + app.close() + + await vi.waitUntil(() => log.mock.calls.length === 1) + close() + expect(progress).not.toHaveBeenCalled() + + toBuildProgress({ ...idleSnapshot, status: 'ready' }) + }) + + it('should refuse a channel request another site made', async () => { + const server = createServer() + const { res, statusOf } = createResponse() + + await server.handler(openRequest({ 'origin': 'https://evil.example', 'sec-fetch-site': 'cross-site' }), res) + + expect(statusOf()).toBe(403) + }) + + it('should refuse a channel request that did not come from the error page', async () => { + const server = createServer() + const { res, statusOf } = createResponse() + + await server.handler(openRequest({ 'origin': 'https://evil.example', 'content-type': 'text/plain' }), res) + + expect(statusOf()).toBe(403) + }) + + it.skipIf(process.platform === 'win32')('should only open files of the project it was pointed at', async () => { + const { dir, file, opened } = await createProject() + + const instance = await useErrorChannel({ cwd: dir }) + await instance.handler(openRequest({}, '/etc/passwd'), createResponse().res) + await instance.handler(openRequest({}, file), createResponse().res) + + await vi.waitUntil(() => existsSync(opened)) + const spawned = await readFile(opened, 'utf8') + expect(spawned).toContain(file) + expect(spawned).not.toContain('passwd') + }) + + it.skipIf(process.platform === 'win32')('should answer its own page served on a host the CLI allowed', async () => { + const { dir, file } = await createProject() + const { res, statusOf } = createResponse() + + const instance = await useErrorChannel({ cwd: dir }) + await instance.handler(openRequest({ 'host': '192.168.1.20:3000', 'origin': 'http://192.168.1.20:3000', 'sec-fetch-site': 'same-origin' }, file), res) + + expect(statusOf()).toBe(204) + }) + + it('should ask whoever is already reporting to post it again', async () => { + const app = new BroadcastChannel(ERROR_BROADCAST_CHANNEL) + const synced = new Promise((resolve) => { + app.onmessage = (event: { data: unknown }) => resolve((event.data as { type?: string }).type) + }) + + const close = openErrorBridge() + + expect(await synced).toBe('nuxt:dev:error:sync') + close() + app.close() + }) + + it('should show the report of every failing request, in the order they arrive', async () => { + const reports: Array<{ report: ErrorReport, context: ReportContext }> = [] + const close = openErrorBridge({ onReport: (report, context) => reports.push({ report, context }) }) + + const app = new BroadcastChannel(ERROR_BROADCAST_CHANNEL) + app.postMessage({ type: 'nuxt:dev:error:report', report: compileReport('/app/app.vue', 3, 1) }) + app.postMessage({ type: 'nuxt:dev:error:report', report: compileReport('/app/app.vue', 3, 1), requestId: 2, request: 'GET /ok' }) + app.close() + + await vi.waitUntil(() => reports.length === 2) + close() + expect(reports[1]!.context.request).toBe('GET /ok') + // The page is served with the incoming report, so the channel shows the same one. + expect((await useErrorChannel()).current?.id).toBe(reports[1]!.report.id) + }) + + it('should write paths relative to the project it was given', async () => { + const summary = await summariseReport(compileReport('/app/app.vue', 16, 6), {}, '/app') + + expect(summary.ansi).toContain('app.vue:16:6') + expect(summary.ansi).not.toContain('/app/app.vue:16:6') + }) + + it('should not echo a cause that only repeats its parent', async () => { + const wrapped = compileReport('/app/app.vue', 3, 1) + wrapped.causes = [{ ...wrapped.causes[0]!, message: wrapped.message, name: 'HTTPError' }] + const summary = await summariseReport(wrapped) + + expect(summary.ansi).not.toContain('HTTPError') + }) + + it('should answer an unknown channel path itself rather than passing it on', async () => { + const server = createServer() + const { res, statusOf } = createResponse() + + await server.handler(request(DEFAULT_ERROR_CHANNEL), res) + + expect(statusOf()).toBe(404) + }) +}) diff --git a/packages/nuxt-cli/test/unit/errors.spec.ts b/packages/nuxt-cli/test/unit/errors.spec.ts index d8e09264f..2016c0290 100644 --- a/packages/nuxt-cli/test/unit/errors.spec.ts +++ b/packages/nuxt-cli/test/unit/errors.spec.ts @@ -1,11 +1,5 @@ -import type { StackFrame } from 'youch-core/types' - -import { mkdtemp, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { applySourceMap, stripCwd } from '../../src/dev/error' import { ActionableError, asActionableError, isRemotePeerError } from '../../src/utils/errors' describe('actionableError', () => { @@ -67,60 +61,3 @@ describe('isRemotePeerError', () => { expect(isRemotePeerError(undefined)).toBe(false) }) }) - -describe('stripCwd', () => { - it('should strip posix working directories', () => { - expect(stripCwd('at /home/me/app/pages/index.vue:3:1', '/home/me/app')).toBe('at ./pages/index.vue:3:1') - }) - - it('should strip both spellings of a windows working directory', () => { - const cwd = 'C:\\Users\\me\\app' - expect(stripCwd('at C:/Users/me/app/pages/index.vue:3:1', cwd)).toBe('at ./pages/index.vue:3:1') - expect(stripCwd('at C:\\Users\\me\\app\\pages\\index.vue:3:1', cwd)).toBe('at .\\pages\\index.vue:3:1') - }) - - it('should leave unrelated paths alone', () => { - expect(stripCwd('at /elsewhere/app/index.vue:1:1', '/home/me/app')).toBe('at /elsewhere/app/index.vue:1:1') - }) -}) - -describe('applySourceMap', () => { - const mappings = 'AAAA,SAAS,IAAI;EACX,OAAO,CAAC;AACV' - - async function withMap(map: Record, frame: Partial) { - const dir = await mkdtemp(join(tmpdir(), 'nuxi-sourcemap-')) - const file = join(dir, 'out.mjs') - await writeFile(file, 'export const noop = () => {}\n') - await writeFile(`${file}.map`, JSON.stringify(map)) - const resolved = { fileName: file, ...frame } as StackFrame - await applySourceMap(resolved) - return resolved - } - - it('should rewrite a frame to its original position', async () => { - const frame = await withMap( - { version: 3, sources: ['src/foo.ts'], names: [], mappings }, - { lineNumber: 2, columnNumber: 2 }, - ) - expect(frame.fileName?.endsWith('src/foo.ts')).toBe(true) - expect(frame.lineNumber).toBe(2) - expect(frame.columnNumber).toBe(2) - }) - - it('should resolve sources against `sourceRoot`', async () => { - const frame = await withMap( - { version: 3, sourceRoot: '../src', sources: ['foo.ts'], names: [], mappings }, - { lineNumber: 2, columnNumber: 2 }, - ) - expect(frame.fileName?.endsWith('src/foo.ts')).toBe(true) - }) - - it('should leave a frame with no mapping untouched', async () => { - const frame = await withMap( - { version: 3, sources: ['src/foo.ts'], names: [], mappings: '' }, - { lineNumber: 4, columnNumber: 0 }, - ) - expect(frame.lineNumber).toBe(4) - expect(frame.fileName?.endsWith('out.mjs')).toBe(true) - }) -}) diff --git a/packages/nuxt-cli/tsdown.config.ts b/packages/nuxt-cli/tsdown.config.ts index a5b791ece..013cdb8e0 100644 --- a/packages/nuxt-cli/tsdown.config.ts +++ b/packages/nuxt-cli/tsdown.config.ts @@ -2,11 +2,10 @@ import type { PackagingContract } from '../../scripts/tsdown.ts' import { defineCliConfig, PARSER_PACKAGES, PARSER_SPECIFIERS } from '../../scripts/tsdown.ts' export const packaging: PackagingContract = { - traced: ['youch', 'youch-core'], external: PARSER_SPECIFIERS, lazy: { 'dist/index.mjs': ['rc9'], - 'dist/dev/index.mjs': ['youch', 'youch-core', 'rc9'], + 'dist/dev/index.mjs': ['rc9', 'my-bad', 'my-bad/channel', 'my-bad/presets', 'my-bad/sinks'], }, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c38a2a31a..c883434cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,6 +211,9 @@ importers: get-port-please: specifier: ^3.2.0 version: 3.2.0 + my-bad: + specifier: https://pkg.pr.new/my-bad@d9f855a + version: https://pkg.pr.new/my-bad@d9f855a(vite@8.2.2) obug: specifier: ^2.1.4 version: 2.1.4 @@ -311,12 +314,6 @@ importers: vitest: specifier: ^4.1.11 version: 4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2) - youch: - specifier: ^4.1.1 - version: 4.1.1 - youch-core: - specifier: ^0.3.3 - version: 0.3.3 packages/nuxt-cli/test/fixtures/dev: dependencies: @@ -3133,6 +3130,10 @@ packages: resolution: {integrity: sha512-JRBIpDbw7S1vaEGvCXfm728cfchkUDtv4I2DjWMMm7IoEygxuFWhO6UQOrg+u61TPI13c/HsGG/RLwl3NBt+4w==} engines: {node: '>=22.1.0'} + clickable-path@0.1.1: + resolution: {integrity: sha512-ROn/mdV2pR8WFdpJjFDCSouLRg9myf95xacO1iy8G/AVO/x5y124pjF3EaoWrTBRWC3vh0DPqa/MhKyx3mMeKg==} + engines: {node: '>=22.1.0'} + clipboardy@3.0.0: resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3540,6 +3541,9 @@ packages: errx@0.1.2: resolution: {integrity: sha512-chfpPHmCerdo/rXr/nNvPZRkV4WwDRwzwnsJ0Uzz3tVi8Z41tDctRjduYy1138ii77AFlts1qvWtX3g/Acg91Q==} + errx@0.2.0: + resolution: {integrity: sha512-jXOl6C1FPUaDqJj7gPG0nrroMRxfmoY5VW+eZX/ttzpJ7ncubpdS2HCg+kMlPVeOiZzTZSAxs/Ny25HbHHfa/w==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -4775,6 +4779,16 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + my-bad@https://pkg.pr.new/my-bad@d9f855a: + resolution: {integrity: sha512-J98HJ/Yc+wsFfxMlBTrEVe/cIfEPOYeFXNXhqJ3JwJ5fNf2WMZnnLLM7+uk1Pc4g0lORJjEBNxMkoper+KR1Gg==, tarball: https://pkg.pr.new/my-bad@d9f855a} + version: 0.0.1 + engines: {node: '>=22.12.0'} + peerDependencies: + vite: '>=6' + peerDependenciesMeta: + vite: + optional: true + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -10181,6 +10195,8 @@ snapshots: clickable-path@0.1.0: {} + clickable-path@0.1.1: {} + clipboardy@3.0.0: dependencies: arch: 2.2.0 @@ -10541,6 +10557,8 @@ snapshots: errx@0.1.2: {} + errx@0.2.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -12064,6 +12082,14 @@ snapshots: muggle-string@0.4.1: {} + my-bad@https://pkg.pr.new/my-bad@d9f855a(vite@8.2.2): + dependencies: + clickable-path: 0.1.1 + errx: 0.2.0 + fnv1a-64: 0.1.2 + optionalDependencies: + vite: 8.2.2(@types/node@24.13.3)(@vitejs/devtools@0.5.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + nanoid@3.3.18: {} nanotar@0.3.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ac95bf4f2..9a060018a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,8 @@ minimumReleaseAgeExclude: - vue - '@vue/*' - fuzzysort@4.0.1 + - errx@0.2.0 + - my-bad packages: - packages/* diff --git a/scripts/check-youch.ts b/scripts/check-youch.ts deleted file mode 100644 index 8c082dbf5..000000000 --- a/scripts/check-youch.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createRequire } from 'node:module' -import { resolve } from 'node:path' -import process from 'node:process' -import { pathToFileURL } from 'node:url' - -/** - * `youch` cannot be bundled: it reads its own stylesheets and client scripts off - * disk relative to `import.meta.url`. The build traces it into - * `dist/node_modules` instead, which a bundler change could silently undo, so - * this asserts the built output can still render an error page. - */ -const entry = resolve(process.cwd(), process.argv[2] ?? 'dist/index.mjs') - -function fail(message: string): never { - console.error(`check-youch: ${message}`) - process.exit(1) -} - -const { Youch } = await import(pathToFileURL(createRequire(pathToFileURL(entry)).resolve('youch')).href) - -const html: string = await new Youch().toHTML(new Error('check-youch smoke test')).catch((error: NodeJS.ErrnoException) => { - fail(`rendering the error page failed: ${error.code ?? ''} ${error.message}`) -}) - -if (!html.includes('check-youch smoke test')) { - fail('the rendered error page does not contain the error message') -} - -if (!html.includes('