From 244b3fd9944ed016c67b28b4ac78092e78067327 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 15 May 2026 01:06:22 +0800 Subject: [PATCH] fix(dev): rewrite HMR WebSocket URL in Tauri mobile dev, closes #4150 (#4160) In Tauri mobile dev the page origin doesn't match the dev server, so Next.js's `getSocketUrl` builds an unreachable HMR URL (`wss://localhost` on iOS, `ws://tauri.localhost` on Android), the HMR client never connects, and the page stays blank. Inject a tiny script in `` (dev + Tauri only) that subclasses `window.WebSocket` and rewrites the broken URL to the actual dev server. `TAURI_DEV_HOST` is forwarded from the build env so `pnpm tauri {ios,android} dev --host ` also routes HMR through the LAN address. Co-authored-by: Claude Opus 4.7 (1M context) --- apps/readest-app/src/app/layout.tsx | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/apps/readest-app/src/app/layout.tsx b/apps/readest-app/src/app/layout.tsx index 48bcf826..48dc0899 100644 --- a/apps/readest-app/src/app/layout.tsx +++ b/apps/readest-app/src/app/layout.tsx @@ -72,12 +72,67 @@ export const viewport: Viewport = { // unrecognized key on every page load, so we keep it out of SSR. }; +// In Tauri mobile dev the page origin doesn't match the dev server, so +// Next.js's `getSocketUrl` builds an unreachable HMR URL (see +// `next/dist/client/dev/hot-reloader/get-socket-url.js`): +// - iOS sim: page at `tauri://localhost` → `wss://localhost/_next/...` +// (no port, non-http scheme falls through to `wss:`) +// - Android emul.: page at `http://tauri.localhost` → `ws://tauri.localhost/_next/...` +// (`tauri.localhost` is intercepted by Tauri's asset handler, but +// WebSocket frames bypass the interceptor and the dev server is on the +// host machine, reachable from the emulator as `10.0.2.2`) +// Rewrite the WebSocket constructor before the HMR client runs. +// When `--host ` is passed, tauri-cli exports `TAURI_DEV_HOST=` +// before invoking `beforeDevCommand`, so we forward that as `devHost` and +// use it for the rewrite (the dev server must also bind to the same address +// — typically `next dev -H 0.0.0.0`). +function patchTauriHmrWebSocket(devHost?: string) { + const isIosTauriProxy = location.protocol === 'tauri:' && location.hostname === 'localhost'; + const isAndroidTauriProxy = + location.protocol === 'http:' && location.hostname === 'tauri.localhost'; + if (!isIosTauriProxy && !isAndroidTauriProxy) return; + + // Priority: explicit --host > platform default loopback alias. + // iOS Simulator can reach the host's localhost directly. + // Android emulator reaches the host machine via 10.0.2.2. + const hmrHost = devHost + ? `${devHost}:3000` + : isIosTauriProxy + ? 'localhost:3000' + : '10.0.2.2:3000'; + const brokenHostPattern = /^wss?:\/\/(localhost|tauri\.localhost)(?=\/_next\/)/; + + const OriginalWebSocket = window.WebSocket; + class PatchedWebSocket extends OriginalWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + const urlStr = url instanceof URL ? url.href : url; + const rewritten = + typeof urlStr === 'string' && brokenHostPattern.test(urlStr) + ? urlStr.replace(brokenHostPattern, `ws://${hmrHost}`) + : url; + super(rewritten, protocols); + } + } + window.WebSocket = PatchedWebSocket; +} + +const shouldInjectDevHmrPatch = + process.env['NODE_ENV'] === 'development' && process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri'; +const devHmrPatchScript = `(${patchTauriHmrWebSocket.toString()})(${JSON.stringify( + process.env['TAURI_DEV_HOST'], +)});`; + export default function RootLayout({ children }: { children: React.ReactNode }) { return ( + {shouldInjectDevHmrPatch ? ( + +