UNPKG

@storybook/react-native

Version:

A better way to develop React Native Components for your app

475 lines (474 loc) 17.1 kB
require("./chunk-Ble4zEEl.js"); const require_buildIndex = require("./buildIndex-l9rzAl79.js"); let ws = require("ws"); let node_http = require("node:http"); let node_https = require("node:https"); let node_stream_consumers = require("node:stream/consumers"); //#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 }, { buildIndex }, valibot, { experimental_manifests }] = await Promise.all([ import("tmcp"), import("@tmcp/adapter-valibot"), import("@tmcp/transport-http"), import("@storybook/mcp"), Promise.resolve().then(() => require("./storyInstructions-FLR5G1Xm.js")), Promise.resolve().then(() => require("./buildIndex-l9rzAl79.js")).then((n) => n.buildIndex_exports), 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 require_buildIndex.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, "createChannelServer", { enumerable: true, get: function() { return createChannelServer; } });