dextunnel 0.1.0
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 +211 -0
- package/README.md +112 -0
- package/SECURITY.md +27 -0
- package/SUPPORT.md +43 -0
- package/package.json +44 -0
- package/public/client-shared.js +1831 -0
- package/public/favicon.svg +11 -0
- package/public/host.html +29 -0
- package/public/host.js +2079 -0
- package/public/index.html +28 -0
- package/public/index.js +98 -0
- package/public/live-bridge-lifecycle.js +258 -0
- package/public/live-bridge-retry-state.js +61 -0
- package/public/live-selection-intent.js +79 -0
- package/public/remote-operator-state.js +316 -0
- package/public/remote.html +167 -0
- package/public/remote.js +3967 -0
- package/public/styles.css +2793 -0
- package/public/surface-view-state.js +89 -0
- package/public/voice-dictation.js +45 -0
- package/src/bin/desktop-rehydration-smoke.mjs +111 -0
- package/src/bin/dextunnel.mjs +41 -0
- package/src/bin/doctor.mjs +48 -0
- package/src/bin/launch-attest.mjs +39 -0
- package/src/bin/launch-status.mjs +49 -0
- package/src/bin/mobile-link-proxy.mjs +221 -0
- package/src/bin/mobile-proof.mjs +164 -0
- package/src/bin/mobile-transport-smoke.mjs +200 -0
- package/src/bin/probe-codex-app-server-write.mjs +36 -0
- package/src/bin/probe-codex-app-server.mjs +30 -0
- package/src/lib/agent-room-context.mjs +54 -0
- package/src/lib/agent-room-runtime.mjs +355 -0
- package/src/lib/agent-room-service.mjs +335 -0
- package/src/lib/agent-room-state.mjs +406 -0
- package/src/lib/agent-room-store.mjs +71 -0
- package/src/lib/agent-room-text.mjs +48 -0
- package/src/lib/app-server-contract.mjs +66 -0
- package/src/lib/app-server-runtime.mjs +60 -0
- package/src/lib/attachment-service.mjs +119 -0
- package/src/lib/bridge-api-handler.mjs +719 -0
- package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
- package/src/lib/bridge-status-builder.mjs +60 -0
- package/src/lib/codex-app-server-client.mjs +1511 -0
- package/src/lib/companion-state.mjs +453 -0
- package/src/lib/control-lease-service.mjs +180 -0
- package/src/lib/debug-harness-service.mjs +173 -0
- package/src/lib/desktop-integration.mjs +146 -0
- package/src/lib/desktop-rehydration-smoke.mjs +269 -0
- package/src/lib/dextunnel-cli.mjs +122 -0
- package/src/lib/discovery-docs.mjs +1321 -0
- package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
- package/src/lib/install-preflight.mjs +373 -0
- package/src/lib/interaction-resolution-service.mjs +185 -0
- package/src/lib/interaction-state.mjs +360 -0
- package/src/lib/launch-release-bar.mjs +158 -0
- package/src/lib/live-control-state.mjs +107 -0
- package/src/lib/live-payload-builder.mjs +298 -0
- package/src/lib/live-selection-transition-state.mjs +49 -0
- package/src/lib/live-transcript-state.mjs +549 -0
- package/src/lib/mobile-network-profile.mjs +39 -0
- package/src/lib/mock-codex-adapter.mjs +62 -0
- package/src/lib/operator-diagnostics.mjs +82 -0
- package/src/lib/repo-changes-service.mjs +527 -0
- package/src/lib/runtime-config.mjs +106 -0
- package/src/lib/selection-state-service.mjs +214 -0
- package/src/lib/session-store.mjs +355 -0
- package/src/lib/shared-room-state.mjs +473 -0
- package/src/lib/shared-selection-state.mjs +40 -0
- package/src/lib/sse-hub.mjs +35 -0
- package/src/lib/static-surface-service.mjs +71 -0
- package/src/lib/surface-access.mjs +189 -0
- package/src/lib/surface-presence-service.mjs +118 -0
- package/src/lib/surface-request-guard.mjs +52 -0
- package/src/lib/thread-sync-state.mjs +536 -0
- package/src/lib/watcher-lifecycle.mjs +287 -0
- package/src/server.mjs +1446 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export function createSurfaceViewState({
|
|
2
|
+
defaults = {},
|
|
3
|
+
scopeId = "",
|
|
4
|
+
surface = "surface",
|
|
5
|
+
storage = globalThis?.localStorage
|
|
6
|
+
} = {}) {
|
|
7
|
+
const normalizedSurface = String(surface || "surface").trim().toLowerCase() || "surface";
|
|
8
|
+
const normalizedScope = String(scopeId || "").trim() || "default";
|
|
9
|
+
|
|
10
|
+
function key(kind, suffix = "") {
|
|
11
|
+
return `dextunnel:view:${normalizedSurface}:${normalizedScope}:${kind}${suffix ? `:${suffix}` : ""}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readJson(storageKey, fallback) {
|
|
15
|
+
if (!storage) {
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const raw = storage.getItem(storageKey);
|
|
21
|
+
if (!raw) {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
} catch {
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeJson(storageKey, value) {
|
|
31
|
+
if (!storage) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
storage.setItem(storageKey, JSON.stringify(value));
|
|
37
|
+
} catch {
|
|
38
|
+
// Best effort only.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
loadExpansionMode(threadId = "none") {
|
|
44
|
+
const parsed = readJson(key("expansion-mode", String(threadId || "none")), defaults.expansionMode || "compact");
|
|
45
|
+
return parsed === "expanded" ? "expanded" : "compact";
|
|
46
|
+
},
|
|
47
|
+
loadSidebarMode() {
|
|
48
|
+
const parsed = readJson(key("sidebar-mode"), defaults.sidebarMode || "expanded");
|
|
49
|
+
return parsed === "collapsed" ? "collapsed" : "expanded";
|
|
50
|
+
},
|
|
51
|
+
loadExpandedSections(threadId = "none") {
|
|
52
|
+
const parsed = readJson(key("expanded", String(threadId || "none")), []);
|
|
53
|
+
return Array.isArray(parsed)
|
|
54
|
+
? parsed.map((value) => String(value || "").trim()).filter(Boolean)
|
|
55
|
+
: [];
|
|
56
|
+
},
|
|
57
|
+
loadFilters() {
|
|
58
|
+
const parsed = readJson(key("filters"), defaults.filters || {});
|
|
59
|
+
if (!parsed || typeof parsed !== "object") {
|
|
60
|
+
return { ...(defaults.filters || {}) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const next = { ...(defaults.filters || {}) };
|
|
64
|
+
for (const [name, fallback] of Object.entries(defaults.filters || {})) {
|
|
65
|
+
next[name] = typeof parsed[name] === "boolean" ? parsed[name] : fallback;
|
|
66
|
+
}
|
|
67
|
+
return next;
|
|
68
|
+
},
|
|
69
|
+
saveExpandedSections(threadId = "none", keys = []) {
|
|
70
|
+
writeJson(
|
|
71
|
+
key("expanded", String(threadId || "none")),
|
|
72
|
+
[...new Set(keys.map((value) => String(value || "").trim()).filter(Boolean))]
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
saveExpansionMode(threadId = "none", mode = "compact") {
|
|
76
|
+
writeJson(key("expansion-mode", String(threadId || "none")), mode === "expanded" ? "expanded" : "compact");
|
|
77
|
+
},
|
|
78
|
+
saveSidebarMode(mode = "expanded") {
|
|
79
|
+
writeJson(key("sidebar-mode"), mode === "collapsed" ? "collapsed" : "expanded");
|
|
80
|
+
},
|
|
81
|
+
saveFilters(filters = {}) {
|
|
82
|
+
const next = {};
|
|
83
|
+
for (const [name, fallback] of Object.entries(defaults.filters || {})) {
|
|
84
|
+
next[name] = typeof filters[name] === "boolean" ? filters[name] : fallback;
|
|
85
|
+
}
|
|
86
|
+
writeJson(key("filters"), next);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function getSpeechRecognitionCtor(root = globalThis) {
|
|
2
|
+
if (!root || typeof root !== "object") {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return root.SpeechRecognition || root.webkitSpeechRecognition || null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function normalizeDictationText(value = "") {
|
|
10
|
+
return String(value || "")
|
|
11
|
+
.replace(/\s+/g, " ")
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function composeDictationDraft({
|
|
16
|
+
baseText = "",
|
|
17
|
+
committedText = "",
|
|
18
|
+
interimText = ""
|
|
19
|
+
} = {}) {
|
|
20
|
+
const parts = [
|
|
21
|
+
normalizeDictationText(baseText),
|
|
22
|
+
normalizeDictationText(committedText),
|
|
23
|
+
normalizeDictationText(interimText)
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
|
|
26
|
+
return parts.join(" ");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function speechRecognitionErrorMessage(code = "") {
|
|
30
|
+
switch (String(code || "").trim()) {
|
|
31
|
+
case "aborted":
|
|
32
|
+
return "Voice memo cancelled.";
|
|
33
|
+
case "audio-capture":
|
|
34
|
+
return "No microphone is available.";
|
|
35
|
+
case "network":
|
|
36
|
+
return "Voice memo failed because speech recognition lost network access.";
|
|
37
|
+
case "not-allowed":
|
|
38
|
+
case "service-not-allowed":
|
|
39
|
+
return "Microphone access is blocked. Allow it and try again.";
|
|
40
|
+
case "no-speech":
|
|
41
|
+
return "No speech detected. Try again.";
|
|
42
|
+
default:
|
|
43
|
+
return "Voice memo failed. Try again.";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
import { runDesktopRehydrationSmoke } from "../lib/desktop-rehydration-smoke.mjs";
|
|
4
|
+
|
|
5
|
+
function printUsage() {
|
|
6
|
+
console.log("Usage: node src/bin/desktop-rehydration-smoke.mjs --thread-id <id> [--cwd <path>] [--skip-probe] [--json]");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const options = {
|
|
11
|
+
cwd: process.cwd(),
|
|
12
|
+
includeProbe: true,
|
|
13
|
+
json: false,
|
|
14
|
+
threadId: null
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
18
|
+
const value = argv[index];
|
|
19
|
+
if (value === "--thread-id") {
|
|
20
|
+
options.threadId = argv[index + 1] || null;
|
|
21
|
+
index += 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (value === "--cwd") {
|
|
25
|
+
options.cwd = argv[index + 1] || process.cwd();
|
|
26
|
+
index += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (value === "--skip-probe") {
|
|
30
|
+
options.includeProbe = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (value === "--json") {
|
|
34
|
+
options.json = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (value === "--help" || value === "-h") {
|
|
38
|
+
options.help = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Unknown argument: ${value}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return options;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function printTextReport(report) {
|
|
48
|
+
console.log(`Desktop rehydration smoke for thread ${report.threadId}`);
|
|
49
|
+
console.log(`cwd: ${report.cwd}`);
|
|
50
|
+
console.log("");
|
|
51
|
+
if (report.probe.included) {
|
|
52
|
+
console.log(`Probe: ${report.probe.status}`);
|
|
53
|
+
console.log(`- prompt: ${report.probe.prompt}`);
|
|
54
|
+
console.log(`- ack: ${report.probe.ack}`);
|
|
55
|
+
if (report.probe.turnId) {
|
|
56
|
+
console.log(`- turn: ${report.probe.turnId} (${report.probe.turnStatus || "unknown"})`);
|
|
57
|
+
}
|
|
58
|
+
if (report.probe.included) {
|
|
59
|
+
console.log(`- prompt visible in app-server readback: ${report.probe.promptVisible === true ? "yes" : report.probe.promptVisible === false ? "no" : "n/a"}`);
|
|
60
|
+
console.log(`- ack visible in app-server readback: ${report.probe.ackVisible === true ? "yes" : report.probe.ackVisible === false ? "no" : "n/a"}`);
|
|
61
|
+
}
|
|
62
|
+
console.log("");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log("Attempts:");
|
|
66
|
+
for (const attempt of report.attempts) {
|
|
67
|
+
console.log(`- [${attempt.status}] ${attempt.label}`);
|
|
68
|
+
console.log(` expected desktop outcome: ${attempt.expectedDesktopOutcome}`);
|
|
69
|
+
if (attempt.detail) {
|
|
70
|
+
console.log(` detail: ${attempt.detail}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log("Manual checks:");
|
|
76
|
+
for (const check of report.manualChecks) {
|
|
77
|
+
console.log(`- ${check}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let options;
|
|
82
|
+
try {
|
|
83
|
+
options = parseArgs(process.argv.slice(2));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error(error.message);
|
|
86
|
+
printUsage();
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (options.help) {
|
|
91
|
+
printUsage();
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!options.threadId) {
|
|
96
|
+
printUsage();
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const report = await runDesktopRehydrationSmoke({
|
|
101
|
+
cwd: options.cwd,
|
|
102
|
+
includeProbe: options.includeProbe,
|
|
103
|
+
threadId: options.threadId
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (options.json) {
|
|
107
|
+
console.log(JSON.stringify(report, null, 2));
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
printTextReport(report);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
packageJsonPathFromImportMetaUrl,
|
|
5
|
+
parseDextunnelCli,
|
|
6
|
+
readPackageVersion,
|
|
7
|
+
renderHelp
|
|
8
|
+
} from "../lib/dextunnel-cli.mjs";
|
|
9
|
+
|
|
10
|
+
const version = readPackageVersion({
|
|
11
|
+
packageJsonPath: packageJsonPathFromImportMetaUrl(import.meta.url)
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const command = parseDextunnelCli(process.argv.slice(2), { version });
|
|
16
|
+
|
|
17
|
+
switch (command.kind) {
|
|
18
|
+
case "help":
|
|
19
|
+
console.log(command.text);
|
|
20
|
+
break;
|
|
21
|
+
case "version":
|
|
22
|
+
console.log(command.text);
|
|
23
|
+
break;
|
|
24
|
+
case "doctor":
|
|
25
|
+
await import("./doctor.mjs");
|
|
26
|
+
break;
|
|
27
|
+
case "serve":
|
|
28
|
+
Object.assign(process.env, command.env);
|
|
29
|
+
await import("../server.mjs");
|
|
30
|
+
break;
|
|
31
|
+
default:
|
|
32
|
+
console.error(renderHelp({ version }));
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
38
|
+
console.error("");
|
|
39
|
+
console.error(renderHelp({ version }));
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createCodexAppServerBridge } from "../lib/codex-app-server-client.mjs";
|
|
2
|
+
import { buildInstallPreflight } from "../lib/install-preflight.mjs";
|
|
3
|
+
import { createRuntimeConfig } from "../lib/runtime-config.mjs";
|
|
4
|
+
|
|
5
|
+
const runtimeConfig = createRuntimeConfig({
|
|
6
|
+
cwd: process.cwd(),
|
|
7
|
+
env: process.env,
|
|
8
|
+
importMetaUrl: import.meta.url
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const bridge = createCodexAppServerBridge({
|
|
12
|
+
binaryPath: runtimeConfig.codexBinaryPath,
|
|
13
|
+
listenUrl: runtimeConfig.appServerListenUrl
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const preflight = await buildInstallPreflight({
|
|
18
|
+
codexAppServer: bridge,
|
|
19
|
+
cwd: runtimeConfig.cwd,
|
|
20
|
+
runtimeConfig,
|
|
21
|
+
warmup: true
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
console.log(`Dextunnel doctor: ${preflight.status.toUpperCase()}`);
|
|
25
|
+
console.log(preflight.summary);
|
|
26
|
+
console.log("");
|
|
27
|
+
for (const check of preflight.checks) {
|
|
28
|
+
console.log(`- ${check.label}: ${check.detail}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (preflight.appServer.startupLogTail.length) {
|
|
32
|
+
console.log("");
|
|
33
|
+
console.log("Recent Codex startup log:");
|
|
34
|
+
for (const line of preflight.appServer.startupLogTail) {
|
|
35
|
+
console.log(` ${line}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log("");
|
|
40
|
+
console.log("Next steps:");
|
|
41
|
+
for (const step of preflight.nextSteps) {
|
|
42
|
+
console.log(`- ${step}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
process.exit(preflight.status === "error" ? 1 : 0);
|
|
46
|
+
} finally {
|
|
47
|
+
await bridge.dispose();
|
|
48
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearLaunchAttestations,
|
|
3
|
+
computeLaunchFingerprint,
|
|
4
|
+
defaultLaunchStatusPath,
|
|
5
|
+
writeLaunchAttestation
|
|
6
|
+
} from "../lib/launch-release-bar.mjs";
|
|
7
|
+
|
|
8
|
+
const command = process.argv[2];
|
|
9
|
+
const statusPath = defaultLaunchStatusPath();
|
|
10
|
+
|
|
11
|
+
if (command === "clear") {
|
|
12
|
+
await clearLaunchAttestations({ statusPath });
|
|
13
|
+
console.log(`Cleared launch attestations at ${statusPath}`);
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (command !== "automated-pass" && command !== "manual-pass") {
|
|
18
|
+
console.error("Usage: node src/bin/launch-attest.mjs <automated-pass|manual-pass|clear>");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const kind = command === "automated-pass" ? "automated" : "manual";
|
|
23
|
+
const fingerprint = computeLaunchFingerprint();
|
|
24
|
+
const state = await writeLaunchAttestation({
|
|
25
|
+
kind,
|
|
26
|
+
statusPath,
|
|
27
|
+
fingerprint
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
console.log(
|
|
31
|
+
`${kind === "automated" ? "Recorded automated launch pass" : "Recorded manual launch pass"} for ${fingerprint.fingerprint}`
|
|
32
|
+
);
|
|
33
|
+
console.log(`Attestations: ${statusPath}`);
|
|
34
|
+
if (state.automated) {
|
|
35
|
+
console.log(`Automated: ${state.automated.recordedAt}`);
|
|
36
|
+
}
|
|
37
|
+
if (state.manual) {
|
|
38
|
+
console.log(`Manual: ${state.manual.recordedAt}`);
|
|
39
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeLaunchFingerprint,
|
|
3
|
+
defaultLaunchStatusPath,
|
|
4
|
+
deriveLaunchBar,
|
|
5
|
+
readLaunchAttestation
|
|
6
|
+
} from "../lib/launch-release-bar.mjs";
|
|
7
|
+
|
|
8
|
+
const json = process.argv.includes("--json");
|
|
9
|
+
|
|
10
|
+
const statusPath = defaultLaunchStatusPath();
|
|
11
|
+
const fingerprint = computeLaunchFingerprint();
|
|
12
|
+
const state = await readLaunchAttestation({ statusPath });
|
|
13
|
+
const payload = {
|
|
14
|
+
...deriveLaunchBar({ fingerprint: fingerprint.fingerprint, state }),
|
|
15
|
+
fingerprint: fingerprint.fingerprint,
|
|
16
|
+
statusPath
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
if (json) {
|
|
20
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(`Dextunnel local launch status: ${payload.status}`);
|
|
25
|
+
console.log(payload.message);
|
|
26
|
+
console.log("");
|
|
27
|
+
console.log("Accepted limitations:");
|
|
28
|
+
for (const line of payload.acceptedLimitations) {
|
|
29
|
+
console.log(`- ${line}`);
|
|
30
|
+
}
|
|
31
|
+
console.log("");
|
|
32
|
+
console.log("Launch references:");
|
|
33
|
+
for (const doc of payload.docs) {
|
|
34
|
+
console.log(`- ${doc}`);
|
|
35
|
+
}
|
|
36
|
+
console.log("");
|
|
37
|
+
console.log("Attestation model:");
|
|
38
|
+
console.log("- automated pass is recorded only by npm run launch:check for the current repo fingerprint");
|
|
39
|
+
console.log("- manual pass is a human attestation recorded by npm run launch:attest-manual");
|
|
40
|
+
console.log("");
|
|
41
|
+
console.log("Manual checks:");
|
|
42
|
+
for (const line of payload.requiredManualChecks) {
|
|
43
|
+
console.log(`- ${line}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (payload.staleAutomated || payload.staleManual) {
|
|
47
|
+
console.log("");
|
|
48
|
+
console.log(`Attestations: ${payload.statusPath}`);
|
|
49
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { URL } from "node:url";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
resolveMobileNetworkProfile,
|
|
8
|
+
withNetworkJitter
|
|
9
|
+
} from "../lib/mobile-network-profile.mjs";
|
|
10
|
+
|
|
11
|
+
function printUsage() {
|
|
12
|
+
console.log(
|
|
13
|
+
"Usage: node src/bin/mobile-link-proxy.mjs --target-base-url http://127.0.0.1:4317 [--listen-port 4417] [--profile weak-mobile|weak-mobile-reconnect]"
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseArgs(argv) {
|
|
18
|
+
const options = {
|
|
19
|
+
listenHost: "127.0.0.1",
|
|
20
|
+
listenPort: 4417,
|
|
21
|
+
profile: "weak-mobile",
|
|
22
|
+
targetBaseUrl: ""
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
26
|
+
const arg = argv[index];
|
|
27
|
+
if (arg === "--help" || arg === "-h") {
|
|
28
|
+
printUsage();
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
if (arg === "--target-base-url" && argv[index + 1]) {
|
|
32
|
+
options.targetBaseUrl = argv[index + 1];
|
|
33
|
+
index += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (arg === "--listen-port" && argv[index + 1]) {
|
|
37
|
+
options.listenPort = Number(argv[index + 1]) || options.listenPort;
|
|
38
|
+
index += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (arg === "--listen-host" && argv[index + 1]) {
|
|
42
|
+
options.listenHost = argv[index + 1] || options.listenHost;
|
|
43
|
+
index += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (arg === "--profile" && argv[index + 1]) {
|
|
47
|
+
options.profile = argv[index + 1] || options.profile;
|
|
48
|
+
index += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!options.targetBaseUrl) {
|
|
55
|
+
throw new Error("--target-base-url is required.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const profile = resolveMobileNetworkProfile(options.profile);
|
|
59
|
+
if (!profile) {
|
|
60
|
+
throw new Error(`Unknown network profile: ${options.profile}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...options,
|
|
65
|
+
profile
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function delay(ms) {
|
|
70
|
+
if (!ms || ms <= 0) {
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readRequestBody(req) {
|
|
77
|
+
const chunks = [];
|
|
78
|
+
for await (const chunk of req) {
|
|
79
|
+
chunks.push(chunk);
|
|
80
|
+
}
|
|
81
|
+
return chunks.length ? Buffer.concat(chunks) : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function pipeWithThrottle(readable, writable, bytesPerSecond, dropAfterMs = null) {
|
|
85
|
+
let dropped = false;
|
|
86
|
+
let dropTimer = null;
|
|
87
|
+
|
|
88
|
+
if (dropAfterMs && Number.isFinite(dropAfterMs) && dropAfterMs > 0) {
|
|
89
|
+
dropTimer = setTimeout(() => {
|
|
90
|
+
dropped = true;
|
|
91
|
+
try {
|
|
92
|
+
readable.destroy(new Error("Synthetic weak-network stream drop."));
|
|
93
|
+
} catch {
|
|
94
|
+
// Ignore teardown failures.
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
writable.destroy();
|
|
98
|
+
} catch {
|
|
99
|
+
// Ignore teardown failures.
|
|
100
|
+
}
|
|
101
|
+
}, dropAfterMs);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
for await (const chunk of readable) {
|
|
106
|
+
if (dropped || writable.destroyed) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!writable.write(chunk)) {
|
|
110
|
+
await once(writable, "drain");
|
|
111
|
+
}
|
|
112
|
+
if (bytesPerSecond && bytesPerSecond > 0) {
|
|
113
|
+
const waitMs = Math.ceil((Buffer.byteLength(chunk) / bytesPerSecond) * 1000);
|
|
114
|
+
if (waitMs > 0) {
|
|
115
|
+
await delay(waitMs);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!writable.destroyed) {
|
|
120
|
+
writable.end();
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
if (dropTimer) {
|
|
124
|
+
clearTimeout(dropTimer);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function main() {
|
|
130
|
+
const options = parseArgs(process.argv.slice(2));
|
|
131
|
+
const targetBaseUrl = new URL(options.targetBaseUrl);
|
|
132
|
+
const clientForTarget = targetBaseUrl.protocol === "https:" ? https : http;
|
|
133
|
+
|
|
134
|
+
const server = http.createServer(async (req, res) => {
|
|
135
|
+
try {
|
|
136
|
+
const body = await readRequestBody(req);
|
|
137
|
+
const requestDelayMs = withNetworkJitter(options.profile.requestDelayMs, options.profile.jitterMs);
|
|
138
|
+
const responseDelayMs = withNetworkJitter(options.profile.responseDelayMs, options.profile.jitterMs);
|
|
139
|
+
await delay(requestDelayMs);
|
|
140
|
+
|
|
141
|
+
const targetUrl = new URL(req.url || "/", targetBaseUrl);
|
|
142
|
+
const upstream = clientForTarget.request(targetUrl, {
|
|
143
|
+
headers: {
|
|
144
|
+
...req.headers,
|
|
145
|
+
host: targetBaseUrl.host
|
|
146
|
+
},
|
|
147
|
+
method: req.method || "GET"
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
upstream.on("response", async (upstreamRes) => {
|
|
151
|
+
await delay(responseDelayMs);
|
|
152
|
+
const responseHeaders = { ...upstreamRes.headers };
|
|
153
|
+
delete responseHeaders["content-length"];
|
|
154
|
+
res.writeHead(upstreamRes.statusCode || 502, responseHeaders);
|
|
155
|
+
const isSse = String(upstreamRes.headers["content-type"] || "").includes("text/event-stream");
|
|
156
|
+
await pipeWithThrottle(
|
|
157
|
+
upstreamRes,
|
|
158
|
+
res,
|
|
159
|
+
options.profile.downstreamBytesPerSecond,
|
|
160
|
+
isSse ? options.profile.dropSseAfterMs : null
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
upstream.on("error", (error) => {
|
|
165
|
+
if (!res.headersSent) {
|
|
166
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
167
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
res.destroy(error);
|
|
172
|
+
} catch {
|
|
173
|
+
// Ignore teardown failures.
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (body?.length) {
|
|
178
|
+
if (options.profile.upstreamBytesPerSecond > 0) {
|
|
179
|
+
const waitMs = Math.ceil((body.length / options.profile.upstreamBytesPerSecond) * 1000);
|
|
180
|
+
upstream.write(body);
|
|
181
|
+
if (waitMs > 0) {
|
|
182
|
+
await delay(waitMs);
|
|
183
|
+
}
|
|
184
|
+
upstream.end();
|
|
185
|
+
} else {
|
|
186
|
+
upstream.end(body);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
upstream.end();
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
193
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await new Promise((resolve, reject) => {
|
|
198
|
+
server.once("error", reject);
|
|
199
|
+
server.listen(options.listenPort, options.listenHost, resolve);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const proxyUrl = `http://${options.listenHost}:${options.listenPort}`;
|
|
203
|
+
console.log(
|
|
204
|
+
`[mobile-link-proxy] listening=${proxyUrl} target=${targetBaseUrl.origin} profile=${options.profile.name}`
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
function shutdown(code = 0) {
|
|
208
|
+
server.close(() => {
|
|
209
|
+
process.exit(code);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
process.on("SIGINT", () => shutdown(0));
|
|
214
|
+
process.on("SIGTERM", () => shutdown(0));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
main().catch((error) => {
|
|
218
|
+
console.error(`[mobile-link-proxy] failed: ${error.message}`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
});
|
|
221
|
+
|