UNPKG

storybook

Version:

Storybook: Develop, document, and test UI components in isolation

1,224 lines (1,203 loc) 112 kB
import CJS_COMPAT_NODE_URL_3za304dh1il from 'node:url'; import CJS_COMPAT_NODE_PATH_3za304dh1il from 'node:path'; import CJS_COMPAT_NODE_MODULE_3za304dh1il from "node:module"; var __filename = CJS_COMPAT_NODE_URL_3za304dh1il.fileURLToPath(import.meta.url); var __dirname = CJS_COMPAT_NODE_PATH_3za304dh1il.dirname(__filename); var require = CJS_COMPAT_NODE_MODULE_3za304dh1il.createRequire(import.meta.url); // ------------------------------------------------------------ // end of CJS compatibility banner, injected by Storybook's esbuild configuration // ------------------------------------------------------------ import { require_build } from "./chunk-NXH67N4S.js"; import { pLimit } from "./chunk-HAN44MVS.js"; import { isI18nPackage, isRouterPackage, isStateManagementPackage, isStylingPackage } from "./chunk-RIARFK7G.js"; import { STORYBOOK_FN_PLACEHOLDER, generateDummyArgsFromArgTypes, loadConfig, printConfig } from "./chunk-XGLK2JRA.js"; import { optionalEnvToBoolean, registerService } from "./chunk-7H2QWASA.js"; import { array, description, lazy, literal, number, object, optional, pipe, record, string, undefined_, variant, void_ } from "./chunk-C2HCSW4Z.js"; import { UniversalStoreFollowerTimeoutError } from "./chunk-A5K36J6C.js"; import { up } from "./chunk-IYHL7P35.js"; import { resolvePackageDir } from "./chunk-2XRAUFH3.js"; import { jsTsSourceExtensions } from "./chunk-EV2CT3GE.js"; import { StorybookError } from "./chunk-D42WAKAJ.js"; import { require_dist } from "./chunk-YOJEDPR4.js"; import { require_picocolors } from "./chunk-UM2ZG4W6.js"; import { errorToErrorLike, reverseIndexToStoriesByFile, toStoryIndexPath } from "./chunk-VKFJLSIZ.js"; import { extname, join, normalize, relative } from "./chunk-BSSMDXLU.js"; import { glob } from "./chunk-BFTJL2HZ.js"; import { __toESM } from "./chunk-57SVN5FU.js"; // src/core-server/utils/server-statics.ts import { existsSync, statSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import { basename, isAbsolute, join as join2, posix, resolve, sep, win32 } from "node:path"; import { getDirectoryFromWorkingDir, getProjectRoot, resolvePathInStorybookCache } from "storybook/internal/common"; import { CLI_COLORS, logger, once } from "storybook/internal/node-logger"; var import_picocolors = __toESM(require_picocolors(), 1), import_sirv = __toESM(require_build(), 1), import_ts_dedent = __toESM(require_dist(), 1); var cacheDir = resolvePathInStorybookCache("", "ignored-sub").split("ignored-sub")[0], files = /* @__PURE__ */ new Map(), readFileOnce = async (path) => { if (files.has(path)) return files.get(path); { let [data, stats] = await Promise.all([readFile(path, "utf-8"), stat(path)]), result = { data, mtime: stats.mtimeMs }; return files.set(path, result), result; } }, faviconWrapperPath = join2( resolvePackageDir("storybook"), "/assets/browser/favicon-wrapper.svg" ), prepareNestedSvg = (svg) => { let [, openingTag, contents, closingTag] = svg?.match(/(<svg[^>]*>)(.*?)(<\/svg>)/s) ?? []; if (!openingTag || !contents || !closingTag) return svg; let width, height, modifiedTag = openingTag.replace(/width=["']([^"']*)["']/g, (_, value) => (width = parseFloat(value), 'width="32px"')).replace(/height=["']([^"']*)["']/g, (_, value) => (height = parseFloat(value), 'height="32px"')); return !/viewBox=["'][^"']*["']/.test(modifiedTag) && width && height && (modifiedTag = modifiedTag.replace(/>$/, ` viewBox="0 0 ${width} ${height}">`)), modifiedTag = modifiedTag.replace(/preserveAspectRatio=["'][^"']*["']/g, "").replace(/>$/, ' preserveAspectRatio="xMidYMid meet">'), modifiedTag + contents + closingTag; }; async function useStatics(app, options) { let staticDirs = await options.presets.apply("staticDirs") ?? [], faviconPath = await options.presets.apply("favicon"), faviconDir = resolve(faviconPath, ".."), faviconFile = basename(faviconPath); app.use(`/${faviconFile}`, async (req, res, next) => { let status = req.query.status; if (status && faviconFile.endsWith(".svg") && ["active", "critical", "negative", "positive", "warning"].includes(status)) { let [faviconInfo, faviconWrapperInfo] = await Promise.all([ readFileOnce(join2(faviconDir, faviconFile)), readFileOnce(faviconWrapperPath) ]).catch((e) => (e instanceof Error && once.warn(`Failed to read favicon: ${e.message}`), [null, null])); if (faviconInfo && faviconWrapperInfo) { let svg = faviconWrapperInfo.data.replace('<g id="mask"', `<g mask="url(#${status}-mask)"`).replace('<use id="status"', `<use href="#${status}"`).replace('<use id="icon" />', prepareNestedSvg(faviconInfo.data)); res.setHeader("Content-Type", "image/svg+xml"), res.setHeader("ETag", `"${faviconWrapperInfo.mtime}-${faviconInfo.mtime}"`), res.end(svg); return; } } return req.url = `/${faviconFile}`, sirvWorkaround(faviconDir)(req, res, next); }), staticDirs.map((dir) => { try { let { staticDir, staticPath, targetEndpoint } = mapStaticDir(dir, options.configDir); if (!targetEndpoint.startsWith("/sb-") && !staticDir.startsWith(cacheDir)) { let relativeStaticDir = relative(getProjectRoot(), staticDir); logger.debug( `Serving static files from ${CLI_COLORS.info(relativeStaticDir)} at ${CLI_COLORS.info(targetEndpoint)}` ); } if (existsSync(staticPath) && statSync(staticPath).isFile()) { let staticPathDir = resolve(staticPath, ".."), staticPathFile = basename(staticPath); app.use(targetEndpoint, (req, res, next) => { req.url = `/${staticPathFile}`, sirvWorkaround(staticPathDir)(req, res, next); }); } else app.use(targetEndpoint, sirvWorkaround(staticPath)); } catch (e) { e instanceof Error && logger.warn(e.message); } }); } var sirvWorkaround = (dir, opts = {}) => (req, res, next) => { let originalParsedUrl = req._parsedUrl, maybeNext = next ? () => { req._parsedUrl = originalParsedUrl, next(); } : void 0; (0, import_sirv.default)(dir, { dev: !0, etag: !0, extensions: [], ...opts })(req, res, maybeNext); }, parseStaticDir = (arg) => { let lastColonIndex = arg.lastIndexOf(":"), isWindowsRawDirOnly = win32.isAbsolute(arg) && lastColonIndex === 1, splitIndex = lastColonIndex !== -1 && !isWindowsRawDirOnly ? lastColonIndex : arg.length, [from, to] = [arg.slice(0, splitIndex), arg.slice(splitIndex + 1)], staticDir = isAbsolute(from) ? from : `./${from}`, staticPath = resolve(staticDir); if (!existsSync(staticPath)) throw new Error( import_ts_dedent.dedent` Failed to load static files, no such directory: ${import_picocolors.default.cyan(staticPath)} Make sure this directory exists. ` ); let targetDir = (to || (statSync(staticPath).isFile() ? basename(staticPath) : "/")).split(sep).join(posix.sep).replace(/^\/?/, "./"), targetEndpoint = targetDir.substring(1); return { staticDir, staticPath, targetDir, targetEndpoint }; }, mapStaticDir = (staticDir, configDir) => { let specifier = typeof staticDir == "string" ? staticDir : `${staticDir.from}:${staticDir.to}`, normalizedDir = isAbsolute(specifier) ? specifier : getDirectoryFromWorkingDir({ configDir, workingDir: process.cwd(), directory: specifier }); return parseStaticDir(normalizedDir); }; // src/shared/universal-store/index.ts var import_ts_dedent2 = __toESM(require_dist(), 1); // src/shared/universal-store/instances.ts var instances = /* @__PURE__ */ new Map(); // src/shared/universal-store/index.ts var CHANNEL_EVENT_PREFIX = "UNIVERSAL_STORE:", ProgressState = { PENDING: "PENDING", RESOLVED: "RESOLVED", REJECTED: "REJECTED" }, UniversalStore = class _UniversalStore { constructor(options, environmentOverrides) { /** Enable debug logs for this store */ this.debugging = !1; // TODO: narrow type of listeners based on event type this.listeners = /* @__PURE__ */ new Map([["*", /* @__PURE__ */ new Set()]]); /** Gets the current state */ this.getState = () => (this.debug("getState", { state: this.state }), this.state); /** * Subscribes to store events * * @returns A function to unsubscribe */ this.subscribe = (eventTypeOrListener, maybeListener) => { let subscribesToAllEvents = typeof eventTypeOrListener == "function", eventType = subscribesToAllEvents ? "*" : eventTypeOrListener, listener = subscribesToAllEvents ? eventTypeOrListener : maybeListener; if (this.debug("subscribe", { eventType, listener }), !listener) throw new TypeError( `Missing first subscribe argument, or second if first is the event type, when subscribing to a UniversalStore with id '${this.id}'` ); return this.listeners.has(eventType) || this.listeners.set(eventType, /* @__PURE__ */ new Set()), this.listeners.get(eventType).add(listener), () => { this.debug("unsubscribe", { eventType, listener }), this.listeners.has(eventType) && (this.listeners.get(eventType).delete(listener), this.listeners.get(eventType)?.size === 0 && this.listeners.delete(eventType)); }; }; /** Sends a custom event to the other stores */ this.send = (event) => { if (this.debug("send", { event }), this.status !== _UniversalStore.Status.READY) throw new TypeError( import_ts_dedent2.dedent`Cannot send event before store is ready. You can get the current status with store.status, or await store.readyPromise to wait for the store to be ready before sending events. ${JSON.stringify( { event, id: this.id, actor: this.actor, environment: this.environment }, null, 2 )}` ); this.emitToListeners(event, { actor: this.actor }), this.emitToChannel(event, { actor: this.actor }); }; if (this.debugging = options.debug ?? !1, !_UniversalStore.isInternalConstructing) throw new TypeError( "UniversalStore is not constructable - use UniversalStore.create() instead" ); if (_UniversalStore.isInternalConstructing = !1, this.id = options.id, this.actorId = Date.now().toString(36) + Math.random().toString(36).substring(2), this.actorType = options.leader ? _UniversalStore.ActorType.LEADER : _UniversalStore.ActorType.FOLLOWER, this.state = options.initialState, this.channelEventName = `${CHANNEL_EVENT_PREFIX}${this.id}`, this.debug("constructor", { options, environmentOverrides, channelEventName: this.channelEventName }), this.actor.type === _UniversalStore.ActorType.LEADER) this.syncing = { state: ProgressState.RESOLVED, promise: Promise.resolve() }; else { let syncingResolve, syncingReject, syncingPromise = new Promise((resolve2, reject) => { syncingResolve = () => { this.syncing.state === ProgressState.PENDING && (this.syncing.state = ProgressState.RESOLVED, resolve2()); }, syncingReject = (reason) => { this.syncing.state === ProgressState.PENDING && (this.syncing.state = ProgressState.REJECTED, reject(reason)); }; }); this.syncing = { state: ProgressState.PENDING, promise: syncingPromise, resolve: syncingResolve, reject: syncingReject }; } this.getState = this.getState.bind(this), this.setState = this.setState.bind(this), this.subscribe = this.subscribe.bind(this), this.onStateChange = this.onStateChange.bind(this), this.send = this.send.bind(this), this.emitToChannel = this.emitToChannel.bind(this), this.prepareThis = this.prepareThis.bind(this), this.emitToListeners = this.emitToListeners.bind(this), this.handleChannelEvents = this.handleChannelEvents.bind(this), this.debug = this.debug.bind(this), this.channel = environmentOverrides?.channel ?? _UniversalStore.preparation.channel, this.environment = environmentOverrides?.environment ?? _UniversalStore.preparation.environment, this.channel && this.environment ? (environmentOverrides || _UniversalStore.preparation.resolve({ channel: this.channel, environment: this.environment }), this.prepareThis({ channel: this.channel, environment: this.environment })) : _UniversalStore.preparation.promise.then(this.prepareThis); } static { /** * Defines the possible actor types in the store system * * @readonly */ this.ActorType = { LEADER: "LEADER", FOLLOWER: "FOLLOWER" }; } static { /** * Defines the possible environments the store can run in * * @readonly */ this.Environment = { SERVER: "SERVER", MANAGER: "MANAGER", PREVIEW: "PREVIEW", UNKNOWN: "UNKNOWN", MOCK: "MOCK" }; } static { /** * Internal event types used for store synchronization * * @readonly */ this.InternalEventType = { EXISTING_STATE_REQUEST: "__EXISTING_STATE_REQUEST", EXISTING_STATE_RESPONSE: "__EXISTING_STATE_RESPONSE", SET_STATE: "__SET_STATE", LEADER_CREATED: "__LEADER_CREATED", FOLLOWER_CREATED: "__FOLLOWER_CREATED" }; } static { this.Status = { UNPREPARED: "UNPREPARED", SYNCING: "SYNCING", READY: "READY", ERROR: "ERROR" }; } static { // This is used to check if constructor was called from the static factory create() this.isInternalConstructing = !1; } static { _UniversalStore.setupPreparationPromise(); } static setupPreparationPromise() { let resolveRef, rejectRef, promise = new Promise( (resolve2, reject) => { resolveRef = (args) => { resolve2(args); }, rejectRef = (...args) => { reject(args); }; } ); _UniversalStore.preparation = { resolve: resolveRef, reject: rejectRef, promise }; } /** The actor object representing the store instance with a unique ID and a type */ get actor() { return Object.freeze({ id: this.actorId, type: this.actorType, environment: this.environment ?? _UniversalStore.Environment.UNKNOWN }); } /** * The current state of the store, that signals both if the store is prepared by Storybook and * also - in the case of a follower - if the state has been synced with the leader's state. */ get status() { if (!this.channel || !this.environment) return _UniversalStore.Status.UNPREPARED; switch (this.syncing?.state) { case ProgressState.PENDING: case void 0: return _UniversalStore.Status.SYNCING; case ProgressState.REJECTED: return _UniversalStore.Status.ERROR; case ProgressState.RESOLVED: default: return _UniversalStore.Status.READY; } } /** * A promise that resolves when the store is fully ready. A leader will be ready when the store * has been prepared by Storybook, which is almost instantly. * * A follower will be ready when the state has been synced with the leader's state, within a few * hundred milliseconds. */ untilReady() { let preparation = this.channel && this.environment ? Promise.resolve() : _UniversalStore.preparation.promise; return Promise.all([preparation, this.syncing?.promise]); } /** Creates a new instance of UniversalStore */ static create(options) { if (!options || typeof options?.id != "string") throw new TypeError("id is required and must be a string, when creating a UniversalStore"); options.debug && console.debug( import_ts_dedent2.dedent`[UniversalStore] create`, { options } ); let existing = instances.get(options.id); if (existing) return console.warn(import_ts_dedent2.dedent`UniversalStore with id "${options.id}" already exists in this environment, re-using existing. You should reuse the existing instance instead of trying to create a new one.`), existing; _UniversalStore.isInternalConstructing = !0; let store = new _UniversalStore(options); return instances.set(options.id, store), store; } /** * Used by Storybook to set the channel for all instances of UniversalStore in the given * environment. * * @internal */ static __prepare(channel, environment) { _UniversalStore.preparation.channel = channel, _UniversalStore.preparation.environment = environment, _UniversalStore.preparation.resolve({ channel, environment }); } /** * Updates the store's state * * Either a new state or a state updater function can be passed to the method. */ setState(updater) { let previousState = this.state, newState = typeof updater == "function" ? updater(previousState) : updater; if (this.debug("setState", { newState, previousState, updater }), this.status !== _UniversalStore.Status.READY) throw new TypeError( import_ts_dedent2.dedent`Cannot set state before store is ready. You can get the current status with store.status, or await store.readyPromise to wait for the store to be ready before sending events. ${JSON.stringify( { newState, id: this.id, actor: this.actor, environment: this.environment }, null, 2 )}` ); this.state = newState; let event = { type: _UniversalStore.InternalEventType.SET_STATE, payload: { state: newState, previousState } }; this.emitToChannel(event, { actor: this.actor }), this.emitToListeners(event, { actor: this.actor }); } /** * Subscribes to state changes * * @returns Unsubscribe function */ onStateChange(listener) { return this.debug("onStateChange", { listener }), this.subscribe( _UniversalStore.InternalEventType.SET_STATE, ({ payload }, eventInfo) => { listener(payload.state, payload.previousState, eventInfo); } ); } emitToChannel(event, eventInfo) { this.debug("emitToChannel", { event, eventInfo, channel: !!this.channel }), this.channel?.emit(this.channelEventName, { event, eventInfo }); } prepareThis({ channel, environment }) { this.channel = channel, this.environment = environment, this.debug("prepared", { channel: !!channel, environment }), this.channel.on(this.channelEventName, this.handleChannelEvents), this.actor.type === _UniversalStore.ActorType.LEADER ? this.emitToChannel( { type: _UniversalStore.InternalEventType.LEADER_CREATED }, { actor: this.actor } ) : (this.emitToChannel( { type: _UniversalStore.InternalEventType.FOLLOWER_CREATED }, { actor: this.actor } ), this.emitToChannel( { type: _UniversalStore.InternalEventType.EXISTING_STATE_REQUEST }, { actor: this.actor } ), setTimeout(() => { this.syncing.reject(new UniversalStoreFollowerTimeoutError(this.id)); }, 1e3)); } emitToListeners(event, eventInfo) { let eventTypeListeners = this.listeners.get(event.type), everythingListeners = this.listeners.get("*"); this.debug("emitToListeners", { event, eventInfo, eventTypeListeners, everythingListeners }), [...eventTypeListeners ?? [], ...everythingListeners ?? []].forEach( (listener) => listener(event, eventInfo) ); } handleChannelEvents(channelEvent) { let { event, eventInfo } = channelEvent; if ([eventInfo.actor.id, eventInfo.forwardingActor?.id].includes(this.actor.id)) { this.debug("handleChannelEvents: Ignoring event from self", { channelEvent }); return; } else if (this.syncing?.state === ProgressState.PENDING && event.type !== _UniversalStore.InternalEventType.EXISTING_STATE_RESPONSE) { this.debug("handleChannelEvents: Ignoring event while syncing", { channelEvent }); return; } if (this.debug("handleChannelEvents", { channelEvent }), this.actor.type === _UniversalStore.ActorType.LEADER) { let shouldForwardEvent = !0; switch (event.type) { case _UniversalStore.InternalEventType.EXISTING_STATE_REQUEST: shouldForwardEvent = !1; let responseEvent = { type: _UniversalStore.InternalEventType.EXISTING_STATE_RESPONSE, payload: this.state }; this.debug("handleChannelEvents: responding to existing state request", { responseEvent }), this.emitToChannel(responseEvent, { actor: this.actor }), this.emitToListeners(responseEvent, { actor: this.actor }); break; case _UniversalStore.InternalEventType.LEADER_CREATED: shouldForwardEvent = !1, this.syncing.state = ProgressState.REJECTED, this.debug("handleChannelEvents: erroring due to second leader being created", { event }), console.error( import_ts_dedent2.dedent`Detected multiple UniversalStore leaders created with the same id "${this.id}". Only one leader can exists at a time, your stores are now in an invalid state. Leaders detected: this: ${JSON.stringify(this.actor, null, 2)} other: ${JSON.stringify(eventInfo.actor, null, 2)}` ); break; } shouldForwardEvent && (this.debug("handleChannelEvents: forwarding event", { channelEvent }), this.emitToChannel(event, { actor: eventInfo.actor, forwardingActor: this.actor })); } if (this.actor.type === _UniversalStore.ActorType.FOLLOWER) switch (event.type) { case _UniversalStore.InternalEventType.EXISTING_STATE_RESPONSE: if (this.debug("handleChannelEvents: Setting state from leader's existing state response", { event }), this.syncing?.state !== ProgressState.PENDING) break; this.syncing.resolve?.(); let setStateEvent = { type: _UniversalStore.InternalEventType.SET_STATE, payload: { state: event.payload, previousState: this.state } }; this.state = event.payload, this.emitToListeners(setStateEvent, eventInfo); break; } event.type === _UniversalStore.InternalEventType.SET_STATE && (this.debug("handleChannelEvents: Setting state", { event }), this.state = event.payload.state), this.emitToListeners(event, { actor: eventInfo.actor }); } debug(message, data) { this.debugging && console.debug( import_ts_dedent2.dedent`[UniversalStore::${this.id}::${this.environment ?? _UniversalStore.Environment.UNKNOWN}] ${message}`, JSON.stringify( { data, actor: this.actor, state: this.state, status: this.status }, null, 2 ) ); } /** * Used to reset the static fields of the UniversalStore class when cleaning up tests * * @internal */ static __reset() { _UniversalStore.preparation.reject(new Error("reset")), _UniversalStore.setupPreparationPromise(), _UniversalStore.isInternalConstructing = !1; } }; // src/shared/open-service/service-definition.ts var defineService = (def) => def; // src/shared/open-service/services/module-graph/server.ts import { STORY_INDEX_INVALIDATED } from "storybook/internal/core-events"; // src/shared/open-service/services/module-graph/definition.ts var errorLikeSchema = object({ message: pipe(string(), description("Human-readable error message.")), name: optional(pipe(string(), description("Error class/name, when available."))), stack: optional(pipe(string(), description("Stack trace, when available."))), cause: optional(lazy(() => errorLikeSchema)) }), moduleGraphStatusSchema = variant("value", [ object({ value: literal("booting") }), object({ value: literal("ready") }), object({ value: literal("error"), error: pipe( errorLikeSchema, description("Serializable error describing why the module graph failed unexpectedly.") ) }), object({ value: literal("unavailable"), reason: pipe( string(), description( "Human-readable reason why the current builder/runtime cannot provide module graph functionality." ) ), error: optional( pipe( errorLikeSchema, description("Optional serializable error reported by the builder adapter.") ) ) }) ]), storyIndexPathSchema = pipe( string(), description("A story-index-style relative path such as `./src/Button.stories.tsx`.") ), storyDependencyDepthSchema = pipe( number(), description( "Breadth-first-search depth: the shortest number of import edges between the source file and this story file." ) ), storiesByFileSchema = record( storyIndexPathSchema, record(storyIndexPathSchema, storyDependencyDepthSchema) ), noInputSchema = undefined_(), moduleGraphServiceDef = defineService({ id: "core/module-graph", description: "Story module dependency graph: reverse index from source files to story files, with reactive updates.", initialState: { workingDir: process.cwd(), status: { value: "booting" }, graphRevision: 0, storiesByFile: {}, storyChangeRevisions: {}, latestChangedStoryFiles: [] }, queries: { storiesForFiles: { description: "Returns, for each input file (same order), story-index-relative story files that depend on it and their breadth-first-search depth: the shortest number of import edges between the input file and the story file.", input: object({ files: pipe( array( pipe( string(), description( "Input source file path. Accepts absolute paths, story-index-style relative paths with `./`, or relative paths without `./`." ) ) ), description("Source files to look up. Output arrays match this input order.") ) }), output: array( array( object({ storyFile: pipe( storyIndexPathSchema, description( "Affected story file, returned in the same `./`-prefixed relative import-path format used by the story index." ) ), depth: storyDependencyDepthSchema }) ) ), handler: (input, ctx) => { let { workingDir } = ctx.self.state; return input.files.map((file) => { let entries = ctx.self.state.storiesByFile[toStoryIndexPath(file, workingDir)]; return entries ? Object.entries(entries).map(([storyFile, depth]) => ({ storyFile, depth })) : []; }); } }, status: { description: "Current module graph lifecycle status. `booting` means the graph is still expected to become ready; `ready` means query state is populated; `error` means an unexpected graph failure; `unavailable` means the current builder/runtime cannot provide module graph functionality.", input: noInputSchema, output: moduleGraphStatusSchema, load: async (_input, ctx) => { await ctx.self.commands._waitForSettledEngine(void 0); }, handler: (_input, ctx) => ctx.self.state.status }, graphRevision: { description: "Monotonic revision counter for module graph changes, advanced only by in-graph file changes and story-index reconciliation (out-of-graph file changes never advance it). Omit the input to watch the entire graph. Provide `storyFiles` to scope the watch to specific stories: returns the highest revision at which any of those story subgraphs last changed (0 if none have changed yet, or for unknown stories).", input: optional( object({ storyFiles: array( pipe( string(), description( "Story file to scope the watch to. Accepts absolute paths, story-index-style relative paths with `./`, or relative paths without `./`. Pass an empty array to watch nothing (returns 0)." ) ) ) }) ), output: number(), handler: (input, ctx) => { if (!input) return ctx.self.state.graphRevision; if (input.storyFiles.length === 0) return 0; let max = 0, { workingDir } = ctx.self.state; for (let file of input.storyFiles) { let revision = ctx.self.state.storyChangeRevisions[toStoryIndexPath(file, workingDir)] ?? 0; revision > max && (max = revision); } return max; } }, latestStoryChanges: { description: "Latest story files whose module graph changed, paired with the graph revision that produced the change set.", input: noInputSchema, output: object({ revision: pipe( number(), description("Graph revision number for this latest story change set.") ), storyFiles: pipe( array(storyIndexPathSchema), description( "Story-index-relative story files touched by the latest module graph change set." ) ) }), handler: (_input, ctx) => ({ revision: ctx.self.state.graphRevision, storyFiles: ctx.self.state.latestChangedStoryFiles }) }, /** @deprecated Use {@link status} instead. */ getStatus: { description: "Deprecated alias for `status`. Use `status` instead.", input: noInputSchema, output: moduleGraphStatusSchema, handler: (input, ctx) => ctx.self.queries.status.get(input), load: async (input, ctx) => { await ctx.self.queries.status.loaded(input); } }, /** @deprecated Use {@link graphRevision} instead. */ getGraphRevision: { description: "Deprecated alias for `graphRevision`. Use `graphRevision` instead.", input: optional( object({ storyFiles: array( pipe( string(), description( "Story file to scope the watch to. Accepts absolute paths, story-index-style relative paths with `./`, or relative paths without `./`. Pass an empty array to watch nothing (returns 0)." ) ) ) }) ), output: number(), handler: (input, ctx) => ctx.self.queries.graphRevision.get(input), load: async (input, ctx) => { await ctx.self.queries.graphRevision.loaded(input); } } }, commands: { _applyGraphSnapshot: { internal: !0, description: "Replaces the reverse index after the initial graph build. Called by the graph engine, not by external consumers.", input: object({ storiesByFile: pipe( storiesByFileSchema, description( "Complete relative reverse index keyed by story-index-style source file paths. Values map affected story-index-style story file paths to breadth-first-search depths." ) ) }), output: void_(), handler: async (input, ctx) => { ctx.self.setState((state) => { state.status = { value: "ready" }, state.storiesByFile = input.storiesByFile, state.storyChangeRevisions = {}; for (let stories of Object.values(input.storiesByFile)) for (let storyFile of Object.keys(stories)) state.storyChangeRevisions[storyFile] = 0; state.latestChangedStoryFiles = []; }); } }, _applyGraphUpdate: { internal: !0, description: "Replaces the reverse index after an incremental patch and bumps versions for affected story files. Called by the graph engine, not by external consumers.", input: object({ storiesByFile: pipe( storiesByFileSchema, description( "Complete relative reverse index keyed by story-index-style source file paths. Values map affected story-index-style story file paths to breadth-first-search depths." ) ), bumpedStoryFiles: pipe( array(storyIndexPathSchema), description( "Story files whose graph changed, using story-index-style relative paths. Each listed file has its version incremented." ) ) }), output: void_(), handler: async (input, ctx) => { ctx.self.setState((state) => { if (state.storiesByFile = input.storiesByFile, input.bumpedStoryFiles.length !== 0) { state.graphRevision += 1, state.latestChangedStoryFiles = input.bumpedStoryFiles; for (let storyFile of input.bumpedStoryFiles) state.storyChangeRevisions[storyFile] = state.graphRevision; } }); } }, _setStatus: { internal: !0, description: "Sets the module graph lifecycle status after engine startup, failure, or adapter availability changes.", input: moduleGraphStatusSchema, output: void_(), handler: async (input, ctx) => { ctx.self.setState((state) => { state.status = input; }); } }, _waitForSettledEngine: { internal: !0, description: "Waits for the module graph engine to finish its current build or patch cycle. Handler is supplied at server registration.", input: noInputSchema, output: void_() } } }); // src/shared/open-service/services/module-graph/engine/module-graph-engine.ts import { writeFile } from "node:fs/promises"; import { getProjectRoot as getProjectRoot2 } from "storybook/internal/common"; import { logger as logger4 } from "storybook/internal/node-logger"; // src/shared/open-service/services/module-graph/errors.ts var ModuleGraphFailureError = class extends Error { constructor(message, options) { super(message, options), this.name = "ModuleGraphFailureError"; } }; // src/shared/open-service/services/module-graph/story-files.ts var cache = /* @__PURE__ */ new WeakMap(); function getStoryIdsByAbsolutePath(storyIndex, workingDir) { let cached = cache.get(storyIndex); if (cached && cached.workingDir === workingDir) return cached.storyIdsByFile; let storyIdsByFile = /* @__PURE__ */ new Map(); return Object.values(storyIndex.entries).forEach((entry) => { if (entry.type === "story" && !entry.importPath.startsWith("virtual:")) { let filePath = normalize(join(workingDir, entry.importPath)), storyIds = storyIdsByFile.get(filePath) ?? /* @__PURE__ */ new Set(); storyIds.add(entry.id), storyIdsByFile.set(filePath, storyIds); } }), cache.set(storyIndex, { workingDir, storyIdsByFile }), storyIdsByFile; } // src/shared/open-service/services/module-graph/engine/dependency-graph/dependency-graph-builder.ts import { cpus } from "node:os"; import { logger as defaultLogger } from "storybook/internal/node-logger"; // src/shared/open-service/services/module-graph/engine/dependency-graph/parse-resolve-cache.ts import { readFile as readFile2 } from "node:fs/promises"; import { parseBarrelInfo } from "storybook/internal/oxc-parser"; // src/shared/open-service/services/module-graph/engine/dependency-graph/scope.ts var NODE_MODULES_SEGMENT = "/node_modules/"; function isInsideAnyWorkspace(absolute, workspaceRoots) { if (absolute.includes(NODE_MODULES_SEGMENT)) return !1; for (let root of workspaceRoots) if (absolute === root || absolute.startsWith(root.endsWith("/") ? root : `${root}/`)) return !0; return !1; } function isInScope(absolute, projectRoot, workspaceRoots) { let projectPrefix = projectRoot.endsWith("/") ? projectRoot : `${projectRoot}/`; return (absolute === projectRoot || absolute.startsWith(projectPrefix)) && !absolute.includes(NODE_MODULES_SEGMENT) ? !0 : isInsideAnyWorkspace(absolute, workspaceRoots); } // src/shared/open-service/services/module-graph/engine/dependency-graph/parse-resolve-cache.ts var BARREL_FOLLOW_MAX_DEPTH = 10, ParseResolveCache = class { constructor(opts) { this.parseCache = /* @__PURE__ */ new Map(); this.resolveCache = /* @__PURE__ */ new Map(); this.barrelInfoCache = /* @__PURE__ */ new Map(); this.registry = opts.registry, this.resolver = opts.resolver, this.workspaceRoots = new Set(Array.from(opts.workspaceRoots, (r) => normalize(r))), this.projectRoot = normalize(opts.projectRoot), this.logger = opts.logger, this.debugTrace = opts.debug ? [] : null; } /** Returns accumulated barrel resolution events, or null when debug mode is off. */ getBarrelTrace() { return this.debugTrace; } /** * Parses the file once and caches the resulting edge list. Returns `[]` for unreadable * files, parse failures, or files whose extension has no registered parser — callers * cannot distinguish between "no edges" and "we couldn't look", which is by design: * either way the file contributes nothing to the dependency graph. */ parseOnce(filePath) { let existing = this.parseCache.get(filePath); if (existing) return existing; let promise = (async () => { let source; try { source = await readFile2(filePath, "utf8"); } catch (error) { return this.logger.debug( `Change detection: could not read ${filePath}: ${error instanceof Error ? error.message : String(error)}` ), []; } try { return await this.registry.parse(filePath, source) ?? []; } catch (error) { return this.logger.debug( `Change detection: failed to parse ${filePath}: ${error instanceof Error ? error.message : String(error)}` ), []; } })(); return this.parseCache.set(filePath, promise), promise; } /** Resolves every in-scope edge declared by `filePath` and returns the dep Set. */ resolveOnce(filePath) { let existing = this.resolveCache.get(filePath); if (existing) return existing; let promise = (async () => { let edges = await this.parseOnce(filePath), deps = /* @__PURE__ */ new Set(); for (let edge of edges) { let resolved = await this.resolver.resolve(filePath, edge.specifier); if (resolved === null) { this.logger.debug(`Could not resolve ${edge.specifier} from ${filePath}`); continue; } let normalised = normalize(resolved); if (isInScope(normalised, this.projectRoot, this.workspaceRoots)) { if (edge.importedNames !== null && edge.importedNames.size > 0) { let { sources, barrels, needBarrel } = await this.followBarrel( normalised, edge.importedNames ); this.debugTrace?.push({ from: filePath, specifier: edge.specifier, barrel: normalised, names: Array.from(edge.importedNames), resolved: Array.from(sources), needBarrel }); for (let src of sources) deps.add(src); for (let barrel of barrels) deps.add(barrel); } deps.add(normalised); } } return deps; })(); return this.resolveCache.set(filePath, promise), promise; } /** * For each name in `requestedNames`, walks the barrel chain starting at `barrelPath` * until it reaches the actual source file that defines the symbol. Handles multi-level * chains where intermediate barrels use `export * from '...'` by recursing through them. * * Returns `needBarrel = true` for any name that could not be fully resolved so the * caller falls back to including the barrel itself. */ async followBarrel(barrelPath, requestedNames) { let barrels = /* @__PURE__ */ new Set(), results = await Promise.all( Array.from(requestedNames, (name) => this.followName(barrelPath, name, /* @__PURE__ */ new Set(), 0, barrels)) ), sources = /* @__PURE__ */ new Set(), needBarrel = !1; for (let source of results) source !== null ? sources.add(source) : needBarrel = !0; return { sources, barrels, needBarrel }; } /** * Recursively follows a single exported name through barrel re-exports. * * 1. Checks named re-exports in `barrelPath`; if found, resolves the specifier and * recurses with the inner name in case the target is itself a barrel. * 2. Falls through to wildcard re-exports (`export * from '...'`) and searches each * transitively until the name is found or all paths are exhausted. * * Returns the normalised absolute path of the first non-barrel source found, or `null` * when the chain is unresolvable (triggering the conservative `needBarrel` fallback). * Cycle detection via `visited`; depth limit of 10 hops prevents infinite recursion. */ async followName(barrelPath, name, visited, depth, barrels) { if (depth > BARREL_FOLLOW_MAX_DEPTH) return this.logger.debug( `Change detection: barrel chain depth limit reached at ${barrelPath} (looking for "${name}")` ), null; if (visited.has(barrelPath)) return null; visited.add(barrelPath), barrels.add(barrelPath); let info = await this.barrelInfoOnce(barrelPath), entry = info.named.get(name); if (entry) { let sourceResolved = await this.resolver.resolve(barrelPath, entry.specifier); if (sourceResolved !== null) { let sourceNorm = normalize(sourceResolved); if (isInScope(sourceNorm, this.projectRoot, this.workspaceRoots)) return await this.followName( sourceNorm, entry.importedName, new Set(visited), depth + 1, barrels ) ?? sourceNorm; } return null; } for (let wildcardSpec of info.wildcards) { let wildcardResolved = await this.resolver.resolve(barrelPath, wildcardSpec); if (wildcardResolved === null) continue; let wildcardNorm = normalize(wildcardResolved); if (!isInScope(wildcardNorm, this.projectRoot, this.workspaceRoots)) continue; let result = await this.followName( wildcardNorm, name, new Set(visited), depth + 1, barrels ); if (result !== null) return result; } return null; } /** * Lazily parses and caches the barrel info (named re-exports + wildcard specifiers) * for `filePath`. Returns empty info for files that cannot be read or have no exports. */ barrelInfoOnce(filePath) { let existing = this.barrelInfoCache.get(filePath); if (existing) return existing; let promise = (async () => { let source; try { source = await readFile2(filePath, "utf8"); } catch { return { named: /* @__PURE__ */ new Map(), wildcards: [] }; } try { return await parseBarrelInfo(filePath, source); } catch { return { named: /* @__PURE__ */ new Map(), wildcards: [] }; } })(); return this.barrelInfoCache.set(filePath, promise), promise; } /** Drops all cached entries for `filePath`. Call on every `change`/`unlink` event. */ invalidate(filePath) { this.parseCache.delete(filePath), this.resolveCache.delete(filePath), this.barrelInfoCache.delete(filePath); } /** Test-only: full reset. */ clear() { this.parseCache.clear(), this.resolveCache.clear(), this.barrelInfoCache.clear(); } }; // src/shared/open-service/services/module-graph/engine/dependency-graph/reverse-index.ts var ReverseIndexImpl = class { constructor() { this.index = /* @__PURE__ */ new Map(); /** Forward mapping from story file -> Set of dep files it reaches. */ this.forwardIndex = /* @__PURE__ */ new Map(); } /** Records (or updates with min) the depth for (dep, story). */ record(dep, story, depth) { let inner = this.index.get(dep); inner || (inner = /* @__PURE__ */ new Map(), this.index.set(dep, inner)); let previous = inner.get(story); if (previous === void 0 || depth < previous) { inner.set(story, depth); let deps = this.forwardIndex.get(story); deps || (deps = /* @__PURE__ */ new Set(), this.forwardIndex.set(story, deps)), deps.add(dep); } } /** Removes a story from every inner map; prunes outer entries that become empty. */ removeStory(story) { let deps = this.forwardIndex.get(story); if (deps) { for (let dep of deps) { let inner = this.index.get(dep); inner && (inner.delete(story), inner.size === 0 && this.index.delete(dep)); } this.forwardIndex.delete(story); } } /** Removes a single (dep, story) pair without affecting other stories' depths to that dep. */ removeEdge(dep, story) { let inner = this.index.get(dep); if (inner && inner.delete(story)) { inner.size === 0 && this.index.delete(dep); let deps = this.forwardIndex.get(story); deps && (deps.delete(dep), deps.size === 0 && this.forwardIndex.delete(story)); } } /** Returns the per-story depth map for dep. EMPTY map (not undefined) if dep unknown. */ lookup(dep) { return this.index.get(dep) ?? /* @__PURE__ */ new Map(); } /** Internal state inspection — for tests. */ asMap() { return this.index; } }; // src/shared/open-service/services/module-graph/engine/dependency-graph/walk-from-story.ts async function walkFromStory({ storyRoot, registry, cache: cache3, reverseIndex, recordEdges }) { reverseIndex.record(storyRoot, storyRoot, 0); let visited = /* @__PURE__ */ new Map(); visited.set(storyRoot, 0); let queue = [{ file: storyRoot, depth: 0 }], head = 0; for (; head < queue.length; ) { let { file, depth } = queue[head++]; if (registry.parserFor(file) === void 0) continue; let resolvedDeps = await cache3.resolveOnce(file); recordEdges(file, resolvedDeps); let nextDepth = depth + 1; for (let normalised of resolvedDeps) { if (nextDepth > 50) continue; let previousDepth = visited.get(normalised); previousDepth !== void 0 && previousDepth <= nextDepth || (visited.set(normalised, nextDepth), reverseIndex.record(normalised, storyRoot, nextDepth), queue.push({ file: normalised, depth: nextDepth })); } } } // src/shared/open-service/services/module-graph/engine/dependency-graph/dependency-graph-builder.ts var DependencyGraphBuilder = class { constructor(opts) { this.registry = opts.registry, this.logger = opts.logger ?? defaultLogger, this.cache = opts.cache ?? new ParseResolveCache({ registry: opts.registry, resolver: opts.resolver, workspaceRoots: opts.workspaceRoots, projectRoot: opts.projectRoot, logger: this.logger }); } async build(storyFiles) { let startedAt = Date.now(), reverseIndex = new ReverseIndexImpl(), graph = /* @__PURE__ */ new Map(), limit = pLimit(cpus().length * 2), stories = Array.from(storyFiles, (s) => normalize(s)); await Promise.all( stories.map( (story) => limit( () => walkFromStory({ storyRoot: story, registry: this.registry, cache: this.cache, reverseIndex, recordEdges: (file, deps) => graph.set(file, deps) }) ) ) ); let elapsed = Date.now() - startedAt; return this.logger.debug( `Change detection graph built: ${stories.length} stories, ${reverseIndex.asMap().size} deps tracked, ${elapsed}ms` ), { reverseIndex, graph }; } }; // src/shared/open-service/services/module-graph/engine/dependency-graph/incremental-patcher.ts import { logger as defaultLogger2 } from "storybook/internal/node-logger"; function setsEqual(a, b) { if (a.size !== b.size) return !1; for (let item of a) if (!b.has(item)) return !1; return !0; } var IncrementalPatcher = class { constructor(opts) { this.reverseIndex = opts.reverseIndex, this.graph = opts.graph, this.registry = opts.registry, this.logger = opts.logger ?? defaultLogger2, this.isStoryFile = opts.isStoryFile, this.cache = opts.cache ?? new ParseResolveCache({ registry: opts.registry, resolver: opts.resolver, workspaceRoots: opts.workspaceRoots, projectRoot: opts.projectRoot, logger: this.logger }); } async patch(event) { let path = normalize(event.path); if (this.cache.invalidate(path), event.kind === "add") { this.isStoryFile(path) && await this.walkStory(path); return; } if (event.kind === "unlink") { let dependentsSet = new Set(this.reverseIndex.lookup(path).keys()); this.graph.delete(path), this.reverseIndex.removeStory(path); let storiesToWalk2 = []; for (let story of dependentsSet) story === path || !this.isStoryFile(story) || (this.reverseIndex.removeStory(story), storiesToWalk2.push(story)); await Promise.all(storiesToWalk2.map((story) => this.walkStory(story))); return; } let affectedStories = new Set(this.reverseIndex.lookup(path).keys()); this.isStoryFile(path) && affectedStories.add(path); let oldDeps = this.graph.get(path); if (oldDeps !== void 0) { let newDeps = await this.cache.resolveOnce(path); if (setsEqual(oldDeps, newDeps)) return; } let storiesToWalk = []; for (let story of affectedStories) this.isStoryFile(story) && (this.reverseIndex.removeStory(story), storiesToWalk.push(story)); await Promise.all(storiesToWalk.map((story) => this.walkStory(story))); } walkStory(storyRoot) { return this.cache.invalidate(storyRoot), walkFromStory({ storyRoot, registry: this.registry, cache: this.cache, reverseIndex: this.reverseIndex, recordEdges: (file, deps) => { this.graph.set(file, deps); } }); } }; // src/shared/open-service/services/module-graph/engine/dependency-graph/resolver-factory.ts import { ResolverFactory as OxcResolverFactory } from "oxc-resolver"; import { logger as logger2 } from "storybook/internal/node-logger