feat(tts): support edge tts on cloudflare worker (#3819)

This commit is contained in:
Huang Xin
2026-04-10 21:32:06 +08:00
committed by GitHub
parent 07e3248780
commit 3df75a67f9
8 changed files with 505 additions and 56 deletions
@@ -18,6 +18,7 @@
## Feature Notes
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
## Architecture Notes
- foliate-js is a git submodule at `packages/foliate-js/`
@@ -0,0 +1,73 @@
---
name: Cloudflare Workers WebSocket
description: How to open and read WebSockets from Cloudflare Workers (the Node `ws` package does not work) and the Blob binary-frame gotcha
type: project
originSessionId: ec3d5424-adc2-4fca-836f-df323797489c
---
# Cloudflare Workers WebSocket on readest-app
## Why the Node `ws` package fails
The Node `ws` npm package (used transitively by `isomorphic-ws`) opens WebSockets by calling `http.request({ createConnection })`. The Cloudflare Workers runtime does not implement `options.createConnection`, so any attempt to `new WebSocket(url, { headers })` in a Worker throws:
```
The options.createConnection option is not implemented
```
This applies even with `compatibility_flags = ["nodejs_compat"]`.
## Correct pattern: fetch-based upgrade
On Workers you open a WebSocket by calling `fetch()` with an `Upgrade: websocket` header against the **https://** (not `wss://`) form of the URL. The response has `status === 101` and a non-standard `webSocket` property that must be `accept()`ed before use:
```ts
const upgradeUrl = url.replace(/^wss:\/\//i, 'https://');
const response = (await fetch(upgradeUrl, {
headers: { ...baseHeaders, Upgrade: 'websocket' },
})) as Response & { webSocket?: WebSocket & { accept(): void } };
if (response.status !== 101 || !response.webSocket) {
throw new Error(`WebSocket upgrade failed with status ${response.status}`);
}
const ws = response.webSocket;
ws.addEventListener('message', onMessage);
ws.accept();
ws.send(payload);
```
Detect the Workers runtime with `typeof globalThis.WebSocketPair !== 'undefined'``WebSocketPair` is a Workers-only global.
## Binary frames arrive as Blob (critical)
Cloudflare Workers deliver WebSocket binary frames as **`Blob`** — not `ArrayBuffer` (browsers) and not `Uint8Array` (Node `ws`). Blob decoding is async via `blob.arrayBuffer()`, so:
1. You must serialize decodes through a promise chain to keep frames in receive order — otherwise parallel awaits can merge bytes out of order.
2. Any terminal text message (e.g. Edge TTS's `Path: turn.end`) arrives **synchronously** and will finalize the stream before the in-flight Blob decodes have flushed. Always `await pendingBinary` in the turn.end handler and the close handler before checking whether data was received.
Example skeleton:
```ts
let pending: Promise<void> = Promise.resolve();
const enqueue = (getBuf: () => Promise<ArrayBufferLike> | ArrayBufferLike) => {
pending = pending.then(async () => {
const buf = await getBuf();
appendBinary(buf);
});
};
ws.addEventListener('message', (event) => {
const data = event.data;
if (data instanceof Blob) enqueue(() => data.arrayBuffer());
else if (data instanceof ArrayBuffer) enqueue(() => data);
else if (data instanceof Uint8Array) enqueue(() => data.buffer.slice(
data.byteOffset, data.byteOffset + data.byteLength,
));
// ... handle text path: turn.end
// -> await pending, then resolve
});
```
## Where this is used
`src/libs/edgeTTS.ts` `#fetchEdgeSpeechWs` has three branches: Tauri (plugin-websocket), Cloudflare Workers (fetch upgrade + Blob handling), and browser/Node fallback (`isomorphic-ws`). The route that exercises the CF branch is `src/app/api/tts/edge/route.ts`, hit when the web client falls back from direct `wss://` (which browsers can't set headers on) to the `/api/tts/edge` HTTPS endpoint.
+2 -2
View File
@@ -82,7 +82,7 @@
"@emnapi/runtime": "^1.7.1",
"@fabianlars/tauri-plugin-oauth": "2",
"@napi-rs/wasm-runtime": "^1.1.1",
"@opennextjs/cloudflare": "^1.17.3",
"@opennextjs/cloudflare": "^1.19.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -230,7 +230,7 @@
"vite": "^7.3.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.18",
"wrangler": "^4.77.0"
"wrangler": "^4.81.1"
},
"browserslist": [
"chrome 92",
@@ -0,0 +1,216 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock isomorphic-ws so that if the legacy (non-fetch) path is hit on
// Cloudflare Workers, the test fails loudly instead of attempting a real
// WebSocket connection.
vi.mock('isomorphic-ws', () => ({
default: class {
constructor() {
throw new Error('isomorphic-ws should not be used on Cloudflare Workers');
}
},
}));
// Stub the Supabase client so importing edgeTTS.ts (transitively via
// @/utils/fetch -> @/utils/access) does not instantiate a real GoTrueClient.
// Each `vi.resetModules()` would otherwise create another client and Supabase
// logs "Multiple GoTrueClient instances detected" to stderr.
vi.mock('@/utils/supabase', () => ({
supabase: { auth: { getSession: async () => ({ data: { session: null } }) } },
createSupabaseClient: () => ({}),
createSupabaseAdminClient: () => ({}),
}));
type GlobalWithWsPair = typeof globalThis & { WebSocketPair?: unknown };
describe('EdgeSpeechTTS on Cloudflare Workers', () => {
let originalWebSocketPair: unknown;
let originalFetch: typeof fetch | undefined;
beforeEach(() => {
// Simulate Cloudflare Workers by defining WebSocketPair on globalThis.
originalWebSocketPair = (globalThis as GlobalWithWsPair).WebSocketPair;
(globalThis as GlobalWithWsPair).WebSocketPair = function WebSocketPair() {};
originalFetch = globalThis.fetch;
vi.resetModules();
});
afterEach(() => {
if (originalWebSocketPair === undefined) {
delete (globalThis as GlobalWithWsPair).WebSocketPair;
} else {
(globalThis as GlobalWithWsPair).WebSocketPair = originalWebSocketPair;
}
if (originalFetch) {
globalThis.fetch = originalFetch;
}
vi.restoreAllMocks();
});
test('uses fetch-based WebSocket upgrade and returns audio Response', async () => {
// Build a mock WebSocket that records listeners and emits a frame
// containing valid audio after both speech.config and ssml are sent.
const listeners: Record<string, Array<(event: unknown) => void>> = {};
const mockSocket = {
accept: vi.fn(),
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn((type: string, cb: (event: unknown) => void) => {
(listeners[type] ??= []).push(cb);
}),
removeEventListener: vi.fn(),
};
// Simulate server responses once both config + ssml messages are sent.
let sendCount = 0;
mockSocket.send.mockImplementation(() => {
sendCount++;
if (sendCount === 2) {
// Binary audio frame: [2-byte big-endian header length][header text][audio body]
const headerText = 'X-RequestId:1\r\nContent-Type:audio/mpeg\r\nPath:audio\r\n';
const headerBytes = new TextEncoder().encode(headerText);
const audioBody = new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]);
const frame = new Uint8Array(2 + headerBytes.byteLength + audioBody.byteLength);
new DataView(frame.buffer).setInt16(0, headerBytes.byteLength);
frame.set(headerBytes, 2);
frame.set(audioBody, 2 + headerBytes.byteLength);
// Dispatch on a microtask so the send() call returns first.
queueMicrotask(() => {
for (const cb of listeners['message'] ?? []) {
cb({ data: frame.buffer });
}
for (const cb of listeners['message'] ?? []) {
cb({ data: 'X-RequestId:1\r\nPath: turn.end\r\n\r\n' });
}
});
}
});
const fetchSpy = vi.fn().mockResolvedValue({
status: 101,
webSocket: mockSocket,
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
// Import AFTER the mocks and globals are set up.
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
const tts = new EdgeSpeechTTS('wss');
const response = await tts.create({
lang: 'en-US',
text: 'hello',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
});
expect(response).toBeInstanceOf(Response);
const buffer = await response.arrayBuffer();
expect(new Uint8Array(buffer)).toEqual(new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]));
// fetch should be called once with an https URL and an Upgrade header.
expect(fetchSpy).toHaveBeenCalledOnce();
const call = fetchSpy.mock.calls[0]!;
const calledUrl = call[0] as string | URL;
const calledInit = call[1] as RequestInit;
expect(String(calledUrl)).toContain('https://speech.platform.bing.com/');
expect(String(calledUrl)).not.toContain('wss://');
const headers = calledInit.headers as Record<string, string>;
expect(headers['Upgrade']).toBe('websocket');
// The WebSocket returned by fetch must be accepted before use, and both
// the speech.config and ssml messages must be sent.
expect(mockSocket.accept).toHaveBeenCalledOnce();
expect(mockSocket.send).toHaveBeenCalledTimes(2);
// Socket is closed once turn.end is received.
expect(mockSocket.close).toHaveBeenCalledOnce();
});
test('decodes Blob binary frames (Cloudflare Workers shape)', async () => {
// On real Cloudflare Workers, WebSocket binary frames arrive as Blob
// instances rather than ArrayBuffer. This test guards that code path
// by having the mock socket emit Blob messages.
const listeners: Record<string, Array<(event: unknown) => void>> = {};
const mockSocket = {
accept: vi.fn(),
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn((type: string, cb: (event: unknown) => void) => {
(listeners[type] ??= []).push(cb);
}),
removeEventListener: vi.fn(),
};
const buildFrame = (body: Uint8Array) => {
const headerText = 'X-RequestId:1\r\nContent-Type:audio/mpeg\r\nPath:audio\r\n';
const headerBytes = new TextEncoder().encode(headerText);
const frame = new Uint8Array(2 + headerBytes.byteLength + body.byteLength);
new DataView(frame.buffer).setInt16(0, headerBytes.byteLength);
frame.set(headerBytes, 2);
frame.set(body, 2 + headerBytes.byteLength);
return new Blob([frame]);
};
let sendCount = 0;
mockSocket.send.mockImplementation(() => {
sendCount++;
if (sendCount === 2) {
queueMicrotask(() => {
// Two binary Blob frames...
for (const cb of listeners['message'] ?? []) {
cb({ data: buildFrame(new Uint8Array([0x01, 0x02, 0x03])) });
}
for (const cb of listeners['message'] ?? []) {
cb({ data: buildFrame(new Uint8Array([0x04, 0x05])) });
}
// ...then turn.end text message (fires before blob.arrayBuffer() resolves).
for (const cb of listeners['message'] ?? []) {
cb({ data: 'X-RequestId:1\r\nPath:turn.end\r\n\r\n' });
}
});
}
});
const fetchSpy = vi.fn().mockResolvedValue({
status: 101,
webSocket: mockSocket,
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
const tts = new EdgeSpeechTTS('wss');
const response = await tts.create({
lang: 'en-US',
text: 'hello',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
});
// Both Blob frames should be decoded in receive order before the
// turn.end message finalizes the audio payload.
const buffer = await response.arrayBuffer();
expect(new Uint8Array(buffer)).toEqual(new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]));
expect(mockSocket.close).toHaveBeenCalled();
});
test('rejects when fetch upgrade returns non-101 status', async () => {
const fetchSpy = vi.fn().mockResolvedValue({
status: 403,
webSocket: undefined,
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
const tts = new EdgeSpeechTTS('wss');
await expect(
tts.create({
lang: 'en-US',
text: 'hello',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
}),
).rejects.toThrow();
});
});
+161 -2
View File
@@ -4,7 +4,30 @@ import { randomMd5 } from '@/utils/misc';
import { LRUCache } from '@/utils/lru';
import { genSSML } from '@/utils/ssml';
import { fetchWithAuth } from '@/utils/fetch';
import { getNodeAPIBaseUrl, isTauriAppPlatform } from '@/services/environment';
import { getAPIBaseUrl, isTauriAppPlatform } from '@/services/environment';
// Cloudflare Workers expose a global `WebSocketPair` that is not available in
// browsers or Node.js. The Node `ws` package (used transitively via
// `isomorphic-ws`) cannot run on Workers because it relies on
// `http.createConnection`, which the Workers runtime does not implement.
// Detecting Workers lets us use the fetch-based WebSocket upgrade pattern
// (`fetch(..., { headers: { Upgrade: 'websocket' } })`) instead.
const isCloudflareWorkers = () =>
typeof (globalThis as { WebSocketPair?: unknown }).WebSocketPair !== 'undefined';
// The WebSocket returned by a Cloudflare Workers upgrade response must be
// `accept()`ed before use. This minimal interface captures the bits we need
// without pulling in `@cloudflare/workers-types`.
interface AcceptableWebSocket {
accept(): void;
send(data: string | ArrayBuffer | ArrayBufferView): void;
close(code?: number, reason?: string): void;
addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void;
addEventListener(type: 'close', listener: () => void): void;
addEventListener(type: 'error', listener: (event: unknown) => void): void;
}
type UpgradeResponse = Response & { webSocket?: AcceptableWebSocket };
const EDGE_SPEECH_URL =
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
@@ -277,7 +300,7 @@ export class EdgeSpeechTTS {
}
async #fetchEdgeSpeechHttp({ lang, text, voice, rate }: EdgeTTSPayload): Promise<Response> {
const url = getNodeAPIBaseUrl() + '/tts/edge';
const url = getAPIBaseUrl() + '/tts/edge';
const response = await fetchWithAuth(url, {
method: 'POST',
@@ -421,6 +444,142 @@ export class EdgeSpeechTTS {
reject(new Error(`WebSocket error occurred: ${error}`));
}
});
} else if (isCloudflareWorkers()) {
return new Promise<Response>((resolve, reject) => {
(async () => {
try {
// Cloudflare Workers cannot use the `ws` npm package because it
// relies on `http.createConnection`. Instead, WebSockets are
// opened by calling `fetch()` with an `Upgrade: websocket`
// header. The response has status 101 and a `webSocket`
// property that must be `accept()`ed before sending data.
const upgradeUrl = url.replace(/^wss:\/\//i, 'https://');
const upgradeResponse = (await fetch(upgradeUrl, {
headers: {
...baseHeaders,
Upgrade: 'websocket',
},
})) as UpgradeResponse;
if (upgradeResponse.status !== 101 || !upgradeResponse.webSocket) {
return reject(
new Error(`WebSocket upgrade failed with status ${upgradeResponse.status}`),
);
}
const ws = upgradeResponse.webSocket;
let audioData = new ArrayBuffer(0);
let settled = false;
// Cloudflare Workers deliver binary WebSocket frames as `Blob`,
// whose conversion to bytes (`blob.arrayBuffer()`) is async.
// Chain every binary message through this promise so frames are
// appended in receive order and `turn.end` (or `close`) can
// await the tail before finalizing the audio payload.
let pendingBinary: Promise<void> = Promise.resolve();
const appendBinary = (buffer: ArrayBufferLike) => {
const dataView = new DataView(buffer);
const headerLength = dataView.getInt16(0);
if (buffer.byteLength > headerLength + 2) {
const newBody = new Uint8Array(buffer).slice(2 + headerLength);
const merged = new Uint8Array(audioData.byteLength + newBody.byteLength);
merged.set(new Uint8Array(audioData), 0);
merged.set(newBody, audioData.byteLength);
audioData = merged.buffer;
}
};
const enqueueBinary = (getBuffer: () => Promise<ArrayBufferLike> | ArrayBufferLike) => {
pendingBinary = pendingBinary.then(async () => {
if (settled) return;
const buffer = await getBuffer();
if (settled) return;
appendBinary(buffer);
});
};
const finalize = () => {
if (settled) return;
settled = true;
if (!audioData.byteLength) {
reject(new Error('No audio data received.'));
} else {
resolve(new Response(audioData));
}
};
const onMessage = (event: { data: unknown }) => {
if (settled) return;
const data = event.data;
if (typeof data === 'string') {
const { headers } = getHeadersAndData(data);
if (headers['Path'] === 'turn.end') {
// Wait for any in-flight Blob decodes to complete before
// deciding whether audio was received.
pendingBinary
.then(() => {
try {
ws.close();
} catch {
// ignore close failures
}
finalize();
})
.catch(() => {
if (settled) return;
settled = true;
reject(new Error('No audio data received.'));
});
}
return;
}
if (data instanceof ArrayBuffer) {
enqueueBinary(() => data);
return;
}
if (data instanceof Uint8Array) {
enqueueBinary(() =>
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
);
return;
}
if (typeof Blob !== 'undefined' && data instanceof Blob) {
// Cloudflare Workers path: convert Blob -> ArrayBuffer asynchronously.
enqueueBinary(() => (data as Blob).arrayBuffer());
return;
}
};
ws.addEventListener('message', onMessage);
ws.addEventListener('close', () => {
if (settled) return;
// Drain any pending Blob decodes that may still be in-flight.
pendingBinary
.then(() => finalize())
.catch(() => {
if (settled) return;
settled = true;
reject(new Error('No audio data received.'));
});
});
ws.addEventListener('error', () => {
if (settled) return;
settled = true;
reject(new Error('WebSocket error occurred.'));
});
ws.accept();
ws.send(config);
ws.send(content);
} catch (error) {
reject(
new Error(
`WebSocket error occurred: ${error instanceof Error ? error.message : String(error)}`,
),
);
}
})();
});
} else {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, {
+1 -1
View File
@@ -1,6 +1,6 @@
name = "readest-web"
main = ".open-next/worker.js"
compatibility_date = "2025-11-17"
compatibility_date = "2026-04-10"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
preview_urls = true