UNPKG

@storybook/react-native

Version:

A better way to develop React Native Components for your app

662 lines (661 loc) 23.5 kB
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; } });