mirror of
https://github.com/readest/readest.git
synced 2026-04-28 03:20:45 +00:00
feat(tts): support edge tts on cloudflare worker (#3819)
This commit is contained in:
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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
216
apps/readest-app/src/__tests__/libs/edgeTTS.test.ts
Normal file
216
apps/readest-app/src/__tests__/libs/edgeTTS.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,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
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,json,md,html,yml}\"",
|
||||
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,css,json,md,html,yml}\""
|
||||
},
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.9",
|
||||
"@sindresorhus/tsconfig": "^6.0.0",
|
||||
|
|
|
|||
100
pnpm-lock.yaml
generated
100
pnpm-lock.yaml
generated
|
|
@ -94,8 +94,8 @@ importers:
|
|||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
'@opennextjs/cloudflare':
|
||||
specifier: ^1.17.3
|
||||
version: 1.17.3(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.77.0)
|
||||
specifier: ^1.19.0
|
||||
version: 1.19.0(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.81.1)
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -533,8 +533,8 @@ importers:
|
|||
specifier: ^4.0.18
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
wrangler:
|
||||
specifier: ^4.77.0
|
||||
version: 4.77.0
|
||||
specifier: ^4.81.1
|
||||
version: 4.81.1
|
||||
|
||||
packages/foliate-js:
|
||||
dependencies:
|
||||
|
|
@ -1165,32 +1165,32 @@ packages:
|
|||
workerd:
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260317.1':
|
||||
resolution: {integrity: sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==}
|
||||
'@cloudflare/workerd-darwin-64@1.20260409.1':
|
||||
resolution: {integrity: sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260317.1':
|
||||
resolution: {integrity: sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==}
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260409.1':
|
||||
resolution: {integrity: sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260317.1':
|
||||
resolution: {integrity: sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==}
|
||||
'@cloudflare/workerd-linux-64@1.20260409.1':
|
||||
resolution: {integrity: sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260317.1':
|
||||
resolution: {integrity: sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==}
|
||||
'@cloudflare/workerd-linux-arm64@1.20260409.1':
|
||||
resolution: {integrity: sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260317.1':
|
||||
resolution: {integrity: sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==}
|
||||
'@cloudflare/workerd-windows-64@1.20260409.1':
|
||||
resolution: {integrity: sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
|
@ -2269,17 +2269,17 @@ packages:
|
|||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@opennextjs/aws@3.9.16':
|
||||
resolution: {integrity: sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q==}
|
||||
'@opennextjs/aws@3.10.1':
|
||||
resolution: {integrity: sha512-Pn8hLuP3M9EWlsZnS/O7Bm41Nt+lIOEsCaW/QJ3KlmIbtAeixnSj1CS80Z9PlQe/LQCe0GnQ8vGJeOdg5a4iWg==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
next: ~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5
|
||||
next: '>=15.5.15 || >=16.2.3'
|
||||
|
||||
'@opennextjs/cloudflare@1.17.3':
|
||||
resolution: {integrity: sha512-BQT8R/CEqrZS2sYydJ6uqv3U+yZAxbTeDUfgqQIlOnhu3wDPsB/QuuUBBseWOZiOdZ8gLegK6F2SLKia74Nv6g==}
|
||||
'@opennextjs/cloudflare@1.19.0':
|
||||
resolution: {integrity: sha512-w7GeQlMmEFtaeo54mf7yV4K21EcMQOcdQj/CV1GTIijQd/ziIF5wErZqdsnw+BtgekkALY1zMQS6SWIhNcSBeQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
next: ~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5
|
||||
next: '>=15.5.15 || >=16.2.3'
|
||||
wrangler: ^4.65.0
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
|
|
@ -6678,8 +6678,8 @@ packages:
|
|||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
miniflare@4.20260317.2:
|
||||
resolution: {integrity: sha512-qNL+yWAFMX6fr0pWU6Lx1vNpPobpnDSF1V8eunIckWvoIQl8y1oBjL2RJFEGY3un+l3f9gwW9dirDPP26usYJQ==}
|
||||
miniflare@4.20260409.0:
|
||||
resolution: {integrity: sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -8696,20 +8696,20 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
workerd@1.20260317.1:
|
||||
resolution: {integrity: sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g==}
|
||||
workerd@1.20260409.1:
|
||||
resolution: {integrity: sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
workerpool@6.5.1:
|
||||
resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==}
|
||||
|
||||
wrangler@4.77.0:
|
||||
resolution: {integrity: sha512-E2Gm69+K++BFd3QvoWjC290RPQj1vDOUotA++sNHmtKPb7EP6C8Qv+1D5Ii73tfZtyNgakpqHlh8lBBbVWTKAQ==}
|
||||
wrangler@4.81.1:
|
||||
resolution: {integrity: sha512-fppPXi+W2KJ5bx1zxdUYe1e7CHj5cWPFVBPXy8hSMZhrHeIojMe3ozAktAOw1voVuQjXzbZJf/GVKyVeSjbF8w==}
|
||||
engines: {node: '>=20.3.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^4.20260317.1
|
||||
'@cloudflare/workers-types': ^4.20260409.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
|
|
@ -10038,25 +10038,25 @@ snapshots:
|
|||
|
||||
'@cloudflare/kv-asset-handler@0.4.2': {}
|
||||
|
||||
'@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1)':
|
||||
'@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260409.1)':
|
||||
dependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
optionalDependencies:
|
||||
workerd: 1.20260317.1
|
||||
workerd: 1.20260409.1
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260317.1':
|
||||
'@cloudflare/workerd-darwin-64@1.20260409.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260317.1':
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260409.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260317.1':
|
||||
'@cloudflare/workerd-linux-64@1.20260409.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260317.1':
|
||||
'@cloudflare/workerd-linux-arm64@1.20260409.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260317.1':
|
||||
'@cloudflare/workerd-windows-64@1.20260409.1':
|
||||
optional: true
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
|
|
@ -10793,7 +10793,7 @@ snapshots:
|
|||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@opennextjs/aws@3.9.16(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
|
||||
'@opennextjs/aws@3.10.1(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
|
||||
dependencies:
|
||||
'@ast-grep/napi': 0.40.5
|
||||
'@aws-sdk/client-cloudfront': 3.984.0
|
||||
|
|
@ -10817,18 +10817,18 @@ snapshots:
|
|||
- aws-crt
|
||||
- supports-color
|
||||
|
||||
'@opennextjs/cloudflare@1.17.3(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.77.0)':
|
||||
'@opennextjs/cloudflare@1.19.0(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.81.1)':
|
||||
dependencies:
|
||||
'@ast-grep/napi': 0.40.5
|
||||
'@dotenvx/dotenvx': 1.31.0
|
||||
'@opennextjs/aws': 3.9.16(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
'@opennextjs/aws': 3.10.1(next@16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
cloudflare: 4.5.0
|
||||
comment-json: 4.6.2
|
||||
enquirer: 2.4.1
|
||||
glob: 13.0.0
|
||||
next: 16.2.2(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
ts-tqdm: 0.8.6
|
||||
wrangler: 4.77.0
|
||||
wrangler: 4.81.1
|
||||
yargs: 18.0.0
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
|
@ -16062,12 +16062,12 @@ snapshots:
|
|||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
miniflare@4.20260317.2:
|
||||
miniflare@4.20260409.0:
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
sharp: 0.34.5
|
||||
undici: 7.24.1
|
||||
workerd: 1.20260317.1
|
||||
workerd: 1.20260409.1
|
||||
ws: 8.18.0
|
||||
youch: 4.1.0-beta.10
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -18324,26 +18324,26 @@ snapshots:
|
|||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
workerd@1.20260317.1:
|
||||
workerd@1.20260409.1:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workerd-darwin-64': 1.20260317.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260317.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260317.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260317.1
|
||||
'@cloudflare/workerd-windows-64': 1.20260317.1
|
||||
'@cloudflare/workerd-darwin-64': 1.20260409.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260409.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260409.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260409.1
|
||||
'@cloudflare/workerd-windows-64': 1.20260409.1
|
||||
|
||||
workerpool@6.5.1: {}
|
||||
|
||||
wrangler@4.77.0:
|
||||
wrangler@4.81.1:
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.4.2
|
||||
'@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1)
|
||||
'@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260409.1)
|
||||
blake3-wasm: 2.1.5
|
||||
esbuild: 0.27.3
|
||||
miniflare: 4.20260317.2
|
||||
miniflare: 4.20260409.0
|
||||
path-to-regexp: 8.4.0
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: 1.20260317.1
|
||||
workerd: 1.20260409.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue