From e427078a418bb25fccf5318b410c8f6b4abd2df9 Mon Sep 17 00:00:00 2001 From: fireairforce <1344492820@qq.com> Date: Fri, 12 Jun 2026 11:58:28 +0800 Subject: [PATCH] feat(pack): surface initial dev compile issues --- packages/pack/src/commands/dev.ts | 10 +- packages/pack/src/core/hmr.test.ts | 152 +++++++++++++++++++++++++++++ packages/pack/src/core/hmr.ts | 123 ++++++++++++++++++----- 3 files changed, 259 insertions(+), 26 deletions(-) create mode 100644 packages/pack/src/core/hmr.test.ts diff --git a/packages/pack/src/commands/dev.ts b/packages/pack/src/commands/dev.ts index 8701155d0..d4ae6a61f 100644 --- a/packages/pack/src/commands/dev.ts +++ b/packages/pack/src/commands/dev.ts @@ -17,7 +17,7 @@ import { resolveBundleOptions, type WebpackConfig, } from "../config/webpackCompat"; -import type { HotReloaderInterface } from "../core/hmr"; +import type { HotReloaderInterface, OnCompileDone } from "../core/hmr"; import { createHotReloader } from "../core/hmr"; import { createHttpProxyMiddleware } from "../core/proxyHono"; import { getOutputPath } from "../utils/cleanOutput"; @@ -114,6 +114,11 @@ export interface StartServerOptions { port: number; hostname: string; }) => void | Promise; + /** + * Called after the initial dev compilation and every following rebuild with + * the same structured errors and warnings sent through HMR. + */ + onCompileDone?: OnCompileDone; } /** Options for honoServe excluding fetch; we only use HTTP or HTTPS (no HTTP/2). */ @@ -243,6 +248,9 @@ async function runDev( bundleOptions, projectPathResolved, rootPathResolved, + { + onCompileDone: serverOptions?.onCompileDone, + }, ); await hotReloader.start(); diff --git a/packages/pack/src/core/hmr.test.ts b/packages/pack/src/core/hmr.test.ts new file mode 100644 index 000000000..29ac4b2b8 --- /dev/null +++ b/packages/pack/src/core/hmr.test.ts @@ -0,0 +1,152 @@ +import type { Issue } from "@utoo/pack-shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Endpoint, Project } from "./types"; + +let mockProject: Project; +let mockEndpoint: Endpoint; + +vi.mock("./project", () => ({ + projectFactory: () => async () => mockProject, +})); + +function createIssue(): Issue { + return { + severity: "error", + stage: "build", + filePath: "[project]/src/index.less", + title: { + type: "text", + value: "Unrecognised input", + }, + description: { + type: "text", + value: "Less parser failed", + }, + documentationLink: "", + importTraces: [], + }; +} + +async function* once(value: T) { + yield value; +} + +async function* empty() {} + +describe("createHotReloader", () => { + beforeEach(() => { + mockEndpoint = { + writeToDisk: vi.fn(async () => ({ + issues: [createIssue()], + })) as any, + clientChanged: vi.fn(async () => empty()), + serverChanged: vi.fn(async () => empty()), + }; + + mockProject = { + update: vi.fn(), + writeAllEntrypointsToDisk: vi.fn(async () => ({ + apps: [mockEndpoint], + appPaths: [], + issues: [createIssue()], + })), + entrypointsSubscribe: vi.fn(() => + once({ + apps: [mockEndpoint], + issues: [], + }), + ), + hmrEvents: vi.fn(), + hmrIdentifiersSubscribe: vi.fn(), + getSourceForAsset: vi.fn(), + getSourceMap: vi.fn(), + getSourceMapSync: vi.fn(), + traceSource: vi.fn(), + updateInfoSubscribe: vi.fn(() => empty()), + shutdown: vi.fn(async () => {}), + onExit: vi.fn(async () => {}), + }; + }); + + async function createStartedHotReloader( + stats = false, + onCompileDone = vi.fn(), + ) { + const { createHotReloader } = await import("./hmr"); + const hotReloader = await createHotReloader( + { + config: { + entry: [], + output: { + clean: false, + path: "dist", + }, + optimization: {}, + persistentCaching: false, + stats, + }, + } as any, + undefined, + undefined, + { onCompileDone }, + ); + + await hotReloader.start(); + return { hotReloader, onCompileDone }; + } + + function collectSyncErrors( + hotReloader: Awaited< + ReturnType + >["hotReloader"], + ) { + const sent: any[] = []; + hotReloader.registerClient({ + send(data) { + sent.push(JSON.parse(data)); + }, + close: vi.fn(), + }); + + const sync = sent.find((message) => message.action === "sync"); + return sync?.errors; + } + + it("surfaces initial endpoint output issues through sync without failing startup", async () => { + const { hotReloader, onCompileDone } = await createStartedHotReloader(); + + const errors = collectSyncErrors(hotReloader); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain("Less parser failed"); + expect(onCompileDone).toHaveBeenCalledWith({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining("Less parser failed"), + }), + ]), + warnings: [], + }); + expect(mockEndpoint.writeToDisk).toHaveBeenCalled(); + + await hotReloader.close(); + }); + + it("surfaces initial project output issues through sync without failing startup", async () => { + const { hotReloader, onCompileDone } = await createStartedHotReloader(true); + + const errors = collectSyncErrors(hotReloader); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain("Less parser failed"); + expect(onCompileDone).toHaveBeenCalledWith({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining("Less parser failed"), + }), + ]), + warnings: [], + }); + expect(mockProject.writeAllEntrypointsToDisk).toHaveBeenCalled(); + + await hotReloader.close(); + }); +}); diff --git a/packages/pack/src/core/hmr.ts b/packages/pack/src/core/hmr.ts index 1e4c7bb2f..d0a33862b 100644 --- a/packages/pack/src/core/hmr.ts +++ b/packages/pack/src/core/hmr.ts @@ -87,6 +87,17 @@ export interface HotReloaderInterface { close(): Promise; } +export interface CompileDoneResult { + errors: CompilationError[]; + warnings: CompilationError[]; +} + +export type OnCompileDone = (result: CompileDoneResult) => void | Promise; + +export interface HotReloaderOptions { + onCompileDone?: OnCompileDone; +} + export type ChangeSubscriptions = Map< string, Promise> @@ -125,27 +136,40 @@ function hasBlockingResultIssues(result: TurbopackResult) { ); } -function addIssuesToErrors( - errors: Map, +function addIssuesToResult( + result: { + errors: Map; + warnings: Map; + }, issues: EntryIssuesMap, ) { for (const issueMap of issues.values()) { for (const [key, issue] of issueMap) { + const message = { + message: formatIssue(issue, false), + }; + if (issue.severity === "warning") { - continue; + result.warnings.set(key, message); + } else { + result.errors.set(key, message); } - - errors.set(key, { - message: formatIssue(issue, false), - }); } } } -function getCompilationErrors(issues: EntryIssuesMap) { +function getCompilationResult(...issuesList: EntryIssuesMap[]) { const errors = new Map(); - addIssuesToErrors(errors, issues); - return [...errors.values()]; + const warnings = new Map(); + + for (const issues of issuesList) { + addIssuesToResult({ errors, warnings }, issues); + } + + return { + errors: [...errors.values()], + warnings: [...warnings.values()], + }; } function getClientIssueKey(id: string) { @@ -156,6 +180,7 @@ export async function createHotReloader( bundleOptions: BundleOptions, projectPath?: string, rootPath?: string, + options: HotReloaderOptions = {}, ): Promise { const resolvedProjectPath = projectPath || process.cwd(); const resolvedRootPath = rootPath || projectPath || process.cwd(); @@ -241,6 +266,7 @@ export async function createHotReloader( let currentWatchedEntrypoints: Endpoint[] = []; let backgroundWatchersStarted = false; + let backgroundWatchersInitialized = false; let backgroundWatchGeneration = 0; const backgroundEndpointWriteTasks = new Map>(); let backgroundProjectWriteTask: Promise | undefined; @@ -293,6 +319,18 @@ export async function createHotReloader( sendEnqueuedMessagesDebounce(); } + async function notifyCompileDone(result: CompileDoneResult) { + if (!options.onCompileDone) { + return; + } + + try { + await options.onCompileDone(result); + } catch (error) { + console.error(error); + } + } + const writtenEndpointPaths = new Map(); function updateWrittenEndpointPaths( @@ -311,6 +349,15 @@ export async function createHotReloader( }); } + function markBlockingOutputIssues(result: TurbopackResult) { + if (hasBlockingResultIssues(result)) { + hmrEventHappened = true; + sendEnqueuedMessagesDebounce(); + return true; + } + return false; + } + async function regenerateHtml() { if (htmlConfigs.length === 0) { return; @@ -330,7 +377,16 @@ export async function createHotReloader( async function writeAllEntrypointsToDisk() { const result = await project.writeAllEntrypointsToDisk(); - processIssues(result, true, true); + processIssues( + currentEntryIssues, + "entrypoints:all:output", + result, + false, + true, + ); + if (markBlockingOutputIssues(result)) { + return; + } updateWrittenEndpointPaths(result.apps, result.appPaths); updateWrittenEndpointPaths(result.libraries, result.libraryPaths); await regenerateHtml(); @@ -338,7 +394,17 @@ export async function createHotReloader( async function writeEntrypointToDisk(entrypoint: Endpoint) { const result = await entrypoint.writeToDisk(); - processIssues(result, true, true); + const entrypointIndex = currentWatchedEntrypoints.indexOf(entrypoint); + processIssues( + currentEntryIssues, + `entrypoint:${entrypointIndex}:output`, + result, + false, + true, + ); + if (markBlockingOutputIssues(result)) { + return; + } writtenEndpointPaths.set(entrypoint, result); await regenerateHtml(); } @@ -351,10 +417,12 @@ export async function createHotReloader( } } - async function disposeBackgroundWatchSubscriptions() { + async function disposeBackgroundWatchSubscriptions(clearIssues = true) { const subscriptions = [...backgroundWatchSubscriptions]; backgroundWatchSubscriptions.clear(); - currentEntryIssues.clear(); + if (clearIssues) { + currentEntryIssues.clear(); + } backgroundEndpointWriteTasks.clear(); backgroundProjectWriteTask = undefined; await Promise.all( @@ -405,12 +473,14 @@ export async function createHotReloader( async function refreshBackgroundWatchers() { const generation = ++backgroundWatchGeneration; - await disposeBackgroundWatchSubscriptions(); + await disposeBackgroundWatchSubscriptions(backgroundWatchersInitialized); if (!backgroundWatchersStarted || closed) { return; } + backgroundWatchersInitialized = true; + await Promise.all( currentWatchedEntrypoints.map(async (entrypoint) => { const [clientChanges, serverChanges] = await Promise.all([ @@ -636,13 +706,13 @@ export async function createHotReloader( }; sendToClient(client, turbopackConnected); - const errors = getCompilationErrors(currentEntryIssues); + const { errors, warnings } = getCompilationResult(currentEntryIssues); (async function () { const sync: SyncAction = { action: HMR_ACTIONS_SENT_TO_BROWSER.SYNC, errors, - warnings: [], + warnings, hash: "", }; @@ -667,11 +737,11 @@ export async function createHotReloader( }; sendToClient(ws, turbopackConnected); - const errors = getCompilationErrors(currentEntryIssues); + const { errors, warnings } = getCompilationResult(currentEntryIssues); const sync: SyncAction = { action: HMR_ACTIONS_SENT_TO_BROWSER.SYNC, errors, - warnings: [], + warnings, hash: "", }; sendToClient(ws, sync); @@ -790,6 +860,7 @@ export async function createHotReloader( // Write empty manifests await currentEntriesHandling; + await notifyCompileDone(getCompilationResult(currentEntryIssues)); async function handleProjectUpdates() { for await (const updateMessage of project.updateInfoSubscribe(30)) { @@ -801,8 +872,8 @@ export async function createHotReloader( case "end": { sendEnqueuedMessages(); - const errors = new Map(); - addIssuesToErrors(errors, currentEntryIssues); + const result = getCompilationResult(currentEntryIssues); + await notifyCompileDone(result); for (const client of clients) { const state = clientStates.get(client); @@ -810,14 +881,16 @@ export async function createHotReloader( continue; } - const clientErrors = new Map(errors); - addIssuesToErrors(clientErrors, state.clientIssues); + const clientResult = getCompilationResult( + currentEntryIssues, + state.clientIssues, + ); sendToClient(client, { action: HMR_ACTIONS_SENT_TO_BROWSER.BUILT, hash: String(++hmrHash), - errors: [...clientErrors.values()], - warnings: [], + errors: clientResult.errors, + warnings: clientResult.warnings, }); }