@thegitai/cli 1.0.0-preview.4 → 1.0.0-preview.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -4
- package/dist/bin/ai.js +119 -111
- package/dist/bin/browser-host.js +265 -0
- package/dist/src/agent-mode.js +11 -6
- package/dist/src/api/auth.js +6 -4
- package/dist/src/api/browser-login.js +152 -37
- package/dist/src/api/chat.js +286 -44
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/default-host.js +1 -0
- package/dist/src/api/http.js +127 -7
- package/dist/src/api/models.js +29 -10
- package/dist/src/background-jobs.js +4 -2
- package/dist/src/browser/bridge.js +232 -0
- package/dist/src/browser/framing.js +42 -0
- package/dist/src/browser/native-host.js +209 -0
- package/dist/src/browser/protocol.js +46 -0
- package/dist/src/browser/session-bridge.js +104 -0
- package/dist/src/cli-args.js +19 -5
- package/dist/src/client-environment.js +2 -0
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +246 -0
- package/dist/src/edit-journal.js +33 -2
- package/dist/src/executor.js +25 -3
- package/dist/src/help-text.js +58 -17
- package/dist/src/patcher.js +43 -4
- package/dist/src/permissions.js +288 -0
- package/dist/src/session-safety.js +0 -12
- package/dist/src/session-store.js +121 -20
- package/dist/src/session.js +19 -4
- package/dist/src/signin.js +58 -0
- package/dist/src/tool-executor.js +12 -46
- package/dist/src/tools/browser.js +628 -0
- package/dist/src/tools/delete-file.js +20 -4
- package/dist/src/tools/index.js +32 -10
- package/dist/src/tools/patch-file.js +12 -26
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/restore-checkpoint.js +0 -1
- package/dist/src/tools/run-command.js +14 -71
- package/dist/src/tools/run-node-script.js +12 -81
- package/dist/src/tools/save-generated-image.js +120 -0
- package/dist/src/tools/str-replace.js +12 -26
- package/dist/src/tools/undo-edit.js +1 -6
- package/dist/src/tools/write-file.js +67 -11
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +802 -195
- package/dist/src/ui/tui/bridge.js +10 -0
- package/dist/src/ui/tui/build-frame.js +517 -165
- package/dist/src/ui/tui/composer-layout.js +76 -0
- package/dist/src/ui/tui/markdown-render.js +81 -73
- package/dist/src/ui/tui/shell-input.js +227 -67
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/ui/tui/user-input.js +568 -0
- package/dist/src/utils.js +9 -0
- package/package.json +20 -8
- package/dist/src/markdown-renderer.js +0 -112
- package/dist/src/project-index.js +0 -221
- package/dist/src/tools/code-intel.js +0 -472
- package/dist/src/tools/find-symbol.js +0 -70
- package/dist/src/tools/hover-symbol.js +0 -95
- package/dist/src/tools/list-symbols.js +0 -55
- package/dist/src/tools/search-code.js +0 -37
- package/dist/src/tools/signature-help.js +0 -118
package/dist/src/api/http.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
|
|
2
|
+
import { DEFAULT_THEGITAI_HOST } from './default-host.js';
|
|
3
3
|
export const TRACE_ID_HEADER = 'x-thegitai-trace-id';
|
|
4
4
|
export const CLIENT_HEADER = 'x-thegitai-client';
|
|
5
5
|
export const CLIENT_PLATFORM_HEADER = 'x-thegitai-client-platform';
|
|
6
6
|
export class ServerApiError extends Error {
|
|
7
7
|
status;
|
|
8
8
|
traceId;
|
|
9
|
-
|
|
9
|
+
code;
|
|
10
|
+
constructor(message, status, traceId, code = '') {
|
|
10
11
|
super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
|
|
11
12
|
this.name = 'ServerApiError';
|
|
12
13
|
this.status = status;
|
|
13
14
|
this.traceId = traceId;
|
|
15
|
+
this.code = code;
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
export function createTraceId() {
|
|
@@ -27,6 +29,14 @@ export function createTraceContext(traceId = createTraceId()) {
|
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
export const REQUEST_TIMEOUT_MS = 8000;
|
|
32
|
+
export const STARTUP_REQUEST_TIMEOUT_MS = 3000;
|
|
33
|
+
export const STARTUP_DEADLINE_MS = 10_000;
|
|
34
|
+
export const STARTUP_RETRY_BUDGET = {
|
|
35
|
+
retries: 3,
|
|
36
|
+
baseDelayMs: 200,
|
|
37
|
+
deadlineMs: STARTUP_DEADLINE_MS,
|
|
38
|
+
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS,
|
|
39
|
+
};
|
|
30
40
|
const TRANSIENT_NETWORK_CODES = new Set([
|
|
31
41
|
'ECONNRESET',
|
|
32
42
|
'ECONNREFUSED',
|
|
@@ -58,7 +68,9 @@ export function isTransientNetworkError(error) {
|
|
|
58
68
|
}
|
|
59
69
|
return false;
|
|
60
70
|
}
|
|
61
|
-
export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {}) {
|
|
71
|
+
export async function retryTransient(run, { retries = 2, baseDelayMs = 400, deadlineMs, now = () => Date.now(), } = {}) {
|
|
72
|
+
const startedAt = now();
|
|
73
|
+
const remainingMs = () => deadlineMs == null ? Infinity : deadlineMs - (now() - startedAt);
|
|
62
74
|
let attempt = 0;
|
|
63
75
|
for (;;) {
|
|
64
76
|
try {
|
|
@@ -68,13 +80,15 @@ export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {
|
|
|
68
80
|
if (attempt >= retries || !isTransientNetworkError(error))
|
|
69
81
|
throw error;
|
|
70
82
|
const delayMs = baseDelayMs * 2 ** attempt;
|
|
83
|
+
if (remainingMs() <= delayMs)
|
|
84
|
+
throw error;
|
|
71
85
|
attempt += 1;
|
|
72
86
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
73
87
|
}
|
|
74
88
|
}
|
|
75
89
|
}
|
|
76
90
|
export function normalizeServerUrl(serverUrl) {
|
|
77
|
-
const normalized = String(serverUrl ||
|
|
91
|
+
const normalized = String(serverUrl || DEFAULT_THEGITAI_HOST)
|
|
78
92
|
.trim()
|
|
79
93
|
.replace(/\/+$/, '');
|
|
80
94
|
if (!/^https?:\/\//i.test(normalized)) {
|
|
@@ -82,6 +96,99 @@ export function normalizeServerUrl(serverUrl) {
|
|
|
82
96
|
}
|
|
83
97
|
return normalized;
|
|
84
98
|
}
|
|
99
|
+
const MAX_NON_JSON_BODY_CHARS = 200;
|
|
100
|
+
const GATEWAY_STATUS_MESSAGES = {
|
|
101
|
+
502: 'Could not reach the TheGitAI server (bad gateway).',
|
|
102
|
+
503: 'The TheGitAI server is temporarily unavailable.',
|
|
103
|
+
504: 'The TheGitAI server did not respond in time (gateway timeout).',
|
|
104
|
+
520: 'The connection to the TheGitAI server failed (edge error 520).',
|
|
105
|
+
521: 'The TheGitAI server is not accepting connections (edge error 521).',
|
|
106
|
+
522: 'The connection to the TheGitAI server timed out (edge error 522).',
|
|
107
|
+
523: 'The TheGitAI server is unreachable (edge error 523).',
|
|
108
|
+
524: 'The TheGitAI server did not respond in time (edge error 524).',
|
|
109
|
+
};
|
|
110
|
+
function truncateByCodePoint(text, maxChars) {
|
|
111
|
+
if (text.length <= maxChars)
|
|
112
|
+
return text;
|
|
113
|
+
const points = Array.from(text);
|
|
114
|
+
if (points.length <= maxChars)
|
|
115
|
+
return text;
|
|
116
|
+
return `${points.slice(0, maxChars).join('').trimEnd()}…`;
|
|
117
|
+
}
|
|
118
|
+
export function nonJsonErrorMessage(body, status) {
|
|
119
|
+
const rayId = /Cloudflare Ray ID:\s*(?:<[^>]*>\s*)*([0-9a-f]{8,})/i.exec(body)?.[1];
|
|
120
|
+
const gateway = GATEWAY_STATUS_MESSAGES[status];
|
|
121
|
+
const summary = truncateByCodePoint(body
|
|
122
|
+
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
123
|
+
.replace(/<[^>]*>/g, ' ')
|
|
124
|
+
.replace(/\s+/g, ' ')
|
|
125
|
+
.trim(), MAX_NON_JSON_BODY_CHARS);
|
|
126
|
+
const base = gateway ?? (summary || `Request failed with ${status}`);
|
|
127
|
+
return rayId ? `${base} (Cloudflare Ray ID: ${rayId})` : base;
|
|
128
|
+
}
|
|
129
|
+
const TRANSPORT_CODES = new Set([
|
|
130
|
+
'ECONNRESET',
|
|
131
|
+
'ECONNREFUSED',
|
|
132
|
+
'ECONNABORTED',
|
|
133
|
+
'EPIPE',
|
|
134
|
+
'ETIMEDOUT',
|
|
135
|
+
'ENOTFOUND',
|
|
136
|
+
'EAI_AGAIN',
|
|
137
|
+
'EHOSTUNREACH',
|
|
138
|
+
'ENETUNREACH',
|
|
139
|
+
'EPROTO',
|
|
140
|
+
'ERR_STREAM_PREMATURE_CLOSE',
|
|
141
|
+
]);
|
|
142
|
+
const TRANSPORT_PHRASES = [
|
|
143
|
+
[/\bsocket hang up\b/i, 'socket hang up'],
|
|
144
|
+
[/\bother side closed\b/i, 'other side closed'],
|
|
145
|
+
[/\bpremature close\b/i, 'premature close'],
|
|
146
|
+
[/\bterminated\b/i, 'terminated'],
|
|
147
|
+
[/\bfetch failed\b/i, 'fetch failed'],
|
|
148
|
+
[/\bnetwork timeout\b/i, 'network timeout'],
|
|
149
|
+
[/\bnetwork error\b/i, 'network error'],
|
|
150
|
+
[/\bheaders timeout\b/i, 'headers timeout'],
|
|
151
|
+
[/\bbody timeout\b/i, 'body timeout'],
|
|
152
|
+
];
|
|
153
|
+
export function transportFailureDetail(error) {
|
|
154
|
+
if (error instanceof ServerApiError)
|
|
155
|
+
return null;
|
|
156
|
+
const parts = [];
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
const queue = [error];
|
|
159
|
+
while (queue.length && seen.size < 24) {
|
|
160
|
+
const current = queue.shift();
|
|
161
|
+
if (!current || typeof current !== 'object' || seen.has(current))
|
|
162
|
+
continue;
|
|
163
|
+
seen.add(current);
|
|
164
|
+
const code = String(current.code ?? '');
|
|
165
|
+
if (TRANSPORT_CODES.has(code) || /^UND_ERR_[A-Z_]+$/.test(code)) {
|
|
166
|
+
if (!parts.includes(code))
|
|
167
|
+
parts.push(code);
|
|
168
|
+
}
|
|
169
|
+
const message = String(current.message ?? '');
|
|
170
|
+
for (const [pattern, label] of TRANSPORT_PHRASES) {
|
|
171
|
+
if (pattern.test(message) && !parts.includes(label))
|
|
172
|
+
parts.push(label);
|
|
173
|
+
}
|
|
174
|
+
if (current.cause)
|
|
175
|
+
queue.push(current.cause);
|
|
176
|
+
const nested = current.errors;
|
|
177
|
+
if (Array.isArray(nested))
|
|
178
|
+
queue.push(...nested);
|
|
179
|
+
}
|
|
180
|
+
return parts.length ? parts.slice(0, 3).join(' <- ') : null;
|
|
181
|
+
}
|
|
182
|
+
function isTransportFailure(error) {
|
|
183
|
+
return transportFailureDetail(error) !== null;
|
|
184
|
+
}
|
|
185
|
+
export function gatewayFailureCategory(error) {
|
|
186
|
+
if (isTransportFailure(error))
|
|
187
|
+
return 'connection_error';
|
|
188
|
+
if (!(error instanceof ServerApiError))
|
|
189
|
+
return null;
|
|
190
|
+
return error.status in GATEWAY_STATUS_MESSAGES ? 'gateway_error' : null;
|
|
191
|
+
}
|
|
85
192
|
export async function readJsonResponse(response) {
|
|
86
193
|
const text = await response.text();
|
|
87
194
|
if (!text.trim())
|
|
@@ -90,15 +197,28 @@ export async function readJsonResponse(response) {
|
|
|
90
197
|
return JSON.parse(text);
|
|
91
198
|
}
|
|
92
199
|
catch {
|
|
93
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
error: { message: nonJsonErrorMessage(text, response.status) },
|
|
202
|
+
};
|
|
94
203
|
}
|
|
95
204
|
}
|
|
96
205
|
export function failureMessage(data, status) {
|
|
97
206
|
return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
|
|
98
207
|
}
|
|
208
|
+
export function failureCode(data) {
|
|
209
|
+
return typeof data?.error?.code === 'string' ? data.error.code : '';
|
|
210
|
+
}
|
|
211
|
+
export function isAuthenticationError(error) {
|
|
212
|
+
return error instanceof ServerApiError && error.status === 401;
|
|
213
|
+
}
|
|
214
|
+
export function authenticationErrorMessage(error) {
|
|
215
|
+
return error.code === 'AUTH_TOKEN_EXPIRED'
|
|
216
|
+
? 'Your login expired after 48 hours of inactivity. Run `ai login` and resume this saved session.'
|
|
217
|
+
: 'Your login is no longer valid. Run `ai login` and resume this saved session.';
|
|
218
|
+
}
|
|
99
219
|
export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
|
|
100
220
|
const data = await readJsonResponse(response);
|
|
101
|
-
return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
|
|
221
|
+
return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data));
|
|
102
222
|
}
|
|
103
223
|
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
|
|
104
224
|
const trace = createTraceContext();
|
|
@@ -115,7 +235,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
|
|
|
115
235
|
});
|
|
116
236
|
const data = await readJsonResponse(response);
|
|
117
237
|
if (!response.ok) {
|
|
118
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
238
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
119
239
|
}
|
|
120
240
|
return data;
|
|
121
241
|
}
|
package/dist/src/api/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
|
-
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
4
|
+
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
5
5
|
function sanitizeModelInfo(raw) {
|
|
6
6
|
if (!raw || typeof raw !== 'object') {
|
|
7
7
|
return null;
|
|
@@ -58,12 +58,21 @@ export function selectCacheForServer(cached, serverUrl) {
|
|
|
58
58
|
export function writeCachedServerModels(cache, env = process.env) {
|
|
59
59
|
const filePath = getModelsCachePath(env);
|
|
60
60
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(tempPath, `${JSON.stringify(cache, null, 2)}\n`, {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
mode: 0o600,
|
|
66
|
+
});
|
|
67
|
+
renameSync(tempPath, filePath);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
rmSync(tempPath, { force: true });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
65
73
|
}
|
|
66
|
-
export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, }) {
|
|
74
|
+
export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, budget = {}, }) {
|
|
75
|
+
const { timeoutMs = REQUEST_TIMEOUT_MS, ...ladder } = budget;
|
|
67
76
|
return retryTransient(async () => {
|
|
68
77
|
const trace = createTraceContext();
|
|
69
78
|
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
|
|
@@ -71,11 +80,11 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
|
|
|
71
80
|
authorization: `Bearer ${config.token}`,
|
|
72
81
|
...trace.headers,
|
|
73
82
|
},
|
|
74
|
-
signal: AbortSignal.timeout(
|
|
83
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
75
84
|
});
|
|
76
85
|
const data = (await readJsonResponse(response));
|
|
77
86
|
if (!response.ok) {
|
|
78
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
87
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
79
88
|
}
|
|
80
89
|
const models = Array.isArray(data?.models)
|
|
81
90
|
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
|
@@ -84,7 +93,7 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
|
|
|
84
93
|
throw new Error('Server returned an invalid model list.');
|
|
85
94
|
}
|
|
86
95
|
return { models };
|
|
87
|
-
});
|
|
96
|
+
}, ladder);
|
|
88
97
|
}
|
|
89
98
|
export function selectServerModel({ requestedModelId, cached, serverModels, }) {
|
|
90
99
|
const supportedIds = new Set(serverModels.models.map((model) => model.id));
|
|
@@ -113,6 +122,16 @@ export function validateServerModel(modelId, serverModels) {
|
|
|
113
122
|
}
|
|
114
123
|
return requested;
|
|
115
124
|
}
|
|
125
|
+
export function resolveResumedSessionModel(storedModelId, serverModels) {
|
|
126
|
+
if (serverModels.models.some((model) => model.id === storedModelId)) {
|
|
127
|
+
return { modelId: storedModelId, fellBack: false };
|
|
128
|
+
}
|
|
129
|
+
const fallbackId = serverModels.models[0]?.id;
|
|
130
|
+
return {
|
|
131
|
+
modelId: fallbackId ?? storedModelId,
|
|
132
|
+
fellBack: fallbackId != null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
116
135
|
export function updateSelectedModelCache({ config, selectedModelId, serverModels, env = process.env, }) {
|
|
117
136
|
writeCachedServerModels({
|
|
118
137
|
serverUrl: normalizeServerUrl(config.serverUrl),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from './colors.js';
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
|
-
import { buildCommandEnv, commandUsesSudo, sanitizeCommandText, terminateChild, } from './executor.js';
|
|
3
|
+
import { buildCommandEnv, commandUsesSudo, resolveCommandShell, sanitizeCommandText, terminateChild, } from './executor.js';
|
|
4
4
|
import { isTuiMode } from './runtime-mode.js';
|
|
5
5
|
import { redactConnectionStringCredentials } from './secret-preview.js';
|
|
6
6
|
const MAX_RUNNING_JOBS = 8;
|
|
@@ -196,7 +196,7 @@ export async function startBackgroundJob(command, cwd, { startupWaitMs, sessionI
|
|
|
196
196
|
const id = `bg_${++jobCounter}`;
|
|
197
197
|
const child = spawn(command, {
|
|
198
198
|
cwd,
|
|
199
|
-
shell:
|
|
199
|
+
shell: resolveCommandShell(),
|
|
200
200
|
detached: process.platform !== 'win32',
|
|
201
201
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
202
202
|
env: buildCommandEnv(cwd),
|
|
@@ -265,6 +265,7 @@ export function getJobOutputTail(id, maxLines) {
|
|
|
265
265
|
if (!record || !belongsToSession(record) || maxLines <= 0)
|
|
266
266
|
return [];
|
|
267
267
|
const lines = sanitizeJobText(record, record.buffer)
|
|
268
|
+
.replace(/\r\n?/g, '\n')
|
|
268
269
|
.split('\n')
|
|
269
270
|
.filter((line) => line.trim().length > 0);
|
|
270
271
|
return lines.slice(-maxLines);
|
|
@@ -277,6 +278,7 @@ export function getJobOutputPreview(id, maxTailLines) {
|
|
|
277
278
|
? sanitizeJobText(record, record.firstOutputLine).trim()
|
|
278
279
|
: '';
|
|
279
280
|
return {
|
|
281
|
+
droppedChars: Math.max(record.totalCaptured - record.buffer.length, 0),
|
|
280
282
|
firstLine,
|
|
281
283
|
tailLines: getJobOutputTail(id, maxTailLines),
|
|
282
284
|
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { registerNativeHost } from './native-host.js';
|
|
6
|
+
import { BROWSER_PROTOCOL_VERSION, browserRuntimeDir, protocolMismatchMessage, sessionKeyFromRecordFile, sessionRecordPath, sessionSocketPath, socketIsFile, } from './protocol.js';
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
8
|
+
export class BrowserBridgeError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(message, code = 'browser_error') {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'BrowserBridgeError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function safeUnlink(target) {
|
|
17
|
+
try {
|
|
18
|
+
fs.unlinkSync(target);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function startBrowserBridge({ sessionKey, label, cwd, env = process.env, }) {
|
|
24
|
+
const dir = browserRuntimeDir(env);
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const socketPath = sessionSocketPath(dir, sessionKey);
|
|
27
|
+
const recordPath = sessionRecordPath(dir, sessionKey);
|
|
28
|
+
if (socketIsFile())
|
|
29
|
+
safeUnlink(socketPath);
|
|
30
|
+
const browsers = new Map();
|
|
31
|
+
const listeners = new Set();
|
|
32
|
+
let selected = null;
|
|
33
|
+
let closed = false;
|
|
34
|
+
const registration = registerNativeHost({ env });
|
|
35
|
+
const server = net.createServer((socket) => {
|
|
36
|
+
socket.setNoDelay(true);
|
|
37
|
+
let buffer = '';
|
|
38
|
+
let key = null;
|
|
39
|
+
socket.on('data', (chunk) => {
|
|
40
|
+
buffer += chunk.toString('utf8');
|
|
41
|
+
let newline = buffer.indexOf('\n');
|
|
42
|
+
while (newline !== -1) {
|
|
43
|
+
const line = buffer.slice(0, newline);
|
|
44
|
+
buffer = buffer.slice(newline + 1);
|
|
45
|
+
newline = buffer.indexOf('\n');
|
|
46
|
+
if (!line.trim())
|
|
47
|
+
continue;
|
|
48
|
+
let frame;
|
|
49
|
+
try {
|
|
50
|
+
frame = JSON.parse(line);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (frame.t === 'hello') {
|
|
56
|
+
if (frame.protocol !== BROWSER_PROTOCOL_VERSION) {
|
|
57
|
+
const message = protocolMismatchMessage(frame.protocol);
|
|
58
|
+
for (const listener of listeners)
|
|
59
|
+
listener('protocol_mismatch', { message });
|
|
60
|
+
socket.destroy();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const announced = frame.browser?.key ?? crypto.randomUUID();
|
|
64
|
+
const existing = key ? browsers.get(key) : null;
|
|
65
|
+
if (existing) {
|
|
66
|
+
if (announced !== key) {
|
|
67
|
+
browsers.delete(key);
|
|
68
|
+
browsers.set(announced, existing);
|
|
69
|
+
if (selected === key)
|
|
70
|
+
selected = announced;
|
|
71
|
+
}
|
|
72
|
+
existing.identity = frame.browser;
|
|
73
|
+
existing.socket = socket;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
browsers.set(announced, {
|
|
77
|
+
identity: frame.browser,
|
|
78
|
+
socket,
|
|
79
|
+
pending: new Map(),
|
|
80
|
+
});
|
|
81
|
+
if (!selected)
|
|
82
|
+
selected = announced;
|
|
83
|
+
}
|
|
84
|
+
key = announced;
|
|
85
|
+
for (const listener of listeners) {
|
|
86
|
+
listener('connected', { browser: frame.browser });
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const browser = key ? browsers.get(key) : null;
|
|
91
|
+
if (!browser)
|
|
92
|
+
continue;
|
|
93
|
+
if (frame.t === 'res') {
|
|
94
|
+
const pending = browser.pending.get(frame.id);
|
|
95
|
+
if (!pending)
|
|
96
|
+
continue;
|
|
97
|
+
browser.pending.delete(frame.id);
|
|
98
|
+
clearTimeout(pending.timer);
|
|
99
|
+
if (frame.ok)
|
|
100
|
+
pending.resolve(frame.result);
|
|
101
|
+
else
|
|
102
|
+
pending.reject(new BrowserBridgeError(frame.error ?? 'Browser operation failed.', frame.code ?? 'browser_error'));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (frame.t === 'event') {
|
|
106
|
+
for (const listener of listeners)
|
|
107
|
+
listener(frame.event, frame.data ?? {});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const drop = () => {
|
|
112
|
+
if (!key)
|
|
113
|
+
return;
|
|
114
|
+
const browser = browsers.get(key);
|
|
115
|
+
browsers.delete(key);
|
|
116
|
+
if (selected === key)
|
|
117
|
+
selected = browsers.keys().next().value ?? null;
|
|
118
|
+
for (const pending of browser?.pending.values() ?? []) {
|
|
119
|
+
clearTimeout(pending.timer);
|
|
120
|
+
pending.reject(new BrowserBridgeError(`The browser disconnected while ${pending.op} was in flight, so its outcome is unknown. Check the page before doing anything that could repeat.`, 'disconnected'));
|
|
121
|
+
}
|
|
122
|
+
for (const listener of listeners)
|
|
123
|
+
listener('disconnected', { browser: key });
|
|
124
|
+
};
|
|
125
|
+
socket.on('close', drop);
|
|
126
|
+
socket.on('error', drop);
|
|
127
|
+
});
|
|
128
|
+
server.listen(socketPath, () => {
|
|
129
|
+
try {
|
|
130
|
+
fs.chmodSync(socketPath, 0o600);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
}
|
|
134
|
+
const announcement = {
|
|
135
|
+
protocol: BROWSER_PROTOCOL_VERSION,
|
|
136
|
+
session: sessionKey,
|
|
137
|
+
pid: process.pid,
|
|
138
|
+
label,
|
|
139
|
+
cwd,
|
|
140
|
+
startedAt: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
try {
|
|
143
|
+
fs.writeFileSync(recordPath, `${JSON.stringify(announcement)}\n`, { mode: 0o600 });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
server.on('error', () => {
|
|
149
|
+
});
|
|
150
|
+
const close = () => {
|
|
151
|
+
if (closed)
|
|
152
|
+
return;
|
|
153
|
+
closed = true;
|
|
154
|
+
for (const browser of browsers.values())
|
|
155
|
+
browser.socket.destroy();
|
|
156
|
+
browsers.clear();
|
|
157
|
+
server.close();
|
|
158
|
+
safeUnlink(socketPath);
|
|
159
|
+
safeUnlink(recordPath);
|
|
160
|
+
};
|
|
161
|
+
process.once('exit', close);
|
|
162
|
+
return {
|
|
163
|
+
sessionKey,
|
|
164
|
+
status() {
|
|
165
|
+
return {
|
|
166
|
+
connected: browsers.size > 0,
|
|
167
|
+
browsers: [...browsers.values()].map((browser) => browser.identity),
|
|
168
|
+
selected,
|
|
169
|
+
socketPath,
|
|
170
|
+
registrationErrors: registration.errors,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
selectBrowser(key) {
|
|
174
|
+
if (browsers.has(key))
|
|
175
|
+
selected = key;
|
|
176
|
+
},
|
|
177
|
+
onEvent(listener) {
|
|
178
|
+
listeners.add(listener);
|
|
179
|
+
return () => listeners.delete(listener);
|
|
180
|
+
},
|
|
181
|
+
request(op, args = {}, options = {}) {
|
|
182
|
+
const target = selected ? browsers.get(selected) : null;
|
|
183
|
+
if (!target) {
|
|
184
|
+
return Promise.reject(new BrowserBridgeError('No browser is connected to this session.', 'not_connected'));
|
|
185
|
+
}
|
|
186
|
+
const id = crypto.randomUUID();
|
|
187
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
target.pending.delete(id);
|
|
191
|
+
reject(new BrowserBridgeError(`The browser did not answer ${op} within ${Math.round(timeoutMs / 1000)}s, so its outcome is unknown. Look at the page before repeating anything that changes state.`, 'timeout'));
|
|
192
|
+
}, timeoutMs);
|
|
193
|
+
target.pending.set(id, { resolve, reject, timer, op });
|
|
194
|
+
target.socket.write(`${JSON.stringify({ t: 'req', id, op, args })}\n`, (error) => {
|
|
195
|
+
if (!error)
|
|
196
|
+
return;
|
|
197
|
+
target.pending.delete(id);
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
reject(new BrowserBridgeError(`Could not reach the browser: ${error.message}`, 'write_failed'));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
close,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export function pruneStaleSessions(env = process.env) {
|
|
207
|
+
const dir = browserRuntimeDir(env);
|
|
208
|
+
let entries;
|
|
209
|
+
try {
|
|
210
|
+
entries = fs.readdirSync(dir);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
const key = sessionKeyFromRecordFile(entry);
|
|
217
|
+
if (!key)
|
|
218
|
+
continue;
|
|
219
|
+
const file = path.join(dir, entry);
|
|
220
|
+
try {
|
|
221
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
222
|
+
process.kill(record.pid, 0);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (error?.code === 'EPERM')
|
|
226
|
+
continue;
|
|
227
|
+
safeUnlink(file);
|
|
228
|
+
if (socketIsFile())
|
|
229
|
+
safeUnlink(sessionSocketPath(dir, key));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const MAX_FRAME_BYTES = 900 * 1024;
|
|
2
|
+
export const FRAGMENT_BYTES = 512 * 1024;
|
|
3
|
+
export function encodeFrame(payload) {
|
|
4
|
+
const header = Buffer.alloc(4);
|
|
5
|
+
header.writeUInt32LE(payload.length, 0);
|
|
6
|
+
return Buffer.concat([header, payload]);
|
|
7
|
+
}
|
|
8
|
+
export function fragmentPayload(payload, id) {
|
|
9
|
+
const total = Math.ceil(payload.length / FRAGMENT_BYTES);
|
|
10
|
+
const frames = [];
|
|
11
|
+
for (let part = 0; part < total; part += 1) {
|
|
12
|
+
frames.push({
|
|
13
|
+
t: 'fragment',
|
|
14
|
+
id,
|
|
15
|
+
part,
|
|
16
|
+
total,
|
|
17
|
+
data: payload.subarray(part * FRAGMENT_BYTES, (part + 1) * FRAGMENT_BYTES).toString('base64'),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return frames;
|
|
21
|
+
}
|
|
22
|
+
export function decodeFrames(inbound) {
|
|
23
|
+
const frames = [];
|
|
24
|
+
let rest = inbound;
|
|
25
|
+
let unparseable = 0;
|
|
26
|
+
for (;;) {
|
|
27
|
+
if (rest.length < 4)
|
|
28
|
+
break;
|
|
29
|
+
const length = rest.readUInt32LE(0);
|
|
30
|
+
if (rest.length < 4 + length)
|
|
31
|
+
break;
|
|
32
|
+
const body = rest.subarray(4, 4 + length);
|
|
33
|
+
rest = rest.subarray(4 + length);
|
|
34
|
+
try {
|
|
35
|
+
frames.push(JSON.parse(body.toString('utf8')));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
unparseable += 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { frames, rest, unparseable };
|
|
42
|
+
}
|