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,203 @@
|
|
|
1
|
+
// `plane:*` — the project file plane over the loopback bridge, so a browser
|
|
2
|
+
// tab can reach a real local box. the envelope and its constraints are decided
|
|
3
|
+
// in plans/rnx-cloud/transport-decision.md; this is the local half.
|
|
4
|
+
//
|
|
5
|
+
// the vocabulary is exactly ProjectFilePlane's members, because the point is
|
|
6
|
+
// that a remote plane is a third provider behind that seam rather than a
|
|
7
|
+
// second file API. it is remotable without changing the interface because
|
|
8
|
+
// `index()` is synchronous but `refresh()` is the async step the contract
|
|
9
|
+
// already requires before an index read: a remote plane refreshes into a local
|
|
10
|
+
// cache and reads that.
|
|
11
|
+
//
|
|
12
|
+
// WHY A ROOT MUST BE REGISTERED FIRST. this listener is loopback-only, but a
|
|
13
|
+
// WebSocket to 127.0.0.1 is not subject to CORS, so any page the user visits
|
|
14
|
+
// can open one. if `plane:open` took an arbitrary path, that page could read
|
|
15
|
+
// any file on the machine through this daemon. a root is reachable only while
|
|
16
|
+
// a box has registered it, and the registration dies with its socket.
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { dispatchPlaneCommand, indexVersion } from '@rnx/box/plane'
|
|
21
|
+
import { CheckoutFilePlane } from '../../cli/commands/box/checkout-plane.ts'
|
|
22
|
+
import type { ProjectFilePlane } from '@rnx/box/plane'
|
|
23
|
+
import type { WebSocket } from 'ws'
|
|
24
|
+
|
|
25
|
+
const WS_OPEN = 1
|
|
26
|
+
|
|
27
|
+
interface OpenPlane {
|
|
28
|
+
root: string
|
|
29
|
+
plane: ProjectFilePlane
|
|
30
|
+
version: string
|
|
31
|
+
sockets: Set<WebSocket>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class PlaneNotRegisteredError extends Error {}
|
|
35
|
+
|
|
36
|
+
export class PlaneHost {
|
|
37
|
+
// roots a box authorized, each tied to the socket that authorized it
|
|
38
|
+
private registered = new Map<string, Set<WebSocket>>()
|
|
39
|
+
private open = new Map<string, OpenPlane>()
|
|
40
|
+
private byRoot = new Map<string, string>()
|
|
41
|
+
private nextPlaneId = 1
|
|
42
|
+
|
|
43
|
+
/** handle a plane:* message. returns true iff it was one. */
|
|
44
|
+
async handleMessage(ws: WebSocket, msg: unknown): Promise<boolean> {
|
|
45
|
+
if (typeof msg !== 'object' || msg === null) return false
|
|
46
|
+
const record: Record<string, unknown> = { ...msg }
|
|
47
|
+
const type = record.type
|
|
48
|
+
if (typeof type !== 'string' || !type.startsWith('plane:')) return false
|
|
49
|
+
const id = record.id
|
|
50
|
+
try {
|
|
51
|
+
this.respond(ws, id, await this.dispatch(ws, type, record))
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const code =
|
|
54
|
+
err instanceof PlaneNotRegisteredError ? 'plane-not-registered' : undefined
|
|
55
|
+
this.respondError(ws, id, err instanceof Error ? err.message : String(err), code)
|
|
56
|
+
}
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
unregisterSocket(ws: WebSocket): void {
|
|
61
|
+
for (const [root, sockets] of [...this.registered]) {
|
|
62
|
+
if (!sockets.delete(ws)) continue
|
|
63
|
+
if (sockets.size > 0) continue
|
|
64
|
+
// the box that authorized this root is gone, so drop the plane with it
|
|
65
|
+
// rather than leaving a reachable handle behind.
|
|
66
|
+
this.registered.delete(root)
|
|
67
|
+
const planeId = this.byRoot.get(root)
|
|
68
|
+
if (planeId) {
|
|
69
|
+
this.open.delete(planeId)
|
|
70
|
+
this.byRoot.delete(root)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const [planeId, entry] of [...this.open]) {
|
|
74
|
+
entry.sockets.delete(ws)
|
|
75
|
+
if (entry.sockets.size === 0 && !this.registered.has(entry.root)) {
|
|
76
|
+
this.open.delete(planeId)
|
|
77
|
+
this.byRoot.delete(entry.root)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** roots currently reachable, for the daemon's status output. */
|
|
83
|
+
registeredRoots(): string[] {
|
|
84
|
+
return [...this.registered.keys()]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async dispatch(
|
|
88
|
+
ws: WebSocket,
|
|
89
|
+
type: string,
|
|
90
|
+
msg: Record<string, unknown>,
|
|
91
|
+
): Promise<unknown> {
|
|
92
|
+
switch (type) {
|
|
93
|
+
case 'plane:register': {
|
|
94
|
+
const root = this.realRoot(String(msg.root ?? ''))
|
|
95
|
+
const sockets = this.registered.get(root) ?? new Set<WebSocket>()
|
|
96
|
+
sockets.add(ws)
|
|
97
|
+
this.registered.set(root, sockets)
|
|
98
|
+
// `holders` INCLUDING this one, because two boxes over one checkout is
|
|
99
|
+
// a mistake only the daemon can see: CheckoutFilePlane re-runs
|
|
100
|
+
// `git ls-files` before every command, so each box changes the file
|
|
101
|
+
// set the other is working from mid-run. the daemon reports it rather
|
|
102
|
+
// than refusing, because a box whose socket dropped and reconnected
|
|
103
|
+
// can briefly race its own stale entry, and a refusal there would
|
|
104
|
+
// brick a legitimate restart. the caller decides; see box.ts, which
|
|
105
|
+
// already refuses a box inside a box for the same reason.
|
|
106
|
+
return { root, holders: sockets.size }
|
|
107
|
+
}
|
|
108
|
+
// READ ONLY, and that is the whole point of it existing beside the
|
|
109
|
+
// `holders` field on plane:register.
|
|
110
|
+
//
|
|
111
|
+
// registration is the AUTHORIZATION step: it is what permits any page on
|
|
112
|
+
// this machine to open that root over a loopback socket that CORS does
|
|
113
|
+
// not protect. so a caller asking "is anyone else in this checkout"
|
|
114
|
+
// cannot be made to authorize the root in order to find out, even
|
|
115
|
+
// briefly. it also means the count is the honest one: no box reads 0 and
|
|
116
|
+
// a single box reads 1, where asking through register would have made
|
|
117
|
+
// every answer one higher and forced a threshold that counts the asker.
|
|
118
|
+
case 'plane:status': {
|
|
119
|
+
const root = this.realRoot(String(msg.root ?? ''))
|
|
120
|
+
const sockets = this.registered.get(root)
|
|
121
|
+
return { root, holders: sockets?.size ?? 0, registered: Boolean(sockets) }
|
|
122
|
+
}
|
|
123
|
+
case 'plane:open': {
|
|
124
|
+
const root = this.realRoot(String(msg.root ?? ''))
|
|
125
|
+
if (!this.registered.has(root)) {
|
|
126
|
+
throw new PlaneNotRegisteredError(
|
|
127
|
+
`no box has registered ${root}; start one with \`rnx box\` there`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
const existingId = this.byRoot.get(root)
|
|
131
|
+
const existing = existingId ? this.open.get(existingId) : undefined
|
|
132
|
+
if (existingId && existing) {
|
|
133
|
+
existing.sockets.add(ws)
|
|
134
|
+
return { planeId: existingId, version: existing.version }
|
|
135
|
+
}
|
|
136
|
+
const plane = new CheckoutFilePlane(root)
|
|
137
|
+
await plane.refresh()
|
|
138
|
+
const planeId = `plane-${this.nextPlaneId++}`
|
|
139
|
+
const entry: OpenPlane = {
|
|
140
|
+
root,
|
|
141
|
+
plane,
|
|
142
|
+
version: indexVersion(plane.index()),
|
|
143
|
+
sockets: new Set([ws]),
|
|
144
|
+
}
|
|
145
|
+
this.open.set(planeId, entry)
|
|
146
|
+
this.byRoot.set(root, planeId)
|
|
147
|
+
return { planeId, version: entry.version }
|
|
148
|
+
}
|
|
149
|
+
case 'plane:close': {
|
|
150
|
+
const entry = this.open.get(String(msg.planeId ?? ''))
|
|
151
|
+
if (entry) entry.sockets.delete(ws)
|
|
152
|
+
return { ok: true }
|
|
153
|
+
}
|
|
154
|
+
default: {
|
|
155
|
+
const entry = this.entry(msg)
|
|
156
|
+
const handled = await dispatchPlaneCommand(entry.plane, type, msg)
|
|
157
|
+
if (!handled) throw new Error(`unknown plane command: ${type}`)
|
|
158
|
+
// no companion change event on a write. the write lands on the real
|
|
159
|
+
// checkout and the dev server's own watcher sees it, so a notification
|
|
160
|
+
// here would be a second channel for something the filesystem already
|
|
161
|
+
// reports. the cloud placement, whose store nothing can watch, is where
|
|
162
|
+
// a write has to fan one out.
|
|
163
|
+
if (handled.version) entry.version = handled.version
|
|
164
|
+
return handled.result
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private entry(msg: Record<string, unknown>): OpenPlane {
|
|
170
|
+
const entry = this.open.get(String(msg.planeId ?? ''))
|
|
171
|
+
if (!entry) throw new Error(`unknown planeId: ${String(msg.planeId)}`)
|
|
172
|
+
return entry
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// resolve symlinks and relative segments before comparing, so a registered
|
|
176
|
+
// root and an opened one cannot disagree about the same directory.
|
|
177
|
+
private realRoot(root: string): string {
|
|
178
|
+
if (!root) throw new Error('root is required')
|
|
179
|
+
const resolved = path.resolve(root)
|
|
180
|
+
let real: string
|
|
181
|
+
try {
|
|
182
|
+
real = fs.realpathSync(resolved)
|
|
183
|
+
} catch {
|
|
184
|
+
throw new Error(`no such directory: ${resolved}`)
|
|
185
|
+
}
|
|
186
|
+
if (!fs.statSync(real).isDirectory()) throw new Error(`not a directory: ${real}`)
|
|
187
|
+
return real
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private respond(ws: WebSocket, id: unknown, result: unknown): void {
|
|
191
|
+
if (ws.readyState !== WS_OPEN) return
|
|
192
|
+
try {
|
|
193
|
+
ws.send(JSON.stringify({ id, result }))
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private respondError(ws: WebSocket, id: unknown, error: string, code?: string): void {
|
|
198
|
+
if (ws.readyState !== WS_OPEN) return
|
|
199
|
+
try {
|
|
200
|
+
ws.send(JSON.stringify({ id, error, ...(code ? { code } : {}) }))
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
compileReplacementModule,
|
|
5
|
+
REPLACEMENT_MODULE_EXTENSIONS,
|
|
6
|
+
} from '../../../contrast-bundler/src/replacementModule.ts'
|
|
7
|
+
import { isLoopbackHost } from '../backend-origin.ts'
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
+
|
|
10
|
+
export const REPLACEMENT_MODULE_ROUTE = '/__sootsim-replacement-module'
|
|
11
|
+
|
|
12
|
+
export interface ReplacementModuleHandlerOptions {
|
|
13
|
+
projectRoot?: string
|
|
14
|
+
requireLoopbackOrigin?: boolean
|
|
15
|
+
requestToken?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sendJavaScript(req: IncomingMessage, res: ServerResponse, source: string): void {
|
|
19
|
+
const origin = req.headers.origin
|
|
20
|
+
let allowedOrigin: string | undefined
|
|
21
|
+
if (origin) {
|
|
22
|
+
try {
|
|
23
|
+
if (isLoopbackHost(new URL(origin).hostname)) allowedOrigin = origin
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
res.writeHead(200, {
|
|
27
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
28
|
+
'Cache-Control': 'no-cache',
|
|
29
|
+
...(allowedOrigin
|
|
30
|
+
? { 'Access-Control-Allow-Origin': allowedOrigin, Vary: 'Origin' }
|
|
31
|
+
: {}),
|
|
32
|
+
})
|
|
33
|
+
res.end(req.method === 'HEAD' ? undefined : source)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isReplacementModuleRequestUrl(rawUrl?: string): boolean {
|
|
37
|
+
if (!rawUrl) return false
|
|
38
|
+
try {
|
|
39
|
+
const pathname = new URL(rawUrl, 'http://rnx.local').pathname
|
|
40
|
+
return pathname === REPLACEMENT_MODULE_ROUTE
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function handleReplacementModuleRequest(
|
|
47
|
+
req: IncomingMessage,
|
|
48
|
+
res: ServerResponse,
|
|
49
|
+
options: ReplacementModuleHandlerOptions = {},
|
|
50
|
+
): Promise<boolean> {
|
|
51
|
+
const rawUrl = req.url || ''
|
|
52
|
+
let url: URL
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(rawUrl, 'http://rnx.local')
|
|
55
|
+
} catch {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (options.requireLoopbackOrigin) {
|
|
60
|
+
const origin = req.headers.origin
|
|
61
|
+
const remoteAddress = req.socket.remoteAddress?.replace(/^::ffff:/, '')
|
|
62
|
+
let allowed = Boolean(remoteAddress && isLoopbackHost(remoteAddress))
|
|
63
|
+
if (allowed && origin) {
|
|
64
|
+
try {
|
|
65
|
+
allowed = isLoopbackHost(new URL(origin).hostname)
|
|
66
|
+
} catch {
|
|
67
|
+
allowed = false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!allowed) {
|
|
71
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
72
|
+
res.end('replacement modules are available only to loopback origins')
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (url.pathname !== REPLACEMENT_MODULE_ROUTE) return false
|
|
78
|
+
|
|
79
|
+
if (options.requestToken && url.searchParams.get('token') !== options.requestToken) {
|
|
80
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
81
|
+
res.end('replacement module request token is invalid')
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const targetPath = url.searchParams.get('path')
|
|
86
|
+
if (!targetPath) {
|
|
87
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
88
|
+
res.end('missing path param')
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const extension = path.extname(targetPath).toLowerCase()
|
|
93
|
+
if (!REPLACEMENT_MODULE_EXTENSIONS.some((allowed) => allowed === extension)) {
|
|
94
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
95
|
+
res.end(`replacement files must be ${REPLACEMENT_MODULE_EXTENSIONS.join(', ')}`)
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!path.isAbsolute(targetPath)) {
|
|
100
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
101
|
+
res.end('replacement file path must be absolute')
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const runtimePackages = new Set(url.searchParams.getAll('runtimePackage'))
|
|
106
|
+
|
|
107
|
+
let resolvedProjectRoot: string | undefined
|
|
108
|
+
if (options.projectRoot) {
|
|
109
|
+
const lexicalProjectRoot = path.resolve(options.projectRoot)
|
|
110
|
+
resolvedProjectRoot = await fs.realpath(options.projectRoot)
|
|
111
|
+
const lexicalTarget = path.relative(lexicalProjectRoot, targetPath)
|
|
112
|
+
if (lexicalTarget.startsWith('..') || path.isAbsolute(lexicalTarget)) {
|
|
113
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
114
|
+
res.end('replacement file must be inside the project root')
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let resolvedTargetPath: string
|
|
120
|
+
try {
|
|
121
|
+
resolvedTargetPath = await fs.realpath(targetPath)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
124
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
125
|
+
res.end(`replacement file not found: ${message}`)
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (resolvedProjectRoot) {
|
|
130
|
+
const relativeTarget = path.relative(resolvedProjectRoot, resolvedTargetPath)
|
|
131
|
+
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
|
132
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
133
|
+
res.end('replacement file must be inside the project root')
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const compiled = compileReplacementModule({
|
|
140
|
+
filePath: resolvedTargetPath,
|
|
141
|
+
source: await fs.readFile(resolvedTargetPath, 'utf8'),
|
|
142
|
+
runtimePackages,
|
|
143
|
+
})
|
|
144
|
+
// inline the map: the file is the app author's own source, and the
|
|
145
|
+
// simulator page is the only thing that ever loads it.
|
|
146
|
+
const map = Buffer.from(JSON.stringify(compiled.sourceMap), 'utf8').toString('base64')
|
|
147
|
+
sendJavaScript(
|
|
148
|
+
req,
|
|
149
|
+
res,
|
|
150
|
+
`${compiled.code}\n//# sourceMappingURL=data:application/json;base64,${map}\n`,
|
|
151
|
+
)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
154
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
155
|
+
res.end(`replacement compile failed: ${message}`)
|
|
156
|
+
}
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { createHash } from 'crypto'
|
|
2
|
+
import { request as requestHttp, type IncomingMessage } from 'http'
|
|
3
|
+
import { request as requestHttps } from 'https'
|
|
4
|
+
import type { Duplex } from 'stream'
|
|
5
|
+
|
|
6
|
+
export const WEBSOCKET_PROXY_PATH = '/__websocket-proxy'
|
|
7
|
+
export const WEBSOCKET_PROXY_HANDSHAKE_TIMEOUT_MS = 10_000
|
|
8
|
+
|
|
9
|
+
const WEBSOCKET_PROTOCOL_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
10
|
+
const WEBSOCKET_KEY = /^[+/0-9A-Za-z]{22}==$/
|
|
11
|
+
|
|
12
|
+
type UpgradeRejection = {
|
|
13
|
+
status: number
|
|
14
|
+
message: string
|
|
15
|
+
headers?: Record<string, string>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STRIP_UPSTREAM_HEADERS = new Set([
|
|
19
|
+
'host',
|
|
20
|
+
'connection',
|
|
21
|
+
'upgrade',
|
|
22
|
+
'transfer-encoding',
|
|
23
|
+
'content-length',
|
|
24
|
+
'sec-websocket-accept',
|
|
25
|
+
'sec-websocket-extensions',
|
|
26
|
+
'sec-websocket-key',
|
|
27
|
+
'sec-websocket-protocol',
|
|
28
|
+
'sec-websocket-version',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
function rejectUpgrade(
|
|
32
|
+
socket: Duplex,
|
|
33
|
+
status: number,
|
|
34
|
+
message: string,
|
|
35
|
+
headers: Record<string, string> = {},
|
|
36
|
+
) {
|
|
37
|
+
try {
|
|
38
|
+
const extraHeaders = Object.entries(headers)
|
|
39
|
+
.map(([name, value]) => `${name}: ${value}\r\n`)
|
|
40
|
+
.join('')
|
|
41
|
+
socket.write(
|
|
42
|
+
`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: ${message.length}\r\n${extraHeaders}\r\n${message}`,
|
|
43
|
+
)
|
|
44
|
+
} catch {}
|
|
45
|
+
socket.destroy()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasHeaderToken(value: string | undefined, token: string): boolean {
|
|
49
|
+
return (value || '').split(',').some((part) => part.trim().toLowerCase() === token)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasValidProtocolOffer(value: string | undefined): boolean {
|
|
53
|
+
if (value === undefined) return true
|
|
54
|
+
if (!WEBSOCKET_PROTOCOL_TOKEN.test(value[0] || '')) return false
|
|
55
|
+
if (!WEBSOCKET_PROTOCOL_TOKEN.test(value[value.length - 1] || '')) return false
|
|
56
|
+
const protocols = new Set<string>()
|
|
57
|
+
for (const part of value.split(',')) {
|
|
58
|
+
const protocol = part.trim()
|
|
59
|
+
if (!WEBSOCKET_PROTOCOL_TOKEN.test(protocol) || protocols.has(protocol)) {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
protocols.add(protocol)
|
|
63
|
+
}
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateUpgradeRequest(req: IncomingMessage): UpgradeRejection | null {
|
|
68
|
+
if (req.method !== 'GET') {
|
|
69
|
+
return { status: 405, message: 'invalid websocket http method' }
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
!hasHeaderToken(req.headers.connection, 'upgrade') ||
|
|
73
|
+
!hasHeaderToken(req.headers.upgrade, 'websocket')
|
|
74
|
+
) {
|
|
75
|
+
return { status: 400, message: 'invalid websocket upgrade headers' }
|
|
76
|
+
}
|
|
77
|
+
const key = req.headers['sec-websocket-key']
|
|
78
|
+
if (
|
|
79
|
+
typeof key !== 'string' ||
|
|
80
|
+
!WEBSOCKET_KEY.test(key) ||
|
|
81
|
+
Buffer.from(key, 'base64').byteLength !== 16 ||
|
|
82
|
+
Buffer.from(key, 'base64').toString('base64') !== key
|
|
83
|
+
) {
|
|
84
|
+
return { status: 400, message: 'invalid websocket key' }
|
|
85
|
+
}
|
|
86
|
+
if (req.headers['sec-websocket-version'] !== '13') {
|
|
87
|
+
return {
|
|
88
|
+
status: 400,
|
|
89
|
+
message: 'invalid websocket version',
|
|
90
|
+
headers: { 'Sec-WebSocket-Version': '13' },
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!hasValidProtocolOffer(req.headers['sec-websocket-protocol'])) {
|
|
94
|
+
return { status: 400, message: 'invalid websocket protocol offer' }
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isSameOriginUpgrade(req: IncomingMessage): boolean {
|
|
100
|
+
const origin = req.headers.origin
|
|
101
|
+
const host = req.headers.host
|
|
102
|
+
if (!origin || !host) return false
|
|
103
|
+
try {
|
|
104
|
+
return new URL(origin).host === host
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function decodeProxyHeaders(encoded: string | null): Record<string, string> {
|
|
111
|
+
if (!encoded) return {}
|
|
112
|
+
const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
|
113
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
|
|
114
|
+
const parsed = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
|
|
115
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
116
|
+
const headers: Record<string, string> = {}
|
|
117
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
118
|
+
if (value == null) continue
|
|
119
|
+
if (STRIP_UPSTREAM_HEADERS.has(key.toLowerCase())) continue
|
|
120
|
+
headers[key] = Array.isArray(value) ? value.join(', ') : String(value)
|
|
121
|
+
}
|
|
122
|
+
return headers
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getDefaultWebSocketOrigin(targetUrl: URL): string {
|
|
126
|
+
const origin = new URL(targetUrl.href)
|
|
127
|
+
origin.protocol = targetUrl.protocol === 'wss:' ? 'https:' : 'http:'
|
|
128
|
+
return origin.origin
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createUpstreamHeaders(
|
|
132
|
+
req: IncomingMessage,
|
|
133
|
+
targetUrl: URL,
|
|
134
|
+
headers: Record<string, string>,
|
|
135
|
+
forwardOrigin: boolean,
|
|
136
|
+
): Record<string, string> {
|
|
137
|
+
const upstreamHeaders = { ...headers }
|
|
138
|
+
const originKeys = Object.keys(upstreamHeaders).filter(
|
|
139
|
+
(key) => key.toLowerCase() === 'origin',
|
|
140
|
+
)
|
|
141
|
+
if (forwardOrigin && originKeys.length === 0) {
|
|
142
|
+
upstreamHeaders.origin = getDefaultWebSocketOrigin(targetUrl)
|
|
143
|
+
} else if (!forwardOrigin) {
|
|
144
|
+
for (const key of originKeys) delete upstreamHeaders[key]
|
|
145
|
+
}
|
|
146
|
+
upstreamHeaders.connection = 'Upgrade'
|
|
147
|
+
upstreamHeaders.upgrade = 'websocket'
|
|
148
|
+
const key = req.headers['sec-websocket-key']
|
|
149
|
+
if (typeof key === 'string') upstreamHeaders['sec-websocket-key'] = key
|
|
150
|
+
const version = req.headers['sec-websocket-version']
|
|
151
|
+
if (typeof version === 'string') upstreamHeaders['sec-websocket-version'] = version
|
|
152
|
+
const protocol = req.headers['sec-websocket-protocol']
|
|
153
|
+
if (typeof protocol === 'string') upstreamHeaders['sec-websocket-protocol'] = protocol
|
|
154
|
+
const extensions = req.headers['sec-websocket-extensions']
|
|
155
|
+
if (typeof extensions === 'string') {
|
|
156
|
+
upstreamHeaders['sec-websocket-extensions'] = extensions
|
|
157
|
+
}
|
|
158
|
+
return upstreamHeaders
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function isWebSocketProxyRequestUrl(rawUrl: string | undefined): boolean {
|
|
162
|
+
if (!rawUrl) return false
|
|
163
|
+
try {
|
|
164
|
+
return new URL(rawUrl, 'http://localhost').pathname === WEBSOCKET_PROXY_PATH
|
|
165
|
+
} catch {
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function handleWebSocketProxyUpgrade(
|
|
171
|
+
req: IncomingMessage,
|
|
172
|
+
socket: Duplex,
|
|
173
|
+
head: Buffer,
|
|
174
|
+
forwardOrigin = true,
|
|
175
|
+
): boolean {
|
|
176
|
+
if (!isWebSocketProxyRequestUrl(req.url)) return false
|
|
177
|
+
if (!isSameOriginUpgrade(req)) {
|
|
178
|
+
rejectUpgrade(socket, 403, 'forbidden websocket proxy origin')
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
const upgradeRejection = validateUpgradeRequest(req)
|
|
182
|
+
if (upgradeRejection) {
|
|
183
|
+
rejectUpgrade(
|
|
184
|
+
socket,
|
|
185
|
+
upgradeRejection.status,
|
|
186
|
+
upgradeRejection.message,
|
|
187
|
+
upgradeRejection.headers,
|
|
188
|
+
)
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let targetUrl: URL
|
|
193
|
+
let headers: Record<string, string>
|
|
194
|
+
try {
|
|
195
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost')
|
|
196
|
+
const target = requestUrl.searchParams.get('url')
|
|
197
|
+
if (!target) {
|
|
198
|
+
rejectUpgrade(socket, 400, 'missing websocket proxy url')
|
|
199
|
+
return true
|
|
200
|
+
}
|
|
201
|
+
targetUrl = new URL(target)
|
|
202
|
+
if (targetUrl.protocol !== 'ws:' && targetUrl.protocol !== 'wss:') {
|
|
203
|
+
rejectUpgrade(socket, 400, 'invalid websocket proxy protocol')
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
206
|
+
headers = decodeProxyHeaders(requestUrl.searchParams.get('headers'))
|
|
207
|
+
} catch {
|
|
208
|
+
rejectUpgrade(socket, 400, 'invalid websocket proxy request')
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const upstreamHeaders = createUpstreamHeaders(req, targetUrl, headers, forwardOrigin)
|
|
213
|
+
let settled = false
|
|
214
|
+
const request = targetUrl.protocol === 'wss:' ? requestHttps : requestHttp
|
|
215
|
+
const upstreamRequestUrl = new URL(targetUrl)
|
|
216
|
+
upstreamRequestUrl.protocol = targetUrl.protocol === 'wss:' ? 'https:' : 'http:'
|
|
217
|
+
const upstreamRequest = request(upstreamRequestUrl, {
|
|
218
|
+
method: 'GET',
|
|
219
|
+
headers: upstreamHeaders,
|
|
220
|
+
})
|
|
221
|
+
let destroyUpstreamSocket = () => {}
|
|
222
|
+
upstreamRequest.once('socket', (upstreamSocket) => {
|
|
223
|
+
destroyUpstreamSocket = () => upstreamSocket.destroy()
|
|
224
|
+
})
|
|
225
|
+
const fail = (status: number, message: string) => {
|
|
226
|
+
if (settled) return
|
|
227
|
+
settled = true
|
|
228
|
+
clearTimeout(timer)
|
|
229
|
+
upstreamRequest.destroy()
|
|
230
|
+
destroyUpstreamSocket()
|
|
231
|
+
rejectUpgrade(socket, status, message)
|
|
232
|
+
}
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
fail(504, 'upstream websocket handshake timeout')
|
|
235
|
+
}, WEBSOCKET_PROXY_HANDSHAKE_TIMEOUT_MS)
|
|
236
|
+
socket.once('close', () => {
|
|
237
|
+
if (!settled) {
|
|
238
|
+
settled = true
|
|
239
|
+
clearTimeout(timer)
|
|
240
|
+
}
|
|
241
|
+
upstreamRequest.destroy()
|
|
242
|
+
destroyUpstreamSocket()
|
|
243
|
+
})
|
|
244
|
+
upstreamRequest.once('response', (response) => {
|
|
245
|
+
response.resume()
|
|
246
|
+
fail(502, 'upstream websocket rejected upgrade')
|
|
247
|
+
})
|
|
248
|
+
upstreamRequest.once('error', () => {
|
|
249
|
+
fail(502, 'upstream websocket error')
|
|
250
|
+
})
|
|
251
|
+
upstreamRequest.once('upgrade', (response, upstreamSocket, upstreamHead) => {
|
|
252
|
+
if (settled) {
|
|
253
|
+
upstreamSocket.destroy()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const accept = response.headers['sec-websocket-accept']
|
|
257
|
+
const upgrade = response.headers.upgrade
|
|
258
|
+
const requestKey = req.headers['sec-websocket-key']
|
|
259
|
+
const expectedAccept =
|
|
260
|
+
typeof requestKey === 'string'
|
|
261
|
+
? createHash('sha1')
|
|
262
|
+
.update(`${requestKey}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
263
|
+
.digest('base64')
|
|
264
|
+
: ''
|
|
265
|
+
if (
|
|
266
|
+
typeof accept !== 'string' ||
|
|
267
|
+
accept !== expectedAccept ||
|
|
268
|
+
typeof upgrade !== 'string' ||
|
|
269
|
+
upgrade.toLowerCase() !== 'websocket'
|
|
270
|
+
) {
|
|
271
|
+
upstreamSocket.destroy()
|
|
272
|
+
fail(502, 'upstream websocket handshake is invalid')
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
const selectedProtocol = response.headers['sec-websocket-protocol']
|
|
276
|
+
const requestedProtocols = (req.headers['sec-websocket-protocol'] || '')
|
|
277
|
+
.split(',')
|
|
278
|
+
.map((protocol) => protocol.trim())
|
|
279
|
+
if (
|
|
280
|
+
typeof selectedProtocol === 'string' &&
|
|
281
|
+
!requestedProtocols.includes(selectedProtocol)
|
|
282
|
+
) {
|
|
283
|
+
upstreamSocket.destroy()
|
|
284
|
+
fail(502, 'upstream websocket selected an unrequested protocol')
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
settled = true
|
|
288
|
+
clearTimeout(timer)
|
|
289
|
+
const responseHeaders = [
|
|
290
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
291
|
+
'Upgrade: websocket',
|
|
292
|
+
'Connection: Upgrade',
|
|
293
|
+
`Sec-WebSocket-Accept: ${accept}`,
|
|
294
|
+
]
|
|
295
|
+
if (typeof selectedProtocol === 'string') {
|
|
296
|
+
responseHeaders.push(`Sec-WebSocket-Protocol: ${selectedProtocol}`)
|
|
297
|
+
}
|
|
298
|
+
const selectedExtensions = response.headers['sec-websocket-extensions']
|
|
299
|
+
if (typeof selectedExtensions === 'string') {
|
|
300
|
+
responseHeaders.push(`Sec-WebSocket-Extensions: ${selectedExtensions}`)
|
|
301
|
+
}
|
|
302
|
+
// the upstream and browser used the same websocket key and extension offer,
|
|
303
|
+
// so their negotiated byte streams can be relayed without decoding frames.
|
|
304
|
+
socket.write(`${responseHeaders.join('\r\n')}\r\n\r\n`)
|
|
305
|
+
if (head.length > 0) upstreamSocket.write(head)
|
|
306
|
+
if (upstreamHead.length > 0) socket.write(upstreamHead)
|
|
307
|
+
socket.once('close', () => upstreamSocket.destroy())
|
|
308
|
+
socket.once('error', () => upstreamSocket.destroy())
|
|
309
|
+
upstreamSocket.once('error', () => socket.destroy())
|
|
310
|
+
socket.pipe(upstreamSocket)
|
|
311
|
+
upstreamSocket.pipe(socket)
|
|
312
|
+
})
|
|
313
|
+
upstreamRequest.end()
|
|
314
|
+
return true
|
|
315
|
+
}
|