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 new file mode 100644 index 0000000..8351373 --- /dev/null +++ b/packages/runtime/src/client/getWSServer.test.ts @@ -0,0 +1,65 @@ +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(), +})); + +vi.mock('../utils/dev-server.js', () => ({ + getDevServerUrl: mocks.getDevServerUrl, +})); + +vi.mock('react-native-url-polyfill', () => ({ + URL, +})); + +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('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', + ); + + 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'); + + 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..79bd434 100644 --- a/packages/runtime/src/client/getWSServer.ts +++ b/packages/runtime/src/client/getWSServer.ts @@ -1,8 +1,15 @@ import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; +import { URL } from 'react-native-url-polyfill'; import { getDevServerUrl } from '../utils/dev-server.js'; export const getWSServer = (): string => { - const devServerUrl = new URL(getDevServerUrl()); + const devServerUrlString = getDevServerUrl(); + const devServerUrl = new URL(devServerUrlString); + + if (!devServerUrl.host) { + throw new TypeError(`Invalid URL: ${devServerUrlString}`); + } + const protocol = devServerUrl.protocol === 'https:' ? 'wss:' : 'ws:'; 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