storybook-builder-rsbuild
Version:
Rsbuild builder for Storybook
740 lines (725 loc) • 24.4 kB
JavaScript
import { __commonJS, __toESM, __require } from './chunk-TTFRSOOU.mjs';
import { createServer } from 'net';
import path, { join, resolve, parse, dirname, isAbsolute } from 'path';
import * as rsbuildReal from '@rsbuild/core';
import { loadConfig, mergeRsbuildConfig } from '@rsbuild/core';
import fs2 from 'fs-extra';
import sirv from 'sirv';
import { corePath } from 'storybook/core-path';
import { normalizeStories, loadPreviewOrConfigFile, getBuilderOptions, readTemplate, resolveAddonName, getPresets, stringifyProcessEnvs, isPreservingSymlinks } from 'storybook/internal/common';
import { WebpackInvocationError } from 'storybook/internal/server-errors';
import { pluginTypeCheck } from '@rsbuild/plugin-type-check';
import { webpack } from '@storybook/addon-docs/dist/preset';
import CaseSensitivePathsPlugin from 'case-sensitive-paths-webpack-plugin';
import { pluginHtmlMinifierTerser } from 'rsbuild-plugin-html-minifier-terser';
import { globalsNameReferenceMap } from 'storybook/internal/preview/globals';
import { dedent } from 'ts-dedent';
import fs from 'fs';
import { webpackIncludeRegexp } from '@storybook/core-webpack';
import findCacheDirectory from 'find-cache-dir';
import { readFile } from 'fs/promises';
// ../../node_modules/.pnpm/pretty-hrtime@1.0.3/node_modules/pretty-hrtime/index.js
var require_pretty_hrtime = __commonJS({
"../../node_modules/.pnpm/pretty-hrtime@1.0.3/node_modules/pretty-hrtime/index.js"(exports, module) {
var minimalDesc = ["h", "min", "s", "ms", "\u03BCs", "ns"];
var verboseDesc = ["hour", "minute", "second", "millisecond", "microsecond", "nanosecond"];
var convert = [60 * 60, 60, 1, 1e6, 1e3, 1];
module.exports = function(source, opts) {
var verbose, precise, i, spot, sourceAtStep, valAtStep, decimals, strAtStep, results, totalSeconds;
verbose = false;
precise = false;
if (opts) {
verbose = opts.verbose || false;
precise = opts.precise || false;
}
if (!Array.isArray(source) || source.length !== 2) {
return "";
}
if (typeof source[0] !== "number" || typeof source[1] !== "number") {
return "";
}
if (source[1] < 0) {
totalSeconds = source[0] + source[1] / 1e9;
source[0] = parseInt(totalSeconds);
source[1] = parseFloat((totalSeconds % 1).toPrecision(9)) * 1e9;
}
results = "";
for (i = 0; i < 6; i++) {
spot = i < 3 ? 0 : 1;
sourceAtStep = source[spot];
if (i !== 3 && i !== 0) {
sourceAtStep = sourceAtStep % convert[i - 1];
}
if (i === 2) {
sourceAtStep += source[1] / 1e9;
}
valAtStep = sourceAtStep / convert[i];
if (valAtStep >= 1) {
if (verbose) {
valAtStep = Math.floor(valAtStep);
}
if (!precise) {
decimals = valAtStep >= 10 ? 0 : 2;
strAtStep = valAtStep.toFixed(decimals);
} else {
strAtStep = valAtStep.toString();
}
if (strAtStep.indexOf(".") > -1 && strAtStep[strAtStep.length - 1] === "0") {
strAtStep = strAtStep.replace(/\.?0+$/, "");
}
if (results) {
results += " ";
}
results += strAtStep;
if (verbose) {
results += " " + verboseDesc[i];
if (strAtStep !== "1") {
results += "s";
}
} else {
results += " " + minimalDesc[i];
}
if (!verbose) {
break;
}
}
}
return results;
};
}
});
// src/index.ts
var import_pretty_hrtime = __toESM(require_pretty_hrtime());
// ../../node_modules/.pnpm/slash@5.1.0/node_modules/slash/index.js
function slash(path2) {
const isExtendedLengthPath = path2.startsWith("\\\\?\\");
if (isExtendedLengthPath) {
return path2;
}
return path2.replace(/\\/g, "/");
}
var getVirtualModules = async (options) => {
const virtualModules = {};
const cwd = process.cwd();
const workingDir = options.cache ? (
// TODO: This is a hard code cache dir, as Rspack doesn't support virtual modules now.
// Remove this when Rspack supports virtual modules.
findCacheDirectory({
name: "storybook-rsbuild-builder",
create: true
})
) : process.cwd();
if (!fs.existsSync(workingDir)) {
fs.mkdirSync(workingDir, { recursive: true });
}
const isProd = options.configType === "PRODUCTION";
const nonNormalizedStories = await options.presets.apply("stories", []);
const entries = [];
const stories = normalizeStories(nonNormalizedStories, {
configDir: options.configDir,
workingDir
});
const realPathRelativeToCwd = path.relative(workingDir, cwd).split(path.sep).join(path.posix.sep);
const previewAnnotations = [
...(await options.presets.apply(
"previewAnnotations",
[],
options
)).map((entry) => {
if (typeof entry === "object") {
return entry.absolute;
}
return slash(entry);
}),
loadPreviewOrConfigFile(options)
].filter(Boolean);
const storiesFilename = "storybook-stories.js";
const storiesPath = resolve(join(workingDir, storiesFilename));
const builderOptions = await getBuilderOptions(options);
const needPipelinedImport = !!builderOptions.lazyCompilation && !isProd;
virtualModules[storiesPath] = toImportFn(stories, realPathRelativeToCwd, {
needPipelinedImport
});
const configEntryPath = resolve(join(workingDir, "storybook-config-entry.js"));
virtualModules[configEntryPath] = (await readTemplate(
__require.resolve(
"storybook-builder-rsbuild/templates/virtualModuleModernEntry.js"
)
)).replaceAll(`'{{storiesFilename}}'`, `'./${storiesFilename}'`).replaceAll(
`'{{previewAnnotations}}'`,
previewAnnotations.filter(Boolean).map((entry) => `'${entry}'`).join(",")
).replaceAll(
`'{{previewAnnotations_requires}}'`,
previewAnnotations.filter(Boolean).map((entry) => `require('${entry}')`).join(",")
).replace(/\\/g, "\\\\");
entries.push(configEntryPath);
for (const [key, value] of Object.entries(virtualModules)) {
fs.writeFileSync(key, value);
}
return {
virtualModules,
entries
};
};
function toImportFnPart(specifier) {
const { directory, importPathMatcher } = specifier;
return dedent`
async (path) => {
if (!${importPathMatcher}.exec(path)) {
return;
}
const pathRemainder = path.substring(${directory.length + 1});
return import(
/* webpackChunkName: "[request]" */
/* webpackInclude: ${webpackIncludeRegexp(specifier)} */
'${directory}/' + pathRemainder
);
}
`;
}
function toImportFn(stories, relativeOffset, { needPipelinedImport } = {}) {
let pipelinedImport = "const pipeline = (x) => x();";
if (needPipelinedImport) {
pipelinedImport = `
const importPipeline = ${importPipeline};
const pipeline = importPipeline();
`;
}
return dedent`
${pipelinedImport}
const importers = [
${stories.map(toImportFnPart).join(",\n")}
];
export async function importFn(path) {
const offset = '${relativeOffset}';
for (let i = 0; i < importers.length; i++) {
const pathWithOffset = buildPath(offset, path)
const moduleExports = await pipeline(() => importers[i](pathWithOffset));
if (moduleExports) {
return moduleExports;
}
}
}
function buildPath(offset, path) {
if(path.startsWith('./')) {
return offset + '/' + path.substring(2);
} else {
return offset + '/' + path;
}
}
`;
}
function importPipeline() {
let importGate = Promise.resolve();
return async (importFn) => {
await importGate;
const moduleExportsPromise = importFn();
importGate = importGate.then(async () => {
await moduleExportsPromise;
});
return moduleExportsPromise;
};
}
// src/preview/iframe-rsbuild.config.ts
var getAbsolutePath = (input) => dirname(__require.resolve(join(input, "package.json")));
var maybeGetAbsolutePath = (input) => {
try {
return getAbsolutePath(input);
} catch (e) {
return false;
}
};
var builtInResolveExtensions = [
".mjs",
".js",
".jsx",
".ts",
".tsx",
".json",
".cjs"
];
var managerAPIPath = maybeGetAbsolutePath("@storybook/manager-api");
var componentsPath = maybeGetAbsolutePath("@storybook/components");
var globalPath = maybeGetAbsolutePath("@storybook/global");
var routerPath = maybeGetAbsolutePath("@storybook/router");
var themingPath = maybeGetAbsolutePath("@storybook/theming");
var storybookPaths = {
...managerAPIPath ? {
"@storybook/manager-api": managerAPIPath
} : {},
...componentsPath ? { "@storybook/components": componentsPath } : {},
...globalPath ? { "@storybook/global": globalPath } : {},
...routerPath ? { "@storybook/router": routerPath } : {},
...themingPath ? { "@storybook/theming": themingPath } : {}
};
var iframe_rsbuild_config_default = async (options, extraWebpackConfig) => {
const { rsbuildConfigPath, addonDocs } = await getBuilderOptions(options);
const appliedDocsWebpack = await webpack({}, { ...options, ...addonDocs });
const {
outputDir = join(".", "public"),
quiet,
packageJson,
configType,
presets,
previewUrl,
typescriptOptions,
features
} = options;
const isProd = configType === "PRODUCTION";
const workingDir = process.cwd();
const [
coreOptions,
frameworkOptions,
envs,
logLevel,
headHtmlSnippet,
bodyHtmlSnippet,
template,
docsOptions,
entries,
nonNormalizedStories,
_modulesCount,
build2,
tagsOptions
] = await Promise.all([
presets.apply("core"),
presets.apply("frameworkOptions"),
presets.apply("env"),
presets.apply("logLevel", void 0),
presets.apply("previewHead"),
presets.apply("previewBody"),
presets.apply("previewMainTemplate"),
presets.apply("docs"),
presets.apply("entries", []),
presets.apply("stories", []),
options.cache?.get("modulesCount", 1e3),
options.presets.apply("build"),
presets.apply("tags", {})
]);
const stories = normalizeStories(nonNormalizedStories, {
configDir: options.configDir,
workingDir
});
const shouldCheckTs = typescriptOptions.check && !typescriptOptions.skipCompiler;
const tsCheckOptions = typescriptOptions.checkOptions || {};
const builderOptions = await getBuilderOptions(options);
const lazyCompilationConfig = builderOptions.lazyCompilation && !isProd ? {
lazyCompilation: { entries: false }
} : {};
if (!template) {
throw new Error(dedent`
Storybook's Webpack5 builder requires a template to be specified.
Somehow you've ended up with a falsy value for the template option.
Please file an issue at https://github.com/storybookjs/storybook with a reproduction.
`);
}
const externals = globalsNameReferenceMap;
if (build2?.test?.disableBlocks) {
externals["@storybook/blocks"] = "__STORYBOOK_BLOCKS_EMPTY_MODULE__";
}
const { virtualModules: _virtualModules, entries: dynamicEntries } = await getVirtualModules(options);
if (!options.cache) {
throw new Error("Cache is required");
}
let contentFromConfig = {};
const { content } = await loadConfig({
cwd: workingDir,
path: rsbuildConfigPath
});
const { environments, ...withoutEnv } = content;
if (content.environments) {
const envCount = Object.keys(content.environments).length;
if (envCount === 0) {
contentFromConfig = withoutEnv;
} else if (envCount === 1) {
contentFromConfig = mergeRsbuildConfig(
withoutEnv,
content.environments[0]
);
} else {
const userEnv = builderOptions.environment;
if (typeof userEnv !== "string") {
throw new Error(
"You must specify an environment when there are multiple environments in the Rsbuild config."
);
}
if (Object.keys(content.environments).includes(userEnv)) {
contentFromConfig = mergeRsbuildConfig(
withoutEnv,
content.environments[userEnv]
);
} else {
throw new Error(
`The specified environment "${userEnv}" is not found in the Rsbuild config.`
);
}
}
} else {
contentFromConfig = content;
}
contentFromConfig.source ??= {};
contentFromConfig.source.entry = {};
const resourceFilename = isProd ? "static/media/[name].[contenthash:8][ext]" : "static/media/[path][name][ext]";
const rsbuildConfig = mergeRsbuildConfig(contentFromConfig, {
output: {
cleanDistPath: false,
assetPrefix: "/",
dataUriLimit: {
media: 1e4
},
sourceMap: {
js: options.build?.test?.disableSourcemaps ? false : "cheap-module-source-map",
css: !options.build?.test?.disableSourcemaps
},
distPath: {
root: resolve(process.cwd(), outputDir)
},
filename: {
js: isProd ? "[name].[contenthash:8].iframe.bundle.js" : "[name].iframe.bundle.js",
image: resourceFilename,
font: resourceFilename,
media: resourceFilename
},
externals
},
server: {
// Storybook will handle public directory itself, disable Rsbuild's public dir
// feature to prevent overwriting Storybook's public directory.
publicDir: false
},
dev: {
assetPrefix: "/",
progressBar: !quiet
},
source: {
// TODO: Rspack doesn't support virtual modules yet, use cache dir instead
// we needed to explicitly set the module in `node_modules` to be compiled
include: [/[\\/]node_modules[\\/].*[\\/]storybook-config-entry\.js/],
alias: {
...storybookPaths
},
entry: {
// to avoid `It's not allowed to load an initial chunk on demand. The chunk name "main" is already used by an entrypoint` of
main: [...entries ?? [], ...dynamicEntries]
},
define: {
...stringifyProcessEnvs(envs),
NODE_ENV: JSON.stringify(process.env.NODE_ENV)
}
},
performance: {
chunkSplit: {
strategy: "custom",
splitChunks: {
chunks: "all"
}
}
},
plugins: [
shouldCheckTs ? pluginTypeCheck(tsCheckOptions) : null,
pluginHtmlMinifierTerser(() => ({
collapseWhitespace: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: false,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true
}))
].filter(Boolean),
tools: {
rspack: (config, { addRules, appendPlugins, rspack, mergeConfig }) => {
addRules({
test: /\.stories\.([tj])sx?$|(stories|story)\.mdx$/,
exclude: /node_modules/,
enforce: "post",
use: [
{
loader: __require.resolve(
"storybook-builder-rsbuild/loaders/export-order-loader"
)
}
]
});
config.resolve ??= {};
config.resolve.symlinks = !isPreservingSymlinks();
config.resolve.extensions = Array.from(
/* @__PURE__ */ new Set([
...config.resolve.extensions ?? [],
...builtInResolveExtensions
])
);
config.watchOptions = {
ignored: /node_modules/
};
config.ignoreWarnings = [
...config.ignoreWarnings || [],
/export '\S+' was not found in 'global'/,
/export '\S+' was not found in '@storybook\/global'/
];
config.resolve ??= {};
config.resolve.fallback ??= {
stream: false,
path: __require.resolve("path-browserify"),
assert: __require.resolve("browser-assert"),
util: __require.resolve("util"),
url: __require.resolve("url"),
fs: false,
constants: __require.resolve("constants-browserify")
};
config.optimization ??= {};
config.optimization.runtimeChunk = true;
config.optimization.usedExports = options.build?.test?.disableTreeShaking ? false : isProd;
config.optimization.moduleIds = "named";
config.module ??= {};
config.module.parser ??= {};
config.module.parser.javascript ??= {};
config.module.parser.javascript.exportsPresence = false;
appendPlugins(
[
new rspack.ProvidePlugin({
process: __require.resolve("process/browser.js")
}),
new CaseSensitivePathsPlugin()
].filter(Boolean)
);
config.experiments ??= {};
config.experiments = {
...config.experiments,
...lazyCompilationConfig
};
return mergeConfig(config, extraWebpackConfig, appliedDocsWebpack);
},
htmlPlugin: {
filename: "iframe.html",
// FIXME: `none` isn't a known option
chunksSortMode: "none",
alwaysWriteToDisk: true,
inject: false,
template,
templateParameters: {
version: packageJson?.version ?? "0.0.0-storybook-rsbuild-unknown-version",
globals: {
CONFIG_TYPE: configType,
LOGLEVEL: logLevel,
FRAMEWORK_OPTIONS: frameworkOptions,
CHANNEL_OPTIONS: coreOptions.channelOptions,
FEATURES: features,
PREVIEW_URL: previewUrl,
STORIES: stories.map((specifier) => ({
...specifier,
importPathMatcher: specifier.importPathMatcher.source
})),
DOCS_OPTIONS: docsOptions,
TAGS_OPTIONS: tagsOptions,
...build2?.test?.disableBlocks ? { __STORYBOOK_BLOCKS_EMPTY_MODULE__: {} } : {}
},
headHtmlSnippet,
bodyHtmlSnippet
}
}
}
});
return rsbuildConfig;
};
var getIsReactVersion18or19 = async (options) => {
const { legacyRootApi } = await options.presets.apply(
"frameworkOptions"
) || {};
if (legacyRootApi) {
return false;
}
const resolvedReact = await options.presets.apply(
"resolvedReact",
{}
);
const reactDom = resolvedReact.reactDom || dirname(__require.resolve("react-dom/package.json"));
if (!isAbsolute(reactDom)) {
return false;
}
const { version } = JSON.parse(
await readFile(join(reactDom, "package.json"), "utf-8")
);
return version.startsWith("18") || version.startsWith("19") || version.startsWith("0.0.0");
};
var applyReactShims = async (config, options) => {
const isReactVersion18 = await getIsReactVersion18or19(options);
if (isReactVersion18) {
return void 0;
}
return {
source: {
alias: {
"@storybook/react-dom-shim": "@storybook/react-dom-shim/dist/react-16"
}
}
};
};
// src/index.ts
var printDuration = (startTime) => (0, import_pretty_hrtime.default)(process.hrtime(startTime)).replace(" ms", " milliseconds").replace(" s", " seconds").replace(" m", " minutes");
var executor = {
get: async (options) => {
const rsbuildInstance = await options.presets.apply("rsbuildInstance") || rsbuildReal;
return rsbuildInstance;
}
};
var isObject = (val) => val != null && typeof val === "object" && Array.isArray(val) === false;
function nonNullables(value) {
return value !== void 0;
}
var rsbuild = async (_, options) => {
const { presets } = options;
const webpackAddons = await presets.apply("webpackAddons");
const resolvedWebpackAddons = (webpackAddons ?? []).map((preset) => {
const addonOptions = isObject(preset) ? preset.options || void 0 : void 0;
const name = isObject(preset) ? preset.name : preset;
return resolveAddonName(options.configDir, name, addonOptions);
}).filter(nonNullables);
const { apply } = await getPresets(resolvedWebpackAddons, options);
const webpackAddonsConfig = await apply(
"webpackFinal",
// TODO: using empty webpack config as base for now. It's better to using the composed rspack
// config in `iframe-rsbuild.config.ts` as base config. But when `tools.rspack` is an async function,
// the following `tools.rspack` raise an ` Promises are not supported` error.
{
output: {},
module: {},
plugins: [],
resolve: {},
devServer: {},
optimization: {},
performance: {},
externals: {},
experiments: {},
node: {},
stats: {},
entry: {}
},
options
);
let defaultConfig = await iframe_rsbuild_config_default(options, webpackAddonsConfig);
const shimsConfig = await applyReactShims(defaultConfig, options);
defaultConfig = rsbuildReal.mergeRsbuildConfig(
defaultConfig,
shimsConfig
);
const finalDefaultConfig = await presets.apply(
"rsbuildFinal",
defaultConfig,
options
);
return finalDefaultConfig;
};
var getConfig = async (options) => {
const { presets } = options;
const typescriptOptions = await presets.apply("typescript", {}, options);
const frameworkOptions = await presets.apply("frameworkOptions");
return rsbuild({}, {
...options,
typescriptOptions,
frameworkOptions
});
};
var server;
async function bail() {
return server?.close();
}
var start = async ({
startTime,
options,
router,
server: storybookServer,
channel
}) => {
const { createRsbuild } = await executor.get(options);
const config = await getConfig(options);
const rsbuildBuild = await createRsbuild({
cwd: process.cwd(),
rsbuildConfig: {
...config,
server: {
...config.server,
port: await getRandomPort(options.host),
host: options.host,
htmlFallback: false,
printUrls: false
}
}
});
const rsbuildServer = await rsbuildBuild.createDevServer();
const waitFirstCompileDone = new Promise((resolve3) => {
rsbuildBuild.onDevCompileDone(({ stats: stats2, isFirstCompile }) => {
if (!isFirstCompile) {
return;
}
resolve3(stats2);
});
});
server = rsbuildServer;
if (!rsbuildBuild) {
throw new WebpackInvocationError({
// eslint-disable-next-line local-rules/no-uncategorized-errors
error: new Error("Missing Rsbuild build instance at runtime!")
});
}
const previewResolvedDir = join(corePath, "dist/preview");
const previewDirOrigin = previewResolvedDir;
router.use(
"/sb-preview",
sirv(previewDirOrigin, { maxAge: 3e5, dev: true, immutable: true })
);
router.use(rsbuildServer.middlewares);
rsbuildServer.connectWebSocket({ server: storybookServer });
const stats = await waitFirstCompileDone;
await server.afterListen();
return {
bail,
stats,
totalTime: process.hrtime(startTime)
};
};
var build = async ({ options }) => {
const { createRsbuild } = await executor.get(options);
const config = await getConfig(options);
const rsbuildBuild = await createRsbuild({
cwd: process.cwd(),
rsbuildConfig: config
});
const previewResolvedDir = join(corePath, "dist/preview");
const previewDirOrigin = previewResolvedDir;
const previewDirTarget = join(options.outputDir || "", "sb-preview");
let stats;
rsbuildBuild.onAfterBuild((params) => {
stats = params.stats;
});
const previewFiles = fs2.copy(previewDirOrigin, previewDirTarget, {
filter: (src) => {
const { ext } = parse(src);
if (ext) {
return ext === ".js";
}
return true;
}
});
rsbuildBuild.onAfterBuild((params) => {
stats = params.stats;
});
const [{ close }] = await Promise.all([rsbuildBuild.build(), previewFiles]);
await close();
return stats;
};
var corePresets = [join(__dirname, "./preview-preset.js")];
var previewMainTemplate = () => __require.resolve("storybook-builder-rsbuild/templates/preview.ejs");
function getRandomPort(host) {
return new Promise((resolve3, reject) => {
const server2 = createServer();
server2.unref();
server2.on("error", reject);
server2.listen({ port: 0, host }, () => {
const { port } = server2.address();
server2.close(() => {
resolve3(port);
});
});
});
}
export { bail, build, corePresets, executor, getConfig, getVirtualModules, importPipeline, previewMainTemplate, printDuration, start, toImportFn, toImportFnPart };