@storybook/react-native
Version:
A better way to develop React Native Components for your app
662 lines (661 loc) • 23.5 kB
JavaScript
const require_rolldown_runtime = require("./rolldown-runtime-Bd6kNlEP.js");
let storybook_internal_preview_api = require("storybook/internal/preview-api");
let storybook_internal_csf = require("storybook/internal/csf");
let path = require("path");
path = require_rolldown_runtime.__toESM(path);
let storybook_internal_common = require("storybook/internal/common");
let ws = require("ws");
let node_http = require("node:http");
let node_https = require("node:https");
let node_fs = require("node:fs");
let glob = require("glob");
let storybook_internal_csf_tools = require("storybook/internal/csf-tools");
let node_stream_consumers = require("node:stream/consumers");
//#region scripts/common.js
var require_common = /* @__PURE__ */ require_rolldown_runtime.__commonJSMin(((exports, module) => {
const { globToRegexp } = require("storybook/internal/common");
const path$2 = require("path");
const fs = require("fs");
const cwd = process.cwd();
const toRequireContext = (specifier) => {
const { directory, files } = specifier;
const match = globToRegexp(`./${files}`);
return {
path: directory,
recursive: files.includes("**") || files.split("/").length > 1,
match
};
};
const supportedExtensions = [
"js",
"jsx",
"ts",
"tsx",
"cjs",
"mjs"
];
function getFilePathExtension({ configPath }, fileName) {
for (const ext of supportedExtensions) {
const filePath = path$2.resolve(cwd, configPath, `${fileName}.${ext}`);
if (fs.existsSync(filePath)) return ext;
}
return null;
}
function getFilePathWithExtension({ configPath }, fileName) {
for (const ext of supportedExtensions) {
const filePath = path$2.resolve(cwd, configPath, `${fileName}.${ext}`);
if (fs.existsSync(filePath)) return filePath;
}
return null;
}
function ensureRelativePathHasDot(relativePath) {
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
}
function getPreviewExists({ configPath }) {
return !!getFilePathExtension({ configPath }, "preview");
}
function resolveAddonFile(addon, file, extensions = [
"js",
"mjs",
"ts"
], configPath) {
if (!addon || typeof addon !== "string") return null;
const resolvePaths = { paths: [cwd] };
try {
const basePath = `${addon}/${file}`;
require.resolve(basePath, resolvePaths);
return basePath;
} catch (_error) {}
for (const ext of extensions) try {
const filePath = `${addon}/${file}.${ext}`;
require.resolve(filePath, resolvePaths);
return filePath;
} catch (_error) {}
if (addon.startsWith("./") || addon.startsWith("../")) try {
if (getFilePathExtension({ configPath }, `${addon}/${file}`)) return `${addon}/${file}`;
} catch (_error) {}
return null;
}
function getAddonName(addon) {
if (typeof addon === "string") return addon;
if (typeof addon === "object" && addon.name && typeof addon.name === "string") return addon.name;
console.error("Invalid addon configuration", addon);
return null;
}
module.exports = {
toRequireContext,
getFilePathExtension,
ensureRelativePathHasDot,
getPreviewExists,
resolveAddonFile,
getAddonName,
getFilePathWithExtension
};
}));
//#endregion
//#region src/metro/buildIndex.ts
var import_common = require_common();
const cwd = process.cwd();
const makeTitle = (fileName, specifier, userTitle) => {
const title = (0, storybook_internal_preview_api.userOrAutoTitleFromSpecifier)(fileName, specifier, userTitle);
if (title) return title.replace("./", "");
else if (userTitle) return userTitle.replace("./", "");
else {
console.error("Could not generate title!!");
process.exit(1);
}
};
function ensureRelativePathHasDot(relativePath) {
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
}
async function buildIndex({ configPath }) {
const main = await (0, storybook_internal_common.loadMainConfig)({
configDir: configPath,
cwd
});
if (!main.stories || !Array.isArray(main.stories)) throw new Error("No stories found");
const storiesSpecifiers = (0, storybook_internal_common.normalizeStories)(main.stories, {
configDir: configPath,
workingDir: cwd
});
const csfStories = storiesSpecifiers.map((specifier) => {
return (0, glob.sync)(specifier.files, {
cwd: path.default.resolve(process.cwd(), specifier.directory),
absolute: true,
ignore: ["**/node_modules"]
}).map((storyPath) => {
const normalizePathForWindows = (str) => path.default.sep === "\\" ? str.replace(/\\/g, "/") : str;
return normalizePathForWindows(storyPath);
});
}).reduce((acc, specifierStoryPathList, specifierIndex) => {
const paths = specifierStoryPathList.map((storyPath) => {
const code = (0, node_fs.readFileSync)(storyPath, { encoding: "utf-8" }).toString();
const relativePath = ensureRelativePathHasDot(path.default.posix.relative(cwd, storyPath));
return {
result: (0, storybook_internal_csf_tools.loadCsf)(code, {
fileName: storyPath,
makeTitle: (userTitle) => makeTitle(relativePath, storiesSpecifiers[specifierIndex], userTitle)
}).parse(),
specifier: storiesSpecifiers[specifierIndex],
fileName: relativePath
};
});
return [...acc, ...paths];
}, new Array());
const index = {
v: 5,
entries: {}
};
for (const { result, specifier, fileName } of csfStories) {
const { meta, stories } = result;
if (stories && stories.length > 0) for (const story of stories) {
const id = story.id ?? (0, storybook_internal_csf.toId)(meta.title, story.name);
if (!id) throw new Error(`Failed to generate id for story ${story.name} in file ${fileName}`);
index.entries[id] = {
type: "story",
subtype: "story",
id,
name: story.name,
title: meta.title,
importPath: `${specifier.directory}/${path.default.posix.relative(specifier.directory, fileName)}`,
tags: ["story"]
};
}
else console.log(`No stories found for ${fileName}`);
}
try {
const storySort = (0, storybook_internal_csf_tools.getStorySortParameter)((0, node_fs.readFileSync)((0, import_common.getFilePathWithExtension)({ configPath }, "preview"), { encoding: "utf-8" }).toString());
const sortableStories = Object.values(index.entries);
(0, storybook_internal_preview_api.sortStoriesV7)(sortableStories, storySort, sortableStories.map((entry) => entry.importPath));
return {
v: 5,
entries: sortableStories.reduce((acc, item) => {
acc[item.id] = item;
return acc;
}, {})
};
} catch {
console.warn("Failed to sort stories, using unordered index");
return index;
}
}
//#endregion
//#region src/metro/mcpServer.ts
/**
* Converts Node.js IncomingHttpHeaders to a format compatible with the Web Headers API.
* Handles multi-value headers by joining them with commas per HTTP spec.
*/
function toHeaderEntries(nodeHeaders) {
const entries = [];
for (const [key, value] of Object.entries(nodeHeaders)) {
if (value === void 0) continue;
entries.push([key, Array.isArray(value) ? value.join(", ") : value]);
}
return entries;
}
/**
* Converts a Node.js IncomingMessage to a Web Request object.
*/
async function incomingMessageToWebRequest(req) {
const host = req.headers.host || "localhost";
const protocol = "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
const url = new URL(req.url || "/", `${protocol}://${host}`);
const bodyBuffer = await (0, node_stream_consumers.buffer)(req);
return new Request(url, {
method: req.method,
headers: toHeaderEntries(req.headers),
body: bodyBuffer.length > 0 ? new Uint8Array(bodyBuffer) : void 0
});
}
/**
* Converts a Web Response to a Node.js ServerResponse.
*/
async function webResponseToServerResponse(webResponse, nodeResponse) {
nodeResponse.statusCode = webResponse.status;
webResponse.headers.forEach((value, key) => {
nodeResponse.setHeader(key, value);
});
if (webResponse.body) {
const reader = webResponse.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
nodeResponse.write(value);
}
} finally {
reader.releaseLock();
}
}
nodeResponse.end();
}
/**
* Creates an MCP (Model Context Protocol) request handler for AI agent integration.
*
* Provides tools for querying component documentation, props, and story snippets,
* plus React Native-specific story writing instructions.
*
* @param configPath - Path to the Storybook config folder, used for building the component manifest.
*/
function createMcpHandler(configPath, wss) {
let handler = null;
let initPromise = null;
async function init() {
if (handler) return;
if (initPromise) {
await initPromise;
return;
}
initPromise = (async () => {
try {
const [{ McpServer }, { ValibotJsonSchemaAdapter }, { HttpTransport }, { addListAllDocumentationTool, addGetDocumentationTool, addGetStoryDocumentationTool }, { storyInstructions }, valibot, { experimental_manifests }] = await Promise.all([
import("tmcp"),
import("@tmcp/adapter-valibot"),
import("@tmcp/transport-http"),
import("@storybook/mcp"),
Promise.resolve().then(() => require("./storyInstructions-m8S0KLFH.js")),
import("valibot"),
import("@storybook/react/preset")
]);
const manifestProvider = async (_request, manifestPath) => {
if (manifestPath.includes("docs.json")) throw new Error("Docs manifest not available in React Native Storybook");
const index = await buildIndex({ configPath });
const manifest = await experimental_manifests({}, { manifestEntries: Object.values(index.entries) });
return JSON.stringify(manifest.components);
};
const server = new McpServer({
name: "@storybook/react-native",
version: "1.0.0",
description: "Storybook React Native MCP server"
}, {
adapter: new ValibotJsonSchemaAdapter(),
capabilities: { tools: { listChanged: true } }
}).withContext();
addListAllDocumentationTool(server);
addGetDocumentationTool(server);
addGetStoryDocumentationTool(server);
server.tool({
name: "get-storybook-story-instructions",
title: "React Native Storybook Story Instructions",
description: "Get instructions for writing React Native Storybook stories. Call this before creating or modifying story files (.stories.tsx, .stories.ts)."
}, async () => ({ content: [{
type: "text",
text: storyInstructions
}] }));
if (wss) {
const broadcastEvent = (event) => {
const message = JSON.stringify(event);
wss.clients.forEach((client) => {
if (client.readyState === 1) client.send(message);
});
};
server.tool({
name: "select-story",
title: "Select Story",
description: "Select and display a story on the connected device. Use the story ID in the format \"title--name\" (e.g. \"button--primary\"). Use the list-all-documentation tool to discover available components and stories.",
schema: valibot.object({ storyId: valibot.string() })
}, async ({ storyId }) => {
try {
const index = await buildIndex({ configPath });
if (!index.entries[storyId]) return {
content: [{
type: "text",
text: `Story "${storyId}" not found. Available stories include: ${Object.keys(index.entries).slice(0, 10).join(", ")}` + (Object.keys(index.entries).length > 10 ? ", ..." : "")
}],
isError: true
};
broadcastEvent({
type: "setCurrentStory",
args: [{
storyId,
viewMode: "story"
}]
});
const entry = index.entries[storyId];
return { content: [{
type: "text",
text: `Selected story "${entry.name}" (${entry.title}) on connected devices.`
}] };
} catch (error) {
return {
content: [{
type: "text",
text: `Failed to select story: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
}
const transport = new HttpTransport(server, { path: null });
handler = (req) => transport.respond(req, {
request: req,
manifestProvider
});
console.log("[Storybook] MCP server initialized");
} catch (error) {
initPromise = null;
console.error("[Storybook] Failed to initialize MCP server:", error);
throw error;
}
})();
await initPromise;
}
/**
* Handles an incoming MCP HTTP request (POST /mcp or GET /mcp).
*/
async function handleMcpRequest(req, res) {
try {
await init();
if (!handler) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "MCP handler not initialized" }));
return;
}
const webRequest = await incomingMessageToWebRequest(req);
await webResponseToServerResponse(await handler(webRequest), res);
} catch (error) {
console.error("[Storybook] MCP request failed:", error);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "MCP request failed" }));
}
}
/**
* Pre-initializes the MCP server (non-blocking).
*/
function preInit() {
init().catch((e) => console.warn("[Storybook] MCP pre-initialization failed (will retry on first request):", e));
}
return {
handleMcpRequest,
preInit
};
}
const SELECT_STORY_SYNC_TIMEOUT_MS = 1e3;
const LAST_RENDERED_STORY_TIMEOUT_MS = 500;
function getRenderedStoryId(event) {
if (!event || typeof event !== "object") return null;
const { type, args } = event;
if (type !== "storyRendered" || !Array.isArray(args) || args.length === 0) return null;
const [firstArg] = args;
if (typeof firstArg === "string") return firstArg;
if (firstArg && typeof firstArg === "object" && "storyId" in firstArg) {
const { storyId } = firstArg;
return typeof storyId === "string" ? storyId : null;
}
return null;
}
function parseStoryIdFromPath(pathname) {
const match = pathname.match(/^\/select-story-sync\/([^/]+)$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]) || null;
} catch {
return null;
}
}
function createSelectStorySyncEndpoint(wss) {
const pendingStorySelections = /* @__PURE__ */ new Map();
const lastRenderedStoryIdByClient = /* @__PURE__ */ new Map();
const waitForStoryRender = (storyId, timeoutMs) => {
let cancelSelection = () => {};
let resolveWait = () => {};
return {
promise: new Promise((resolve, reject) => {
resolveWait = resolve;
let selections = pendingStorySelections.get(storyId);
if (!selections) {
selections = /* @__PURE__ */ new Set();
pendingStorySelections.set(storyId, selections);
}
const cleanup = () => {
clearTimeout(selection.timeout);
selections.delete(selection);
if (selections.size === 0) pendingStorySelections.delete(storyId);
};
const selection = {
resolve: () => {
if (selection.settled) return;
selection.settled = true;
cleanup();
resolve();
},
timeout: setTimeout(() => {
if (selection.settled) return;
selection.settled = true;
cleanup();
reject(/* @__PURE__ */ new Error(`Story "${storyId}" did not render in time`));
}, timeoutMs),
settled: false
};
cancelSelection = () => {
if (selection.settled) return;
selection.settled = true;
cleanup();
resolveWait();
};
selections.add(selection);
}),
cancel: cancelSelection
};
};
const resolveStorySelection = (storyId) => {
const selections = pendingStorySelections.get(storyId);
if (!selections) return;
[...selections].forEach((selection) => selection.resolve());
};
const handleRequest = async (pathname, res) => {
const storyId = parseStoryIdFromPath(pathname);
if (!storyId) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: false,
error: "Invalid story id"
}));
return;
}
const waitForRender = waitForStoryRender(storyId, SELECT_STORY_SYNC_TIMEOUT_MS);
const message = JSON.stringify({
type: "setCurrentStory",
args: [{
viewMode: "story",
storyId
}]
});
wss.clients.forEach((wsClient) => {
if (wsClient.readyState === ws.WebSocket.OPEN) wsClient.send(message);
});
try {
if ([...wss.clients].some((client) => client.readyState === ws.WebSocket.OPEN && lastRenderedStoryIdByClient.get(client) === storyId)) {
if (await Promise.race([waitForRender.promise.then(() => "rendered"), new Promise((resolve) => {
setTimeout(() => resolve("alreadyRendered"), LAST_RENDERED_STORY_TIMEOUT_MS);
})]) === "alreadyRendered") waitForRender.cancel();
} else await waitForRender.promise;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: true,
storyId
}));
} catch (error) {
res.writeHead(408, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: false,
storyId,
error: error instanceof Error ? error.message : String(error)
}));
}
};
const onSocketMessage = (event, ws$2) => {
const renderedStoryId = getRenderedStoryId(event);
if (renderedStoryId) {
lastRenderedStoryIdByClient.set(ws$2, renderedStoryId);
resolveStorySelection(renderedStoryId);
}
};
const onSocketClose = (ws$3) => {
lastRenderedStoryIdByClient.delete(ws$3);
};
return {
handleRequest,
onSocketMessage,
onSocketClose
};
}
//#endregion
//#region src/metro/channelServer.ts
/**
* Creates a channel server for syncing storybook instances and sending events.
* The server provides both WebSocket and REST endpoints:
* - WebSocket: broadcasts all received messages to all connected clients
* - POST /send-event: sends an event to all WebSocket clients
* - POST /select-story-sync/{storyId}: sets the current story and waits for a storyRendered event
* - GET /index.json: returns the story index built from story files
* - POST /mcp: MCP endpoint for AI agent integration (when experimental_mcp option is enabled)
*
* @param options - Configuration options for the channel server.
* @param options.port - The port to listen on.
* @param options.host - The host to bind to.
* @param options.configPath - The path to the Storybook config folder.
* @param options.experimental_mcp - Whether to enable MCP server support.
* @param options.websockets - Whether to enable WebSocket server support.
* @param options.secured - Whether to use HTTPS/WSS for the channel server.
* @param options.ssl - TLS credentials used when `secured` is true.
* @param options.keepAlive - Whether the channel server should keep the Node.js process alive.
* @returns The created WebSocketServer instance, or null when websockets are disabled.
*/
function createChannelServer({ port = 7007, host = void 0, configPath, experimental_mcp = false, websockets = true, secured = false, ssl, keepNodeProcessAlive = false }) {
if (secured && (!ssl?.key || !ssl?.cert)) throw new Error("[Storybook] Secure channel server requires both `ssl.key` and `ssl.cert`.");
const httpServer = secured ? (0, node_https.createServer)(ssl) : (0, node_http.createServer)();
const wss = websockets ? new ws.WebSocketServer({ server: httpServer }) : null;
const mcpServer = experimental_mcp ? createMcpHandler(configPath, wss ?? void 0) : null;
const selectStorySyncEndpoint = wss ? createSelectStorySyncEndpoint(wss) : null;
httpServer.on("request", async (req, res) => {
const protocol = "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
const requestUrl = new URL(req.url ?? "/", `${protocol}://${req.headers.host ?? "localhost"}`);
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
if (req.method === "GET" && requestUrl.pathname === "/index.json") {
try {
const index = await buildIndex({ configPath });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(index));
} catch (error) {
console.error("Failed to build index:", error);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Failed to build story index" }));
}
return;
}
if (req.method === "POST" && requestUrl.pathname === "/send-event") {
if (!wss) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: false,
error: "WebSockets are disabled"
}));
return;
}
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", () => {
try {
const json = JSON.parse(body);
wss.clients.forEach((wsClient) => wsClient.send(JSON.stringify(json)));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
} catch (error) {
console.error("Failed to parse event:", error);
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: false,
error: "Invalid JSON"
}));
}
});
return;
}
if (req.method === "POST" && requestUrl.pathname.startsWith("/select-story-sync/")) {
if (!selectStorySyncEndpoint) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: false,
error: "WebSockets are disabled"
}));
return;
}
await selectStorySyncEndpoint.handleRequest(requestUrl.pathname, res);
return;
}
if (mcpServer && requestUrl.pathname === "/mcp" && (req.method === "POST" || req.method === "GET")) {
await mcpServer.handleMcpRequest(req, res);
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
if (wss) {
wss.on("error", () => {});
setInterval(function ping() {
wss.clients.forEach(function each(client) {
if (client.readyState === ws.WebSocket.OPEN) client.send(JSON.stringify({
type: "ping",
args: []
}));
});
}, 1e4).unref?.();
wss.on("connection", function connection(ws$1) {
console.log("WebSocket connection established");
ws$1.on("error", console.error);
ws$1.on("message", function message(data) {
try {
const json = JSON.parse(data.toString());
selectStorySyncEndpoint?.onSocketMessage(json, ws$1);
const msg = JSON.stringify(json);
wss.clients.forEach((wsClient) => {
if (wsClient !== ws$1 && wsClient.readyState === ws.WebSocket.OPEN) wsClient.send(msg);
});
} catch (error) {
console.error(error);
}
});
ws$1.on("close", () => {
selectStorySyncEndpoint?.onSocketClose(ws$1);
});
});
}
httpServer.on("error", (error) => {
if (error.code === "EADDRINUSE") console.warn(`[Storybook] Port ${port} is already in use. The channel server will not start. Another instance may already be running.`);
else console.error(`[Storybook] Channel server error:`, error);
});
httpServer.listen(port, host, () => {
console.log(`${wss ? secured ? "WSS" : "WebSocket" : secured ? "HTTPS" : "HTTP"} server listening on ${host ?? "localhost"}:${port}`);
});
if (!keepNodeProcessAlive) httpServer.unref();
process.once("beforeExit", () => httpServer.close());
mcpServer?.preInit();
return wss;
}
//#endregion
Object.defineProperty(exports, "buildIndex", {
enumerable: true,
get: function() {
return buildIndex;
}
});
Object.defineProperty(exports, "createChannelServer", {
enumerable: true,
get: function() {
return createChannelServer;
}
});
Object.defineProperty(exports, "require_common", {
enumerable: true,
get: function() {
return require_common;
}
});