rnxsim 0.1.538 → 0.1.540
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.
- package/cli/cloud-test-tunnel.ts +354 -0
- package/cli/commands/test.ts +333 -92
- package/cli/outbound-endpoints.ts +17 -1
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +28 -2
- package/dist-lib/host/fetch-proxy-handler.cjs +28 -2
- package/dist-lib/host/fetch-proxy-overrides.cjs +33 -3
- package/dist-lib/host/fetch-proxy-overrides.mjs +30 -2
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +29 -3
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite.cjs +1 -1
- package/package.json +1 -1
- package/src/host/fetch-proxy-handler.ts +2 -1
- 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
|
+
}
|