@volter/twin-world 0.1.0 → 0.1.2
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/known-external-services.json +1192 -0
- package/package.json +12 -5
- package/src/app-url.ts +175 -0
- package/src/attach.ts +113 -0
- package/src/browser-proxy-cli.ts +2 -1
- package/src/changeset.ts +416 -0
- package/src/cli.ts +589 -10
- package/src/covers.ts +724 -0
- package/src/fixture-env.ts +207 -0
- package/src/host-cli.ts +2 -1
- package/src/host-worker.ts +4 -3
- package/src/host.ts +26 -4
- package/src/index.ts +97 -1
- package/src/init.ts +1142 -0
- package/src/inject-map.ts +70 -0
- package/src/managed-infra-cli.ts +208 -0
- package/src/pack-facts.ts +136 -0
- package/src/pglite-backing.ts +125 -0
- package/src/pglite-host.mjs +147 -0
- package/src/prerequisites.ts +15 -58
- package/src/project-inspect.ts +683 -0
- package/src/redirect-proxy.ts +109 -85
- package/src/reflect.ts +443 -0
- package/src/resource-holder.ts +11 -0
- package/src/resources.ts +160 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +667 -106
- package/src/schema.ts +67 -0
- package/src/serve.ts +102 -0
- package/src/tail.ts +203 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-world",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"src",
|
|
22
|
+
"known-external-services.json",
|
|
22
23
|
"client",
|
|
23
24
|
"test-fixtures",
|
|
24
25
|
"README.md",
|
|
@@ -41,21 +42,27 @@
|
|
|
41
42
|
"bin": {
|
|
42
43
|
"volter-world": "src/cli.ts",
|
|
43
44
|
"volter-world-browser-proxy": "src/browser-proxy-cli.ts",
|
|
44
|
-
"volter-world-host": "src/host-cli.ts"
|
|
45
|
+
"volter-world-host": "src/host-cli.ts",
|
|
46
|
+
"volter-world-managed-infra": "src/managed-infra-cli.ts"
|
|
45
47
|
},
|
|
46
48
|
"scripts": {
|
|
47
49
|
"test": "bun test src/*.test.ts",
|
|
48
50
|
"typecheck": "tsc --noEmit"
|
|
49
51
|
},
|
|
50
|
-
"dependencies": {
|
|
51
|
-
"@volter/twin": "0.1.0"
|
|
52
|
-
},
|
|
53
52
|
"devDependencies": {
|
|
54
53
|
"@types/bun": "^1.2.20",
|
|
55
54
|
"@types/node": "^24.0.0",
|
|
55
|
+
"pg": "8.16.3",
|
|
56
56
|
"typescript": "^5.9.0"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"bun": ">=1.2.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@volter/twin": "^0.1.2"
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"@electric-sql/pglite": "0.5.8",
|
|
66
|
+
"pg-gateway": "0.3.0-beta.4"
|
|
60
67
|
}
|
|
61
68
|
}
|
package/src/app-url.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// `volter-world app-url` — the recorded, queryable APP endpoint of a world instance.
|
|
2
|
+
//
|
|
3
|
+
// Worlds own the lifecycle of SERVICES; the app under test is usually booted OUTSIDE the world by
|
|
4
|
+
// the caller (the attach pattern), so nothing in the instance knows where the app listens — every
|
|
5
|
+
// consumer (a browser step, a webhook target, a teammate's curl) re-derives it by folklore. The
|
|
6
|
+
// subject pilots hit exactly this: the world's URLs are one `volter-world url` away, the APP's URL
|
|
7
|
+
// is tribal knowledge. This verb makes the app endpoint a first-class instance record:
|
|
8
|
+
//
|
|
9
|
+
// • the BOOTER writes it once, as the LAST boot step — `--set <url>`, or `--detect <pid|port>`
|
|
10
|
+
// to read it off the already-listening process instead of typing it;
|
|
11
|
+
// • any consumer reads it back (`volter-world app-url <world> [--json]`);
|
|
12
|
+
// • an UNSET read fails LOUDLY with the registration recipe — never a guess, never a default.
|
|
13
|
+
//
|
|
14
|
+
// A world that boots the app itself declares an `app` service (`type: 'process'`) — that existing
|
|
15
|
+
// convention needs no registration: the read falls back to the `app` service's assigned URL.
|
|
16
|
+
//
|
|
17
|
+
// RUNTIME MINIMALISM HOLDS: this is a record, not orchestration. The runtime never boots, probes,
|
|
18
|
+
// supervises, or health-checks the app — `--detect` inspects the caller-named pid/port exactly
|
|
19
|
+
// once, at the caller's request, and stores what it saw. The record lives in the instance dir, so
|
|
20
|
+
// a re-`up` (which wipes the dir) or `down --purge` clears it: a recorded URL never outlives the
|
|
21
|
+
// instance it described.
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import net from 'node:net';
|
|
26
|
+
import { instanceDir, statusWorld } from './runtime.ts';
|
|
27
|
+
|
|
28
|
+
/** Who answered: an explicit `--set`, a `--detect` inspection, or the world's own `app` service. */
|
|
29
|
+
export type AppUrlSource = 'set' | 'detect' | 'service';
|
|
30
|
+
|
|
31
|
+
export type AppUrlRecord = {
|
|
32
|
+
world: string;
|
|
33
|
+
url: string;
|
|
34
|
+
via: AppUrlSource;
|
|
35
|
+
/** ISO timestamp of the registration (absent for the `app`-service fallback — nothing was recorded) */
|
|
36
|
+
recordedAt?: string;
|
|
37
|
+
/** how the URL was obtained, for the human reading `--json` output later */
|
|
38
|
+
detail?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function appUrlFile(root: string, name: string): string {
|
|
42
|
+
return join(instanceDir(root, name), 'app-url.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertHttpUrl(url: string): URL {
|
|
46
|
+
let parsed: URL;
|
|
47
|
+
try {
|
|
48
|
+
parsed = new URL(url);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`volter-world app-url: "${url}" is not a URL (want e.g. http://127.0.0.1:3000)`);
|
|
51
|
+
}
|
|
52
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
53
|
+
throw new Error(`volter-world app-url: "${url}" must be http(s) — an app endpoint consumers can fetch`);
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Record `url` as the app endpoint of the RUNNING/booted instance `name`. The instance must
|
|
59
|
+
* exist (a record for a world that was never upped would describe nothing); the app itself is
|
|
60
|
+
* deliberately NOT probed — the booter declaring "this is where I put the app" is the truth. */
|
|
61
|
+
export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string } = {}): AppUrlRecord {
|
|
62
|
+
const root = options.root ?? process.cwd();
|
|
63
|
+
const status = statusWorld(name, root); // throws "World instance not found" — the loud path
|
|
64
|
+
assertHttpUrl(url);
|
|
65
|
+
const record: AppUrlRecord = {
|
|
66
|
+
world: status.name,
|
|
67
|
+
url,
|
|
68
|
+
via: options.via ?? 'set',
|
|
69
|
+
recordedAt: new Date().toISOString(),
|
|
70
|
+
...(options.detail === undefined ? {} : { detail: options.detail }),
|
|
71
|
+
};
|
|
72
|
+
writeFileSync(appUrlFile(root, name), `${JSON.stringify(record, null, 2)}\n`);
|
|
73
|
+
return record;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The listening TCP ports of a live pid, via lsof (macOS + Linux). */
|
|
77
|
+
function listeningTcpPorts(pid: number): number[] {
|
|
78
|
+
const result = spawnSync('lsof', ['-a', '-p', String(pid), '-iTCP', '-sTCP:LISTEN', '-P', '-n', '-Fn'], { encoding: 'utf8' });
|
|
79
|
+
if (result.error) {
|
|
80
|
+
throw new Error(`volter-world app-url: \`lsof\` is unavailable (${result.error.message}) — pass the port, or --set the URL directly`);
|
|
81
|
+
}
|
|
82
|
+
const ports = new Set<number>();
|
|
83
|
+
for (const line of (result.stdout ?? '').split('\n')) {
|
|
84
|
+
if (!line.startsWith('n')) continue;
|
|
85
|
+
const port = Number(line.slice(line.lastIndexOf(':') + 1));
|
|
86
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535) ports.add(port);
|
|
87
|
+
}
|
|
88
|
+
return [...ports].sort((a, b) => a - b);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pidIsLive(pid: number): boolean {
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One TCP connection attempt to 127.0.0.1:<port> — is anything listening RIGHT NOW? */
|
|
101
|
+
function tcpListening(port: number, timeoutMs = 1500): Promise<boolean> {
|
|
102
|
+
return new Promise((resolvePromise) => {
|
|
103
|
+
const socket = net.connect({ port, host: '127.0.0.1' });
|
|
104
|
+
const done = (ok: boolean): void => {
|
|
105
|
+
socket.destroy();
|
|
106
|
+
resolvePromise(ok);
|
|
107
|
+
};
|
|
108
|
+
socket.once('connect', () => done(true));
|
|
109
|
+
socket.once('error', () => done(false));
|
|
110
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
*`--detect <pid|port>`: resolve the app URL off what is ALREADY listening and record it.
|
|
116
|
+
* Disambiguation (both are just numbers): a live pid with listening ports wins — its single
|
|
117
|
+
* listening port is the app; several listening ports is a loud error (name the port instead).
|
|
118
|
+
* Otherwise the number is tried as a loopback port and must actually be listening. Detection
|
|
119
|
+
* that finds nothing refuses loudly rather than recording a URL nothing serves.
|
|
120
|
+
*/
|
|
121
|
+
export async function detectAppUrl(name: string, target: string, options: { root?: string } = {}): Promise<AppUrlRecord> {
|
|
122
|
+
const numeric = Number(target);
|
|
123
|
+
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
124
|
+
throw new Error(`volter-world app-url: --detect wants a pid or a port, got "${target}"`);
|
|
125
|
+
}
|
|
126
|
+
if (pidIsLive(numeric)) {
|
|
127
|
+
const ports = listeningTcpPorts(numeric);
|
|
128
|
+
if (ports.length === 1) {
|
|
129
|
+
return setAppUrl(name, `http://127.0.0.1:${ports[0]}`, { ...options, via: 'detect', detail: `detected from pid ${numeric} (listening on ${ports[0]})` });
|
|
130
|
+
}
|
|
131
|
+
if (ports.length > 1) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`volter-world app-url: pid ${numeric} is listening on ${ports.length} ports (${ports.join(', ')}) — `
|
|
134
|
+
+ `ambiguous. Detect the app's port directly (--detect <port>) or record it explicitly (--set <url>).`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// a live pid with NO listeners: fall through — the number was probably meant as a port
|
|
138
|
+
}
|
|
139
|
+
if (numeric <= 65535 && await tcpListening(numeric)) {
|
|
140
|
+
return setAppUrl(name, `http://127.0.0.1:${numeric}`, { ...options, via: 'detect', detail: `port ${numeric} is listening on 127.0.0.1` });
|
|
141
|
+
}
|
|
142
|
+
throw new Error(
|
|
143
|
+
`volter-world app-url: nothing to detect from "${target}" — it is not a live pid with a listening TCP port, `
|
|
144
|
+
+ `and nothing is listening on 127.0.0.1:${target}. Boot the app first, then register its URL (--set <url> once it listens).`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The recorded app URL of `name`, or the `app` service's assigned URL when nothing was recorded
|
|
150
|
+
* (the world-boots-the-app convention), or null. Null is the CALLER's loud-error cue — the CLI
|
|
151
|
+
* turns it into exit 1 with the registration recipe; a library consumer decides for itself.
|
|
152
|
+
*/
|
|
153
|
+
export function readAppUrl(name: string, options: { root?: string } = {}): AppUrlRecord | null {
|
|
154
|
+
const root = options.root ?? process.cwd();
|
|
155
|
+
const status = statusWorld(name, root); // loud when the world does not exist at all
|
|
156
|
+
const file = appUrlFile(root, name);
|
|
157
|
+
if (existsSync(file)) {
|
|
158
|
+
return JSON.parse(readFileSync(file, 'utf8')) as AppUrlRecord;
|
|
159
|
+
}
|
|
160
|
+
const app = status.services['app'];
|
|
161
|
+
if (app?.url !== undefined) {
|
|
162
|
+
return { world: status.name, url: app.url, via: 'service', detail: 'the world\'s own "app" service (nothing was recorded)' };
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The message the CLI prints when no app URL is known — the registration recipe, not a guess. */
|
|
168
|
+
export function appUrlUnsetMessage(name: string): string {
|
|
169
|
+
return (
|
|
170
|
+
`World ${name} has no recorded app URL.\n`
|
|
171
|
+
+ `If the app is booted OUTSIDE the world (the attach pattern), the booter registers it as the LAST boot step:\n`
|
|
172
|
+
+ ` volter-world app-url ${name} --set http://127.0.0.1:<port> # or: --detect <pid|port>\n`
|
|
173
|
+
+ `A world that boots the app itself can declare an "app" service (type "process") — its URL answers automatically.`
|
|
174
|
+
);
|
|
175
|
+
}
|
package/src/attach.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The attach surface of the two-part model (docs/ATTACH.md): resolving a world
|
|
2
|
+
// reference and deriving the world manifest — the single document a world
|
|
3
|
+
// publishes and every attachment mechanism consumes. `world.env` /
|
|
4
|
+
// `instance.json` remain the local renderings; this module derives the manifest
|
|
5
|
+
// FROM them (it invents nothing). Attaching itself stays thin: `--via env`
|
|
6
|
+
// delegates to runWithWorldEnv, `--via direct` prints the manifest. Reflect and
|
|
7
|
+
// remote refs are proposals (docs/ATTACH.md) and are refused loudly, never
|
|
8
|
+
// silently degraded.
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { activeVendorMap } from './redirect-proxy.ts';
|
|
12
|
+
import { statusWorld } from './runtime.ts';
|
|
13
|
+
|
|
14
|
+
export const WORLD_REF_FILE = '.volter-world';
|
|
15
|
+
|
|
16
|
+
export type WorldRefSource = 'explicit' | 'env' | 'file';
|
|
17
|
+
|
|
18
|
+
export interface ResolvedWorldRef {
|
|
19
|
+
ref: string;
|
|
20
|
+
source: WorldRefSource;
|
|
21
|
+
/** set when source === 'file' — the .volter-world file that supplied the ref */
|
|
22
|
+
file?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A world reference is remote when it is a URL; every other ref is a local world name. */
|
|
26
|
+
export function isRemoteWorldRef(ref: string): boolean {
|
|
27
|
+
return /^https?:\/\//i.test(ref);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Find the nearest .volter-world file from `cwd` upward. First non-empty,
|
|
31
|
+
* non-comment line is the ref (à la .nvmrc). */
|
|
32
|
+
export function findWorldRefFile(cwd: string): { file: string; ref: string } | undefined {
|
|
33
|
+
let dir = resolve(cwd);
|
|
34
|
+
for (;;) {
|
|
35
|
+
const candidate = join(dir, WORLD_REF_FILE);
|
|
36
|
+
if (existsSync(candidate)) {
|
|
37
|
+
const line = readFileSync(candidate, 'utf8')
|
|
38
|
+
.split('\n')
|
|
39
|
+
.map((entry) => entry.trim())
|
|
40
|
+
.find((entry) => entry.length > 0 && !entry.startsWith('#'));
|
|
41
|
+
if (line) return { file: candidate, ref: line };
|
|
42
|
+
}
|
|
43
|
+
const parent = dirname(dir);
|
|
44
|
+
if (parent === dir) return undefined;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolution order (docs/ATTACH.md): explicit argument → VOLTER_WORLD env var →
|
|
50
|
+
* nearest committed .volter-world file. No silent machine-default fallback:
|
|
51
|
+
* a missing ref is an explicit, actionable error. */
|
|
52
|
+
export function resolveWorldRef(
|
|
53
|
+
explicit: string | undefined,
|
|
54
|
+
options: { cwd?: string; env?: Record<string, string | undefined> } = {},
|
|
55
|
+
): ResolvedWorldRef {
|
|
56
|
+
if (explicit !== undefined && explicit.length > 0) return { ref: explicit, source: 'explicit' };
|
|
57
|
+
const env = options.env ?? process.env;
|
|
58
|
+
const fromEnv = env.VOLTER_WORLD;
|
|
59
|
+
if (fromEnv !== undefined && fromEnv.trim().length > 0) return { ref: fromEnv.trim(), source: 'env' };
|
|
60
|
+
const fromFile = findWorldRefFile(options.cwd ?? process.cwd());
|
|
61
|
+
if (fromFile) return { ref: fromFile.ref, source: 'file', file: fromFile.file };
|
|
62
|
+
throw new Error(
|
|
63
|
+
`no world reference: pass one (volter-world attach <world> …), set VOLTER_WORLD, or commit a ${WORLD_REF_FILE} file`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The manifest (docs/ATTACH.md): what a world publishes to its attachers. */
|
|
68
|
+
export interface WorldManifest {
|
|
69
|
+
name: string;
|
|
70
|
+
/** vendor host → twin endpoint, exactly the map the injector/proxy redirect */
|
|
71
|
+
vendors: Record<string, string>;
|
|
72
|
+
/** session CA (PEM) when the world has one, else null */
|
|
73
|
+
ca: string | null;
|
|
74
|
+
/** attachment traffic proxy when present, else null */
|
|
75
|
+
proxy: string | null;
|
|
76
|
+
/** suggested fake credentials — the world env minus runtime internals */
|
|
77
|
+
env: Record<string, string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Internal runtime vars are plumbing, not suggested credentials. CA-trust
|
|
81
|
+
// bundle vars are local renderings of `ca` (they hold machine-local paths), so
|
|
82
|
+
// a manifest never carries them — an attacher re-renders trust from `ca`.
|
|
83
|
+
const MANIFEST_ENV_EXCLUDED =
|
|
84
|
+
/^(volter_world_|node_options$|twin_inject$|no_proxy$|https?_proxy$|node_extra_ca_certs$|ssl_cert_file$|(aws|curl|requests)_ca_bundle$)/i;
|
|
85
|
+
|
|
86
|
+
/** Derive the manifest from a world's generated env map (the local rendering).
|
|
87
|
+
* Pure — the CA is inlined only when its file is readable. */
|
|
88
|
+
export function deriveWorldManifest(name: string, worldEnv: Record<string, string | undefined>): WorldManifest {
|
|
89
|
+
const vendors = activeVendorMap(worldEnv);
|
|
90
|
+
const caPath = worldEnv.VOLTER_WORLD_CA;
|
|
91
|
+
let ca: string | null = null;
|
|
92
|
+
if (caPath && existsSync(caPath)) {
|
|
93
|
+
try {
|
|
94
|
+
ca = readFileSync(caPath, 'utf8');
|
|
95
|
+
} catch {
|
|
96
|
+
ca = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const env: Record<string, string> = {};
|
|
100
|
+
for (const [key, value] of Object.entries(worldEnv)) {
|
|
101
|
+
if (value === undefined) continue;
|
|
102
|
+
if (MANIFEST_ENV_EXCLUDED.test(key)) continue;
|
|
103
|
+
if (/_TWIN_URL$/.test(key)) continue; // rendered as `vendors`, not credentials
|
|
104
|
+
env[key] = value;
|
|
105
|
+
}
|
|
106
|
+
return { name, vendors, ca, proxy: worldEnv.VOLTER_WORLD_PROXY ?? null, env };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The manifest of a local world instance (name resolved against `root` like every other verb). */
|
|
110
|
+
export function worldManifest(name: string, root = process.cwd()): WorldManifest {
|
|
111
|
+
const status = statusWorld(name, root);
|
|
112
|
+
return deriveWorldManifest(name, status.env);
|
|
113
|
+
}
|
package/src/browser-proxy-cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @ts-nocheck
|
|
3
|
+
import { keepProcessAlive } from '@volter/twin/lifecycle';
|
|
3
4
|
// Browser proxy launcher for fullstack app worlds. This is APP/WORLD orchestration glue
|
|
4
5
|
// (it wires specific app vendors + app URLs from env), so it lives in @volter/twin-world,
|
|
5
6
|
// NOT in the vendor-agnostic kernel — control-plane must never name a vendor (A2). The kernel
|
|
@@ -40,4 +41,4 @@ const redirectOrigins = [
|
|
|
40
41
|
|
|
41
42
|
const proxy = createTwinProxy({ target, map, redirectOrigins, port });
|
|
42
43
|
process.stdout.write(`[browser-proxy] http://127.0.0.1:${proxy.port} -> ${target} (${Object.keys(map).join(', ') || 'no browser vendors'})\n`);
|
|
43
|
-
await
|
|
44
|
+
await keepProcessAlive();
|