@storybook/react-native
Version:
A better way to develop React Native Components for your app
475 lines (474 loc) • 17.1 kB
JavaScript
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;
}
});