From 0ab0414e3bb995845b6f974028b4f2948cf184ad Mon Sep 17 00:00:00 2001 From: Shinyaigeek Date: Sat, 30 May 2026 16:01:13 +0900 Subject: [PATCH] fix(react-headless): fall back when crypto.randomUUID is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crypto.randomUUID()` is only defined in a secure context (HTTPS, or `http://localhost` / `127.0.0.1` / `[::1]`). When an app is served from a non-secure origin — e.g. a LAN IP over plain HTTP or `0.0.0.0` — `crypto.randomUUID` is `undefined`, so sending a message threw `crypto.randomUUID is not a function` in `createChatStore`. Add a `safeRandomUUID()` helper that uses `crypto.randomUUID()` when present and otherwise falls back to a sufficiently-unique id, and use it for client-side message ids. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/store/createChatStore.ts | 3 ++- .../src/stream/adapters/langgraph.ts | 5 ++-- .../src/stream/adapters/openai-completions.ts | 3 ++- .../stream/adapters/openai-readable-stream.ts | 3 ++- .../formats/langgraph-message-format.ts | 5 ++-- .../openai-conversation-message-format.ts | 3 ++- .../stream/formats/openai-message-format.ts | 17 +++++++------- .../src/stream/processStreamedMessage.ts | 3 ++- .../src/utils/safe-random-uuid.ts | 23 +++++++++++++++++++ 9 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 packages/react-headless/src/utils/safe-random-uuid.ts diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index 95e7d7fb8..e8b711495 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -3,6 +3,7 @@ import { subscribeWithSelector } from "zustand/middleware"; import { processStreamedMessage } from "../stream/processStreamedMessage"; import { identityMessageFormat } from "../types/messageFormat"; import type { ChatProviderProps, ChatStore, Message, Thread, UserMessage } from "./types"; +import { safeRandomUUID } from "../utils/safe-random-uuid"; type StoreConfig = Omit; @@ -213,7 +214,7 @@ export const createChatStore = (config: StoreConfig) => { const abortController = new AbortController(); const optimisticMessage: UserMessage = { ...message, - id: crypto.randomUUID(), + id: safeRandomUUID(), role: "user", }; diff --git a/packages/react-headless/src/stream/adapters/langgraph.ts b/packages/react-headless/src/stream/adapters/langgraph.ts index 8cc58fd17..ac15425b2 100644 --- a/packages/react-headless/src/stream/adapters/langgraph.ts +++ b/packages/react-headless/src/stream/adapters/langgraph.ts @@ -1,4 +1,5 @@ import { AGUIEvent, EventType, StreamProtocolAdapter } from "../../types"; +import { safeRandomUUID } from "../../utils/safe-random-uuid"; /** * Represents a LangGraph AI message (or chunk) as received in the @@ -66,7 +67,7 @@ export const langGraphAdapter = (options?: LangGraphAdapterOptions): StreamProto if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); - const messageId = crypto.randomUUID(); + const messageId = safeRandomUUID(); const toolCallIds: Record = {}; let messageStarted = false; let buffer = ""; @@ -174,7 +175,7 @@ export const langGraphAdapter = (options?: LangGraphAdapterOptions): StreamProto const tc = msg.tool_calls[i]; if (!tc) continue; - const toolCallId = tc.id || crypto.randomUUID(); + const toolCallId = tc.id || safeRandomUUID(); // Only emit if we haven't already started this tool call // via tool_call_chunks diff --git a/packages/react-headless/src/stream/adapters/openai-completions.ts b/packages/react-headless/src/stream/adapters/openai-completions.ts index d45ca9be8..e98db065d 100644 --- a/packages/react-headless/src/stream/adapters/openai-completions.ts +++ b/packages/react-headless/src/stream/adapters/openai-completions.ts @@ -1,5 +1,6 @@ import type { ChatCompletionChunk } from "openai/resources/chat/completions"; import { AGUIEvent, EventType, StreamProtocolAdapter } from "../../types"; +import { safeRandomUUID } from "../../utils/safe-random-uuid"; export const openAIAdapter = (): StreamProtocolAdapter => ({ async *parse(response: Response): AsyncIterable { @@ -7,7 +8,7 @@ export const openAIAdapter = (): StreamProtocolAdapter => ({ if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); - const messageId = crypto.randomUUID(); + const messageId = safeRandomUUID(); const toolCallIds: Record = {}; let messageStarted = false; diff --git a/packages/react-headless/src/stream/adapters/openai-readable-stream.ts b/packages/react-headless/src/stream/adapters/openai-readable-stream.ts index 9497a4ad1..4cf175eb9 100644 --- a/packages/react-headless/src/stream/adapters/openai-readable-stream.ts +++ b/packages/react-headless/src/stream/adapters/openai-readable-stream.ts @@ -1,5 +1,6 @@ import type { ChatCompletionChunk } from "openai/resources/chat/completions"; import { AGUIEvent, EventType, StreamProtocolAdapter } from "../../types"; +import { safeRandomUUID } from "../../utils/safe-random-uuid"; /** * Adapter for streams produced by the OpenAI SDK's `Stream.toReadableStream()`. @@ -12,7 +13,7 @@ export const openAIReadableStreamAdapter = (): StreamProtocolAdapter => ({ if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); - const messageId = crypto.randomUUID(); + const messageId = safeRandomUUID(); const toolCallIds: Record = {}; let messageStarted = false; let buffer = ""; diff --git a/packages/react-headless/src/stream/formats/langgraph-message-format.ts b/packages/react-headless/src/stream/formats/langgraph-message-format.ts index 7bb85fc92..35674c128 100644 --- a/packages/react-headless/src/stream/formats/langgraph-message-format.ts +++ b/packages/react-headless/src/stream/formats/langgraph-message-format.ts @@ -1,5 +1,6 @@ import type { AssistantMessage, Message, ToolMessage, UserMessage } from "../../types"; import type { MessageFormat } from "../../types/messageFormat"; +import { safeRandomUUID } from "../../utils/safe-random-uuid"; // ── LangGraph / LangChain message types ────────────────────────── @@ -61,7 +62,7 @@ function toLangChainMessage(message: Message): LangChainMessage { // ── Inbound (LangGraph → AG-UI) ──────────────────────────────── function fromLangChainMessage(msg: LangChainMessage): Message { - const id = msg.id ?? crypto.randomUUID(); + const id = msg.id ?? safeRandomUUID(); switch (msg.type) { case "human": @@ -139,7 +140,7 @@ function safeParseArgs(args: string): Record | string { * LangGraph → AG-UI (fromApi): * - Maps `type` to `role` (`"human"` → `"user"`, `"ai"` → `"assistant"`) * - Converts tool call `args` object to JSON string - * - Generates `id` via `crypto.randomUUID()` if not present + * - Generates `id` via `safeRandomUUID()` if not present */ export const langGraphMessageFormat: MessageFormat = { toApi(messages: Message[]): LangChainMessage[] { diff --git a/packages/react-headless/src/stream/formats/openai-conversation-message-format.ts b/packages/react-headless/src/stream/formats/openai-conversation-message-format.ts index 04d376f00..dd8a8276d 100644 --- a/packages/react-headless/src/stream/formats/openai-conversation-message-format.ts +++ b/packages/react-headless/src/stream/formats/openai-conversation-message-format.ts @@ -9,6 +9,7 @@ import type { } from "openai/resources/responses/responses"; import type { AssistantMessage, Message, ToolMessage, UserMessage } from "../../types"; import type { MessageFormat } from "../../types/messageFormat"; +import { safeRandomUUID } from "../../utils/safe-random-uuid"; // ── Outbound (AG-UI → OpenAI Responses/Conversations input) ───── @@ -162,7 +163,7 @@ function fromItems(items: ConversationItem[]): Message[] { const tc = item as { id?: string; call_id: string; name: string; arguments: string }; if (!currentAssistant) { - currentAssistant = { id: crypto.randomUUID(), role: "assistant" }; + currentAssistant = { id: safeRandomUUID(), role: "assistant" }; } currentAssistant = { diff --git a/packages/react-headless/src/stream/formats/openai-message-format.ts b/packages/react-headless/src/stream/formats/openai-message-format.ts index 1641e347a..8fbea7386 100644 --- a/packages/react-headless/src/stream/formats/openai-message-format.ts +++ b/packages/react-headless/src/stream/formats/openai-message-format.ts @@ -6,6 +6,7 @@ import type { } from "openai/resources/chat/completions"; import type { AssistantMessage, Message, ToolMessage, UserMessage } from "../../types"; import type { MessageFormat } from "../../types/messageFormat"; +import { safeRandomUUID } from "../../utils/safe-random-uuid"; // ── Outbound (AG-UI → OpenAI Completions) ─────────────────────── @@ -83,7 +84,7 @@ function fromOpenAIAssistant(msg: ChatCompletionAssistantMessageParam): Assistan const content = typeof msg.content === "string" ? msg.content : undefined; const result: AssistantMessage = { - id: crypto.randomUUID(), + id: safeRandomUUID(), role: "assistant", content, }; @@ -106,7 +107,7 @@ function fromOpenAIAssistant(msg: ChatCompletionAssistantMessageParam): Assistan function fromOpenAIUser(msg: ChatCompletionUserMessageParam): UserMessage { if (typeof msg.content === "string") { - return { id: crypto.randomUUID(), role: "user", content: msg.content }; + return { id: safeRandomUUID(), role: "user", content: msg.content }; } const content = msg.content.map((part): { type: "text"; text: string } => { @@ -114,7 +115,7 @@ function fromOpenAIUser(msg: ChatCompletionUserMessageParam): UserMessage { return { type: "text", text: "" }; }); - return { id: crypto.randomUUID(), role: "user", content }; + return { id: safeRandomUUID(), role: "user", content }; } function fromOpenAITool(msg: ChatCompletionToolMessageParam): ToolMessage { @@ -122,7 +123,7 @@ function fromOpenAITool(msg: ChatCompletionToolMessageParam): ToolMessage { typeof msg.content === "string" ? msg.content : msg.content.map((p) => p.text).join(""); return { - id: crypto.randomUUID(), + id: safeRandomUUID(), role: "tool", content, toolCallId: msg.tool_call_id, @@ -139,18 +140,18 @@ function fromOpenAI(data: ChatCompletionMessageParam): Message { return fromOpenAITool(data); case "system": return { - id: crypto.randomUUID(), + id: safeRandomUUID(), role: "system", content: typeof data.content === "string" ? data.content : "", }; case "developer": return { - id: crypto.randomUUID(), + id: safeRandomUUID(), role: "developer", content: typeof data.content === "string" ? data.content : "", }; default: - return { id: crypto.randomUUID(), role: "system", content: "" }; + return { id: safeRandomUUID(), role: "system", content: "" }; } } @@ -170,7 +171,7 @@ function fromOpenAI(data: ChatCompletionMessageParam): Message { * - Converts multipart `content` arrays to OpenAI content format * * OpenAI → AG-UI (fromApi): - * - Generates `id` via `crypto.randomUUID()` + * - Generates `id` via `safeRandomUUID()` * - Converts `tool_calls` → `toolCalls` * - Converts `tool_call_id` → `toolCallId` */ diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index 507f28c2a..7b0eff58d 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -1,5 +1,6 @@ import { AssistantMessage, EventType, StreamProtocolAdapter } from "../types"; import { agUIAdapter } from "./adapters"; +import { safeRandomUUID } from "../utils/safe-random-uuid"; /** * @inline @@ -27,7 +28,7 @@ export const processStreamedMessage = async ({ adapter = agUIAdapter(), }: Parameters): Promise => { let currentMessage: AssistantMessage = { - id: crypto.randomUUID(), + id: safeRandomUUID(), role: "assistant", content: "", toolCalls: [], diff --git a/packages/react-headless/src/utils/safe-random-uuid.ts b/packages/react-headless/src/utils/safe-random-uuid.ts new file mode 100644 index 000000000..498f30986 --- /dev/null +++ b/packages/react-headless/src/utils/safe-random-uuid.ts @@ -0,0 +1,23 @@ +/** + * Returns a UUID, falling back gracefully when `crypto.randomUUID` is unavailable. + * + * `crypto.randomUUID()` is only defined in a secure context — HTTPS, or + * `http://localhost` / `127.0.0.1` / `[::1]`. When the app is served from a + * non-secure origin (for example a LAN IP over plain HTTP, or `0.0.0.0`), + * `crypto.randomUUID` is `undefined` and calling it throws + * "crypto.randomUUID is not a function". This helper detects that case and + * falls back to a sufficiently-unique id, which is all that is needed for + * client-side message keys. + */ +export function safeRandomUUID(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return ( + "uid-" + + Date.now().toString(36) + + "-" + + Math.random().toString(36).slice(2, 10) + + Math.random().toString(36).slice(2, 10) + ); +}