@tanstack/ai-client 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@ export { InterruptManager } from './interrupt-manager.js';
5
5
  export type { InterruptManagerHydration, InterruptManagerOptions, InterruptManagerSubmission, } from './interrupt-manager.js';
6
6
  export { createMcpAppBridge } from './mcp-app-bridge.js';
7
7
  export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge.js';
8
+ export { registerWebMCPTools } from './web-mcp-tools.js';
9
+ export type { RegisterWebMCPToolsOptions, WebMCPToolAnnotations, WebMCPToolOptions, WebMCPToolOptionsByName, } from './web-mcp-tools.js';
8
10
  export { RealtimeClient } from './realtime-client.js';
9
11
  export { GenerationClient } from './generation-client.js';
10
12
  export { VideoGenerationClient } from './video-generation-client.js';
package/dist/esm/index.js CHANGED
@@ -4,6 +4,7 @@ import { DurableStreamIncompleteError, StreamReconnectLimitError, StreamTruncate
4
4
  import { InterruptManager } from "./interrupt-manager.js";
5
5
  import { ChatClient } from "./chat-client.js";
6
6
  import { createMcpAppBridge } from "./mcp-app-bridge.js";
7
+ import { registerWebMCPTools } from "./web-mcp-tools.js";
7
8
  import { RealtimeClient } from "./realtime-client.js";
8
9
  import { GENERATION_EVENTS } from "./generation-types.js";
9
10
  import { GenerationClient } from "./generation-client.js";
@@ -13,4 +14,4 @@ import { clientTools, createChatClientOptions } from "./types.js";
13
14
  import { StorageUnavailableError, indexedDBPersistence, localStoragePersistence, sessionStoragePersistence } from "./storage-adapters.js";
14
15
  import { createAIDevtoolsGenerationPreview } from "./devtools.js";
15
16
  import { BatchStrategy, CompositeStrategy, ImmediateStrategy, PartialJSONParser, PunctuationStrategy, StreamProcessor, WordBoundaryStrategy, convertMessagesToModelMessages, defaultJSONParser, generateMessageId, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, parsePartialJSON, uiMessageToModelMessages } from "@tanstack/ai/client";
16
- export { AudioRecorder, BatchStrategy, ChatClient, CompositeStrategy, DurableStreamIncompleteError, GENERATION_EVENTS, GenerationClient, ImmediateStrategy, InterruptManager, PartialJSONParser, PunctuationStrategy, RealtimeClient, StorageUnavailableError, StreamProcessor, StreamReconnectLimitError, StreamTruncatedError, UnsupportedResponseStreamError, VideoGenerationClient, WordBoundaryStrategy, clientTools, convertMessagesToModelMessages, createAIDevtoolsGenerationPreview, createChatClientOptions, createMcpAppBridge, defaultJSONParser, fetchHttpStream, fetchServerSentEvents, generateMessageId, indexedDBPersistence, localStoragePersistence, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, parsePartialJSON, reconstructAudioResult, reconstructImageResult, reconstructSpeechResult, reconstructSummarizeResult, reconstructTranscriptionResult, rpcStream, sessionStoragePersistence, stream, uiMessageToModelMessages, webSocket, xhrHttpStream, xhrServerSentEvents };
17
+ export { AudioRecorder, BatchStrategy, ChatClient, CompositeStrategy, DurableStreamIncompleteError, GENERATION_EVENTS, GenerationClient, ImmediateStrategy, InterruptManager, PartialJSONParser, PunctuationStrategy, RealtimeClient, StorageUnavailableError, StreamProcessor, StreamReconnectLimitError, StreamTruncatedError, UnsupportedResponseStreamError, VideoGenerationClient, WordBoundaryStrategy, clientTools, convertMessagesToModelMessages, createAIDevtoolsGenerationPreview, createChatClientOptions, createMcpAppBridge, defaultJSONParser, fetchHttpStream, fetchServerSentEvents, generateMessageId, indexedDBPersistence, localStoragePersistence, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, parsePartialJSON, reconstructAudioResult, reconstructImageResult, reconstructSpeechResult, reconstructSummarizeResult, reconstructTranscriptionResult, registerWebMCPTools, rpcStream, sessionStoragePersistence, stream, uiMessageToModelMessages, webSocket, xhrHttpStream, xhrServerSentEvents };
@@ -0,0 +1,49 @@
1
+ import { AnyClientTool } from '@tanstack/ai/client';
2
+ import { ClientContextOptionFromTools, InferredClientContext } from './types.js';
3
+ /** WebMCP behavior hints for one registered tool. */
4
+ export interface WebMCPToolAnnotations {
5
+ /** Indicates that the tool does not modify state. */
6
+ readOnlyHint?: boolean;
7
+ /** Indicates that the tool can return content that the application does not trust. */
8
+ untrustedContentHint?: boolean;
9
+ }
10
+ /** Display and behavior options for one WebMCP tool. */
11
+ export interface WebMCPToolOptions {
12
+ /** A human-readable title for browser user interfaces. */
13
+ title?: string;
14
+ /** Optional behavior hints for browser agents. */
15
+ annotations?: WebMCPToolAnnotations;
16
+ }
17
+ /** WebMCP options keyed by the inferred names in a client tool list. */
18
+ export type WebMCPToolOptionsByName<TTools extends ReadonlyArray<AnyClientTool>> = Partial<{
19
+ [TName in TTools[number]['name']]: WebMCPToolOptions;
20
+ }>;
21
+ /**
22
+ * Options for {@link registerWebMCPTools}.
23
+ *
24
+ * The signal controls the registration lifetime. Context is required when a
25
+ * client tool declares a required runtime context.
26
+ */
27
+ export type RegisterWebMCPToolsOptions<TTools extends ReadonlyArray<AnyClientTool>, TContext = InferredClientContext<TTools>> = {
28
+ /** Removes all tools from this call when the signal aborts. */
29
+ signal: AbortSignal;
30
+ /** Per-tool display and behavior options. */
31
+ toolOptions?: WebMCPToolOptionsByName<TTools>;
32
+ } & ClientContextOptionFromTools<TTools, TContext>;
33
+ /**
34
+ * Registers executable TanStack client tools with the browser WebMCP API.
35
+ *
36
+ * Unsupported browsers and server environments resolve without registration.
37
+ * Abort `options.signal` to remove every tool registered by this call.
38
+ *
39
+ * @param tools - The executable client tools to expose through WebMCP.
40
+ * @param options - The registration signal, runtime context, and per-tool options.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * const controller = new AbortController()
45
+ * await registerWebMCPTools(tools, { signal: controller.signal })
46
+ * controller.abort()
47
+ * ```
48
+ */
49
+ export declare function registerWebMCPTools<const TTools extends ReadonlyArray<AnyClientTool>, TContext = InferredClientContext<TTools>>(tools: TTools, options: RegisterWebMCPToolsOptions<TTools, TContext>): Promise<void>;
@@ -0,0 +1,80 @@
1
+ import { convertSchemaToJsonSchema, validateWithStandardSchema } from "@tanstack/ai/client";
2
+ //#region src/web-mcp-tools.ts
3
+ function isWebMCPModelContext(value) {
4
+ return value !== null && typeof value === "object" && "registerTool" in value && typeof value.registerTool === "function";
5
+ }
6
+ function getToolOptions(toolOptions, name) {
7
+ return toolOptions?.[name];
8
+ }
9
+ async function validateSchemaValue(schema, value) {
10
+ const result = await validateWithStandardSchema(schema, value);
11
+ if (result.success) return result.data;
12
+ throw new Error(`Validation failed: ${result.issues.map((issue) => issue.message).join(", ")}`);
13
+ }
14
+ /**
15
+ * Registers executable TanStack client tools with the browser WebMCP API.
16
+ *
17
+ * Unsupported browsers and server environments resolve without registration.
18
+ * Abort `options.signal` to remove every tool registered by this call.
19
+ *
20
+ * @param tools - The executable client tools to expose through WebMCP.
21
+ * @param options - The registration signal, runtime context, and per-tool options.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const controller = new AbortController()
26
+ * await registerWebMCPTools(tools, { signal: controller.signal })
27
+ * controller.abort()
28
+ * ```
29
+ */
30
+ async function registerWebMCPTools(tools, options) {
31
+ if (typeof document === "undefined" || typeof isSecureContext !== "undefined" && !isSecureContext || !("modelContext" in document) || !isWebMCPModelContext(document.modelContext)) return;
32
+ if (tools.length === 0) return;
33
+ const names = /* @__PURE__ */ new Set();
34
+ const webMCPTools = tools.map((tool) => {
35
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) throw new Error(`WebMCP tool name "${tool.name}" must contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods.`);
36
+ if (names.has(tool.name)) throw new Error(`Duplicate WebMCP tool name "${tool.name}".`);
37
+ if (tool.description.trim() === "") throw new Error(`WebMCP tool "${tool.name}" must have a description.`);
38
+ if (typeof tool.execute !== "function") throw new Error(`WebMCP tool "${tool.name}" must have an execute handler.`);
39
+ if (tool.needsApproval === true) throw new Error(`WebMCP tool "${tool.name}" cannot use needsApproval: true.`);
40
+ names.add(tool.name);
41
+ const toolOptions = getToolOptions(options.toolOptions, tool.name);
42
+ const inputSchema = tool.inputSchema;
43
+ const outputSchema = tool.outputSchema;
44
+ const convertedInputSchema = convertSchemaToJsonSchema(inputSchema);
45
+ const inputSchemaType = convertedInputSchema?.type;
46
+ if (typeof inputSchemaType === "string" && inputSchemaType !== "object" || Array.isArray(inputSchemaType) && !inputSchemaType.includes("object")) throw new Error(`WebMCP tool "${tool.name}" input schema must accept an object.`);
47
+ const execute = tool.execute;
48
+ return {
49
+ name: tool.name,
50
+ description: tool.description,
51
+ ...toolOptions?.title !== void 0 ? { title: toolOptions.title } : {},
52
+ ...convertedInputSchema !== void 0 ? { inputSchema: convertedInputSchema } : {},
53
+ ...toolOptions?.annotations !== void 0 ? { annotations: toolOptions.annotations } : {},
54
+ async execute(input, executionOptions) {
55
+ const validatedInput = await validateSchemaValue(inputSchema, input);
56
+ const output = await execute(validatedInput, {
57
+ abortSignal: executionOptions?.signal,
58
+ context: options.context,
59
+ emitCustomEvent() {}
60
+ });
61
+ return validateSchemaValue(outputSchema, output);
62
+ }
63
+ };
64
+ });
65
+ const registrationController = new AbortController();
66
+ const abortRegistration = () => registrationController.abort(options.signal.reason);
67
+ if (options.signal.aborted) abortRegistration();
68
+ else options.signal.addEventListener("abort", abortRegistration, { once: true });
69
+ try {
70
+ for (const tool of webMCPTools) await document.modelContext.registerTool(tool, { signal: registrationController.signal });
71
+ } catch (error) {
72
+ registrationController.abort(error);
73
+ options.signal.removeEventListener("abort", abortRegistration);
74
+ throw error;
75
+ }
76
+ }
77
+ //#endregion
78
+ export { registerWebMCPTools };
79
+
80
+ //# sourceMappingURL=web-mcp-tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-mcp-tools.js","names":[],"sources":["../../src/web-mcp-tools.ts"],"sourcesContent":["import {\n convertSchemaToJsonSchema,\n validateWithStandardSchema,\n} from '@tanstack/ai/client'\nimport type { AnyClientTool } from '@tanstack/ai/client'\nimport type {\n ClientContextOptionFromTools,\n InferredClientContext,\n} from './types'\n\ninterface WebMCPTool {\n name: string\n title?: string\n description: string\n inputSchema?: object\n annotations?: WebMCPToolAnnotations\n execute: (input: object, options: { signal: AbortSignal }) => Promise<unknown>\n}\n\ninterface WebMCPModelContext {\n registerTool: (\n tool: WebMCPTool,\n options: { signal: AbortSignal },\n ) => Promise<void>\n}\n\n/** WebMCP behavior hints for one registered tool. */\nexport interface WebMCPToolAnnotations {\n /** Indicates that the tool does not modify state. */\n readOnlyHint?: boolean\n /** Indicates that the tool can return content that the application does not trust. */\n untrustedContentHint?: boolean\n}\n\n/** Display and behavior options for one WebMCP tool. */\nexport interface WebMCPToolOptions {\n /** A human-readable title for browser user interfaces. */\n title?: string\n /** Optional behavior hints for browser agents. */\n annotations?: WebMCPToolAnnotations\n}\n\n/** WebMCP options keyed by the inferred names in a client tool list. */\nexport type WebMCPToolOptionsByName<\n TTools extends ReadonlyArray<AnyClientTool>,\n> = Partial<{\n [TName in TTools[number]['name']]: WebMCPToolOptions\n}>\n\n/**\n * Options for {@link registerWebMCPTools}.\n *\n * The signal controls the registration lifetime. Context is required when a\n * client tool declares a required runtime context.\n */\nexport type RegisterWebMCPToolsOptions<\n TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n> = {\n /** Removes all tools from this call when the signal aborts. */\n signal: AbortSignal\n /** Per-tool display and behavior options. */\n toolOptions?: WebMCPToolOptionsByName<TTools>\n} & ClientContextOptionFromTools<TTools, TContext>\n\nfunction isWebMCPModelContext(value: unknown): value is WebMCPModelContext {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'registerTool' in value &&\n typeof value.registerTool === 'function'\n )\n}\n\nfunction getToolOptions<TName extends string>(\n toolOptions: Partial<Record<TName, WebMCPToolOptions>> | undefined,\n name: TName,\n) {\n return toolOptions?.[name]\n}\n\nasync function validateSchemaValue(schema: unknown, value: unknown) {\n const result = await validateWithStandardSchema(schema, value)\n if (result.success) {\n return result.data\n }\n\n throw new Error(\n `Validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`,\n )\n}\n\n/**\n * Registers executable TanStack client tools with the browser WebMCP API.\n *\n * Unsupported browsers and server environments resolve without registration.\n * Abort `options.signal` to remove every tool registered by this call.\n *\n * @param tools - The executable client tools to expose through WebMCP.\n * @param options - The registration signal, runtime context, and per-tool options.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * await registerWebMCPTools(tools, { signal: controller.signal })\n * controller.abort()\n * ```\n */\nexport async function registerWebMCPTools<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(tools: TTools, options: RegisterWebMCPToolsOptions<TTools, TContext>) {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPModelContext(document.modelContext)\n ) {\n return\n }\n if (tools.length === 0) {\n return\n }\n\n const names = new Set<string>()\n const webMCPTools = tools.map((tool) => {\n if (!/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) {\n throw new Error(\n `WebMCP tool name \"${tool.name}\" must contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods.`,\n )\n }\n if (names.has(tool.name)) {\n throw new Error(`Duplicate WebMCP tool name \"${tool.name}\".`)\n }\n if (tool.description.trim() === '') {\n throw new Error(`WebMCP tool \"${tool.name}\" must have a description.`)\n }\n if (typeof tool.execute !== 'function') {\n throw new Error(\n `WebMCP tool \"${tool.name}\" must have an execute handler.`,\n )\n }\n if (tool.needsApproval === true) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" cannot use needsApproval: true.`,\n )\n }\n\n names.add(tool.name)\n const toolOptions = getToolOptions(options.toolOptions, tool.name)\n const inputSchema = tool.inputSchema\n const outputSchema = tool.outputSchema\n const convertedInputSchema = convertSchemaToJsonSchema(inputSchema)\n const inputSchemaType = convertedInputSchema?.type\n const requiresNonObjectInput =\n (typeof inputSchemaType === 'string' && inputSchemaType !== 'object') ||\n (Array.isArray(inputSchemaType) && !inputSchemaType.includes('object'))\n if (requiresNonObjectInput) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" input schema must accept an object.`,\n )\n }\n const execute = tool.execute\n\n return {\n name: tool.name,\n description: tool.description,\n ...(toolOptions?.title !== undefined ? { title: toolOptions.title } : {}),\n ...(convertedInputSchema !== undefined\n ? { inputSchema: convertedInputSchema }\n : {}),\n ...(toolOptions?.annotations !== undefined\n ? { annotations: toolOptions.annotations }\n : {}),\n async execute(\n input: object,\n executionOptions?: { signal?: AbortSignal },\n ) {\n const validatedInput = await validateSchemaValue(inputSchema, input)\n const output = await execute(validatedInput, {\n abortSignal: executionOptions?.signal,\n context: options.context,\n emitCustomEvent() {},\n })\n return validateSchemaValue(outputSchema, output)\n },\n }\n })\n\n const registrationController = new AbortController()\n const abortRegistration = () =>\n registrationController.abort(options.signal.reason)\n\n if (options.signal.aborted) {\n abortRegistration()\n } else {\n options.signal.addEventListener('abort', abortRegistration, { once: true })\n }\n\n try {\n for (const tool of webMCPTools) {\n await document.modelContext.registerTool(tool, {\n signal: registrationController.signal,\n })\n }\n } catch (error) {\n registrationController.abort(error)\n options.signal.removeEventListener('abort', abortRegistration)\n throw error\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "Framework-agnostic headless client for TanStack AI chat, realtime sessions, streaming transports, and media generations.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -57,8 +57,8 @@
57
57
  "src"
58
58
  ],
59
59
  "dependencies": {
60
- "@tanstack/ai": "^0.52.1",
61
- "@tanstack/ai-event-client": "^0.11.2",
60
+ "@tanstack/ai": "^0.54.0",
61
+ "@tanstack/ai-event-client": "^0.11.3",
62
62
  "@tanstack/ai-utils": "^0.4.0"
63
63
  },
64
64
  "devDependencies": {
@@ -5,7 +5,7 @@ import {
5
5
  maskKey,
6
6
  } from '@tanstack/ai/byok'
7
7
  import { memoryStorage } from './storage'
8
- import type { ProviderId } from '@tanstack/ai/byok'
8
+ import type { ByokProvider, ProviderId } from '@tanstack/ai/byok'
9
9
  import type { Keyring, KeyringStorage } from './storage'
10
10
 
11
11
  export type KeyStatus =
@@ -28,6 +28,11 @@ export type ByokSnapshot = {
28
28
 
29
29
  export interface DefineByokOptions {
30
30
  storage?: KeyringStorage
31
+ /**
32
+ * Descriptors whose `with` companions the store expands: `headers()` and
33
+ * `prepare()` for such a provider cover its companions too.
34
+ */
35
+ providers?: ReadonlyArray<ByokProvider>
31
36
  }
32
37
 
33
38
  export const EMPTY_BYOK_SNAPSHOT: ByokSnapshot = {
@@ -85,6 +90,7 @@ export class ByokClient {
85
90
  #prompt: ByokPrompt | null = null
86
91
  #coverageAll = false
87
92
  #coverage: Record<string, boolean> = {}
93
+ readonly #companions: Record<string, Array<ProviderId>> = {}
88
94
  readonly #listeners = new Set<() => void>()
89
95
  #snapshot: ByokSnapshot
90
96
  #storageError: string | null = null
@@ -92,6 +98,13 @@ export class ByokClient {
92
98
 
93
99
  constructor(options: DefineByokOptions = {}) {
94
100
  this.storage = options.storage ?? memoryStorage()
101
+ for (const provider of options.providers ?? []) {
102
+ if (provider.with?.length) {
103
+ this.#companions[requireProviderId(provider.id)] = provider.with.map(
104
+ (companion) => requireProviderId(companion.id),
105
+ )
106
+ }
107
+ }
95
108
  this.#locked = Boolean(this.storage.unlockable)
96
109
  this.#snapshot = this.#buildSnapshot()
97
110
  this.#ready = this.#hydrate()
@@ -146,12 +159,19 @@ export class ByokClient {
146
159
  return this.#coverageAll || this.#coverage[provider] === true
147
160
  }
148
161
 
162
+ /** The slug plus every companion declared on its descriptor. */
163
+ #expand(provider: ProviderId): Array<ProviderId> {
164
+ return [provider, ...(this.#companions[provider] ?? [])]
165
+ }
166
+
149
167
  headers(provider?: ProviderId): Record<string, string> {
150
168
  const headers: Record<string, string> = {}
151
169
  if (provider) {
152
170
  requireProviderId(provider)
153
- const key = this.#keys[provider]
154
- if (key) headers[byokHeaderName(provider)] = key
171
+ for (const id of this.#expand(provider)) {
172
+ const key = this.#keys[id]
173
+ if (key) headers[byokHeaderName(id)] = key
174
+ }
155
175
  return headers
156
176
  }
157
177
  for (const [id, key] of Object.entries(this.#keys)) {
@@ -167,10 +187,11 @@ export class ByokClient {
167
187
  }
168
188
  if (!provider) return
169
189
  requireProviderId(provider)
170
- if (this.#keys[provider]) return
171
- if (this.#hasCoverage(provider)) return
172
- this.request(provider, 'missing')
173
- throw new ByokBlockedError(provider, 'missing')
190
+ for (const id of this.#expand(provider)) {
191
+ if (this.#keys[id] || this.#hasCoverage(id)) continue
192
+ this.request(id, 'missing')
193
+ throw new ByokBlockedError(id, 'missing')
194
+ }
174
195
  }
175
196
 
176
197
  async update(
@@ -83,6 +83,22 @@ export function isPasskeyStorageSupported(): boolean {
83
83
  export async function deriveAesKey(
84
84
  prfOutput: BufferSource,
85
85
  ): Promise<CryptoKey> {
86
+ // Some passkey providers return a plain byte array instead of BufferSource.
87
+ if (Array.isArray(prfOutput)) {
88
+ if (
89
+ prfOutput.length !== 32 ||
90
+ !Array.from(prfOutput).every(
91
+ (byte: unknown) =>
92
+ typeof byte === 'number' &&
93
+ Number.isInteger(byte) &&
94
+ byte >= 0 &&
95
+ byte <= 255,
96
+ )
97
+ ) {
98
+ throw new Error('Invalid passkey PRF byte array')
99
+ }
100
+ prfOutput = new Uint8Array(prfOutput)
101
+ }
86
102
  const base = await crypto.subtle.importKey('raw', prfOutput, 'HKDF', false, [
87
103
  'deriveKey',
88
104
  ])
@@ -196,6 +212,25 @@ function requirePublicKeyCredential(
196
212
  return credential
197
213
  }
198
214
 
215
+ /**
216
+ * Some browsers (Dia, Safari) silently suppress `navigator.credentials.get()`
217
+ * — no prompt, never resolves — when it runs without transient user activation.
218
+ * The activation from a click expires (~5s) and is lost across enough async
219
+ * work, so an unlock buried deep in a send pipeline just hangs. Fail fast with
220
+ * a clear, catchable error so the app can re-run it from a fresh gesture.
221
+ */
222
+ function requireUserActivation(action: string): void {
223
+ // ponytail: only enforce where the API exists; absent means "can't tell", so
224
+ // fall through rather than block a ceremony that would have worked.
225
+ const activation = globalThis.navigator?.userActivation
226
+ if (activation && activation.isActive === false) {
227
+ throw new Error(
228
+ `Passkey ${action} needs a fresh user action (e.g. a click). Run it ` +
229
+ 'directly from the event handler, before awaiting other work.',
230
+ )
231
+ }
232
+ }
233
+
199
234
  async function registerPasskey(
200
235
  rpName: string,
201
236
  userName: string,
@@ -316,6 +351,7 @@ export function passkeyStorage(
316
351
  }
317
352
  const existing = await idbGet(dbName)
318
353
  if (existing) {
354
+ requireUserActivation('unlock')
319
355
  const prf = await evaluatePrf(existing.credentialId, existing.salt)
320
356
  cachedKey = await deriveAesKey(prf)
321
357
  cachedMeta = {
@@ -324,6 +360,8 @@ export function passkeyStorage(
324
360
  }
325
361
  } else {
326
362
  const reg = await registerPasskey(rpName, userName, rpId)
363
+ // Registration can consume activation. Let the browser handle its
364
+ // follow-up PRF ceremony rather than rejecting a valid new-key save.
327
365
  const prf = reg.prf ?? (await evaluatePrf(reg.credentialId, reg.salt))
328
366
  cachedKey = await deriveAesKey(prf)
329
367
  cachedMeta = { credentialId: reg.credentialId, salt: reg.salt }
@@ -2023,7 +2023,7 @@ export class ChatClient<
2023
2023
  if (emptyMessage) {
2024
2024
  return
2025
2025
  }
2026
- if (this.hasBlockingInterrupts()) {
2026
+ if (this.hasPendingInterrupts()) {
2027
2027
  throw new Error(
2028
2028
  'ChatClient: cannot send normal input while pending interrupts exist. Use resumeInterrupts() instead.',
2029
2029
  )
@@ -2065,14 +2065,6 @@ export class ChatClient<
2065
2065
  return this.interruptManager.getDescriptors().length > 0
2066
2066
  }
2067
2067
 
2068
- /** True while an interrupt batch owns the next user turn. */
2069
- private hasBlockingInterrupts(): boolean {
2070
- return (
2071
- this.activeInterruptSubmission !== undefined ||
2072
- this.hasPendingInterrupts()
2073
- )
2074
- }
2075
-
2076
2068
  /** True while a stream is active, a send is claiming the client, or the queue is draining. */
2077
2069
  private isSendBusy(): boolean {
2078
2070
  return this.isLoading || this.sendInFlight || this.messageQueueDraining
@@ -2188,7 +2180,7 @@ export class ChatClient<
2188
2180
  */
2189
2181
  async append(message: UIMessage | ModelMessage): Promise<void> {
2190
2182
  this.mountDevtools()
2191
- if (this.hasBlockingInterrupts()) {
2183
+ if (this.hasPendingInterrupts()) {
2192
2184
  throw new Error(
2193
2185
  'ChatClient: cannot append normal input while pending interrupts exist. Use resumeInterrupts() instead.',
2194
2186
  )
@@ -1042,6 +1042,7 @@ export function normalizeConnectionAdapter(
1042
1042
  // Legacy connect() wrapper
1043
1043
  let activeBuffer: Array<StreamChunk> = []
1044
1044
  let activeWaiters: Array<(chunk: StreamChunk | null) => void> = []
1045
+ let activeSubscriber: typeof activeWaiters | undefined
1045
1046
 
1046
1047
  function push(chunk: StreamChunk, runId?: string): void {
1047
1048
  if (runId) {
@@ -1062,18 +1063,16 @@ export function normalizeConnectionAdapter(
1062
1063
  // previous chunk has left processIncomingChunk. Empty waiters with an
1063
1064
  // empty buffer is in-flight delivery, not idle.
1064
1065
  const idle = () =>
1065
- activeBuffer.length === 0 &&
1066
- (activeWaiters.length > 0 || abortSignal?.aborted)
1066
+ activeSubscriber !== activeWaiters ||
1067
+ (activeBuffer.length === 0 &&
1068
+ (activeWaiters.length > 0 || abortSignal?.aborted))
1067
1069
  for (let i = 0; i < 16 && !abortSignal?.aborted; i++) {
1068
1070
  if (idle()) return
1069
1071
  await Promise.resolve()
1070
1072
  }
1071
- let macrotaskWaits = 0
1072
1073
  while (!abortSignal?.aborted) {
1073
1074
  if (idle()) return
1074
1075
  await new Promise<void>((resolve) => setTimeout(resolve, 0))
1075
- macrotaskWaits++
1076
- if (activeWaiters.length === 0 && macrotaskWaits >= 32) return
1077
1076
  }
1078
1077
  }
1079
1078
 
@@ -1087,22 +1086,27 @@ export function normalizeConnectionAdapter(
1087
1086
  activeWaiters = myWaiters
1088
1087
 
1089
1088
  return (async function* () {
1090
- while (!abortSignal?.aborted) {
1091
- let chunk: StreamChunk | null
1092
- const buffered = myBuffer.shift()
1093
- if (buffered !== undefined) {
1094
- chunk = buffered
1095
- } else {
1096
- chunk = await new Promise<StreamChunk | null>((resolve) => {
1097
- const onAbort = () => resolve(null)
1098
- myWaiters.push((c) => {
1099
- abortSignal?.removeEventListener('abort', onAbort)
1100
- resolve(c)
1089
+ activeSubscriber = myWaiters
1090
+ try {
1091
+ while (!abortSignal?.aborted) {
1092
+ let chunk: StreamChunk | null
1093
+ const buffered = myBuffer.shift()
1094
+ if (buffered !== undefined) {
1095
+ chunk = buffered
1096
+ } else {
1097
+ chunk = await new Promise<StreamChunk | null>((resolve) => {
1098
+ const onAbort = () => resolve(null)
1099
+ myWaiters.push((c) => {
1100
+ abortSignal?.removeEventListener('abort', onAbort)
1101
+ resolve(c)
1102
+ })
1103
+ abortSignal?.addEventListener('abort', onAbort, { once: true })
1101
1104
  })
1102
- abortSignal?.addEventListener('abort', onAbort, { once: true })
1103
- })
1105
+ }
1106
+ if (chunk !== null) yield chunk
1104
1107
  }
1105
- if (chunk !== null) yield chunk
1108
+ } finally {
1109
+ if (activeSubscriber === myWaiters) activeSubscriber = undefined
1106
1110
  }
1107
1111
  })()
1108
1112
  },
package/src/index.ts CHANGED
@@ -14,6 +14,13 @@ export type {
14
14
  } from './interrupt-manager'
15
15
  export { createMcpAppBridge } from './mcp-app-bridge'
16
16
  export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge'
17
+ export { registerWebMCPTools } from './web-mcp-tools'
18
+ export type {
19
+ RegisterWebMCPToolsOptions,
20
+ WebMCPToolAnnotations,
21
+ WebMCPToolOptions,
22
+ WebMCPToolOptionsByName,
23
+ } from './web-mcp-tools'
17
24
  export { RealtimeClient } from './realtime-client'
18
25
  export { GenerationClient } from './generation-client'
19
26
  export { VideoGenerationClient } from './video-generation-client'