From 2bb6585cb554ce4bdf2edc5cbd1937d46247a7af Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 7 Apr 2026 11:09:59 +0200 Subject: [PATCH 1/3] fix: replace runtime URL parsing for websocket bridge --- .../runtime/src/client/getWSServer.test.ts | 48 +++++++++++++++ packages/runtime/src/client/getWSServer.ts | 60 ++++++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/client/getWSServer.test.ts diff --git a/packages/runtime/src/client/getWSServer.test.ts b/packages/runtime/src/client/getWSServer.test.ts new file mode 100644 index 0000000..d3174ef --- /dev/null +++ b/packages/runtime/src/client/getWSServer.test.ts @@ -0,0 +1,48 @@ +import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getDevServerUrl: vi.fn(), +})); + +vi.mock('../utils/dev-server.js', () => ({ + getDevServerUrl: mocks.getDevServerUrl, +})); + +import { getWSServer } from './getWSServer.js'; + +describe('getWSServer', () => { + beforeEach(() => { + mocks.getDevServerUrl.mockReset(); + }); + + it('builds a websocket bridge URL from an http dev server URL', () => { + mocks.getDevServerUrl.mockReturnValue( + 'http://localhost:8081/index.bundle?platform=ios&dev=true#main', + ); + + expect(getWSServer()).toBe(`ws://localhost:8081${HARNESS_BRIDGE_PATH}`); + }); + + it('builds a secure websocket bridge URL from an https dev server URL', () => { + mocks.getDevServerUrl.mockReturnValue('HTTPS://Example.COM:19000/'); + + expect(getWSServer()).toBe(`wss://example.com:19000${HARNESS_BRIDGE_PATH}`); + }); + + it('drops user info while preserving the host for ipv6 URLs', () => { + mocks.getDevServerUrl.mockReturnValue( + 'http://user:secret@[::1]:8081/status', + ); + + expect(getWSServer()).toBe(`ws://[::1]:8081${HARNESS_BRIDGE_PATH}`); + }); + + it('throws for non-absolute dev server URLs', () => { + mocks.getDevServerUrl.mockReturnValue('localhost:8081'); + + expect(() => getWSServer()).toThrow( + new TypeError('Invalid URL: localhost:8081'), + ); + }); +}); diff --git a/packages/runtime/src/client/getWSServer.ts b/packages/runtime/src/client/getWSServer.ts index f0cc3f2..25d6afa 100644 --- a/packages/runtime/src/client/getWSServer.ts +++ b/packages/runtime/src/client/getWSServer.ts @@ -1,9 +1,67 @@ import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; import { getDevServerUrl } from '../utils/dev-server.js'; +const ABSOLUTE_URL_PATTERN = + /^(?[A-Za-z][A-Za-z\d+.-]*):\/\/(?[^/?#\s]+)(?:[/?#]|$)/; + export const getWSServer = (): string => { - const devServerUrl = new URL(getDevServerUrl()); + const devServerUrl = parseAbsoluteUrl(getDevServerUrl()); const protocol = devServerUrl.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${devServerUrl.host}${HARNESS_BRIDGE_PATH}`; }; + +const parseAbsoluteUrl = ( + value: string, +): { + protocol: string; + host: string; +} => { + const normalizedValue = value.trim(); + const match = normalizedValue.match(ABSOLUTE_URL_PATTERN); + + if (!match?.groups) { + throw new TypeError(`Invalid URL: ${value}`); + } + + const authority = stripUserInfo(match.groups.authority); + + if (!authority) { + throw new TypeError(`Invalid URL: ${value}`); + } + + return { + protocol: `${match.groups.protocol.toLowerCase()}:`, + host: normalizeHost(authority), + }; +}; + +const stripUserInfo = (authority: string): string => { + const userInfoSeparatorIndex = authority.lastIndexOf('@'); + + return userInfoSeparatorIndex === -1 + ? authority + : authority.slice(userInfoSeparatorIndex + 1); +}; + +const normalizeHost = (host: string): string => { + if (host.startsWith('[')) { + const closingBracketIndex = host.indexOf(']'); + + if (closingBracketIndex === -1) { + throw new TypeError(`Invalid URL host: ${host}`); + } + + return `${host.slice(0, closingBracketIndex + 1).toLowerCase()}${host.slice( + closingBracketIndex + 1, + )}`; + } + + const portSeparatorIndex = host.lastIndexOf(':'); + + if (portSeparatorIndex === -1) { + return host.toLowerCase(); + } + + return `${host.slice(0, portSeparatorIndex).toLowerCase()}${host.slice(portSeparatorIndex)}`; +}; From bc03cc2b7f6154d4a6a19f99c96a39ea7fbfcb05 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 7 Apr 2026 11:52:34 +0200 Subject: [PATCH 2/3] fix: use URL polyfill for websocket bridge --- packages/runtime/package.json | 1 + .../runtime/src/client/getWSServer.test.ts | 18 +++++ packages/runtime/src/client/getWSServer.ts | 65 ++----------------- pnpm-lock.yaml | 31 ++++++++- 4 files changed, 55 insertions(+), 60 deletions(-) diff --git a/packages/runtime/package.json b/packages/runtime/package.json index fa0bd09..45d3d41 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -45,6 +45,7 @@ "@vitest/spy": "4.0.16", "chai": "^6.2.2", "event-target-shim": "^6.0.2", + "react-native-url-polyfill": "^3.0.0", "use-sync-external-store": "^1.6.0", "zustand": "^5.0.5" }, diff --git a/packages/runtime/src/client/getWSServer.test.ts b/packages/runtime/src/client/getWSServer.test.ts index d3174ef..8c35eed 100644 --- a/packages/runtime/src/client/getWSServer.test.ts +++ b/packages/runtime/src/client/getWSServer.test.ts @@ -9,6 +9,10 @@ vi.mock('../utils/dev-server.js', () => ({ getDevServerUrl: mocks.getDevServerUrl, })); +vi.mock('react-native-url-polyfill', () => ({ + URL, +})); + import { getWSServer } from './getWSServer.js'; describe('getWSServer', () => { @@ -30,6 +34,12 @@ describe('getWSServer', () => { expect(getWSServer()).toBe(`wss://example.com:19000${HARNESS_BRIDGE_PATH}`); }); + it('preserves the explicit port for hostnames', () => { + mocks.getDevServerUrl.mockReturnValue('http://example.com:31337/status'); + + expect(getWSServer()).toBe(`ws://example.com:31337${HARNESS_BRIDGE_PATH}`); + }); + it('drops user info while preserving the host for ipv6 URLs', () => { mocks.getDevServerUrl.mockReturnValue( 'http://user:secret@[::1]:8081/status', @@ -38,6 +48,14 @@ describe('getWSServer', () => { expect(getWSServer()).toBe(`ws://[::1]:8081${HARNESS_BRIDGE_PATH}`); }); + it('preserves the port for ipv6 URLs without user info', () => { + mocks.getDevServerUrl.mockReturnValue('http://[2001:db8::1]:19001/status'); + + expect(getWSServer()).toBe( + `ws://[2001:db8::1]:19001${HARNESS_BRIDGE_PATH}`, + ); + }); + it('throws for non-absolute dev server URLs', () => { mocks.getDevServerUrl.mockReturnValue('localhost:8081'); diff --git a/packages/runtime/src/client/getWSServer.ts b/packages/runtime/src/client/getWSServer.ts index 25d6afa..79bd434 100644 --- a/packages/runtime/src/client/getWSServer.ts +++ b/packages/runtime/src/client/getWSServer.ts @@ -1,67 +1,16 @@ import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; +import { URL } from 'react-native-url-polyfill'; import { getDevServerUrl } from '../utils/dev-server.js'; -const ABSOLUTE_URL_PATTERN = - /^(?[A-Za-z][A-Za-z\d+.-]*):\/\/(?[^/?#\s]+)(?:[/?#]|$)/; - export const getWSServer = (): string => { - const devServerUrl = parseAbsoluteUrl(getDevServerUrl()); - const protocol = devServerUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - - return `${protocol}//${devServerUrl.host}${HARNESS_BRIDGE_PATH}`; -}; - -const parseAbsoluteUrl = ( - value: string, -): { - protocol: string; - host: string; -} => { - const normalizedValue = value.trim(); - const match = normalizedValue.match(ABSOLUTE_URL_PATTERN); - - if (!match?.groups) { - throw new TypeError(`Invalid URL: ${value}`); - } - - const authority = stripUserInfo(match.groups.authority); - - if (!authority) { - throw new TypeError(`Invalid URL: ${value}`); - } + const devServerUrlString = getDevServerUrl(); + const devServerUrl = new URL(devServerUrlString); - return { - protocol: `${match.groups.protocol.toLowerCase()}:`, - host: normalizeHost(authority), - }; -}; - -const stripUserInfo = (authority: string): string => { - const userInfoSeparatorIndex = authority.lastIndexOf('@'); - - return userInfoSeparatorIndex === -1 - ? authority - : authority.slice(userInfoSeparatorIndex + 1); -}; - -const normalizeHost = (host: string): string => { - if (host.startsWith('[')) { - const closingBracketIndex = host.indexOf(']'); - - if (closingBracketIndex === -1) { - throw new TypeError(`Invalid URL host: ${host}`); - } - - return `${host.slice(0, closingBracketIndex + 1).toLowerCase()}${host.slice( - closingBracketIndex + 1, - )}`; + if (!devServerUrl.host) { + throw new TypeError(`Invalid URL: ${devServerUrlString}`); } - const portSeparatorIndex = host.lastIndexOf(':'); - - if (portSeparatorIndex === -1) { - return host.toLowerCase(); - } + const protocol = devServerUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${host.slice(0, portSeparatorIndex).toLowerCase()}${host.slice(portSeparatorIndex)}`; + return `${protocol}//${devServerUrl.host}${HARNESS_BRIDGE_PATH}`; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e0be87..4528a58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -532,6 +532,9 @@ importers: event-target-shim: specifier: ^6.0.2 version: 6.0.2 + react-native-url-polyfill: + specifier: ^3.0.0 + version: 3.0.0(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3)) use-sync-external-store: specifier: ^1.6.0 version: 1.6.0(react@19.2.3) @@ -7070,6 +7073,11 @@ packages: react-lazy-with-preload@2.2.1: resolution: {integrity: sha512-ONSb8gizLE5jFpdHAclZ6EAAKuFX2JydnFXPPPjoUImZlLjGtKzyBS8SJgJq7CpLgsGKh9QCZdugJyEEOVC16Q==} + react-native-url-polyfill@3.0.0: + resolution: {integrity: sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==} + peerDependencies: + react-native: '*' + react-native-web@0.21.2: resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} peerDependencies: @@ -8146,6 +8154,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@5.0.0: + resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} + engines: {node: '>=8'} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -8176,6 +8188,10 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + whatwg-url-without-unicode@8.0.0-3: + resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==} + engines: {node: '>=10'} + whatwg-url@12.0.1: resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==} engines: {node: '>=14'} @@ -10816,9 +10832,7 @@ snapshots: metro-config: 0.83.3 metro-runtime: 0.83.3 transitivePeerDependencies: - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.74.89': {} @@ -16875,6 +16889,11 @@ snapshots: react-lazy-with-preload@2.2.1: {} + react-native-url-polyfill@3.0.0(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3)): + dependencies: + react-native: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + whatwg-url-without-unicode: 8.0.0-3 + react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.27.6 @@ -18192,6 +18211,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@5.0.0: {} + webidl-conversions@7.0.0: {} webpack-sources@3.3.3: {} @@ -18236,6 +18257,12 @@ snapshots: whatwg-mimetype@3.0.0: {} + whatwg-url-without-unicode@8.0.0-3: + dependencies: + buffer: 5.7.1 + punycode: 2.3.1 + webidl-conversions: 5.0.0 + whatwg-url@12.0.1: dependencies: tr46: 4.1.1 From be1a333a6cf922578b785cde8c1bbdb7f118a163 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 7 Apr 2026 11:55:52 +0200 Subject: [PATCH 3/3] fix: satisfy runtime lint for websocket URL tests --- packages/runtime/src/client/getWSServer.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/runtime/src/client/getWSServer.test.ts b/packages/runtime/src/client/getWSServer.test.ts index 8c35eed..8351373 100644 --- a/packages/runtime/src/client/getWSServer.test.ts +++ b/packages/runtime/src/client/getWSServer.test.ts @@ -1,5 +1,6 @@ import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getWSServer } from './getWSServer.js'; const mocks = vi.hoisted(() => ({ getDevServerUrl: vi.fn(), @@ -13,8 +14,6 @@ vi.mock('react-native-url-polyfill', () => ({ URL, })); -import { getWSServer } from './getWSServer.js'; - describe('getWSServer', () => { beforeEach(() => { mocks.getDevServerUrl.mockReset();