diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index dee4b67e..05810ed6 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -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/` diff --git a/apps/readest-app/.claude/memory/cloudflare-workers-websocket.md b/apps/readest-app/.claude/memory/cloudflare-workers-websocket.md new file mode 100644 index 00000000..16d60e0c --- /dev/null +++ b/apps/readest-app/.claude/memory/cloudflare-workers-websocket.md @@ -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 = Promise.resolve(); +const enqueue = (getBuf: () => Promise | 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. diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 660ed7a7..4f840ff6 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -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", diff --git a/apps/readest-app/src/__tests__/libs/edgeTTS.test.ts b/apps/readest-app/src/__tests__/libs/edgeTTS.test.ts new file mode 100644 index 00000000..c9cd1e1f --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/edgeTTS.test.ts @@ -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 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; + 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 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(); + }); +}); diff --git a/apps/readest-app/src/libs/edgeTTS.ts b/apps/readest-app/src/libs/edgeTTS.ts index 2807834c..872c768d 100644 --- a/apps/readest-app/src/libs/edgeTTS.ts +++ b/apps/readest-app/src/libs/edgeTTS.ts @@ -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 { - 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((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 = 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) => { + 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, { diff --git a/apps/readest-app/wrangler.toml b/apps/readest-app/wrangler.toml index 8423768a..df3c0097 100644 --- a/apps/readest-app/wrangler.toml +++ b/apps/readest-app/wrangler.toml @@ -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 diff --git a/package.json b/package.json index 2c03314b..ce8c8930 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47771e64..39859466 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: