peachsim 0.1.527
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/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- package/src/worklets-babel.ts +250 -0
|
@@ -0,0 +1,1530 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
fstatSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { dirname, extname, join, resolve } from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
embedMetroAssetFiles,
|
|
13
|
+
extractAssetDescriptors,
|
|
14
|
+
isEmbeddedAssetDescriptor,
|
|
15
|
+
metroAssetPlaceholderBytes,
|
|
16
|
+
metroAssetScaleFilename,
|
|
17
|
+
metroIosAssetDestRelatives,
|
|
18
|
+
resolveMetroIosAssetDestFiles,
|
|
19
|
+
type MetroArtifactAssetSize,
|
|
20
|
+
type MetroAssetDescriptor,
|
|
21
|
+
} from '../../contrast-bundler/src/metroAssetArtifact.ts'
|
|
22
|
+
import {
|
|
23
|
+
buildRnxCloudArtifact,
|
|
24
|
+
type RnxCloudArtifactReceipt,
|
|
25
|
+
} from '../../contrast-bundler/src/metroCloudArtifact.ts'
|
|
26
|
+
import { loadMetroFingerprintRegistry } from '../../sootsim-engine/src/metro-fingerprint-registry-client.ts'
|
|
27
|
+
import { inferMetroModuleIdentity } from '../../sootsim-engine/src/metro-fingerprint.ts'
|
|
28
|
+
import { isLoopbackHost } from '../src/backend-origin.ts'
|
|
29
|
+
import {
|
|
30
|
+
isRnxCloudCommandType,
|
|
31
|
+
isRnxCloudSimStatus,
|
|
32
|
+
RNX_CLOUD_MAX_ARTIFACT_BYTES,
|
|
33
|
+
RNX_CLOUD_TOKEN_SCOPES,
|
|
34
|
+
rnxCloudCommandScope,
|
|
35
|
+
rnxCloudScopesAllowCommand,
|
|
36
|
+
type RnxCloudBoxCreateReceipt,
|
|
37
|
+
type RnxCloudClaim,
|
|
38
|
+
type RnxCloudSimCreateResponse,
|
|
39
|
+
type RnxCloudSimInstance,
|
|
40
|
+
type RnxCloudSimView,
|
|
41
|
+
} from '../src/cloud-contract.ts'
|
|
42
|
+
import {
|
|
43
|
+
RNX_METRO_MODULE_IDENTITY_VERSION,
|
|
44
|
+
type RNXMetroModuleIdentityMetadata,
|
|
45
|
+
} from '../src/metro-production-bundle.ts'
|
|
46
|
+
import { rnxPublicBrand } from '../src/public-brand.ts'
|
|
47
|
+
import { CHOOSE_ACCOUNT_HINT } from './auth.ts'
|
|
48
|
+
import {
|
|
49
|
+
createCloudSession,
|
|
50
|
+
normalizeCloudOrigin,
|
|
51
|
+
readCloudSession,
|
|
52
|
+
type CloudSession,
|
|
53
|
+
type CloudSimSession,
|
|
54
|
+
type RemotePlatform,
|
|
55
|
+
} from './cloud-session.ts'
|
|
56
|
+
import { detectProject } from './commands/detect.ts'
|
|
57
|
+
import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract.ts'
|
|
58
|
+
import type {
|
|
59
|
+
BridgeClaimResult,
|
|
60
|
+
BridgeSimInfo,
|
|
61
|
+
ParsedBridgeCliArgs,
|
|
62
|
+
WsBridge,
|
|
63
|
+
} from './ws-bridge.ts'
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_RNX_CLOUD_ORIGIN = rnxPublicBrand.origin
|
|
66
|
+
export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
|
|
67
|
+
|
|
68
|
+
// every message this module writes names the command through this constant.
|
|
69
|
+
const REMOTE_COMMAND = 'rnx remote'
|
|
70
|
+
const PREVIEW_COMMAND = 'rnx preview'
|
|
71
|
+
|
|
72
|
+
// a Metro asset dest is a shallow tree of images. a walk that runs past this
|
|
73
|
+
// is pointed at something else entirely, and its extra entries cannot resolve
|
|
74
|
+
// an asset anyway.
|
|
75
|
+
const MAX_ASSET_DEST_FILES = 25_000
|
|
76
|
+
|
|
77
|
+
interface CreateCloudBoxOptions {
|
|
78
|
+
bundlePath: string
|
|
79
|
+
device: string
|
|
80
|
+
authorization: string
|
|
81
|
+
/** the account to bill when the authorization is a session token; an api key names its own */
|
|
82
|
+
accountId: string | null
|
|
83
|
+
platform?: RemotePlatform
|
|
84
|
+
apiOrigin?: string
|
|
85
|
+
/** the name this box carries in the account's box list. */
|
|
86
|
+
boxName?: string | null
|
|
87
|
+
/**
|
|
88
|
+
* the storage and route the first simulator boots with, captured from a
|
|
89
|
+
* running app. a snapshot reaches a megabyte, so a create that carries one
|
|
90
|
+
* is multipart rather than a bare artifact body.
|
|
91
|
+
*/
|
|
92
|
+
storage?: StorageSnapshot | null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface CloudCommandEnvelope {
|
|
96
|
+
id: string
|
|
97
|
+
claimId: string
|
|
98
|
+
command: Record<string, unknown>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
102
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// three services answer this client and each states a failure its own way:
|
|
106
|
+
// the box service says { error: '<sentence>' }, the site's public api says
|
|
107
|
+
// { error: '<code>', message: '<sentence>' }, and its service-to-service api
|
|
108
|
+
// says { error: { code, message } }. all three carry one sentence meant for
|
|
109
|
+
// the person at the terminal, so read whichever field holds it. without this
|
|
110
|
+
// the sentence never reaches them and they get the raw json body instead.
|
|
111
|
+
function failureSentence(text: string): string | null {
|
|
112
|
+
let parsed: unknown
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(text)
|
|
115
|
+
} catch {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
if (!isRecord(parsed)) return null
|
|
119
|
+
if (isRecord(parsed.error) && typeof parsed.error.message === 'string') {
|
|
120
|
+
// the code is the contract a script matches on; the message is for people.
|
|
121
|
+
const code =
|
|
122
|
+
typeof parsed.error.code === 'string' && parsed.error.code
|
|
123
|
+
? ` [${parsed.error.code}]`
|
|
124
|
+
: ''
|
|
125
|
+
return `${parsed.error.message}${code}`
|
|
126
|
+
}
|
|
127
|
+
if (typeof parsed.message === 'string') return parsed.message
|
|
128
|
+
if (typeof parsed.error === 'string') return parsed.error
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// the account service says what it needs in its own words; the CLI says what
|
|
133
|
+
// the person can do about it, which is choose the account at `rnx login`.
|
|
134
|
+
const ACCOUNT_CHOICE_REFUSALS = new Set([
|
|
135
|
+
'accountId is required',
|
|
136
|
+
'you do not have access to this account',
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
function responseError(status: number, text: string): Error {
|
|
140
|
+
const parsed = text ? failureSentence(text) : null
|
|
141
|
+
const sentence =
|
|
142
|
+
parsed && ACCOUNT_CHOICE_REFUSALS.has(parsed) ? CHOOSE_ACCOUNT_HINT : parsed
|
|
143
|
+
return new Error(
|
|
144
|
+
`remote simulator request failed (${status})${
|
|
145
|
+
sentence ? `: ${sentence}` : text.trim() ? `: ${text.trim()}` : ''
|
|
146
|
+
}`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveCloudOrigin(value?: string): string {
|
|
151
|
+
const configured = value?.trim() || process.env.RNX_CLOUD_ORIGIN?.trim()
|
|
152
|
+
const source = configured || DEFAULT_RNX_CLOUD_ORIGIN
|
|
153
|
+
return normalizeCloudOrigin(source)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readImmutableBundleSource(bundlePath: string): string {
|
|
157
|
+
const filepath = resolve(bundlePath)
|
|
158
|
+
if (extname(filepath) !== '.js') {
|
|
159
|
+
throw new Error(`${REMOTE_COMMAND} requires one local Metro .js bundle`)
|
|
160
|
+
}
|
|
161
|
+
const descriptor = openSync(filepath, 'r')
|
|
162
|
+
try {
|
|
163
|
+
const before = fstatSync(descriptor, { bigint: true })
|
|
164
|
+
if (!before.isFile()) {
|
|
165
|
+
throw new Error(`${REMOTE_COMMAND} bundle must be a regular local file`)
|
|
166
|
+
}
|
|
167
|
+
if (before.size <= 0n) {
|
|
168
|
+
throw new Error(`${REMOTE_COMMAND} bundle is empty`)
|
|
169
|
+
}
|
|
170
|
+
const bytes = readFileSync(descriptor)
|
|
171
|
+
const after = fstatSync(descriptor, { bigint: true })
|
|
172
|
+
if (
|
|
173
|
+
before.dev !== after.dev ||
|
|
174
|
+
before.ino !== after.ino ||
|
|
175
|
+
before.size !== after.size ||
|
|
176
|
+
before.mtimeNs !== after.mtimeNs ||
|
|
177
|
+
bytes.length !== Number(before.size)
|
|
178
|
+
) {
|
|
179
|
+
throw new Error(`${REMOTE_COMMAND} bundle changed while it was being read`)
|
|
180
|
+
}
|
|
181
|
+
let source: string
|
|
182
|
+
try {
|
|
183
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
184
|
+
} catch {
|
|
185
|
+
throw new Error(`${REMOTE_COMMAND} bundle must be valid UTF-8 JavaScript`)
|
|
186
|
+
}
|
|
187
|
+
if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
|
|
188
|
+
throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
|
|
189
|
+
}
|
|
190
|
+
return source
|
|
191
|
+
} finally {
|
|
192
|
+
closeSync(descriptor)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function fetchBundleSource(bundleUrl: string): Promise<string> {
|
|
197
|
+
let response: Response
|
|
198
|
+
try {
|
|
199
|
+
response = await fetch(bundleUrl)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: ${
|
|
203
|
+
error instanceof Error ? error.message : String(error)
|
|
204
|
+
}`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: HTTP ${response.status}${
|
|
210
|
+
response.statusText ? ` ${response.statusText}` : ''
|
|
211
|
+
}`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
const text = await response.text()
|
|
215
|
+
if (text.length === 0) {
|
|
216
|
+
throw new Error(`${REMOTE_COMMAND} bundle is empty`)
|
|
217
|
+
}
|
|
218
|
+
if (text.includes('\0') || !text.includes('__d(') || !text.includes('__r(')) {
|
|
219
|
+
throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
|
|
220
|
+
}
|
|
221
|
+
return text
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// a bundle served over plain http is only trustworthy when it comes from this
|
|
225
|
+
// machine, which is what "send to box" reads: the Metro dev server the app is
|
|
226
|
+
// already running on.
|
|
227
|
+
function isLoopbackBundleUrl(bundleInput: string): boolean {
|
|
228
|
+
if (!bundleInput.startsWith('http://')) return false
|
|
229
|
+
try {
|
|
230
|
+
return isLoopbackHost(new URL(bundleInput).hostname)
|
|
231
|
+
} catch {
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isRemoteBundleInput(bundleInput: string): boolean {
|
|
237
|
+
return bundleInput.startsWith('https://') || bundleInput.startsWith('http://')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function loadBundleSource(bundleInput: string): Promise<string> {
|
|
241
|
+
if (bundleInput.startsWith('http://') && !isLoopbackBundleUrl(bundleInput)) {
|
|
242
|
+
throw new Error(`${REMOTE_COMMAND} bundle URL must use HTTPS`)
|
|
243
|
+
}
|
|
244
|
+
if (isRemoteBundleInput(bundleInput)) {
|
|
245
|
+
return fetchBundleSource(bundleInput)
|
|
246
|
+
}
|
|
247
|
+
return readImmutableBundleSource(bundleInput)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// `react-native bundle --assets-dest` dest paths are not always the JS
|
|
251
|
+
// descriptor's httpServerLocation with `../` replaced by `_`. Metro 0.83 does
|
|
252
|
+
// that (`__node_modules`); Metro 0.84 often dests `node_modules` and keeps the
|
|
253
|
+
// project-root-relative app path. resolveMetroIosAssetDestFiles matches both.
|
|
254
|
+
interface MetroAssetDestListing {
|
|
255
|
+
directory: string
|
|
256
|
+
files: string[]
|
|
257
|
+
/** the walk stopped at the cap, so "not in this listing" proves nothing. */
|
|
258
|
+
truncated: boolean
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// a Metro asset dest never holds these, but a project root does, and a
|
|
262
|
+
// candidate can be either. `node_modules` is not in the list on purpose: a real
|
|
263
|
+
// dest DOES carry one, because that is where `react-native bundle` copies the
|
|
264
|
+
// assets that live inside packages. it is skipped only under a project root,
|
|
265
|
+
// which is what a package.json at the candidate marks.
|
|
266
|
+
const PROJECT_ONLY_DIRECTORIES = new Set([
|
|
267
|
+
'node_modules',
|
|
268
|
+
'Pods',
|
|
269
|
+
'DerivedData',
|
|
270
|
+
'.expo',
|
|
271
|
+
'.next',
|
|
272
|
+
'.turbo',
|
|
273
|
+
'.cache',
|
|
274
|
+
'.gradle',
|
|
275
|
+
])
|
|
276
|
+
|
|
277
|
+
function listMetroAssetDest(assetsPath: string): MetroAssetDestListing {
|
|
278
|
+
const directory = resolve(assetsPath)
|
|
279
|
+
if (!existsSync(directory)) return { directory, files: [], truncated: false }
|
|
280
|
+
const skipNested = existsSync(join(directory, 'package.json'))
|
|
281
|
+
const files: string[] = []
|
|
282
|
+
let truncated = false
|
|
283
|
+
const walk = (dir: string, prefix: string) => {
|
|
284
|
+
let entries
|
|
285
|
+
try {
|
|
286
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
287
|
+
} catch {
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
if (files.length >= MAX_ASSET_DEST_FILES) {
|
|
292
|
+
truncated = true
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
if (entry.isDirectory()) {
|
|
296
|
+
if (entry.name === '.git') continue
|
|
297
|
+
if (skipNested && PROJECT_ONLY_DIRECTORIES.has(entry.name)) continue
|
|
298
|
+
walk(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name)
|
|
299
|
+
} else if (entry.isFile()) {
|
|
300
|
+
files.push(prefix ? `${prefix}/${entry.name}` : entry.name)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
walk(directory, '')
|
|
305
|
+
return { directory, files, truncated }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Metro's own identity for an asset. `getAbsoluteAssetInfo` in metro/src/Assets
|
|
310
|
+
* md5s every scale file's bytes into ONE digest, in the ascending scale order
|
|
311
|
+
* `buildAssetMap` keeps `scales` and `files` parallel in, and that digest is
|
|
312
|
+
* the descriptor's `hash`. so a directory can be asked whether it holds THIS
|
|
313
|
+
* asset instead of merely a file with the same name.
|
|
314
|
+
*/
|
|
315
|
+
function metroDescriptorHash(scaleBytes: readonly Buffer[]): string {
|
|
316
|
+
const hasher = createHash('md5')
|
|
317
|
+
for (const bytes of scaleBytes) hasher.update(bytes)
|
|
318
|
+
return hasher.digest('hex')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function metroAssetKey(descriptor: MetroAssetDescriptor): string {
|
|
322
|
+
return `${descriptor.httpServerLocation}/${descriptor.name}.${descriptor.type}`
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** where one descriptor's scales sit inside one candidate directory. */
|
|
326
|
+
interface DescriptorPlacement {
|
|
327
|
+
/** the dest-relative file chosen for each declared scale that is present. */
|
|
328
|
+
files: Map<number, string>
|
|
329
|
+
/** files a scale matched that hold different images, so none was chosen. */
|
|
330
|
+
ambiguous: string[]
|
|
331
|
+
/** every declared scale is present and their bytes are Metro's `hash`. */
|
|
332
|
+
verified: boolean
|
|
333
|
+
/** every declared scale is present and their bytes are something else. */
|
|
334
|
+
mismatched: boolean
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// an ambiguous scale is rare and its file count is small, so trying every
|
|
338
|
+
// combination against the descriptor hash is cheap. the cap only stops a
|
|
339
|
+
// pathological dest from turning identity into a search.
|
|
340
|
+
const MAX_PLACEMENT_COMBINATIONS = 64
|
|
341
|
+
|
|
342
|
+
function placementCombinations(perScale: readonly string[][]): string[][] {
|
|
343
|
+
let total = 1
|
|
344
|
+
for (const options of perScale) total *= options.length
|
|
345
|
+
if (total > MAX_PLACEMENT_COMBINATIONS) return []
|
|
346
|
+
let combinations: string[][] = [[]]
|
|
347
|
+
for (const options of perScale) {
|
|
348
|
+
combinations = combinations.flatMap((prefix) =>
|
|
349
|
+
options.map((option) => [...prefix, option]),
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
return combinations
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* one file per distinct image, out of the files a scale matched.
|
|
357
|
+
*
|
|
358
|
+
* two paths holding byte-identical images are not a choice to make, and the
|
|
359
|
+
* descriptor's hash could not separate them if they were.
|
|
360
|
+
*/
|
|
361
|
+
function distinctByContent(directory: string, options: readonly string[]): string[] {
|
|
362
|
+
if (options.length < 2) return [...options]
|
|
363
|
+
const byContent = new Map<string, string>()
|
|
364
|
+
for (const relative of options) {
|
|
365
|
+
let bytes: Buffer
|
|
366
|
+
try {
|
|
367
|
+
bytes = readFileSync(join(directory, relative))
|
|
368
|
+
} catch {
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
const digest = createHash('md5').update(bytes).digest('hex')
|
|
372
|
+
if (!byContent.has(digest)) byContent.set(digest, relative)
|
|
373
|
+
}
|
|
374
|
+
return [...byContent.values()]
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* decides what a candidate directory holds for one descriptor, by bytes.
|
|
379
|
+
*
|
|
380
|
+
* Metro hashes the asset's SOURCE files, and `react-native bundle` copies only
|
|
381
|
+
* @1x..@3x to an iOS dest while leaving `scales` unfiltered. so a descriptor
|
|
382
|
+
* that declares an Android rung such as @1.5x records a digest no iOS dest can
|
|
383
|
+
* reproduce, however complete that dest is. those come back neither verified
|
|
384
|
+
* nor mismatched, and the caller refuses when a whole bundle is like that
|
|
385
|
+
* rather than picking a directory it cannot check.
|
|
386
|
+
*
|
|
387
|
+
* a source tree holding both `icon.png` and `icon@1x.png` declares scale 1
|
|
388
|
+
* twice, and Metro hashes both files while a dest keeps one file per scale. the
|
|
389
|
+
* expo asset plugin records one md5 per declared file in `fileHashes`, and
|
|
390
|
+
* that is the identity check for such a descriptor: the file chosen for a
|
|
391
|
+
* scale must be one of the files Metro hashed at that scale.
|
|
392
|
+
*/
|
|
393
|
+
function placeMetroDescriptor(
|
|
394
|
+
listing: MetroAssetDestListing,
|
|
395
|
+
descriptor: MetroAssetDescriptor,
|
|
396
|
+
): DescriptorPlacement {
|
|
397
|
+
const scales = [...new Set(descriptor.scales)]
|
|
398
|
+
const perScale = scales.map((scale) =>
|
|
399
|
+
distinctByContent(
|
|
400
|
+
listing.directory,
|
|
401
|
+
resolveMetroIosAssetDestFiles(listing.files, descriptor, scale),
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
// a scale that resolved to one image names its file whatever the verdict
|
|
405
|
+
// below is. one that resolved to several is left for the hash to settle.
|
|
406
|
+
const files = new Map<number, string>()
|
|
407
|
+
const ambiguous: string[] = []
|
|
408
|
+
scales.forEach((scale, index) => {
|
|
409
|
+
const options = perScale[index] ?? []
|
|
410
|
+
if (options.length === 1 && options[0]) files.set(scale, options[0])
|
|
411
|
+
else if (options.length > 1) ambiguous.push(...options)
|
|
412
|
+
})
|
|
413
|
+
if (perScale.length === 0 || perScale.some((options) => options.length === 0)) {
|
|
414
|
+
return { files, ambiguous, verified: false, mismatched: false }
|
|
415
|
+
}
|
|
416
|
+
// a descriptor that is already embedded carries the embedder's sha256s in
|
|
417
|
+
// `fileHashes`; only Metro's own md5 per source file is an identity here.
|
|
418
|
+
const fileHashes =
|
|
419
|
+
!descriptor.fileUris && descriptor.fileHashes?.length === descriptor.scales.length
|
|
420
|
+
? descriptor.fileHashes
|
|
421
|
+
: null
|
|
422
|
+
for (const combination of placementCombinations(perScale)) {
|
|
423
|
+
let bytes: Buffer[]
|
|
424
|
+
try {
|
|
425
|
+
bytes = combination.map((relative) =>
|
|
426
|
+
readFileSync(join(listing.directory, relative)),
|
|
427
|
+
)
|
|
428
|
+
} catch {
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
const identical = fileHashes
|
|
432
|
+
? bytes.every((file, index) => {
|
|
433
|
+
const digest = createHash('md5').update(file).digest('hex')
|
|
434
|
+
return descriptor.scales.some(
|
|
435
|
+
(scale, declared) =>
|
|
436
|
+
scale === scales[index] && fileHashes[declared] === digest,
|
|
437
|
+
)
|
|
438
|
+
})
|
|
439
|
+
: scales.length === descriptor.scales.length &&
|
|
440
|
+
metroDescriptorHash(bytes) === descriptor.hash
|
|
441
|
+
if (!identical) continue
|
|
442
|
+
const chosen = new Map<number, string>()
|
|
443
|
+
scales.forEach((scale, index) => {
|
|
444
|
+
const relative = combination[index]
|
|
445
|
+
if (relative) chosen.set(scale, relative)
|
|
446
|
+
})
|
|
447
|
+
return { files: chosen, ambiguous: [], verified: true, mismatched: false }
|
|
448
|
+
}
|
|
449
|
+
// a duplicated scale with no per-file md5 leaves Metro's digest covering a
|
|
450
|
+
// file this dest does not hold, so nothing here can be called wrong.
|
|
451
|
+
if (!fileHashes && scales.length !== descriptor.scales.length) {
|
|
452
|
+
return { files, ambiguous, verified: false, mismatched: false }
|
|
453
|
+
}
|
|
454
|
+
// every declared scale is here and no arrangement of them is this asset, so
|
|
455
|
+
// the directory holds different images under these names.
|
|
456
|
+
return { files, ambiguous, verified: false, mismatched: true }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
interface MetroAssetDest {
|
|
460
|
+
directory: string
|
|
461
|
+
/** the placement of each descriptor, in the order the bundle declares them. */
|
|
462
|
+
placements: DescriptorPlacement[]
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
interface MetroAssetSearch {
|
|
466
|
+
/** the directory the producer reads asset bytes from, or null if none held any. */
|
|
467
|
+
dest: MetroAssetDest | null
|
|
468
|
+
/** every directory the search considered, in order, for the warning text. */
|
|
469
|
+
searched: string[]
|
|
470
|
+
/** a walk stopped at the cap, so no directory can be called fully looked at. */
|
|
471
|
+
truncated: boolean
|
|
472
|
+
/**
|
|
473
|
+
* the dest holds every filename but not one of Metro's digests could be
|
|
474
|
+
* reproduced from it, so it was taken on layout alone. ordinary when every
|
|
475
|
+
* asset in the bundle carries an Android rung such as @1.5x that an iOS dest
|
|
476
|
+
* never receives, and indistinguishable from a previous build's images left
|
|
477
|
+
* in place, so the producer says so rather than staying quiet.
|
|
478
|
+
*/
|
|
479
|
+
unconfirmed: boolean
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* what two candidates could still disagree about.
|
|
484
|
+
*
|
|
485
|
+
* a verified placement's bytes reproduce Metro's own digest, so it is the right
|
|
486
|
+
* content wherever it was read from and two directories that both verified it
|
|
487
|
+
* are not in conflict. only the unverified files are a real fork, and they are
|
|
488
|
+
* keyed by absolute path because `<dir>/assets` and `<dir>` are both candidates
|
|
489
|
+
* and list the same files under different dest-relative names.
|
|
490
|
+
*/
|
|
491
|
+
function unverifiedFileKey(dest: MetroAssetDest): string {
|
|
492
|
+
return dest.placements
|
|
493
|
+
.filter((placement) => !placement.verified)
|
|
494
|
+
.flatMap((placement) =>
|
|
495
|
+
[...placement.files.values()].map((relative) => join(dest.directory, relative)),
|
|
496
|
+
)
|
|
497
|
+
.sort()
|
|
498
|
+
.join('\n')
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* finds the `--assets-dest` directory by RESOLVING the bundle's own asset
|
|
503
|
+
* descriptors against a short ordered list of the places Metro writes one, and
|
|
504
|
+
* CONFIRMING the bytes it finds against the md5 Metro recorded in each
|
|
505
|
+
* descriptor. name and layout alone are a popularity contest an unrelated build
|
|
506
|
+
* output can win; the hash makes it an identity check.
|
|
507
|
+
*/
|
|
508
|
+
function searchMetroAssetDest(
|
|
509
|
+
bundlePath: string,
|
|
510
|
+
descriptors: readonly MetroAssetDescriptor[],
|
|
511
|
+
): MetroAssetSearch {
|
|
512
|
+
const bundleDirectory = dirname(resolve(bundlePath))
|
|
513
|
+
const candidates = [
|
|
514
|
+
join(bundleDirectory, 'assets'),
|
|
515
|
+
bundleDirectory,
|
|
516
|
+
resolve(bundleDirectory, '..', 'assets'),
|
|
517
|
+
]
|
|
518
|
+
const cwd = process.cwd()
|
|
519
|
+
let projectDirectory: string | null = null
|
|
520
|
+
for (const start of [bundleDirectory, cwd]) {
|
|
521
|
+
let directory = resolve(start)
|
|
522
|
+
while (true) {
|
|
523
|
+
if (detectProject(directory)?.hasMetroConfig) {
|
|
524
|
+
projectDirectory = directory
|
|
525
|
+
break
|
|
526
|
+
}
|
|
527
|
+
const parent = dirname(directory)
|
|
528
|
+
if (parent === directory) break
|
|
529
|
+
directory = parent
|
|
530
|
+
}
|
|
531
|
+
if (projectDirectory) break
|
|
532
|
+
}
|
|
533
|
+
if (projectDirectory) {
|
|
534
|
+
candidates.push(
|
|
535
|
+
join(projectDirectory, 'assets'),
|
|
536
|
+
join(projectDirectory, 'dist'),
|
|
537
|
+
join(projectDirectory, 'dist', 'assets'),
|
|
538
|
+
join(projectDirectory, 'build'),
|
|
539
|
+
join(projectDirectory, 'build', 'assets'),
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
const searched: string[] = []
|
|
543
|
+
const holders: { dest: MetroAssetDest; verified: number }[] = []
|
|
544
|
+
const stale: string[] = []
|
|
545
|
+
let truncated = false
|
|
546
|
+
for (const candidate of candidates) {
|
|
547
|
+
const directory = resolve(candidate)
|
|
548
|
+
if (searched.includes(directory)) continue
|
|
549
|
+
searched.push(directory)
|
|
550
|
+
const listing = listMetroAssetDest(directory)
|
|
551
|
+
// a cut-short walk is remembered even when the candidate resolves nothing,
|
|
552
|
+
// because the reason it resolved nothing may be that it never finished.
|
|
553
|
+
if (listing.truncated) truncated = true
|
|
554
|
+
if (listing.files.length === 0) continue
|
|
555
|
+
const placements = descriptors.map((descriptor) =>
|
|
556
|
+
placeMetroDescriptor(listing, descriptor),
|
|
557
|
+
)
|
|
558
|
+
// one wrong asset is enough: a directory that provably holds different
|
|
559
|
+
// bytes under these names is a different build, not a partial dest.
|
|
560
|
+
if (placements.some((placement) => placement.mismatched)) {
|
|
561
|
+
stale.push(directory)
|
|
562
|
+
continue
|
|
563
|
+
}
|
|
564
|
+
const verified = placements.filter((placement) => placement.verified).length
|
|
565
|
+
if (verified === 0 && placements.every((placement) => placement.files.size === 0))
|
|
566
|
+
continue
|
|
567
|
+
holders.push({ dest: { directory, placements }, verified })
|
|
568
|
+
}
|
|
569
|
+
if (holders.length === 0) {
|
|
570
|
+
// every scale is on disk and none of it is this bundle's. saying the
|
|
571
|
+
// assets are missing would be false, and a stand-in over a stale image is
|
|
572
|
+
// the wrong repair for a dest that only needs rebuilding.
|
|
573
|
+
if (stale.length > 0) {
|
|
574
|
+
throw new Error(
|
|
575
|
+
`${REMOTE_COMMAND} found this bundle's assets in ${stale.join(', ')}, but their bytes are not the ones it was built against. Rebuild with \`react-native bundle --assets-dest ${stale[0]}\` so the bundle and the images match.`,
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
return { dest: null, searched, truncated, unconfirmed: false }
|
|
579
|
+
}
|
|
580
|
+
const best = Math.max(...holders.map((holder) => holder.verified))
|
|
581
|
+
// a directory that confirmed nothing cannot outrank one that confirmed
|
|
582
|
+
// something, and when nothing anywhere confirmed, every claimant is a rival.
|
|
583
|
+
const rivals = holders.filter((holder) => holder.verified === best)
|
|
584
|
+
const distinct = new Set(rivals.map((rival) => unverifiedFileKey(rival.dest)))
|
|
585
|
+
const winner = rivals[0]
|
|
586
|
+
if (!winner) return { dest: null, searched, truncated, unconfirmed: false }
|
|
587
|
+
if (distinct.size > 1) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`${REMOTE_COMMAND} found this bundle's assets in more than one directory and cannot tell which build wrote them: ${rivals
|
|
590
|
+
.map((rival) => rival.dest.directory)
|
|
591
|
+
.join(
|
|
592
|
+
', ',
|
|
593
|
+
)}. Delete the stale one, or rebuild with \`react-native bundle --assets-dest\` into a directory beside the bundle.`,
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
// `best === 0` means no digest could be reproduced, which is ordinary for an
|
|
597
|
+
// asset carrying an Android rung such as @1.5x. with one claimant there is
|
|
598
|
+
// nothing to choose between, and a genuinely half-copied dest still fails
|
|
599
|
+
// per asset below, naming the file it could not read.
|
|
600
|
+
return { dest: winner.dest, searched, truncated, unconfirmed: best === 0 }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function largestEmbeddedAssets(
|
|
604
|
+
sizes: readonly MetroArtifactAssetSize[],
|
|
605
|
+
count: number,
|
|
606
|
+
): string {
|
|
607
|
+
if (sizes.length === 0) return ''
|
|
608
|
+
const largest = sizes
|
|
609
|
+
.slice(0, count)
|
|
610
|
+
.map((entry) => `${entry.asset} ${entry.bytes} bytes`)
|
|
611
|
+
.join(', ')
|
|
612
|
+
return ` Largest embedded assets: ${largest}.`
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function inferCloudModuleIdentity(
|
|
616
|
+
source: string,
|
|
617
|
+
): Promise<RNXMetroModuleIdentityMetadata> {
|
|
618
|
+
let receipt
|
|
619
|
+
try {
|
|
620
|
+
receipt = await loadMetroFingerprintRegistry()
|
|
621
|
+
} catch (error) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`${REMOTE_COMMAND} could not load the Metro fingerprint registry needed to identify this bundle: ${
|
|
624
|
+
error instanceof Error ? error.message : String(error)
|
|
625
|
+
}. Build it through the published Metro plugin: withRNX(config).`,
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
const inferred = await inferMetroModuleIdentity({
|
|
630
|
+
source,
|
|
631
|
+
registry: receipt.registry,
|
|
632
|
+
registryIntegrity: receipt.integrity,
|
|
633
|
+
})
|
|
634
|
+
return {
|
|
635
|
+
version: RNX_METRO_MODULE_IDENTITY_VERSION,
|
|
636
|
+
identitySource: 'contrast-bundler',
|
|
637
|
+
modulePaths: inferred.modulePaths,
|
|
638
|
+
logicalSpecifiers: {},
|
|
639
|
+
}
|
|
640
|
+
} catch (error) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
`${REMOTE_COMMAND} could not infer module identity for this Metro bundle: ${
|
|
643
|
+
error instanceof Error ? error.message : String(error)
|
|
644
|
+
}. Build it through the published Metro plugin: withRNX(config).`,
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* turns the Metro production bundle a caller built into the immutable artifact
|
|
651
|
+
* the runner executes: every asset scale is read from the asset graph and
|
|
652
|
+
* embedded, module identity is normalized, and the JavaScript is minified.
|
|
653
|
+
* an ordinary unannotated Metro production bundle is identified through the
|
|
654
|
+
* published fingerprint registry; a withRNX footer remains the fast path.
|
|
655
|
+
*/
|
|
656
|
+
export interface CloudArtifactProduction {
|
|
657
|
+
bytes: Buffer
|
|
658
|
+
sha256: string
|
|
659
|
+
receipt: RnxCloudArtifactReceipt
|
|
660
|
+
/** the asset dest directory the producer read, or null when it read none. */
|
|
661
|
+
assetsDirectory: string | null
|
|
662
|
+
/** what the CLI prints after the receipt. */
|
|
663
|
+
warnings: string[]
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export async function produceCloudArtifact(
|
|
667
|
+
bundlePath: string,
|
|
668
|
+
/**
|
|
669
|
+
* the largest artifact the consumer accepts. the nano simulator's limit is
|
|
670
|
+
* the default; a box that accepts a larger artifact passes its own.
|
|
671
|
+
*/
|
|
672
|
+
maxBytes: number = MAX_RNX_CLOUD_BUNDLE_BYTES,
|
|
673
|
+
): Promise<CloudArtifactProduction> {
|
|
674
|
+
return produceArtifact(bundlePath, maxBytes, REMOTE_COMMAND, 'allow-placeholders')
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** prepares a standalone preview whose bytes must represent every Metro asset. */
|
|
678
|
+
export async function producePreviewArtifact(
|
|
679
|
+
bundlePath: string,
|
|
680
|
+
): Promise<CloudArtifactProduction> {
|
|
681
|
+
return produceArtifact(
|
|
682
|
+
bundlePath,
|
|
683
|
+
Number.POSITIVE_INFINITY,
|
|
684
|
+
PREVIEW_COMMAND,
|
|
685
|
+
'require-assets',
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export interface CloudBoxArtifactProduction {
|
|
690
|
+
bytes: Buffer
|
|
691
|
+
assetsDirectory: string | null
|
|
692
|
+
warnings: string[]
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** prepares a prebuilt bundle for a Box by embedding any beside-the-bundle Metro assets. */
|
|
696
|
+
export async function produceCloudBoxArtifact(
|
|
697
|
+
bundlePath: string,
|
|
698
|
+
): Promise<CloudBoxArtifactProduction> {
|
|
699
|
+
const prepared = await embedBundleAssets(
|
|
700
|
+
bundlePath,
|
|
701
|
+
'rnx box create',
|
|
702
|
+
'allow-placeholders',
|
|
703
|
+
)
|
|
704
|
+
const warnings: string[] = []
|
|
705
|
+
if (prepared.search.unconfirmed && prepared.dest) {
|
|
706
|
+
warnings.push(
|
|
707
|
+
`rnx box create read this bundle's assets from ${prepared.dest.directory} on layout alone: every asset declares a scale that directory does not hold, so not one of Metro's hashes could be reproduced. Confirm these are the images this bundle was built against.`,
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
if (prepared.substituted.length > 0) {
|
|
711
|
+
warnings.push(
|
|
712
|
+
`rnx box create embedded a placeholder image for ${prepared.substituted.length} asset(s) no scale of which is in ${prepared.search.searched.join(', ') || 'any asset dest directory'}: ${prepared.substituted.join(', ')}. Rebuild with \`react-native bundle --assets-dest\` to ship the real images.`,
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
bytes: Buffer.from(prepared.code, 'utf8'),
|
|
717
|
+
assetsDirectory: prepared.dest?.directory ?? null,
|
|
718
|
+
warnings,
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function embedBundleAssets(
|
|
723
|
+
bundlePath: string,
|
|
724
|
+
command: string,
|
|
725
|
+
assetPolicy: 'allow-placeholders' | 'require-assets',
|
|
726
|
+
): Promise<{
|
|
727
|
+
source: string
|
|
728
|
+
code: string
|
|
729
|
+
search: MetroAssetSearch
|
|
730
|
+
dest: MetroAssetDest | null
|
|
731
|
+
substituted: string[]
|
|
732
|
+
}> {
|
|
733
|
+
const source = await loadBundleSource(bundlePath)
|
|
734
|
+
const descriptors = extractAssetDescriptors(source).filter(
|
|
735
|
+
(descriptor) => !isEmbeddedAssetDescriptor(descriptor),
|
|
736
|
+
)
|
|
737
|
+
if (descriptors.length === 0) {
|
|
738
|
+
return {
|
|
739
|
+
source,
|
|
740
|
+
code: source,
|
|
741
|
+
search: { dest: null, searched: [], truncated: false, unconfirmed: false },
|
|
742
|
+
dest: null,
|
|
743
|
+
substituted: [],
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// an https bundle has no local tree to search, and a bundle with no assets
|
|
747
|
+
// has nothing to search for.
|
|
748
|
+
const search: MetroAssetSearch = !isRemoteBundleInput(bundlePath)
|
|
749
|
+
? searchMetroAssetDest(bundlePath, descriptors)
|
|
750
|
+
: { dest: null, searched: [], truncated: false, unconfirmed: false }
|
|
751
|
+
const dest = search.dest
|
|
752
|
+
// `embedMetroAssetFiles` re-parses the bundle, so its descriptors are equal
|
|
753
|
+
// to the ones the search placed but not the same objects.
|
|
754
|
+
const placements = new Map<string, DescriptorPlacement>()
|
|
755
|
+
descriptors.forEach((descriptor, index) => {
|
|
756
|
+
const placement = dest?.placements[index]
|
|
757
|
+
if (placement) placements.set(metroAssetKey(descriptor), placement)
|
|
758
|
+
})
|
|
759
|
+
const substituted: string[] = []
|
|
760
|
+
const embedded = await embedMetroAssetFiles(source, async (variant) => {
|
|
761
|
+
const label = metroAssetKey(variant.descriptor)
|
|
762
|
+
const placement = placements.get(label)
|
|
763
|
+
if (placement?.mismatched && dest) {
|
|
764
|
+
throw new Error(
|
|
765
|
+
`${command} found ${variant.descriptor.name}.${variant.descriptor.type} in ${dest.directory}, but its bytes are not the ones this bundle was built against. Rebuild with \`react-native bundle --assets-dest ${dest.directory}\` so the bundle and the images match.`,
|
|
766
|
+
)
|
|
767
|
+
}
|
|
768
|
+
const relative = placement?.files.get(variant.scale)
|
|
769
|
+
if (relative && dest) return readFileSync(join(dest.directory, relative))
|
|
770
|
+
// iOS copies only @1x..@3x, so a declared scale with no file is ordinary
|
|
771
|
+
// and the producer narrows the descriptor.
|
|
772
|
+
if (placement && placement.files.size > 0) return null
|
|
773
|
+
if (placement && placement.ambiguous.length > 0 && dest) {
|
|
774
|
+
throw new Error(
|
|
775
|
+
`${command} found more than one image for ${label} in ${dest.directory} and none of them is the one Metro hashed: ${placement.ambiguous.sort().join(', ')}. Rebuild with \`react-native bundle --assets-dest\` into a directory that holds only this build's assets.`,
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
// a stand-in is only honest when the asset is genuinely absent from a
|
|
779
|
+
// directory we know is the right one. a truncated walk never finished
|
|
780
|
+
// looking, so it refuses instead of shipping bytes nobody chose.
|
|
781
|
+
if (search.truncated) {
|
|
782
|
+
throw new Error(
|
|
783
|
+
`${command} stopped listing ${search.searched.join(', ')} after ${MAX_ASSET_DEST_FILES} files and never reached ${label}, so it cannot tell a missing asset from an unread one. Rebuild with \`react-native bundle --assets-dest\` into a directory beside the bundle.`,
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
if (assetPolicy === 'require-assets') {
|
|
787
|
+
throw new Error(
|
|
788
|
+
`${command} could not find ${label}. Build the bundle with \`react-native bundle --assets-dest <dir>\` so its assets land beside it.`,
|
|
789
|
+
)
|
|
790
|
+
}
|
|
791
|
+
// no scale of this asset exists anywhere the search looked. an image can
|
|
792
|
+
// stand in visibly and let the app run; every declared scale gets the same
|
|
793
|
+
// stand-in, because narrowing to one would rewrite a `scales` list the
|
|
794
|
+
// descriptor may not even carry.
|
|
795
|
+
const placeholder = metroAssetPlaceholderBytes(variant.descriptor.type)
|
|
796
|
+
if (placeholder) {
|
|
797
|
+
if (!substituted.includes(label)) substituted.push(label)
|
|
798
|
+
return placeholder
|
|
799
|
+
}
|
|
800
|
+
const filename = metroAssetScaleFilename(
|
|
801
|
+
variant.descriptor.name,
|
|
802
|
+
variant.descriptor.type,
|
|
803
|
+
variant.scale,
|
|
804
|
+
)
|
|
805
|
+
const classic =
|
|
806
|
+
metroIosAssetDestRelatives(variant.descriptor.httpServerLocation, filename)[0] ??
|
|
807
|
+
filename
|
|
808
|
+
throw new Error(
|
|
809
|
+
`${command} could not read ${join(dest?.directory ?? '.', classic)} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}, and has no stand-in for a .${variant.descriptor.type} asset. Rebuild with \`react-native bundle --assets-dest\`.`,
|
|
810
|
+
)
|
|
811
|
+
})
|
|
812
|
+
return {
|
|
813
|
+
source,
|
|
814
|
+
code: embedded.code,
|
|
815
|
+
search,
|
|
816
|
+
dest,
|
|
817
|
+
substituted,
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function produceArtifact(
|
|
822
|
+
bundlePath: string,
|
|
823
|
+
maxBytes: number,
|
|
824
|
+
command: string,
|
|
825
|
+
assetPolicy: 'allow-placeholders' | 'require-assets',
|
|
826
|
+
): Promise<CloudArtifactProduction> {
|
|
827
|
+
const prepared = await embedBundleAssets(bundlePath, command, assetPolicy)
|
|
828
|
+
const artifact = await buildRnxCloudArtifact(prepared.code, {
|
|
829
|
+
inferModuleIdentity: inferCloudModuleIdentity,
|
|
830
|
+
})
|
|
831
|
+
const bytes = Buffer.from(artifact.code, 'utf8')
|
|
832
|
+
const assets = artifact.receipt.assets
|
|
833
|
+
if (bytes.length > maxBytes) {
|
|
834
|
+
throw new Error(
|
|
835
|
+
`${REMOTE_COMMAND} artifact is ${bytes.length} bytes and exceeds the ${maxBytes}-byte limit. Embedded assets weigh ${assets.bytes} bytes on disk, and base64 embedding costs about a third on top of that, so the practical asset budget is roughly ${Math.round((maxBytes * 0.75) / 1e6)} MB.${largestEmbeddedAssets(assets.sizes, 10)}`,
|
|
836
|
+
)
|
|
837
|
+
}
|
|
838
|
+
const warnings: string[] = []
|
|
839
|
+
if (prepared.search.unconfirmed && prepared.dest) {
|
|
840
|
+
warnings.push(
|
|
841
|
+
`${command} read this bundle's assets from ${prepared.dest.directory} on layout alone: every asset declares a scale that directory does not hold, so not one of Metro's hashes could be reproduced. Confirm these are the images this bundle was built against.`,
|
|
842
|
+
)
|
|
843
|
+
}
|
|
844
|
+
if (prepared.substituted.length > 0) {
|
|
845
|
+
warnings.push(
|
|
846
|
+
`${command} embedded a placeholder image for ${prepared.substituted.length} asset(s) no scale of which is in ${prepared.search.searched.join(', ') || 'any asset dest directory'}: ${prepared.substituted.join(', ')}. Rebuild with \`react-native bundle --assets-dest\` to ship the real images.`,
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
// an artifact this close to the ceiling is one asset away from being refused,
|
|
850
|
+
// and the producer knows which assets it would be.
|
|
851
|
+
if (bytes.length >= Math.floor(maxBytes * 0.8)) {
|
|
852
|
+
warnings.push(
|
|
853
|
+
`${command} artifact is ${bytes.length} bytes, ${Math.round((bytes.length / maxBytes) * 100)}% of the ${maxBytes}-byte limit.${largestEmbeddedAssets(assets.sizes, 5)}`,
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
bytes,
|
|
858
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
859
|
+
receipt: artifact.receipt,
|
|
860
|
+
assetsDirectory: prepared.dest?.directory ?? null,
|
|
861
|
+
warnings,
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function readCreateResponse(
|
|
866
|
+
value: unknown,
|
|
867
|
+
expectedBytes: number,
|
|
868
|
+
expectedSha256: string,
|
|
869
|
+
): {
|
|
870
|
+
receipt: RnxCloudBoxCreateReceipt
|
|
871
|
+
boxToken: string
|
|
872
|
+
simulatorToken: string
|
|
873
|
+
} {
|
|
874
|
+
if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON')
|
|
875
|
+
const artifactId = `sha256:${expectedSha256}` as const
|
|
876
|
+
const isExpectedArtifact = (artifact: unknown): artifact is { bytes: number } =>
|
|
877
|
+
isRecord(artifact) &&
|
|
878
|
+
artifact.id === artifactId &&
|
|
879
|
+
typeof artifact.bytes === 'number' &&
|
|
880
|
+
Number.isInteger(artifact.bytes) &&
|
|
881
|
+
artifact.bytes === expectedBytes
|
|
882
|
+
const boxId = value.boxId
|
|
883
|
+
const boxToken = value.token
|
|
884
|
+
const simulator = value.simulator
|
|
885
|
+
const simId = isRecord(simulator) ? simulator.simId : null
|
|
886
|
+
const claim = isRecord(simulator) ? simulator.claim : null
|
|
887
|
+
const simulatorToken = isRecord(simulator) ? simulator.token : null
|
|
888
|
+
if (
|
|
889
|
+
typeof boxId !== 'string' ||
|
|
890
|
+
!boxId ||
|
|
891
|
+
value.size !== 'nano' ||
|
|
892
|
+
typeof boxToken !== 'string' ||
|
|
893
|
+
!boxToken ||
|
|
894
|
+
!isExpectedArtifact(value.artifact) ||
|
|
895
|
+
!isRecord(simulator) ||
|
|
896
|
+
typeof simId !== 'string' ||
|
|
897
|
+
!simId ||
|
|
898
|
+
!isExpectedArtifact(simulator.artifact) ||
|
|
899
|
+
!isRecord(claim) ||
|
|
900
|
+
typeof claim.id !== 'string' ||
|
|
901
|
+
!claim.id ||
|
|
902
|
+
typeof claim.expiresAt !== 'number' ||
|
|
903
|
+
typeof simulatorToken !== 'string' ||
|
|
904
|
+
!simulatorToken
|
|
905
|
+
) {
|
|
906
|
+
throw new Error('remote simulator create returned an invalid receipt')
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
receipt: {
|
|
910
|
+
boxId,
|
|
911
|
+
size: 'nano',
|
|
912
|
+
artifact: { id: artifactId, bytes: expectedBytes },
|
|
913
|
+
simulator: {
|
|
914
|
+
simId,
|
|
915
|
+
artifact: { id: artifactId, bytes: expectedBytes },
|
|
916
|
+
claim: { id: claim.id, expiresAt: claim.expiresAt },
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
boxToken,
|
|
920
|
+
simulatorToken,
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// "send to box" wraps the running app in a nano box with its first simulator
|
|
925
|
+
// in one request, and the shell then targets that simulator
|
|
926
|
+
export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
|
|
927
|
+
receipt: RnxCloudBoxCreateReceipt
|
|
928
|
+
session: CloudSession
|
|
929
|
+
artifact: RnxCloudArtifactReceipt
|
|
930
|
+
assetsDirectory: string | null
|
|
931
|
+
warnings: string[]
|
|
932
|
+
}> {
|
|
933
|
+
const platform = options.platform ?? 'ios'
|
|
934
|
+
const apiOrigin = resolveCloudOrigin(options.apiOrigin)
|
|
935
|
+
const artifact = await produceCloudArtifact(options.bundlePath)
|
|
936
|
+
const boxName = options.boxName?.trim()
|
|
937
|
+
const headers: Record<string, string> = {
|
|
938
|
+
authorization: options.authorization,
|
|
939
|
+
'x-rnx-box-size': 'nano',
|
|
940
|
+
'x-rnx-artifact-sha256': artifact.sha256,
|
|
941
|
+
'x-rnx-platform': platform,
|
|
942
|
+
'x-rnx-device': options.device,
|
|
943
|
+
...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null),
|
|
944
|
+
...(boxName ? { 'x-rnx-box-name': boxName } : null),
|
|
945
|
+
}
|
|
946
|
+
let body: BodyInit
|
|
947
|
+
if (options.storage) {
|
|
948
|
+
const form = new FormData()
|
|
949
|
+
form.append(
|
|
950
|
+
'artifact',
|
|
951
|
+
new Blob([new Uint8Array(artifact.bytes)], { type: 'application/javascript' }),
|
|
952
|
+
'bundle.js',
|
|
953
|
+
)
|
|
954
|
+
form.append('storage', JSON.stringify(options.storage))
|
|
955
|
+
body = form
|
|
956
|
+
} else {
|
|
957
|
+
headers['content-type'] = 'application/javascript'
|
|
958
|
+
body = new Uint8Array(artifact.bytes)
|
|
959
|
+
}
|
|
960
|
+
let response: Response
|
|
961
|
+
try {
|
|
962
|
+
response = await fetch(`${apiOrigin}/v1/boxes`, {
|
|
963
|
+
method: 'POST',
|
|
964
|
+
headers,
|
|
965
|
+
body,
|
|
966
|
+
})
|
|
967
|
+
} catch (error) {
|
|
968
|
+
throw new Error(
|
|
969
|
+
`${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
|
|
970
|
+
error instanceof Error ? error.message : String(error)
|
|
971
|
+
}`,
|
|
972
|
+
)
|
|
973
|
+
}
|
|
974
|
+
const responseText = await response.text()
|
|
975
|
+
if (!response.ok) throw responseError(response.status, responseText)
|
|
976
|
+
let parsed: unknown
|
|
977
|
+
try {
|
|
978
|
+
parsed = JSON.parse(responseText)
|
|
979
|
+
} catch {
|
|
980
|
+
throw new Error('remote simulator create returned unreadable JSON')
|
|
981
|
+
}
|
|
982
|
+
const created = readCreateResponse(parsed, artifact.bytes.length, artifact.sha256)
|
|
983
|
+
const session = createCloudSession({
|
|
984
|
+
boxId: created.receipt.boxId,
|
|
985
|
+
boxToken: created.boxToken,
|
|
986
|
+
simId: created.receipt.simulator.simId,
|
|
987
|
+
apiOrigin,
|
|
988
|
+
claimId: created.receipt.simulator.claim.id,
|
|
989
|
+
token: created.simulatorToken,
|
|
990
|
+
platform,
|
|
991
|
+
device: options.device,
|
|
992
|
+
artifact: {
|
|
993
|
+
sha256: artifact.sha256,
|
|
994
|
+
bytes: artifact.bytes.length,
|
|
995
|
+
},
|
|
996
|
+
})
|
|
997
|
+
return {
|
|
998
|
+
receipt: created.receipt,
|
|
999
|
+
session,
|
|
1000
|
+
artifact: artifact.receipt,
|
|
1001
|
+
assetsDirectory: artifact.assetsDirectory,
|
|
1002
|
+
warnings: artifact.warnings,
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// one customer plane: the sim-service instance API. every `rnx remote`
|
|
1007
|
+
// simulator is created, listed, read, and deleted here; the box stays the
|
|
1008
|
+
// host behind it and is never the addressed object.
|
|
1009
|
+
|
|
1010
|
+
export interface CreateRemoteSimOptions {
|
|
1011
|
+
bundlePath: string
|
|
1012
|
+
device: string
|
|
1013
|
+
authorization: string
|
|
1014
|
+
/** the account to bill when the authorization is a session token; an api key names its own */
|
|
1015
|
+
accountId: string | null
|
|
1016
|
+
platform: RemotePlatform
|
|
1017
|
+
apiOrigin?: string
|
|
1018
|
+
/** match an existing simulator for the same artifact and labels instead of creating */
|
|
1019
|
+
reuse?: boolean
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function readStringMap(value: unknown): Record<string, string> {
|
|
1023
|
+
if (!isRecord(value)) return {}
|
|
1024
|
+
const labels: Record<string, string> = {}
|
|
1025
|
+
for (const [key, label] of Object.entries(value)) {
|
|
1026
|
+
if (typeof label === 'string') labels[key] = label
|
|
1027
|
+
}
|
|
1028
|
+
return labels
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function readSimCreateResponse(
|
|
1032
|
+
value: unknown,
|
|
1033
|
+
expectedBytes: number,
|
|
1034
|
+
expectedSha256: string,
|
|
1035
|
+
): RnxCloudSimCreateResponse {
|
|
1036
|
+
if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON')
|
|
1037
|
+
const artifactId = `sha256:${expectedSha256}` as const
|
|
1038
|
+
const artifact = isRecord(value.artifact) ? value.artifact : null
|
|
1039
|
+
const claim = isRecord(value.claim) ? value.claim : null
|
|
1040
|
+
if (
|
|
1041
|
+
typeof value.simId !== 'string' ||
|
|
1042
|
+
!value.simId ||
|
|
1043
|
+
!artifact ||
|
|
1044
|
+
artifact.id !== artifactId ||
|
|
1045
|
+
artifact.bytes !== expectedBytes ||
|
|
1046
|
+
!claim ||
|
|
1047
|
+
typeof claim.id !== 'string' ||
|
|
1048
|
+
!claim.id ||
|
|
1049
|
+
typeof claim.expiresAt !== 'number' ||
|
|
1050
|
+
typeof value.token !== 'string' ||
|
|
1051
|
+
!value.token ||
|
|
1052
|
+
typeof value.status !== 'string' ||
|
|
1053
|
+
!isRnxCloudSimStatus(value.status) ||
|
|
1054
|
+
(value.streamUrl !== null && typeof value.streamUrl !== 'string')
|
|
1055
|
+
) {
|
|
1056
|
+
throw new Error('remote simulator create returned an invalid receipt')
|
|
1057
|
+
}
|
|
1058
|
+
return {
|
|
1059
|
+
simId: value.simId,
|
|
1060
|
+
artifact: { id: artifactId, bytes: expectedBytes },
|
|
1061
|
+
claim: { id: claim.id, expiresAt: claim.expiresAt },
|
|
1062
|
+
token: value.token,
|
|
1063
|
+
status: value.status,
|
|
1064
|
+
labels: readStringMap(value.labels),
|
|
1065
|
+
streamUrl: value.streamUrl,
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// `rnx remote ios|android <bundle>` creates one simulator through the
|
|
1070
|
+
// instance API and waits for its app to answer ready. both platforms run
|
|
1071
|
+
// this same typed path: an android create is refused by the service with a
|
|
1072
|
+
// typed 400, never by a cli-side branch.
|
|
1073
|
+
export async function createRemoteSim(options: CreateRemoteSimOptions): Promise<{
|
|
1074
|
+
receipt: RnxCloudSimCreateResponse
|
|
1075
|
+
session: CloudSession
|
|
1076
|
+
artifact: RnxCloudArtifactReceipt
|
|
1077
|
+
assetsDirectory: string | null
|
|
1078
|
+
warnings: string[]
|
|
1079
|
+
reused: boolean
|
|
1080
|
+
}> {
|
|
1081
|
+
const apiOrigin = resolveCloudOrigin(options.apiOrigin)
|
|
1082
|
+
const artifact = await produceCloudArtifact(options.bundlePath)
|
|
1083
|
+
const query = new URLSearchParams({
|
|
1084
|
+
labels: `rnx.remote=1,rnx.platform=${options.platform},rnx.bundle=${artifact.sha256.slice(0, 12)}`,
|
|
1085
|
+
wait: 'true',
|
|
1086
|
+
})
|
|
1087
|
+
if (options.reuse) query.set('reuseIfExists', 'true')
|
|
1088
|
+
const headers: Record<string, string> = {
|
|
1089
|
+
authorization: options.authorization,
|
|
1090
|
+
'content-type': 'application/javascript',
|
|
1091
|
+
'x-rnx-artifact-sha256': artifact.sha256,
|
|
1092
|
+
'x-rnx-platform': options.platform,
|
|
1093
|
+
'x-rnx-device': options.device,
|
|
1094
|
+
...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null),
|
|
1095
|
+
}
|
|
1096
|
+
let response: Response
|
|
1097
|
+
try {
|
|
1098
|
+
response = await fetch(`${apiOrigin}/v1/sims?${query}`, {
|
|
1099
|
+
method: 'POST',
|
|
1100
|
+
headers,
|
|
1101
|
+
body: new Uint8Array(artifact.bytes),
|
|
1102
|
+
})
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
throw new Error(
|
|
1105
|
+
`${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
|
|
1106
|
+
error instanceof Error ? error.message : String(error)
|
|
1107
|
+
}`,
|
|
1108
|
+
)
|
|
1109
|
+
}
|
|
1110
|
+
const responseText = await response.text()
|
|
1111
|
+
if (!response.ok) throw responseError(response.status, responseText)
|
|
1112
|
+
let parsed: unknown
|
|
1113
|
+
try {
|
|
1114
|
+
parsed = JSON.parse(responseText)
|
|
1115
|
+
} catch {
|
|
1116
|
+
throw new Error('remote simulator create returned unreadable JSON')
|
|
1117
|
+
}
|
|
1118
|
+
const receipt = readSimCreateResponse(parsed, artifact.bytes.length, artifact.sha256)
|
|
1119
|
+
const session = createCloudSession({
|
|
1120
|
+
simId: receipt.simId,
|
|
1121
|
+
apiOrigin,
|
|
1122
|
+
claimId: receipt.claim.id,
|
|
1123
|
+
token: receipt.token,
|
|
1124
|
+
streamUrl: receipt.streamUrl,
|
|
1125
|
+
platform: options.platform,
|
|
1126
|
+
device: options.device,
|
|
1127
|
+
artifact: {
|
|
1128
|
+
sha256: artifact.sha256,
|
|
1129
|
+
bytes: artifact.bytes.length,
|
|
1130
|
+
},
|
|
1131
|
+
})
|
|
1132
|
+
return {
|
|
1133
|
+
receipt,
|
|
1134
|
+
session,
|
|
1135
|
+
artifact: artifact.receipt,
|
|
1136
|
+
assetsDirectory: artifact.assetsDirectory,
|
|
1137
|
+
warnings: artifact.warnings,
|
|
1138
|
+
reused: response.status === 200,
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function readSimView(value: unknown): RnxCloudSimView {
|
|
1143
|
+
if (!isRecord(value) || !isRecord(value.labels)) {
|
|
1144
|
+
throw new Error('remote simulator list returned an invalid simulator')
|
|
1145
|
+
}
|
|
1146
|
+
const labels: Record<string, string> = {}
|
|
1147
|
+
for (const [key, label] of Object.entries(value.labels)) {
|
|
1148
|
+
if (typeof label !== 'string') {
|
|
1149
|
+
throw new Error('remote simulator list returned an invalid simulator')
|
|
1150
|
+
}
|
|
1151
|
+
labels[key] = label
|
|
1152
|
+
}
|
|
1153
|
+
const artifact = isRecord(value.artifact) ? value.artifact : null
|
|
1154
|
+
const claimValue = isRecord(value.claim) ? value.claim : null
|
|
1155
|
+
let claim: RnxCloudClaim | null = null
|
|
1156
|
+
if (claimValue !== null) {
|
|
1157
|
+
if (
|
|
1158
|
+
typeof claimValue.id !== 'string' ||
|
|
1159
|
+
!claimValue.id ||
|
|
1160
|
+
typeof claimValue.expiresAt !== 'number'
|
|
1161
|
+
) {
|
|
1162
|
+
throw new Error('remote simulator list returned an invalid simulator')
|
|
1163
|
+
}
|
|
1164
|
+
claim = { id: claimValue.id, expiresAt: claimValue.expiresAt }
|
|
1165
|
+
}
|
|
1166
|
+
const inactivityTimeoutMs = value.inactivityTimeoutMs
|
|
1167
|
+
const hardTimeoutAt = value.hardTimeoutAt
|
|
1168
|
+
if (
|
|
1169
|
+
typeof value.simId !== 'string' ||
|
|
1170
|
+
!value.simId ||
|
|
1171
|
+
typeof value.status !== 'string' ||
|
|
1172
|
+
!isRnxCloudSimStatus(value.status) ||
|
|
1173
|
+
!artifact ||
|
|
1174
|
+
typeof artifact.sha256 !== 'string' ||
|
|
1175
|
+
typeof artifact.bytes !== 'number' ||
|
|
1176
|
+
typeof value.createdAt !== 'number' ||
|
|
1177
|
+
typeof value.lastActiveAt !== 'number' ||
|
|
1178
|
+
(typeof inactivityTimeoutMs !== 'number' && inactivityTimeoutMs !== null) ||
|
|
1179
|
+
(typeof hardTimeoutAt !== 'number' && hardTimeoutAt !== null)
|
|
1180
|
+
) {
|
|
1181
|
+
throw new Error('remote simulator list returned an invalid simulator')
|
|
1182
|
+
}
|
|
1183
|
+
return {
|
|
1184
|
+
simId: value.simId,
|
|
1185
|
+
status: value.status,
|
|
1186
|
+
labels,
|
|
1187
|
+
artifact: { sha256: artifact.sha256, bytes: artifact.bytes },
|
|
1188
|
+
claim,
|
|
1189
|
+
createdAt: value.createdAt,
|
|
1190
|
+
lastActiveAt: value.lastActiveAt,
|
|
1191
|
+
inactivityTimeoutMs,
|
|
1192
|
+
hardTimeoutAt,
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// `rnx remote list`: the simulators this account runs that `rnx remote` made.
|
|
1197
|
+
export async function listRemoteSims(options: {
|
|
1198
|
+
apiOrigin?: string
|
|
1199
|
+
authorization: string
|
|
1200
|
+
accountId?: string | null
|
|
1201
|
+
}): Promise<RnxCloudSimView[]> {
|
|
1202
|
+
const apiOrigin = resolveCloudOrigin(options.apiOrigin)
|
|
1203
|
+
const selector = new URLSearchParams({ labelSelector: 'rnx.remote=1' })
|
|
1204
|
+
const headers: Record<string, string> = { authorization: options.authorization }
|
|
1205
|
+
if (options.accountId) headers['x-rnx-account-id'] = options.accountId
|
|
1206
|
+
let response: Response
|
|
1207
|
+
try {
|
|
1208
|
+
response = await fetch(`${apiOrigin}/v1/sims?${selector}`, { headers })
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
throw new Error(
|
|
1211
|
+
`${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
|
|
1212
|
+
error instanceof Error ? error.message : String(error)
|
|
1213
|
+
}`,
|
|
1214
|
+
)
|
|
1215
|
+
}
|
|
1216
|
+
const responseText = await response.text()
|
|
1217
|
+
if (!response.ok) throw responseError(response.status, responseText)
|
|
1218
|
+
let parsed: unknown
|
|
1219
|
+
try {
|
|
1220
|
+
parsed = JSON.parse(responseText)
|
|
1221
|
+
} catch {
|
|
1222
|
+
throw new Error('remote simulator list returned unreadable JSON')
|
|
1223
|
+
}
|
|
1224
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.sims)) {
|
|
1225
|
+
throw new Error('remote simulator list returned invalid JSON')
|
|
1226
|
+
}
|
|
1227
|
+
return parsed.sims.map(readSimView)
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// one simulator reread by id: the reattach liveness probe, and the fresh
|
|
1231
|
+
// watch url an api-key caller mints with it. a simulator this account no
|
|
1232
|
+
// longer runs reads back as missing rather than forbidden, so 404 is null
|
|
1233
|
+
// and anything else refused throws.
|
|
1234
|
+
export async function getRemoteSim(options: {
|
|
1235
|
+
apiOrigin?: string
|
|
1236
|
+
simId: string
|
|
1237
|
+
authorization: string
|
|
1238
|
+
}): Promise<RnxCloudSimInstance | null> {
|
|
1239
|
+
const apiOrigin = resolveCloudOrigin(options.apiOrigin)
|
|
1240
|
+
let response: Response
|
|
1241
|
+
try {
|
|
1242
|
+
response = await fetch(`${apiOrigin}/v1/sims/${encodeURIComponent(options.simId)}`, {
|
|
1243
|
+
headers: { authorization: options.authorization },
|
|
1244
|
+
})
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
throw new Error(
|
|
1247
|
+
`${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
|
|
1248
|
+
error instanceof Error ? error.message : String(error)
|
|
1249
|
+
}`,
|
|
1250
|
+
)
|
|
1251
|
+
}
|
|
1252
|
+
const responseText = await response.text()
|
|
1253
|
+
if (response.status === 404) return null
|
|
1254
|
+
if (!response.ok) throw responseError(response.status, responseText)
|
|
1255
|
+
let parsed: unknown
|
|
1256
|
+
try {
|
|
1257
|
+
parsed = JSON.parse(responseText)
|
|
1258
|
+
} catch {
|
|
1259
|
+
throw new Error('remote simulator read returned unreadable JSON')
|
|
1260
|
+
}
|
|
1261
|
+
if (!isRecord(parsed) || !isRecord(parsed.sim)) {
|
|
1262
|
+
throw new Error('remote simulator read returned invalid JSON')
|
|
1263
|
+
}
|
|
1264
|
+
const view = readSimView(parsed.sim)
|
|
1265
|
+
const streamUrl = parsed.sim.streamUrl
|
|
1266
|
+
if (streamUrl !== null && streamUrl !== undefined && typeof streamUrl !== 'string') {
|
|
1267
|
+
throw new Error('remote simulator read returned an invalid watch url')
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
...view,
|
|
1271
|
+
streamUrl: typeof streamUrl === 'string' ? streamUrl : null,
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// `rnx remote stop <id>`: delete one simulator by id. missing is null so the
|
|
1276
|
+
// caller can name what was not there; anything else refused throws.
|
|
1277
|
+
export async function deleteRemoteSim(options: {
|
|
1278
|
+
apiOrigin?: string
|
|
1279
|
+
simId: string
|
|
1280
|
+
authorization: string
|
|
1281
|
+
accountId?: string | null
|
|
1282
|
+
}): Promise<{ simId: string; status: string } | null> {
|
|
1283
|
+
const apiOrigin = resolveCloudOrigin(options.apiOrigin)
|
|
1284
|
+
const headers: Record<string, string> = { authorization: options.authorization }
|
|
1285
|
+
if (options.accountId) headers['x-rnx-account-id'] = options.accountId
|
|
1286
|
+
let response: Response
|
|
1287
|
+
try {
|
|
1288
|
+
response = await fetch(`${apiOrigin}/v1/sims/${encodeURIComponent(options.simId)}`, {
|
|
1289
|
+
method: 'DELETE',
|
|
1290
|
+
headers,
|
|
1291
|
+
})
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
throw new Error(
|
|
1294
|
+
`${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
|
|
1295
|
+
error instanceof Error ? error.message : String(error)
|
|
1296
|
+
}`,
|
|
1297
|
+
)
|
|
1298
|
+
}
|
|
1299
|
+
const responseText = await response.text()
|
|
1300
|
+
if (response.status === 404) return null
|
|
1301
|
+
if (!response.ok) throw responseError(response.status, responseText)
|
|
1302
|
+
let parsed: unknown
|
|
1303
|
+
try {
|
|
1304
|
+
parsed = JSON.parse(responseText)
|
|
1305
|
+
} catch {
|
|
1306
|
+
throw new Error('remote simulator delete returned unreadable JSON')
|
|
1307
|
+
}
|
|
1308
|
+
if (
|
|
1309
|
+
!isRecord(parsed) ||
|
|
1310
|
+
typeof parsed.simId !== 'string' ||
|
|
1311
|
+
typeof parsed.status !== 'string'
|
|
1312
|
+
) {
|
|
1313
|
+
throw new Error('remote simulator delete returned invalid JSON')
|
|
1314
|
+
}
|
|
1315
|
+
return { simId: parsed.simId, status: parsed.status }
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
export function resolveCloudSessionForParsed(
|
|
1319
|
+
parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'>,
|
|
1320
|
+
): CloudSession | null {
|
|
1321
|
+
const session = readCloudSession()
|
|
1322
|
+
if (!session) return null
|
|
1323
|
+
if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
|
|
1324
|
+
throw new Error(
|
|
1325
|
+
`this shell is connected to remote simulator ${session.simId}; ${
|
|
1326
|
+
parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
|
|
1327
|
+
} is not available in this shell`,
|
|
1328
|
+
)
|
|
1329
|
+
}
|
|
1330
|
+
return session
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function cloudCommand(command: {
|
|
1334
|
+
type: string
|
|
1335
|
+
[key: string]: unknown
|
|
1336
|
+
}): Record<string, unknown> {
|
|
1337
|
+
const clean: Record<string, unknown> = {}
|
|
1338
|
+
for (const [key, value] of Object.entries(command)) {
|
|
1339
|
+
if (key !== 'id' && key !== 'simId') clean[key] = value
|
|
1340
|
+
}
|
|
1341
|
+
if (!isRnxCloudCommandType(command.type)) {
|
|
1342
|
+
throw new Error(
|
|
1343
|
+
`a remote simulator does not support bridge command type ${command.type}`,
|
|
1344
|
+
)
|
|
1345
|
+
}
|
|
1346
|
+
if (!rnxCloudScopesAllowCommand(RNX_CLOUD_TOKEN_SCOPES, command.type)) {
|
|
1347
|
+
throw new Error(
|
|
1348
|
+
`a remote simulator command ${command.type} requires the ${rnxCloudCommandScope(command.type)} scope`,
|
|
1349
|
+
)
|
|
1350
|
+
}
|
|
1351
|
+
return clean
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
class CloudBridge implements WsBridge {
|
|
1355
|
+
readonly plane = 'cloud' as const
|
|
1356
|
+
|
|
1357
|
+
constructor(private readonly session: CloudSimSession) {}
|
|
1358
|
+
|
|
1359
|
+
async send(
|
|
1360
|
+
command: { type: string; [key: string]: unknown },
|
|
1361
|
+
options?: { timeoutMs?: number },
|
|
1362
|
+
): Promise<unknown> {
|
|
1363
|
+
const body: CloudCommandEnvelope = {
|
|
1364
|
+
id: randomUUID(),
|
|
1365
|
+
claimId: this.session.claimId,
|
|
1366
|
+
command: cloudCommand(command),
|
|
1367
|
+
}
|
|
1368
|
+
let response: Response
|
|
1369
|
+
try {
|
|
1370
|
+
response = await fetch(
|
|
1371
|
+
`${this.session.apiOrigin}/v1/sims/${encodeURIComponent(this.session.simId)}/commands`,
|
|
1372
|
+
{
|
|
1373
|
+
method: 'POST',
|
|
1374
|
+
headers: {
|
|
1375
|
+
authorization: `Bearer ${this.session.token}`,
|
|
1376
|
+
'content-type': 'application/json',
|
|
1377
|
+
},
|
|
1378
|
+
body: JSON.stringify(body),
|
|
1379
|
+
...(options?.timeoutMs
|
|
1380
|
+
? { signal: AbortSignal.timeout(options.timeoutMs) }
|
|
1381
|
+
: {}),
|
|
1382
|
+
},
|
|
1383
|
+
)
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
throw new Error(
|
|
1386
|
+
`could not reach the remote simulator at ${this.session.apiOrigin}: ${
|
|
1387
|
+
error instanceof Error ? error.message : String(error)
|
|
1388
|
+
}`,
|
|
1389
|
+
)
|
|
1390
|
+
}
|
|
1391
|
+
const text = await response.text()
|
|
1392
|
+
if (!response.ok) throw responseError(response.status, text)
|
|
1393
|
+
let parsed: unknown
|
|
1394
|
+
try {
|
|
1395
|
+
parsed = JSON.parse(text)
|
|
1396
|
+
} catch {
|
|
1397
|
+
throw new Error('remote simulator command returned unreadable JSON')
|
|
1398
|
+
}
|
|
1399
|
+
if (!isRecord(parsed) || parsed.id !== body.id) {
|
|
1400
|
+
throw new Error('remote simulator command returned an invalid command receipt')
|
|
1401
|
+
}
|
|
1402
|
+
if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
|
|
1403
|
+
if (!Object.hasOwn(parsed, 'result')) {
|
|
1404
|
+
throw new Error('remote simulator command receipt has no result')
|
|
1405
|
+
}
|
|
1406
|
+
return parsed.result
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
listSims(): Promise<BridgeSimInfo[]> {
|
|
1410
|
+
return Promise.reject(
|
|
1411
|
+
new Error('a remote simulator does not expose a local simulator list'),
|
|
1412
|
+
)
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
focusSim(): Promise<never> {
|
|
1416
|
+
return Promise.reject(new Error('remote simulators have no browser window to focus'))
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
resolveReloadedSim(): Promise<string | null> {
|
|
1420
|
+
// cloud sims carry no local browser host, so there is no lineage to follow
|
|
1421
|
+
return Promise.resolve(null)
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
closeSim(): Promise<never> {
|
|
1425
|
+
return Promise.reject(
|
|
1426
|
+
new Error('remote simulator close uses the simulator DELETE route'),
|
|
1427
|
+
)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
claim(): Promise<BridgeClaimResult> {
|
|
1431
|
+
return Promise.reject(
|
|
1432
|
+
new Error('remote simulator claim uses the simulator claim route'),
|
|
1433
|
+
)
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
close(): void {}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
export function createCloudBridgeForParsed(
|
|
1440
|
+
parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'>,
|
|
1441
|
+
): WsBridge | null {
|
|
1442
|
+
const session = resolveCloudSessionForParsed(parsed)
|
|
1443
|
+
if (!session) return null
|
|
1444
|
+
return new CloudBridge(session)
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
|
|
1448
|
+
const claim = isRecord(value) ? value.claim : null
|
|
1449
|
+
if (
|
|
1450
|
+
!isRecord(claim) ||
|
|
1451
|
+
typeof claim.id !== 'string' ||
|
|
1452
|
+
!claim.id ||
|
|
1453
|
+
typeof claim.expiresAt !== 'number'
|
|
1454
|
+
) {
|
|
1455
|
+
throw new Error('remote simulator claim returned an invalid receipt')
|
|
1456
|
+
}
|
|
1457
|
+
return { id: claim.id, expiresAt: claim.expiresAt }
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
export async function claimCloudSimulator(
|
|
1461
|
+
session: CloudSimSession,
|
|
1462
|
+
options: { force?: boolean } = {},
|
|
1463
|
+
): Promise<{
|
|
1464
|
+
claim: { id: string; expiresAt: number }
|
|
1465
|
+
session: CloudSimSession
|
|
1466
|
+
}> {
|
|
1467
|
+
const response = await fetch(
|
|
1468
|
+
`${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}/claim`,
|
|
1469
|
+
{
|
|
1470
|
+
method: 'POST',
|
|
1471
|
+
headers: {
|
|
1472
|
+
authorization: `Bearer ${session.token}`,
|
|
1473
|
+
'content-type': 'application/json',
|
|
1474
|
+
},
|
|
1475
|
+
body: JSON.stringify(
|
|
1476
|
+
options.force ? { force: true } : { claimId: session.claimId },
|
|
1477
|
+
),
|
|
1478
|
+
},
|
|
1479
|
+
)
|
|
1480
|
+
const text = await response.text()
|
|
1481
|
+
if (!response.ok) throw responseError(response.status, text)
|
|
1482
|
+
let parsed: unknown
|
|
1483
|
+
try {
|
|
1484
|
+
parsed = JSON.parse(text)
|
|
1485
|
+
} catch {
|
|
1486
|
+
throw new Error('remote simulator claim returned unreadable JSON')
|
|
1487
|
+
}
|
|
1488
|
+
const claim = readClaimResponse(parsed)
|
|
1489
|
+
return {
|
|
1490
|
+
claim,
|
|
1491
|
+
session: createCloudSession({ ...session, claimId: claim.id }),
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
export async function confirmCloudSimulator(session: CloudSimSession): Promise<void> {
|
|
1496
|
+
const confirmed = await claimCloudSimulator(session)
|
|
1497
|
+
if (confirmed.session.claimId !== session.claimId) {
|
|
1498
|
+
throw new Error('remote simulator confirmation replaced the initial claim')
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
export async function closeCloudSession(session: CloudSession): Promise<void> {
|
|
1503
|
+
const boxId = session.boxId
|
|
1504
|
+
const boxToken = session.boxToken
|
|
1505
|
+
if (boxId && boxToken) {
|
|
1506
|
+
const response = await fetch(
|
|
1507
|
+
`${session.apiOrigin}/v1/boxes/${encodeURIComponent(boxId)}`,
|
|
1508
|
+
{
|
|
1509
|
+
method: 'DELETE',
|
|
1510
|
+
headers: { authorization: `Bearer ${boxToken}` },
|
|
1511
|
+
},
|
|
1512
|
+
)
|
|
1513
|
+
const text = await response.text()
|
|
1514
|
+
if (!response.ok && response.status !== 404) {
|
|
1515
|
+
throw responseError(response.status, text)
|
|
1516
|
+
}
|
|
1517
|
+
return
|
|
1518
|
+
}
|
|
1519
|
+
const response = await fetch(
|
|
1520
|
+
`${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}`,
|
|
1521
|
+
{
|
|
1522
|
+
method: 'DELETE',
|
|
1523
|
+
headers: { authorization: `Bearer ${session.token}` },
|
|
1524
|
+
},
|
|
1525
|
+
)
|
|
1526
|
+
const text = await response.text()
|
|
1527
|
+
if (!response.ok && response.status !== 404) {
|
|
1528
|
+
throw responseError(response.status, text)
|
|
1529
|
+
}
|
|
1530
|
+
}
|