🐛 Fix embedded audio uploads (#2523)
Some checks failed
Create Tag / create-tag (push) Has been cancelled
Deploy Workflows (Fly.io) / deploy (push) Has been cancelled

- Keep CORS headers on upload proxy error responses.
- Surface failed audio/file uploads as Typebot errors instead of leaving
the recorder stuck.
- Prevent recorded-audio Send clicks from submitting the host page form
in embedded bots.
- Prevent duplicate embed uploads while an upload is already in flight.
- Disable recorder abort while recorded audio is being processed or
uploaded.
- Bump @typebot.io/js and @typebot.io/react to 0.10.4.
This commit is contained in:
Baptiste Arnaud 2026-06-08 15:52:56 +02:00 committed by GitHub
parent b252a9c996
commit c82ac4324a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 152 additions and 78 deletions

View File

@ -35,6 +35,10 @@ const handleUploadRequest = async (request: Request, context: RouteContext) => {
headers: createUploadProxyCorsHeaders(request),
});
throw error;
console.error(error);
return new Response("Could not upload file", {
status: 500,
headers: createUploadProxyCorsHeaders(request),
});
}
};

View File

@ -35,6 +35,10 @@ const handleUploadRequest = async (request: Request, context: RouteContext) => {
headers: createUploadProxyCorsHeaders(request),
});
throw error;
console.error(error);
return new Response("Could not upload file", {
status: 500,
headers: createUploadProxyCorsHeaders(request),
});
}
};

View File

@ -601,7 +601,7 @@
},
"packages/embeds/js": {
"name": "@typebot.io/js",
"version": "0.10.3",
"version": "0.10.4",
"devDependencies": {
"@ai-sdk/ui-utils": "^1.2.11",
"@ark-ui/solid": "^5.19.0",
@ -636,7 +636,7 @@
},
"packages/embeds/react": {
"name": "@typebot.io/react",
"version": "0.10.3",
"version": "0.10.4",
"dependencies": {
"@typebot.io/js": "workspace:*",
"react": "^19.2.4",

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/js",
"version": "0.10.3",
"version": "0.10.4",
"description": "Javascript library to display typebots on your website",
"license": "FSL-1.1-ALv2",
"type": "module",

View File

@ -26,7 +26,7 @@ export const SendButton = (props: SendButtonProps) => {
return (
<Button
{...buttonProps}
type="submit"
type={buttonProps.type ?? "submit"}
class={cx(buttonProps.class, "flex items-center")}
aria-label={showIcon ? "Send" : undefined}
>

View File

@ -61,9 +61,17 @@ export const uploadFiles = async ({
presignedUrl: data.presignedUrl,
formData: data.formData,
file,
}).catch((error) => {
errors.push(parseUploadError(error));
return;
});
if (!upload.ok) continue;
if (!upload) continue;
if (!upload.ok) {
errors.push(await parseUploadResponseError(upload));
continue;
}
urls.push({ url: data.fileUrl, type: file.type });
}
@ -71,3 +79,16 @@ export const uploadFiles = async ({
? { type: "error", error: errors.join(", ") }
: { type: "success", urls };
};
const parseUploadResponseError = async (response: Response) => {
const body = await response.text().catch(() => undefined);
return (
body ||
response.statusText ||
`Upload failed with status ${response.status}`
);
};
const parseUploadError = (error: unknown) =>
error instanceof Error ? error.message : "Could not upload file";

View File

@ -46,6 +46,7 @@ export const TextInput = (props: Props) => {
const [uploadProgress, setUploadProgress] = createSignal<
{ fileIndex: number; progress: number } | undefined
>(undefined);
const [isUploading, setIsUploading] = createSignal(false);
const [isDraggingOver, setIsDraggingOver] = createSignal(false);
const [recordingStatus, setRecordingStatus] = createSignal<
"started" | "asking" | "stopped"
@ -60,43 +61,57 @@ export const TextInput = (props: Props) => {
inputRef?.value !== "" && inputRef?.reportValidity();
const submit = async () => {
if (isUploading()) return;
if (recordingStatus() === "started" && mediaRecorder) {
mediaRecorder.stop();
if (mediaRecorder.state !== "inactive") mediaRecorder.stop();
return;
}
if (checkIfInputIsValid()) {
let attachments: Attachment[] | undefined;
if (selectedFiles().length > 0) {
setUploadProgress(undefined);
const result = await uploadFiles({
apiHost:
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
files: selectedFiles().map((file) => ({
file: file,
input: {
blockId: props.block.id,
sessionId: props.context.sessionId,
fileName: file.name,
},
})),
onUploadProgress: setUploadProgress,
});
if (result.type === "error") {
try {
setIsUploading(true);
setUploadProgress(undefined);
const result = await uploadFiles({
apiHost:
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
files: selectedFiles().map((file) => ({
file: file,
input: {
blockId: props.block.id,
sessionId: props.context.sessionId,
fileName: file.name,
},
})),
onUploadProgress: setUploadProgress,
}).finally(() => {
setIsUploading(false);
setUploadProgress(undefined);
});
if (result.type === "error") {
toaster.create({
description: result.error,
});
return;
}
attachments = result.urls
?.map((urls, index) =>
urls
? {
...urls,
blobUrl: URL.createObjectURL(selectedFiles()[index]),
}
: null,
)
.filter(isDefined);
} catch (error) {
toaster.create({
description: result.error,
description:
error instanceof Error ? error.message : "Could not upload file",
});
return;
}
attachments = result.urls
?.map((urls, index) =>
urls
? {
...urls,
blobUrl: URL.createObjectURL(selectedFiles()[index]),
}
: null,
)
.filter(isDefined);
}
props.onSubmit({
type: "text",
@ -186,6 +201,7 @@ export const TextInput = (props: Props) => {
};
const recordVoice = () => {
if (isUploading()) return;
setRecordingStatus("asking");
};
@ -207,58 +223,82 @@ export const TextInput = (props: Props) => {
if (recordingStatus() !== "started" || recordedChunks.length === 0)
return;
const duration = Date.now() - startTime;
try {
setIsUploading(true);
setUploadProgress(undefined);
const duration = Date.now() - startTime;
const blob = await fixWebmDuration(
new Blob(recordedChunks, { type: mimeType }),
duration,
);
const blob = await fixWebmDuration(
new Blob(recordedChunks, { type: mimeType }),
duration,
);
const audioFile = new File(
[blob],
`rec-${props.block.id}-${Date.now()}.${
mimeType === "audio/webm" ? "webm" : "mp4"
}`,
{
type: mimeType,
},
);
setUploadProgress(undefined);
const result = await uploadFiles({
apiHost:
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
files: [
const audioFile = new File(
[blob],
`rec-${props.block.id}-${Date.now()}.${
mimeType === "audio/webm" ? "webm" : "mp4"
}`,
{
file: audioFile,
input: {
blockId: props.block.id,
sessionId: props.context.sessionId,
fileName: audioFile.name,
},
type: mimeType,
},
],
onUploadProgress: setUploadProgress,
});
if (result.type === "error") {
toaster.create({
description: result.error,
);
const result = await uploadFiles({
apiHost:
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
files: [
{
file: audioFile,
input: {
blockId: props.block.id,
sessionId: props.context.sessionId,
fileName: audioFile.name,
},
},
],
onUploadProgress: setUploadProgress,
}).finally(() => {
setIsUploading(false);
setUploadProgress(undefined);
});
if (result.type === "error") {
setRecordingStatus("stopped");
toaster.create({
description: result.error,
});
return;
}
const url = result.urls.find(isDefined)?.url;
if (!url) {
setRecordingStatus("stopped");
toaster.create({
description: "Could not upload audio file",
});
return;
}
props.onSubmit({
type: "recording",
url,
blobUrl: URL.createObjectURL(audioFile),
});
} catch (error) {
setIsUploading(false);
setUploadProgress(undefined);
setRecordingStatus("stopped");
toaster.create({
description:
error instanceof Error ? error.message : "Could not upload audio",
});
return;
}
const urls = result.urls.filter(isDefined).map((url) => url.url);
props.onSubmit({
type: "recording",
url: urls[0],
blobUrl: URL.createObjectURL(audioFile),
});
};
mediaRecorder.start();
setRecordingStatus("started");
};
const handleRecordingAbort = () => {
mediaRecorder?.stop();
if (mediaRecorder && mediaRecorder.state !== "inactive")
mediaRecorder.stop();
setRecordingStatus("stopped");
mediaRecorder = undefined;
recordedChunks = [];
@ -287,6 +327,7 @@ export const TextInput = (props: Props) => {
recordingStatus={recordingStatus()}
buttonsTheme={props.context.typebot.theme.chat?.buttons}
context={props.context}
isAbortDisabled={isUploading()}
onRecordingConfirmed={handleRecordingConfirmed}
onAbortRecording={handleRecordingAbort}
/>
@ -370,6 +411,7 @@ export const TextInput = (props: Props) => {
>
<Button
type="button"
isDisabled={isUploading()}
class="h-14 flex items-center"
on:click={recordVoice}
aria-label="Record voice"
@ -379,9 +421,10 @@ export const TextInput = (props: Props) => {
</Match>
<Match when={true}>
<SendButton
type="submit"
isDisabled={Boolean(uploadProgress())}
type="button"
isDisabled={isUploading()}
class="h-14"
on:click={submit}
>
{props.block.options?.labels?.button}
</SendButton>

View File

@ -19,6 +19,7 @@ type Props = {
recordingStatus: "asking" | "started" | "stopped";
buttonsTheme: NonNullable<Theme["chat"]>["buttons"];
context: BotContext;
isAbortDisabled?: boolean;
onAbortRecording: () => void;
onRecordingConfirmed: (stream: MediaStream) => void;
};
@ -168,7 +169,8 @@ export const VoiceRecorder = (props: Props) => {
>
<button
type="button"
class="p-0.5 rounded-full"
class="p-0.5 rounded-full disabled:opacity-50 disabled:cursor-not-allowed"
disabled={props.isAbortDisabled}
on:click={stopRecording}
aria-label="Stop recording"
>

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/react",
"version": "0.10.3",
"version": "0.10.4",
"description": "Convenient library to display typebots on your React app",
"license": "FSL-1.1-ALv2",
"type": "module",