rnxsim 0.1.539 → 0.1.541

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/cli/cloud-test-tunnel.ts +354 -0
  2. package/cli/commands/test.ts +333 -92
  3. package/cli/outbound-endpoints.ts +17 -1
  4. package/dist-lib/agent-daemon-client.cjs +1 -1
  5. package/dist-lib/agent-events.cjs +1 -1
  6. package/dist-lib/agent-identity.cjs +1 -1
  7. package/dist-lib/agent-sessions.cjs +1 -1
  8. package/dist-lib/attached-projects.cjs +1 -1
  9. package/dist-lib/auth/shared-session.cjs +1 -1
  10. package/dist-lib/backend-origin.cjs +1 -1
  11. package/dist-lib/beta.cjs +1 -1
  12. package/dist-lib/beta.mjs +1 -1
  13. package/dist-lib/bridge-constants.cjs +1 -1
  14. package/dist-lib/bridge-contract-input.cjs +1 -1
  15. package/dist-lib/bridge-contract-input.mjs +1 -1
  16. package/dist-lib/bridge-contract.cjs +1 -1
  17. package/dist-lib/bridge-contract.mjs +1 -1
  18. package/dist-lib/capture-contract.cjs +1 -1
  19. package/dist-lib/capture-contract.mjs +1 -1
  20. package/dist-lib/cli-constants.cjs +1 -1
  21. package/dist-lib/cloud-contract.cjs +1 -1
  22. package/dist-lib/cloud-contract.mjs +1 -1
  23. package/dist-lib/cloud.cjs +1 -1
  24. package/dist-lib/cloud.mjs +1 -1
  25. package/dist-lib/config.cjs +1 -1
  26. package/dist-lib/detox/index.cjs +1 -1
  27. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  28. package/dist-lib/home-paths.cjs +1 -1
  29. package/dist-lib/host/bridge-host.cjs +28 -2
  30. package/dist-lib/host/fetch-proxy-handler.cjs +28 -2
  31. package/dist-lib/host/fetch-proxy-overrides.cjs +33 -3
  32. package/dist-lib/host/fetch-proxy-overrides.mjs +30 -2
  33. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  34. package/dist-lib/host/websocket-proxy.cjs +1 -1
  35. package/dist-lib/index.cjs +1 -1
  36. package/dist-lib/jump-to-source-babel.cjs +1 -1
  37. package/dist-lib/jump-to-source-native.cjs +1 -1
  38. package/dist-lib/menu.cjs +1 -1
  39. package/dist-lib/menu.mjs +1 -1
  40. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  41. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  42. package/dist-lib/metro-production-bundle.cjs +1 -1
  43. package/dist-lib/metro-production-bundle.mjs +1 -1
  44. package/dist-lib/metro.cjs +1 -1
  45. package/dist-lib/profiles.cjs +1 -1
  46. package/dist-lib/public-brand.cjs +1 -1
  47. package/dist-lib/react-native-host-modules.cjs +1 -1
  48. package/dist-lib/react-native-host-modules.mjs +1 -1
  49. package/dist-lib/render-mode.cjs +1 -1
  50. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  51. package/dist-lib/sdk.cjs +1 -1
  52. package/dist-lib/sdk.mjs +1 -1
  53. package/dist-lib/skills.cjs +29 -3
  54. package/dist-lib/swift-submit.cjs +1 -1
  55. package/dist-lib/swift-submit.mjs +1 -1
  56. package/dist-lib/swift.cjs +1 -1
  57. package/dist-lib/vite.cjs +1 -1
  58. package/package.json +1 -1
  59. package/src/host/fetch-proxy-handler.ts +2 -1
  60. package/src/host/fetch-proxy-overrides.ts +41 -0
@@ -0,0 +1,354 @@
1
+ // rnx test --cloud live-dev-stack publisher (tunnel CLI half).
2
+ //
3
+ // when --app names a live packager (http loopback) instead of a prebuilt
4
+ // bundle, the submitter publishes that stack through a token-gated
5
+ // cloudflared quick tunnel and submits the tunnel origin plus the mode-2
6
+ // fields (hostHeader, jobToken, sidecarPorts). the recorder projects the
7
+ // origin back to loopback (scripts/runner/sootsim-recorder.ts), so flows
8
+ // keep their unmodified localhost authorities.
9
+ //
10
+ // the handshake mirrors packages/contrast-runner/src/hosted.ts, which this
11
+ // published package cannot import: Bearer-gated proxy between cloudflared
12
+ // and the dev server, --http-host-header on the dev authority, forwarded
13
+ // headers stripped so Metro emits loopback authorities the recorder
14
+ // mirrors. header names must match hostedPorts.ts and loopback-proxy.mjs.
15
+
16
+ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
17
+ import { randomBytes } from 'node:crypto'
18
+ import {
19
+ chmodSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ unlinkSync,
24
+ writeFileSync,
25
+ } from 'node:fs'
26
+ import { createServer, request as httpRequest } from 'node:http'
27
+ import { tmpdir } from 'node:os'
28
+ import { join } from 'node:path'
29
+
30
+ // must match packages/contrast-runner/src/hostedPorts.ts.
31
+ const UPSTREAM_PORT_HEADER = 'x-contrast-upstream-port'
32
+ const APPLICATION_AUTH_HEADER = 'x-contrast-application-authorization'
33
+ const MAX_SIDECAR_PORTS = 8
34
+
35
+ export type CloudTestSidecarResult =
36
+ | { ok: true; ports: number[] }
37
+ | { ok: false; message: string }
38
+
39
+ // same grammar as the jobs API (parseHostedSidecarPorts): comma-separated
40
+ // ports 1-65535, at most 8, deduped, primary excluded.
41
+ export function parseCloudTestSidecarPorts(
42
+ value: string | null | undefined,
43
+ primaryPort?: number,
44
+ ): CloudTestSidecarResult {
45
+ if (!value?.trim()) return { ok: true, ports: [] }
46
+ const values = value.split(',').map((part) => part.trim())
47
+ if (values.length > MAX_SIDECAR_PORTS) {
48
+ return {
49
+ ok: false,
50
+ message: `sidecar-ports accepts at most ${MAX_SIDECAR_PORTS} ports`,
51
+ }
52
+ }
53
+ const ports: number[] = []
54
+ for (const raw of values) {
55
+ if (!/^[1-9][0-9]{0,4}$/.test(raw)) {
56
+ return {
57
+ ok: false,
58
+ message: 'sidecar-ports must be a comma-separated list of ports from 1 to 65535',
59
+ }
60
+ }
61
+ const port = Number(raw)
62
+ if (port > 65_535) {
63
+ return {
64
+ ok: false,
65
+ message: 'sidecar-ports must be a comma-separated list of ports from 1 to 65535',
66
+ }
67
+ }
68
+ if (port !== primaryPort && !ports.includes(port)) ports.push(port)
69
+ }
70
+ return { ok: true, ports }
71
+ }
72
+
73
+ function isLoopbackHostname(hostname: string): boolean {
74
+ return (
75
+ hostname === 'localhost' ||
76
+ hostname === '127.0.0.1' ||
77
+ hostname === '[::1]' ||
78
+ hostname === '::1'
79
+ )
80
+ }
81
+
82
+ // a live dev stack is an http(s) loopback URL: the packager the recorder
83
+ // cannot reach directly. https loopback is accepted for tunnels that
84
+ // terminate TLS locally; everything else (files, https remotes, share ids)
85
+ // stays on the prebuilt path.
86
+ export function parseLiveDevStackApp(app: string): { port: number } | null {
87
+ if (!app.startsWith('http://') && !app.startsWith('https://')) return null
88
+ let url: URL
89
+ try {
90
+ url = new URL(app)
91
+ } catch {
92
+ return null
93
+ }
94
+ if (!isLoopbackHostname(url.hostname)) return null
95
+ const port = Number(url.port)
96
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) return null
97
+ return { port }
98
+ }
99
+
100
+ function resolveUpstreamPort(
101
+ value: string | string[] | undefined,
102
+ primaryPort: number,
103
+ sidecarPorts: readonly number[],
104
+ ): number | null {
105
+ if (value === undefined) return primaryPort
106
+ if (Array.isArray(value) || !/^[1-9][0-9]{0,4}$/.test(value)) return null
107
+ const port = Number(value)
108
+ return sidecarPorts.includes(port) ? port : null
109
+ }
110
+
111
+ // Bearer-gating reverse proxy between cloudflared and the dev server.
112
+ // rejects without the per-job token; strips cloudflared forwarding headers
113
+ // so Metro builds absolute URLs on the dev authority the recorder mirrors.
114
+ export function startCloudTestTokenProxy(
115
+ listenPort: number,
116
+ upstreamPort: number,
117
+ sidecarPorts: readonly number[],
118
+ token: string,
119
+ ): Promise<{ close: () => void } | null> {
120
+ const server = createServer((req, res) => {
121
+ const auth = req.headers.authorization || ''
122
+ if (auth !== `Bearer ${token}`) {
123
+ res.writeHead(401, { 'content-type': 'text/plain' })
124
+ res.end('unauthorized')
125
+ return
126
+ }
127
+ const headers = { ...req.headers }
128
+ const applicationAuthorization = headers[APPLICATION_AUTH_HEADER]
129
+ delete headers[APPLICATION_AUTH_HEADER]
130
+ delete headers.authorization
131
+ if (typeof applicationAuthorization === 'string') {
132
+ headers.authorization = applicationAuthorization
133
+ }
134
+ const selectedPort = resolveUpstreamPort(
135
+ headers[UPSTREAM_PORT_HEADER],
136
+ upstreamPort,
137
+ sidecarPorts,
138
+ )
139
+ delete headers[UPSTREAM_PORT_HEADER]
140
+ if (selectedPort === null) {
141
+ res.writeHead(403, { 'content-type': 'text/plain' })
142
+ res.end('upstream port is not enabled for this run')
143
+ return
144
+ }
145
+ if (selectedPort !== upstreamPort) headers.host = `127.0.0.1:${selectedPort}`
146
+ for (const key of Object.keys(headers)) {
147
+ const lower = key.toLowerCase()
148
+ if (lower.startsWith('x-forwarded-') || lower.startsWith('cf-')) {
149
+ delete headers[key]
150
+ }
151
+ }
152
+ const up = httpRequest(
153
+ {
154
+ host: '127.0.0.1',
155
+ port: selectedPort,
156
+ path: req.url,
157
+ method: req.method,
158
+ headers,
159
+ },
160
+ (upRes) => {
161
+ res.writeHead(upRes.statusCode || 502, upRes.headers)
162
+ upRes.pipe(res)
163
+ },
164
+ )
165
+ up.on('error', (err) => {
166
+ res.writeHead(502, { 'content-type': 'text/plain' })
167
+ res.end(`upstream error: ${err.message}`)
168
+ })
169
+ req.pipe(up)
170
+ })
171
+ return new Promise((resolve) => {
172
+ server.on('error', () => resolve(null))
173
+ server.listen(listenPort, '127.0.0.1', () => {
174
+ resolve({ close: () => server.close() })
175
+ })
176
+ })
177
+ }
178
+
179
+ function cloudflaredAvailable(): boolean {
180
+ const probe = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' })
181
+ return probe.status === 0
182
+ }
183
+
184
+ async function ensureCloudflared(): Promise<boolean> {
185
+ if (cloudflaredAvailable()) return true
186
+ const archMap: Record<string, string> = { x64: 'amd64', arm64: 'arm64' }
187
+ const arch = archMap[process.arch]
188
+ const osMap: Record<string, string> = { linux: 'linux', darwin: 'darwin' }
189
+ const os = osMap[process.platform]
190
+ if (!arch || !os) return false
191
+ const dest = join(tmpdir(), 'rnx-cloudflared')
192
+ const binary = join(dest, 'cloudflared')
193
+ if (existsSync(binary)) {
194
+ process.env.PATH = `${dest}:${process.env.PATH ?? ''}`
195
+ return cloudflaredAvailable()
196
+ }
197
+ // linux publishes a bare binary; darwin publishes a tarball.
198
+ const asset =
199
+ os === 'darwin' ? `cloudflared-${os}-${arch}.tgz` : `cloudflared-${os}-${arch}`
200
+ try {
201
+ const res = await fetch(
202
+ `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
203
+ )
204
+ if (!res.ok) return false
205
+ mkdirSync(dest, { recursive: true })
206
+ if (os === 'darwin') {
207
+ const tgz = join(dest, asset)
208
+ writeFileSync(tgz, Buffer.from(await res.arrayBuffer()))
209
+ const untar = spawnSync('tar', ['-xzf', tgz, '-C', dest])
210
+ try {
211
+ unlinkSync(tgz)
212
+ } catch {}
213
+ if (untar.status !== 0 || !existsSync(binary)) return false
214
+ } else {
215
+ writeFileSync(binary, Buffer.from(await res.arrayBuffer()))
216
+ }
217
+ } catch {
218
+ return false
219
+ }
220
+ chmodSync(binary, 0o755)
221
+ process.env.PATH = `${dest}:${process.env.PATH ?? ''}`
222
+ return cloudflaredAvailable()
223
+ }
224
+
225
+ function sleep(ms: number): Promise<void> {
226
+ return new Promise((resolve) => setTimeout(resolve, ms))
227
+ }
228
+
229
+ function childAlive(child: ChildProcess): boolean {
230
+ return child.exitCode === null && child.signalCode === null
231
+ }
232
+
233
+ async function launchQuickTunnel(
234
+ originUrl: string,
235
+ hostHeader: string,
236
+ logFile: string,
237
+ ): Promise<{ url: string; child: ChildProcess } | null> {
238
+ writeFileSync(logFile, '')
239
+ const child = spawn(
240
+ 'cloudflared',
241
+ [
242
+ 'tunnel',
243
+ '--url',
244
+ originUrl,
245
+ '--http-host-header',
246
+ hostHeader,
247
+ '--protocol',
248
+ 'http2',
249
+ '--no-autoupdate',
250
+ ],
251
+ { stdio: ['ignore', 'pipe', 'pipe'] },
252
+ )
253
+ // cloudflared logs the tunnel URL to stderr; mirror both streams into the
254
+ // log file and poll it for the trycloudflare origin.
255
+ const lines: string[] = []
256
+ child.stderr?.on('data', (chunk) => {
257
+ lines.push(String(chunk))
258
+ writeFileSync(logFile, lines.join(''))
259
+ })
260
+ child.stdout?.on('data', (chunk) => {
261
+ lines.push(String(chunk))
262
+ writeFileSync(logFile, lines.join(''))
263
+ })
264
+ for (let i = 0; i < 60; i++) {
265
+ let text = ''
266
+ try {
267
+ text = readFileSync(logFile, 'utf8')
268
+ } catch {
269
+ text = ''
270
+ }
271
+ const match = text.match(/https:\/\/[-a-z0-9]+\.trycloudflare\.com/g)
272
+ const url = match?.[match.length - 1]
273
+ if (url) return { url, child }
274
+ if (!childAlive(child)) return null
275
+ await sleep(1000)
276
+ }
277
+ child.kill()
278
+ return null
279
+ }
280
+
281
+ export interface PublishedLiveDevStack {
282
+ targetUrl: string
283
+ hostHeader: string
284
+ jobToken: string
285
+ sidecarPorts: number[]
286
+ close: () => void
287
+ }
288
+
289
+ // publish a live dev stack for one hosted test run. the caller keeps the
290
+ // returned tunnel open until the job reaches a terminal state, then closes
291
+ // it. throws on any setup failure with a message the CLI prints.
292
+ export async function publishLiveDevStack(args: {
293
+ devPort: number
294
+ sidecarPorts: readonly number[]
295
+ }): Promise<PublishedLiveDevStack> {
296
+ const { devPort, sidecarPorts } = args
297
+ const jobToken = randomBytes(16).toString('hex')
298
+ // free loopback port for the token proxy; the advertised authority stays
299
+ // the dev server's own port so Metro URLs resolve on the recorder.
300
+ const { Server } = await import('node:net')
301
+ const proxyPort = await new Promise<number>((resolve, reject) => {
302
+ const probe = new Server()
303
+ probe.once('error', reject)
304
+ probe.listen(0, '127.0.0.1', () => {
305
+ const address = probe.address()
306
+ const port = address && typeof address === 'object' ? address.port : 0
307
+ probe.close(() => resolve(port))
308
+ })
309
+ })
310
+ if (!proxyPort) throw new Error('could not allocate a tunnel proxy port')
311
+ const proxy = await startCloudTestTokenProxy(proxyPort, devPort, sidecarPorts, jobToken)
312
+ if (!proxy) {
313
+ throw new Error(`could not start the tunnel token proxy on 127.0.0.1:${proxyPort}`)
314
+ }
315
+ const closeProxy = proxy.close
316
+ const hostHeader = `127.0.0.1:${devPort}`
317
+ if (!(await ensureCloudflared())) {
318
+ closeProxy()
319
+ throw new Error(
320
+ 'cloudflared is required for live dev stacks (rnx test --cloud with http://localhost:<port>). install it or check your network.',
321
+ )
322
+ }
323
+ const logFile = join(tmpdir(), `rnx-test-tunnel-${Date.now()}-${proxyPort}.log`)
324
+ const tunnel = await launchQuickTunnel(
325
+ `http://127.0.0.1:${proxyPort}`,
326
+ hostHeader,
327
+ logFile,
328
+ )
329
+ if (!tunnel) {
330
+ closeProxy()
331
+ let tail = ''
332
+ try {
333
+ tail = readFileSync(logFile, 'utf8').slice(-500)
334
+ } catch {
335
+ tail = ''
336
+ }
337
+ throw new Error(
338
+ `could not establish the cloudflared tunnel for the live dev stack${tail ? `: ${tail.trim().split('\n').pop()}` : ''}`,
339
+ )
340
+ }
341
+ let closed = false
342
+ return {
343
+ targetUrl: tunnel.url,
344
+ hostHeader,
345
+ jobToken,
346
+ sidecarPorts: [...sidecarPorts],
347
+ close: () => {
348
+ if (closed) return
349
+ closed = true
350
+ tunnel.child.kill()
351
+ closeProxy()
352
+ },
353
+ }
354
+ }