rnxsim 0.1.381 → 0.1.383
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/README.md +19 -6
- package/cli/app-fonts.ts +1 -1
- package/cli/bin.ts +118 -26
- package/cli/bridge-diagnostics.ts +1 -1
- package/cli/bridge-flow-runner.ts +9 -9
- package/cli/browser-evals.ts +3 -3
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +407 -0
- package/cli/cloud-dispatch.ts +165 -0
- package/cli/cloud-session.ts +156 -0
- package/cli/command-contract.ts +2 -2
- package/cli/command-registry.ts +9 -0
- package/cli/commands/agent-wrapper.ts +4 -4
- package/cli/commands/agent.ts +3 -3
- package/cli/commands/box/checkout-plane.ts +16 -8
- package/cli/commands/box/rnx-command.ts +2 -2
- package/cli/commands/box/search-command.ts +2 -2
- package/cli/commands/box.ts +123 -70
- package/cli/commands/config.ts +3 -2
- package/cli/commands/control.ts +6 -6
- package/cli/commands/cpu-profile.ts +2 -2
- package/cli/commands/daemon.ts +7 -7
- package/cli/commands/debug.ts +2 -2
- package/cli/commands/detox.ts +7 -7
- package/cli/commands/diagnose.ts +1 -1
- package/cli/commands/electron.ts +1 -1
- package/cli/commands/film.ts +1 -1
- package/cli/commands/flow.ts +4 -4
- package/cli/commands/inspect/core.ts +58 -7
- package/cli/commands/inspect/env.ts +5 -3
- package/cli/commands/inspect/shared.ts +2 -1
- package/cli/commands/inspect/wait-event.ts +1 -1
- package/cli/commands/inspect/wait-ready.ts +15 -5
- package/cli/commands/inspect.ts +31 -9
- package/cli/commands/install-desktop.ts +2 -2
- package/cli/commands/maestro.ts +3 -3
- package/cli/commands/permissions.ts +1 -1
- package/cli/commands/platform.ts +91 -0
- package/cli/commands/record.ts +3 -3
- package/cli/commands/report-issue.ts +1 -1
- package/cli/commands/runtime.ts +1 -1
- package/cli/commands/screenshot-mode.ts +1 -1
- package/cli/commands/screenshot.ts +25 -0
- package/cli/commands/serve.ts +1 -1
- package/cli/commands/skills.ts +1 -1
- package/cli/commands/timeline.ts +1 -1
- package/cli/commands/upgrade.ts +8 -34
- package/cli/commands/upload.ts +3 -3
- package/cli/commands/version.ts +14 -3
- package/cli/commands/what-happened.ts +2 -2
- package/cli/current-sim.ts +43 -30
- package/cli/desktop-companion.ts +1 -1
- package/cli/drivers/electron.ts +1 -1
- package/cli/drivers/playwright.ts +3 -3
- package/cli/drivers/types.ts +4 -4
- package/cli/flow-file.ts +3 -3
- package/cli/help-core.ts +18 -11
- package/cli/hidden-runtime-alias.ts +1 -1
- package/cli/hints.ts +2 -2
- package/cli/maestro-js.ts +2 -2
- package/cli/outbound-endpoints.ts +40 -6
- package/cli/parse-args.ts +1 -1
- package/cli/registry.ts +1 -1
- package/cli/resolve-assets.ts +2 -2
- package/cli/secure-file.ts +31 -0
- package/cli/shell-init.ts +98 -0
- package/cli/ws-bridge.ts +18 -8
- package/detox/expectations.ts +1 -1
- package/detox/gestures.ts +3 -3
- package/detox/index.ts +3 -3
- 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 +20 -8
- package/dist-lib/bridge-contract-input.mjs +20 -8
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +217 -0
- package/dist-lib/capture-contract.mjs +187 -0
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +41 -0
- package/dist-lib/cloud-contract.mjs +13 -0
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +4 -4
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +9 -1
- package/dist-lib/host/bridge-host.cjs +15 -10
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +361 -75
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.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 +44 -6
- package/dist-lib/sdk.mjs +44 -6
- package/dist-lib/skills.cjs +436 -155
- package/dist-lib/vite.cjs +1 -1
- package/package.json +20 -4
- package/scripts/dev-server-scanner.ts +3 -3
- package/skills/contrast/SKILL.md +1 -1
- package/skills/rnx-perf/SKILL.md +68 -52
- package/src/app-url.ts +2 -3
- package/src/bridge-contract-input.ts +25 -6
- package/src/bridge-contract.ts +35 -5
- package/src/capture-contract.ts +576 -0
- package/src/cloud-contract.ts +18 -0
- package/src/home-paths.ts +9 -3
- package/src/host/agent-host.ts +1 -1
- package/src/host/bridge-host.ts +13 -11
- package/src/host/plane-host.ts +2 -2
- package/src/index.ts +25 -0
- package/src/native-dev-bundle-url.ts +2 -2
- package/src/screenshots/orchestrate.ts +1 -1
- package/src/screenshots/org-deck.ts +1 -1
- package/src/settings-bundle.ts +1 -1
- package/src/sim-client.ts +4 -0
- package/src/vite-plugin-one.ts +2 -2
- package/src/vite-plugin.ts +1 -1
- package/cli/commands/box/cloud-box.ts +0 -294
package/src/host/bridge-host.ts
CHANGED
|
@@ -165,7 +165,7 @@ interface BridgeCliLease {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
// interactive commands change app state and must respect the lease.
|
|
168
|
-
// observational/lifecycle commands (evaluate/tree/query/resolve/screenshot/focus/call/close)
|
|
168
|
+
// observational/lifecycle commands (evaluate/tree/query/resolve/screenshot/capture/focus/call/close)
|
|
169
169
|
// pass through without taking or checking a lease so cleanup never gets stuck
|
|
170
170
|
// behind a stale cli owner.
|
|
171
171
|
// `call` used to acquire a lease too, but most call paths are queries
|
|
@@ -228,7 +228,7 @@ export interface BridgeHostOptions {
|
|
|
228
228
|
portFallbackCount?: number
|
|
229
229
|
/** when true, write ~/.rnx/daemon.json on successful bind + update
|
|
230
230
|
* it on a heartbeat interval + remove it on close. only the standalone
|
|
231
|
-
* `
|
|
231
|
+
* `rnx serve` process should set this — tests, vite plugin, and
|
|
232
232
|
* anything embedded should leave it off to avoid clobbering a real
|
|
233
233
|
* daemon's lockfile. defaults to false. */
|
|
234
234
|
writeLockfile?: boolean
|
|
@@ -249,7 +249,7 @@ export interface BridgeHostOptions {
|
|
|
249
249
|
* resolves the daemon's own loopback host (localhost:<runtimePort>) to
|
|
250
250
|
* the dev `contrast.localhost:3000` stack, so a *prod* CLI user's preview
|
|
251
251
|
* recording would upload to a localhost stack that doesn't exist. the
|
|
252
|
-
* `
|
|
252
|
+
* `rnx serve` command resolves this with the same probe the
|
|
253
253
|
* upload itself uses (`resolveDefaultUploadOrigin`). */
|
|
254
254
|
contrastOrigin?: string
|
|
255
255
|
/** override the abandoned-sim GC TTL (ms). defaults to
|
|
@@ -325,7 +325,7 @@ const CAMERA_FIXTURE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
|
325
325
|
* the same server on the same port, but a daemon whose preferred 7668
|
|
326
326
|
* was taken falls back to 7669+. without this the runtime page would
|
|
327
327
|
* hardcode the 7668 default in `resolveBridgePort` and register on a
|
|
328
|
-
* *different* daemon (or none), so `
|
|
328
|
+
* *different* daemon (or none), so `rnx open` times out waiting for
|
|
329
329
|
* a sim that connected to the wrong bridge.
|
|
330
330
|
* - `window.__sootsimContrastOrigin` — the contrast origin for auth /
|
|
331
331
|
* billing / preview uploads. the daemon serves runtimes from its own
|
|
@@ -400,8 +400,8 @@ export class SootSimBridgeHost {
|
|
|
400
400
|
private static USER_BOOT_LEASE_TTL_MS = 60_000
|
|
401
401
|
private static SIM_RECONNECT_TTL_MS = 30_000
|
|
402
402
|
// abandoned-tab GC. the ws heartbeat only reaps sims whose socket went
|
|
403
|
-
// dead — but a background tab from a prior `
|
|
404
|
-
// keeps its socket alive (the page still pongs) forever, so `
|
|
403
|
+
// dead — but a background tab from a prior `rnx open` / QA / test run
|
|
404
|
+
// keeps its socket alive (the page still pongs) forever, so `rnx list`
|
|
405
405
|
// accretes dozens of zombie sims that all time out on every command (QA
|
|
406
406
|
// F21-3, carried F19-2). reap a sim only when it is provably nobody's:
|
|
407
407
|
// not primary, not user-focused, no CLI attached, no active lease, and no
|
|
@@ -409,7 +409,7 @@ export class SootSimBridgeHost {
|
|
|
409
409
|
// terminal 4001 code, so the client closes the abandoned window instead
|
|
410
410
|
// of reconnecting the zombie straight back in.
|
|
411
411
|
private static SIM_IDLE_REAP_TTL_MS = 30 * 60_000
|
|
412
|
-
// automation sims (spawned by the playwright driver: `
|
|
412
|
+
// automation sims (spawned by the playwright driver: `rnx open --driver
|
|
413
413
|
// playwright`, the factory/screenshot capture path — `meta.sootsimHostDriver
|
|
414
414
|
// === 'playwright'`) are unattended one-shot browsers, never a human's tab. a
|
|
415
415
|
// capture run opens one per slide/app/retry and the 30-min human/dev TTL lets
|
|
@@ -440,7 +440,7 @@ export class SootSimBridgeHost {
|
|
|
440
440
|
// dropped, sim crashed) leave their server-side WebSocket sitting "open"
|
|
441
441
|
// forever. ping every WS_HEARTBEAT_INTERVAL_MS; if the previous round's
|
|
442
442
|
// ping was never answered, terminate(). that fires 'close' which runs the
|
|
443
|
-
// sim-cleanup path and stops `
|
|
443
|
+
// sim-cleanup path and stops `rnx list` from showing 8 zombie
|
|
444
444
|
// sims that all time out on every command.
|
|
445
445
|
private wsHeartbeatTimer: NodeJS.Timeout | null = null
|
|
446
446
|
private wsIsAlive = new WeakMap<WebSocket, boolean>()
|
|
@@ -462,7 +462,7 @@ export class SootSimBridgeHost {
|
|
|
462
462
|
constructor(opts: BridgeHostOptions = {}) {
|
|
463
463
|
this.preferredPort = opts.port || DEFAULT_SOOTSIM_BRIDGE_PORT
|
|
464
464
|
this.port = this.preferredPort
|
|
465
|
-
// default off — callers that want the lockfile (
|
|
465
|
+
// default off — callers that want the lockfile (rnx serve) opt in.
|
|
466
466
|
this.shouldWriteLockfile = opts.writeLockfile === true
|
|
467
467
|
this.shouldWriteDevLockfile = opts.writeDevLockfile === true
|
|
468
468
|
this.getShellPort = opts.getShellPort ?? null
|
|
@@ -1920,7 +1920,7 @@ export class SootSimBridgeHost {
|
|
|
1920
1920
|
this.reapIdleSims(now)
|
|
1921
1921
|
}
|
|
1922
1922
|
|
|
1923
|
-
// GC abandoned sims so `
|
|
1923
|
+
// GC abandoned sims so `rnx list` stays usable across long dev / QA /
|
|
1924
1924
|
// test sessions (F21-3). conservative on purpose: a sim is only reaped
|
|
1925
1925
|
// when every "someone cares about this" signal is absent.
|
|
1926
1926
|
//
|
|
@@ -1936,7 +1936,7 @@ export class SootSimBridgeHost {
|
|
|
1936
1936
|
// using is the primary and/or holds a fresh user-active lease; one that is
|
|
1937
1937
|
// none of those and idle past the TTL is abandoned regardless of a stale
|
|
1938
1938
|
// focus flag.
|
|
1939
|
-
// automation sims are spawned by the playwright driver (`
|
|
1939
|
+
// automation sims are spawned by the playwright driver (`rnx open
|
|
1940
1940
|
// --driver playwright`, the factory/screenshot capture path), which tags them
|
|
1941
1941
|
// with `meta.sootsimHostDriver === 'playwright'` via the host's addInitScript.
|
|
1942
1942
|
// they are unattended one-shot browsers, never a human's tab, so they get a
|
|
@@ -2551,6 +2551,8 @@ export class SootSimBridgeHost {
|
|
|
2551
2551
|
return 'evaluated page state'
|
|
2552
2552
|
case 'screenshot':
|
|
2553
2553
|
return 'captured screenshot'
|
|
2554
|
+
case 'capture':
|
|
2555
|
+
return 'captured app screen'
|
|
2554
2556
|
case 'tap':
|
|
2555
2557
|
return 'sent tap event'
|
|
2556
2558
|
case 'keyboard':
|
package/src/host/plane-host.ts
CHANGED
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
import fs from 'node:fs'
|
|
19
19
|
import path from 'node:path'
|
|
20
|
-
import { dispatchPlaneCommand, indexVersion } from '@
|
|
20
|
+
import { dispatchPlaneCommand, indexVersion } from '@rnx/box/plane'
|
|
21
21
|
import { CheckoutFilePlane } from '../../cli/commands/box/checkout-plane'
|
|
22
|
-
import type { ProjectFilePlane } from '@
|
|
22
|
+
import type { ProjectFilePlane } from '@rnx/box/plane'
|
|
23
23
|
import type { WebSocket } from 'ws'
|
|
24
24
|
|
|
25
25
|
const WS_OPEN = 1
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,31 @@ export type {
|
|
|
35
35
|
SimSemanticResolveSelector,
|
|
36
36
|
SimSemanticSelector,
|
|
37
37
|
} from './bridge-contract'
|
|
38
|
+
export {
|
|
39
|
+
isRnxCaptureRegionAtlas,
|
|
40
|
+
isRnxScreenCapture,
|
|
41
|
+
rnxCaptureRasterRegions,
|
|
42
|
+
rnxRasterFrames,
|
|
43
|
+
RNX_SCREEN_CAPTURE_VERSION,
|
|
44
|
+
selectRnxRasterNodeIds,
|
|
45
|
+
type RnxCaptureAccessibility,
|
|
46
|
+
type RnxCaptureAccessibilityState,
|
|
47
|
+
type RnxCaptureAccessibilityValue,
|
|
48
|
+
type RnxCaptureAtlasRegion,
|
|
49
|
+
type RnxCaptureInteractionState,
|
|
50
|
+
type RnxCaptureNode,
|
|
51
|
+
type RnxCaptureRect,
|
|
52
|
+
type RnxCaptureRegionAtlas,
|
|
53
|
+
type RnxCaptureRegionRequest,
|
|
54
|
+
type RnxCaptureSource,
|
|
55
|
+
type RnxCaptureStyleTokenBinding,
|
|
56
|
+
type RnxCaptureTextLine,
|
|
57
|
+
type RnxCaptureTextSegment,
|
|
58
|
+
type RnxCaptureTree,
|
|
59
|
+
type RnxCaptureVisualEvidence,
|
|
60
|
+
type RnxRasterRegionTree,
|
|
61
|
+
type RnxScreenCapture,
|
|
62
|
+
} from './capture-contract'
|
|
38
63
|
|
|
39
64
|
export { rnxPlugin } from './vite-plugin-one'
|
|
40
65
|
export { default as metroPlugin } from './metro-plugin'
|
|
@@ -42,7 +42,7 @@ export function alignLoopbackBundleUrlWithProbe(
|
|
|
42
42
|
// unreliably on small CI runners (tens of seconds when metro is warm,
|
|
43
43
|
// minutes cold, sometimes past any wait-ready budget). a minified bundle
|
|
44
44
|
// parses/executes far faster and reboots reliably. gated on
|
|
45
|
-
// SOOTSIM_PREVIEW_PROD_BUNDLE so normal interactive `
|
|
45
|
+
// SOOTSIM_PREVIEW_PROD_BUNDLE so normal interactive `rnx open` keeps
|
|
46
46
|
// dev=true. app-agnostic: rewrites whatever metro/launchAsset advertised.
|
|
47
47
|
function applyPreviewProdBundle(parsed: URL): void {
|
|
48
48
|
if (!process.env.SOOTSIM_PREVIEW_PROD_BUNDLE) return
|
|
@@ -95,7 +95,7 @@ export function normalizeNativeDevBundleUrl(bundleUrl: string): string {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
// explicit override used by `
|
|
98
|
+
// explicit override used by `rnx open` to flip metro's `hot` query when
|
|
99
99
|
// the caller knows their context: interactive opens want `hot=true` so metro
|
|
100
100
|
// includes its HMR/react-refresh runtime in the bundle prelude and pushes
|
|
101
101
|
// edits as `update` events; `--driver playwright` / agent-test runs want
|
|
@@ -20,7 +20,7 @@ export interface RunScreenshotsOverrides {
|
|
|
20
20
|
captureOnly?: boolean
|
|
21
21
|
composeOnly?: boolean
|
|
22
22
|
// copy/colors pulled from the team's org deck to overlay onto the plan's
|
|
23
|
-
// compose slides (
|
|
23
|
+
// compose slides (rnx screenshot appstore --org … --repo …).
|
|
24
24
|
orgDeckFrames?: OrgDeckFrame[] | null
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// fetch the team's screenshot deck (the copy / colors founders set up in the
|
|
2
2
|
// org dashboard) and overlay it onto a screenshots plan, so the programmatic
|
|
3
|
-
// `
|
|
3
|
+
// `rnx screenshot appstore --plan … --org … --repo …` flow renders the org's
|
|
4
4
|
// words + brand gradient instead of the local yaml's. the plan still owns
|
|
5
5
|
// which screen each slide captures and the device frame; the deck owns copy +
|
|
6
6
|
// colors. 3d scenes don't translate to the cli's 2d composer, so only copy and
|
package/src/settings-bundle.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { parsePlistSource } from './plist.ts'
|
|
4
|
-
import type { AppPreferenceValue, AppSettingsSpecifier } from '@
|
|
4
|
+
import type { AppPreferenceValue, AppSettingsSpecifier } from '@rnx/globals'
|
|
5
5
|
|
|
6
6
|
const MAX_PLIST_BYTES = 1_048_576
|
|
7
7
|
const MAX_CHILD_DEPTH = 8
|
package/src/sim-client.ts
CHANGED
|
@@ -334,6 +334,10 @@ export class SimClient implements WsBridge, InspectBridge {
|
|
|
334
334
|
return openAppUrl(this, target)
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
async setAppearance(appearance: 'light' | 'dark'): Promise<void> {
|
|
338
|
+
await this.send({ type: 'setAppearance', appearance })
|
|
339
|
+
}
|
|
340
|
+
|
|
337
341
|
/**
|
|
338
342
|
* block until the guest app has mounted and painted real content.
|
|
339
343
|
* throws SimReadyTimeoutError (with the last probe status) on timeout.
|
package/src/vite-plugin-one.ts
CHANGED
|
@@ -84,7 +84,7 @@ export function rnxPlugin(options: RnxPluginOptions = {}): Plugin {
|
|
|
84
84
|
: DEFAULT_SOOTSIM_BRIDGE_PORT
|
|
85
85
|
// inject the bridge port + a default bundle (only when the URL has none,
|
|
86
86
|
// so the CLI's resolved bundle wins), and PRESERVE the existing query.
|
|
87
|
-
// preserving the query is essential: `
|
|
87
|
+
// preserving the query is essential: `rnx open` stamps the open URL
|
|
88
88
|
// with `inspectOpen=<token>` and matches the registered sim by that
|
|
89
89
|
// token (waitForSimMatch). the old code forced `?bundle=` only and
|
|
90
90
|
// stripped inspectOpen, so the sim registered but never matched and
|
|
@@ -122,7 +122,7 @@ export function rnxPlugin(options: RnxPluginOptions = {}): Plugin {
|
|
|
122
122
|
// bundle. dev-bundle-resolution's resolvePortViaServerScan fetches
|
|
123
123
|
// `/__server-scan` RELATIVE to the shell origin; when the shell is loaded
|
|
124
124
|
// from this target origin, that hits here. without it the request SPA-
|
|
125
|
-
// falls-back to 404 and the bundle never resolves (
|
|
125
|
+
// falls-back to 404 and the bundle never resolves (rnx open hangs).
|
|
126
126
|
// we describe only this server (the shell drives THIS app) — no localhost-
|
|
127
127
|
// wide scan, so the published plugin pulls in no shell-only scanner
|
|
128
128
|
// internals. shape matches DiscoveredServer enough for the resolver.
|
package/src/vite-plugin.ts
CHANGED
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
// the client half of a cloud box: create it, seed this checkout into it, and
|
|
2
|
-
// run its shell over the box's own WebSocket.
|
|
3
|
-
//
|
|
4
|
-
// the box holds the files and the shell; this process holds neither. so the
|
|
5
|
-
// REPL here sends `shell:exec` and prints what comes back, and `cwd` is
|
|
6
|
-
// whatever the box last reported rather than anything tracked locally. that is
|
|
7
|
-
// the whole difference between the two placements, and it is why the same
|
|
8
|
-
// readline loop drives both.
|
|
9
|
-
//
|
|
10
|
-
// there is no default endpoint. a cloud box has to be deployed before one can
|
|
11
|
-
// exist, and pointing at a URL that answers nothing would advertise a
|
|
12
|
-
// capability that always fails.
|
|
13
|
-
|
|
14
|
-
import { WebSocket } from 'ws'
|
|
15
|
-
import type { CheckoutFilePlane } from './checkout-plane'
|
|
16
|
-
import type { HarnessShellResult } from '@contrast/harness/shell'
|
|
17
|
-
|
|
18
|
-
// a checkout is 15k files on this repo, so seeding is batched by BYTES rather
|
|
19
|
-
// than by count: one request carrying every file would be a single multi-
|
|
20
|
-
// hundred-megabyte body, and one request per file would be 15k round trips.
|
|
21
|
-
const SEED_BATCH_BYTES = 4 * 1024 * 1024
|
|
22
|
-
// base64 costs 4 bytes for every 3, and a request carries the encoded size.
|
|
23
|
-
const BASE64_OVERHEAD = 4 / 3
|
|
24
|
-
|
|
25
|
-
export interface CloudBoxSession {
|
|
26
|
-
readonly cwd: string
|
|
27
|
-
exec(command: string): Promise<HarnessShellResult>
|
|
28
|
-
close(): void
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export class CloudBoxError extends Error {}
|
|
32
|
-
|
|
33
|
-
function endpointOf(base: string, name: string, verb: string): string {
|
|
34
|
-
return `${base.replace(/\/+$/, '')}/v1/boxes/${encodeURIComponent(name)}/${verb}`
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function boxFetch(
|
|
38
|
-
base: string,
|
|
39
|
-
name: string,
|
|
40
|
-
verb: string,
|
|
41
|
-
token: string | null,
|
|
42
|
-
body?: unknown,
|
|
43
|
-
): Promise<Record<string, unknown>> {
|
|
44
|
-
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
45
|
-
if (token) headers.authorization = `Bearer ${token}`
|
|
46
|
-
let response: Response
|
|
47
|
-
try {
|
|
48
|
-
response = await fetch(endpointOf(base, name, verb), {
|
|
49
|
-
method: body === undefined ? 'GET' : 'POST',
|
|
50
|
-
headers,
|
|
51
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
52
|
-
})
|
|
53
|
-
} catch (error) {
|
|
54
|
-
throw new CloudBoxError(
|
|
55
|
-
`could not reach the box service at ${base}: ${
|
|
56
|
-
error instanceof Error ? error.message : String(error)
|
|
57
|
-
}`,
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
const text = await response.text()
|
|
61
|
-
const parsed = text ? JSON.parse(text) : {}
|
|
62
|
-
if (!response.ok) {
|
|
63
|
-
throw new CloudBoxError(
|
|
64
|
-
String(parsed.error ?? `box ${verb} failed (${response.status})`),
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
return parsed
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* create the box and copy this checkout into it.
|
|
72
|
-
*
|
|
73
|
-
* `apiKey` is the ACCOUNT's key, which the box service forwards to
|
|
74
|
-
* /v1/rnx/sessions to authenticate the caller and start the session that pays
|
|
75
|
-
* for the box. the box's own token comes back minted by the box; a caller does
|
|
76
|
-
* not choose it.
|
|
77
|
-
*/
|
|
78
|
-
export async function createCloudBox(options: {
|
|
79
|
-
endpoint: string
|
|
80
|
-
name: string
|
|
81
|
-
apiKey: string
|
|
82
|
-
plane: CheckoutFilePlane
|
|
83
|
-
onProgress?: (seeded: number, total: number) => void
|
|
84
|
-
}): Promise<{ files: number; token: string; sessionId: string }> {
|
|
85
|
-
const { endpoint, name, apiKey, plane } = options
|
|
86
|
-
const created = await boxFetch(endpoint, name, 'create', apiKey, { name })
|
|
87
|
-
const token = String(created.token ?? '')
|
|
88
|
-
if (!token) throw new CloudBoxError('the box service returned no access token')
|
|
89
|
-
|
|
90
|
-
const paths = [...plane.index().keys()].sort()
|
|
91
|
-
let batch: [string, string][] = []
|
|
92
|
-
let batchBytes = 0
|
|
93
|
-
let seeded = 0
|
|
94
|
-
|
|
95
|
-
const flush = async () => {
|
|
96
|
-
if (batch.length === 0) return
|
|
97
|
-
await boxFetch(endpoint, name, 'seed', token, { files: batch })
|
|
98
|
-
seeded += batch.length
|
|
99
|
-
options.onProgress?.(seeded, paths.length)
|
|
100
|
-
batch = []
|
|
101
|
-
batchBytes = 0
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
for (const path of paths) {
|
|
105
|
-
const bytes = await plane.readBytes(path)
|
|
106
|
-
batch.push([path, Buffer.from(bytes).toString('base64')])
|
|
107
|
-
batchBytes += bytes.byteLength * BASE64_OVERHEAD
|
|
108
|
-
if (batchBytes >= SEED_BATCH_BYTES) await flush()
|
|
109
|
-
}
|
|
110
|
-
await flush()
|
|
111
|
-
|
|
112
|
-
const status = await boxFetch(endpoint, name, 'status', token)
|
|
113
|
-
return {
|
|
114
|
-
files: Number(status.files ?? 0),
|
|
115
|
-
token,
|
|
116
|
-
sessionId: String(status.sessionId ?? ''),
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* stop a box, or stop and wipe it.
|
|
122
|
-
*
|
|
123
|
-
* takes the ACCOUNT key, not the box token: it ends the session the account is
|
|
124
|
-
* billed for, which the box deliberately cannot do on its own.
|
|
125
|
-
*/
|
|
126
|
-
export async function stopCloudBox(options: {
|
|
127
|
-
endpoint: string
|
|
128
|
-
name: string
|
|
129
|
-
apiKey: string
|
|
130
|
-
purge?: boolean
|
|
131
|
-
}): Promise<Record<string, unknown>> {
|
|
132
|
-
return boxFetch(
|
|
133
|
-
options.endpoint,
|
|
134
|
-
options.name,
|
|
135
|
-
options.purge ? 'delete' : 'stop',
|
|
136
|
-
options.apiKey,
|
|
137
|
-
{},
|
|
138
|
-
)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* the account's running boxes, from the session meter rather than from the box
|
|
143
|
-
* service. the meter already knows what this account is running and what it
|
|
144
|
-
* cost, so a second per-account index of boxes would be a second answer to the
|
|
145
|
-
* same question, free to drift.
|
|
146
|
-
*/
|
|
147
|
-
export async function listCloudBoxes(options: {
|
|
148
|
-
apiOrigin: string
|
|
149
|
-
apiKey: string
|
|
150
|
-
}): Promise<{ id: string; name: string | null; startedAt: string; meteredMs: number }[]> {
|
|
151
|
-
const response = await fetch(
|
|
152
|
-
`${options.apiOrigin.replace(/\/+$/, '')}/v1/rnx/sessions`,
|
|
153
|
-
{
|
|
154
|
-
headers: { authorization: `Bearer ${options.apiKey}` },
|
|
155
|
-
},
|
|
156
|
-
)
|
|
157
|
-
const text = await response.text()
|
|
158
|
-
const parsed = text ? JSON.parse(text) : {}
|
|
159
|
-
if (!response.ok) {
|
|
160
|
-
throw new CloudBoxError(
|
|
161
|
-
String(parsed.message ?? parsed.error ?? 'could not list boxes'),
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
const sessions = Array.isArray(parsed.sessions) ? parsed.sessions : []
|
|
165
|
-
return sessions
|
|
166
|
-
.filter((session: { kind?: string }) => session.kind === 'box')
|
|
167
|
-
.map((session: Record<string, unknown>) => ({
|
|
168
|
-
id: String(session.id),
|
|
169
|
-
name: typeof session.name === 'string' ? session.name : null,
|
|
170
|
-
startedAt: String(session.startedAt ?? ''),
|
|
171
|
-
meteredMs: Number(session.meteredMs ?? 0),
|
|
172
|
-
}))
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* bank the wall clock while a box is open.
|
|
177
|
-
*
|
|
178
|
-
* the box holds no account credential and so cannot heartbeat for itself; the
|
|
179
|
-
* client that is actually using it does. when this process dies the heartbeats
|
|
180
|
-
* stop and the site's stale sweep closes the session at the last one, which is
|
|
181
|
-
* why there is no second idle timer in the box.
|
|
182
|
-
*/
|
|
183
|
-
export function heartbeatSession(options: {
|
|
184
|
-
apiOrigin: string
|
|
185
|
-
apiKey: string
|
|
186
|
-
sessionId: string
|
|
187
|
-
everyMs?: number
|
|
188
|
-
}): { stop: () => void } {
|
|
189
|
-
const url = `${options.apiOrigin.replace(/\/+$/, '')}/v1/rnx/sessions/${encodeURIComponent(
|
|
190
|
-
options.sessionId,
|
|
191
|
-
)}`
|
|
192
|
-
const timer = setInterval(() => {
|
|
193
|
-
void fetch(url, {
|
|
194
|
-
method: 'POST',
|
|
195
|
-
headers: { authorization: `Bearer ${options.apiKey}` },
|
|
196
|
-
}).catch(() => {
|
|
197
|
-
// a missed heartbeat is not an error worth interrupting a shell for.
|
|
198
|
-
// the sweep closes the session at the last one that landed.
|
|
199
|
-
})
|
|
200
|
-
}, options.everyMs ?? 60_000)
|
|
201
|
-
// do not hold the process open just to keep billing alive
|
|
202
|
-
timer.unref?.()
|
|
203
|
-
return { stop: () => clearInterval(timer) }
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** open the box's socket and return a session the REPL can drive. */
|
|
207
|
-
export async function connectCloudBox(options: {
|
|
208
|
-
endpoint: string
|
|
209
|
-
name: string
|
|
210
|
-
token: string
|
|
211
|
-
}): Promise<CloudBoxSession> {
|
|
212
|
-
const { endpoint, name, token } = options
|
|
213
|
-
const url = endpointOf(endpoint, name, 'connect').replace(/^http/, 'ws')
|
|
214
|
-
// as a subprotocol, not a header. a browser cannot set a header on a
|
|
215
|
-
// WebSocket, and the box takes the token one way rather than one way per
|
|
216
|
-
// kind of caller.
|
|
217
|
-
const socket = new WebSocket(url, [`rnx-box-token.${token}`])
|
|
218
|
-
|
|
219
|
-
// a persistent handler, installed before the handshake can fail. ws emits
|
|
220
|
-
// 'error' again after a refused upgrade, and a `once` listener consumed by
|
|
221
|
-
// the rejection leaves that second emit unhandled, which crashes the
|
|
222
|
-
// process rather than failing the command.
|
|
223
|
-
let socketError: string | null = null
|
|
224
|
-
socket.on('error', (error) => {
|
|
225
|
-
socketError = error.message
|
|
226
|
-
})
|
|
227
|
-
await new Promise<void>((resolve, reject) => {
|
|
228
|
-
socket.once('open', resolve)
|
|
229
|
-
socket.once('error', (error) => {
|
|
230
|
-
socket.close()
|
|
231
|
-
reject(error)
|
|
232
|
-
})
|
|
233
|
-
}).catch(async (error: unknown) => {
|
|
234
|
-
// ws's handshake error text is runtime-specific: node says "Unexpected
|
|
235
|
-
// server response: 401" and bun says "Expected 101 status code" with no
|
|
236
|
-
// status at all. so ask the service itself why, over plain HTTP, and
|
|
237
|
-
// report THAT. this runs only on the failure path.
|
|
238
|
-
const reason = await boxFetch(endpoint, name, 'status', token).then(
|
|
239
|
-
() => null,
|
|
240
|
-
(failure: unknown) =>
|
|
241
|
-
failure instanceof Error ? failure.message : String(failure),
|
|
242
|
-
)
|
|
243
|
-
const fallback = error instanceof Error ? error.message : String(error)
|
|
244
|
-
throw new CloudBoxError(`could not open the box shell: ${reason ?? fallback}`)
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
let nextId = 0
|
|
248
|
-
const waiting = new Map<number, (reply: Record<string, unknown>) => void>()
|
|
249
|
-
socket.on('message', (data) => {
|
|
250
|
-
let parsed: unknown
|
|
251
|
-
try {
|
|
252
|
-
parsed = JSON.parse(String(data))
|
|
253
|
-
} catch {
|
|
254
|
-
return
|
|
255
|
-
}
|
|
256
|
-
if (typeof parsed !== 'object' || parsed === null) return
|
|
257
|
-
const reply = parsed as Record<string, unknown>
|
|
258
|
-
const resolve = waiting.get(Number(reply.id))
|
|
259
|
-
if (!resolve) return
|
|
260
|
-
waiting.delete(Number(reply.id))
|
|
261
|
-
resolve(reply)
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
// the socket dying mid-command must fail the command rather than hang the
|
|
265
|
-
// prompt forever, so every waiter is settled when it closes.
|
|
266
|
-
let closedReason: string | null = null
|
|
267
|
-
const settleAll = (reason: string) => {
|
|
268
|
-
closedReason = reason
|
|
269
|
-
for (const [id, resolve] of waiting) resolve({ id, error: reason })
|
|
270
|
-
waiting.clear()
|
|
271
|
-
}
|
|
272
|
-
socket.on('close', () => settleAll(socketError ?? 'the box closed the connection'))
|
|
273
|
-
socket.on('error', (error) => settleAll(error.message))
|
|
274
|
-
|
|
275
|
-
let cwd = '/project'
|
|
276
|
-
return {
|
|
277
|
-
get cwd() {
|
|
278
|
-
return cwd
|
|
279
|
-
},
|
|
280
|
-
async exec(command: string): Promise<HarnessShellResult> {
|
|
281
|
-
if (closedReason) throw new CloudBoxError(closedReason)
|
|
282
|
-
const id = nextId++
|
|
283
|
-
const reply = await new Promise<Record<string, unknown>>((resolve) => {
|
|
284
|
-
waiting.set(id, resolve)
|
|
285
|
-
socket.send(JSON.stringify({ type: 'shell:exec', id, command }))
|
|
286
|
-
})
|
|
287
|
-
if (reply.error) throw new CloudBoxError(String(reply.error))
|
|
288
|
-
const result = reply.result as HarnessShellResult
|
|
289
|
-
if (result.cwd) cwd = result.cwd
|
|
290
|
-
return result
|
|
291
|
-
},
|
|
292
|
-
close: () => socket.close(),
|
|
293
|
-
}
|
|
294
|
-
}
|