Skip to content

Commit

Permalink
standardize errors thrown in webpack builder
Browse files Browse the repository at this point in the history
- also bring back error logging on dev mode, which seemed to be lost in Storybook 7.0.0
  • Loading branch information
yannbf committed Sep 1, 2023
1 parent e92d874 commit 553789e
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 93 deletions.
131 changes: 65 additions & 66 deletions code/builders/builder-webpack5/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { dirname, join, parse } from 'path';
import express from 'express';
import fs from 'fs-extra';
import { PREVIEW_BUILDER_PROGRESS } from '@storybook/core-events';
import { WebpackCompilationError } from '@storybook/core-events/server-errors';
import {
WebpackCompilationError,
WebpackInvocationError,
WebpackMissingStatsError,
} from '@storybook/core-events/server-errors';

import prettyTime from 'pretty-hrtime';

Expand Down Expand Up @@ -118,21 +122,19 @@ const starter: StarterFunction = async function* starterGeneratorFn({
yield;

const config = await getConfig(options);

if (config.stats === 'none' || config.stats === 'summary') {
throw new WebpackMissingStatsError();
}
yield;

const compiler = webpackInstance(config);

if (!compiler) {
const err = `${config.name}: missing webpack compiler at runtime!`;
logger.error(err);
return {
bail,
totalTime: process.hrtime(startTime),
stats: {
hasErrors: () => true,
hasWarnings: () => false,
toJson: () => ({ warnings: [] as any[], errors: [err] }),
} as any as Stats,
};
throw new WebpackInvocationError({
// eslint-disable-next-line local-rules/no-uncategorized-errors
error: new Error(`Missing Webpack compiler at runtime!`),
});
}

yield;
Expand Down Expand Up @@ -173,6 +175,7 @@ const starter: StarterFunction = async function* starterGeneratorFn({
const middlewareOptions: Parameters<typeof webpackDevMiddleware>[1] = {
publicPath: config.output?.publicPath as string,
writeToDisk: true,
stats: 'errors-only',
};

compilation = webpackDevMiddleware(compiler, middlewareOptions);
Expand All @@ -185,18 +188,24 @@ const starter: StarterFunction = async function* starterGeneratorFn({
router.use(compilation);
router.use(webpackHotMiddleware(compiler, { log: false }));

const stats = await new Promise<Stats>((ready, stop) => {
compilation?.waitUntilValid(ready as any);
reject = stop;
const stats = await new Promise<Stats>((res, rej) => {
compilation?.waitUntilValid(res as any);
reject = rej;
});
yield;

if (!stats) {
throw new Error('no stats after building preview');
throw new WebpackMissingStatsError();
}

if (stats.hasErrors()) {
throw new WebpackCompilationError({ error: stats });
const { warnings, errors } = getWebpackStats({ config, stats });

if (warnings.length > 0) {
warnings?.forEach((e) => logger.error(e.message));
}

if (errors.length > 0) {
throw new WebpackCompilationError({ errors });
}

return {
Expand All @@ -206,6 +215,22 @@ const starter: StarterFunction = async function* starterGeneratorFn({
};
};

function getWebpackStats({ config, stats }: { config: Configuration; stats: Stats }) {
const statsOptions =
typeof config.stats === 'string'
? config.stats
: {
...(config.stats as StatsOptions),
warnings: true,
errors: true,
};
const { warnings = [], errors = [] } = stats?.toJson(statsOptions) || {};
return {
warnings,
errors,
};
}

/**
* This function is a generator so that we can abort it mid process
* in case of failure coming from other processes e.g. manager builder
Expand All @@ -215,73 +240,47 @@ const starter: StarterFunction = async function* starterGeneratorFn({
const builder: BuilderFunction = async function* builderGeneratorFn({ startTime, options }) {
const webpackInstance = await executor.get(options);
yield;
logger.info('=> Compiling preview..');
const config = await getConfig(options);

if (config.stats === 'none' || config.stats === 'summary') {
throw new WebpackMissingStatsError();
}
yield;

const compiler = webpackInstance(config);

if (!compiler) {
const err = `${config.name}: missing webpack compiler at runtime!`;
logger.error(err);
return {
hasErrors: () => true,
hasWarnings: () => false,
toJson: () => ({ warnings: [] as any[], errors: [err] }),
} as any as Stats;
throw new WebpackInvocationError({
// eslint-disable-next-line local-rules/no-uncategorized-errors
error: new Error(`Missing Webpack compiler at runtime!`),
});
}

const webpackCompilation = new Promise<Stats>((succeed, fail) => {
compiler.run((error, stats) => {
if (error || !stats || stats.hasErrors()) {
logger.error('=> Failed to build the preview');
process.exitCode = 1;

if (error) {
logger.error(error.message);
if (error) {
compiler.close(() => fail(new WebpackInvocationError({ error })));
return;
}

compiler.close(() => fail(error));
if (!stats) {
throw new WebpackMissingStatsError();
}

return;
}
const { warnings, errors } = getWebpackStats({ config, stats });

if (stats && (stats.hasErrors() || stats.hasWarnings())) {
const { warnings = [], errors = [] } = stats.toJson(
typeof config.stats === 'string'
? config.stats
: {
warnings: true,
errors: true,
...(config.stats as StatsOptions),
}
);

errors.forEach((e) => logger.error(e.message));
warnings.forEach((e) => logger.error(e.message));

compiler.close(() =>
options.debugWebpack
? fail(stats)
: fail(new Error('=> Webpack failed, learn more with --debug-webpack'))
);

return;
}
if (warnings.length > 0) {
warnings?.forEach((e) => logger.error(e.message));
}

logger.trace({ message: '=> Preview built', time: process.hrtime(startTime) });
if (stats && stats.hasWarnings()) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know it has warnings because of hasWarnings()
stats
.toJson({ warnings: true } as StatsOptions)
.warnings!.forEach((e) => logger.warn(e.message));
if (errors.length > 0) {
compiler.close(() => fail(new WebpackCompilationError({ errors })));
return;
}

// https://webpack.js.org/api/node/#run
// #15227
compiler.close((closeErr) => {
if (closeErr) {
return fail(closeErr);
return fail(new WebpackInvocationError({ error: closeErr }));
}

return succeed(stats as Stats);
Expand Down
85 changes: 59 additions & 26 deletions code/lib/core-events/src/errors/server-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,47 +138,80 @@ export class InvalidStoriesEntryError extends StorybookError {
}
}

export class WebpackCompilationError extends StorybookError {
export class WebpackMissingStatsError extends StorybookError {
readonly category = Category.BUILDER_WEBPACK5;

readonly code = 1;

public documentation = [
'https://webpack.js.org/configuration/stats/',
'https://storybook.js.org/docs/react/builders/webpack#configure',
];

template() {
return dedent`
No Webpack stats found. Did you turn off stats reporting in your webpack config?
Storybook needs Webpack stats (including errors) in order to build correctly.
`;
}
}

export class WebpackInvocationError extends StorybookError {
readonly category = Category.BUILDER_WEBPACK5;

readonly code = 2;

private errorMessage = '';

constructor(
public data: {
error:
| (Error & {
error?: Error;
stats?: { compilation: { errors: Error[] } };
compilation?: { errors: Error[] };
})
| {
compilation?: { errors: Error[] };
};
error: Error;
}
) {
super();
this.errorMessage = data.error.message;
}

template() {
return this.errorMessage.trim();
}
}
function removeAnsiEscapeCodes(input = '') {
// eslint-disable-next-line no-control-regex
return input.replace(/\u001B\[[0-9;]*m/g, '');
}

export class WebpackCompilationError extends StorybookError {
readonly category = Category.BUILDER_WEBPACK5;

readonly code = 3;

if (data.error instanceof Error) {
if (data.error.error) {
this.errorMessage = data.error.error.message;
this.stack = data.error.error.stack;
} else if (data.error.stats && data.error.stats.compilation.errors) {
data.error.stats.compilation.errors.forEach((e: Error) => {
this.errorMessage += `${e.name}: ${e.message}\n\n`;
});
} else {
this.errorMessage = data.error.message;
}
} else if (data.error.compilation?.errors) {
data.error.compilation.errors.forEach((e: Error) => {
this.errorMessage += `${e.name}: ${e.message}\n\n`;
});
constructor(
public data: {
errors: {
message: string;
stack?: string;
name?: string;
}[];
}
) {
super();

this.data.errors = data.errors.map((e, i) => {
return {
...e,
message: removeAnsiEscapeCodes(e.message),
stack: removeAnsiEscapeCodes(e.stack),
name: e.name,
};
});
}

template() {
return this.errorMessage.trim();
// This error message is a followup of errors logged by Webpack to the user
return dedent`
There were problems when compiling your code with Webpack.
Run Storybook with --debug-webpack for more information.
`;
}
}
12 changes: 11 additions & 1 deletion code/lib/core-server/src/build-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,23 +199,33 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption

if (options.ignorePreview) {
logger.info(`=> Not building preview`);
} else {
logger.info('=> Building preview..');
}

const startTime = process.hrtime();
await Promise.all([
...(options.ignorePreview
? []
: [
previewBuilder
.build({
startTime: process.hrtime(),
startTime,
options: fullOptions,
})
.then(async (previewStats) => {
logger.trace({ message: '=> Preview built', time: process.hrtime(startTime) });

if (options.webpackStatsJson) {
const target =
options.webpackStatsJson === true ? options.outputDir : options.webpackStatsJson;
await outputStats(target, previewStats);
}
})
.catch((error) => {
logger.error('=> Failed to build the preview');
process.exitCode = 1;
throw error;
}),
]),
...effects,
Expand Down
5 changes: 5 additions & 0 deletions code/lib/core-server/src/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { CoreConfig, Options, StorybookConfig } from '@storybook/types';

import { logConfig } from '@storybook/core-common';

import { logger } from '@storybook/node-logger';
import { getMiddleware } from './utils/middleware';
import { getServerAddresses } from './utils/server-address';
import { getServer } from './utils/server-init';
Expand Down Expand Up @@ -90,6 +91,7 @@ export async function storybookDevServer(options: Options) {
let previewStarted: Promise<any> = Promise.resolve();

if (!options.ignorePreview) {
logger.info('=> Starting preview..');
previewStarted = previewBuilder
.start({
startTime: process.hrtime(),
Expand All @@ -99,6 +101,9 @@ export async function storybookDevServer(options: Options) {
channel: serverChannel,
})
.catch(async (e: any) => {
logger.error('=> Failed to build the preview');
process.exitCode = 1;

await managerBuilder?.bail().catch();
// For some reason, even when Webpack fails e.g. wrong main.js config,
// the preview may continue to print to stdout, which can affect output
Expand Down

0 comments on commit 553789e

Please sign in to comment.