@philau2512/fast-context-mcp 1.5.3
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 +21 -0
- package/README.md +306 -0
- package/package.json +53 -0
- package/scripts/link-local-bin.mjs +50 -0
- package/src/core.mjs +2518 -0
- package/src/directory-scorer.mjs +1086 -0
- package/src/executor.mjs +659 -0
- package/src/extract-key.mjs +334 -0
- package/src/project-path.mjs +47 -0
- package/src/protobuf.mjs +235 -0
- package/src/server.mjs +325 -0
- package/src/tree.mjs +82 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windsurf / Devin API Key extraction from local installation.
|
|
3
|
+
*
|
|
4
|
+
* Cross-platform: macOS / Windows / Linux.
|
|
5
|
+
* Uses sql.js (pure JS/WASM) to read state.vscdb — no native compilation needed.
|
|
6
|
+
*
|
|
7
|
+
* Lookup order (auto-detect):
|
|
8
|
+
* 1) Devin CLI credentials.toml on Linux/WSL
|
|
9
|
+
* 2) Windsurf / Devin state.vscdb candidates
|
|
10
|
+
* Prefer keys that start with `devin-session-token$`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { homedir, platform } from "node:os";
|
|
16
|
+
import initSqlJs from "sql.js";
|
|
17
|
+
|
|
18
|
+
const TOML_API_KEY_FIELDS = [
|
|
19
|
+
"api_key",
|
|
20
|
+
"apiKey",
|
|
21
|
+
"devin_api_key",
|
|
22
|
+
"devinApiKey",
|
|
23
|
+
"windsurf_api_key",
|
|
24
|
+
"windsurfApiKey",
|
|
25
|
+
"access_token",
|
|
26
|
+
"accessToken",
|
|
27
|
+
"token",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Official app folder names only — Windsurf and Devin (never "Deviv"). */
|
|
31
|
+
const APP_NAMES_MAC_WIN = ["Windsurf", "Devin"];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve APPDATA with injectable override.
|
|
35
|
+
* Explicit `deps.appdata` (including empty string) wins over process.env.
|
|
36
|
+
* @param {Object} [deps]
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function resolveAppdata(deps = {}) {
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(deps, "appdata")) {
|
|
41
|
+
return deps.appdata || "";
|
|
42
|
+
}
|
|
43
|
+
return process.env.APPDATA || "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get all candidate paths to state.vscdb across Windsurf and Devin installs.
|
|
48
|
+
*
|
|
49
|
+
* Windsurf was succeeded by Devin (same team). DB schema is unchanged
|
|
50
|
+
* (`ItemTable`, `windsurfAuthStatus`); only the app directory name differs.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} [deps]
|
|
53
|
+
* @param {string} [deps.platform]
|
|
54
|
+
* @param {string} [deps.home]
|
|
55
|
+
* @param {string} [deps.xdgConfigHome]
|
|
56
|
+
* @param {string} [deps.appdata]
|
|
57
|
+
* @returns {string[]}
|
|
58
|
+
*/
|
|
59
|
+
export function getDbPathCandidates(deps = {}) {
|
|
60
|
+
const plat = deps.platform ?? platform();
|
|
61
|
+
const home = deps.home ?? homedir();
|
|
62
|
+
const xdg = deps.xdgConfigHome ?? process.env.XDG_CONFIG_HOME;
|
|
63
|
+
const out = [];
|
|
64
|
+
|
|
65
|
+
if (plat === "darwin") {
|
|
66
|
+
const appSupp = join(home, "Library", "Application Support");
|
|
67
|
+
for (const appName of APP_NAMES_MAC_WIN) {
|
|
68
|
+
out.push(join(appSupp, appName, "User", "globalStorage", "state.vscdb"));
|
|
69
|
+
}
|
|
70
|
+
} else if (plat === "win32") {
|
|
71
|
+
const appdata = resolveAppdata(deps);
|
|
72
|
+
if (!appdata) {
|
|
73
|
+
throw new Error("Cannot determine APPDATA path");
|
|
74
|
+
}
|
|
75
|
+
for (const appName of APP_NAMES_MAC_WIN) {
|
|
76
|
+
out.push(join(appdata, appName, "User", "globalStorage", "state.vscdb"));
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
// Linux: Windsurf (Title Case) + devin (lowercase observed) + Devin (Title)
|
|
80
|
+
const config = xdg || join(home, ".config");
|
|
81
|
+
for (const appName of ["Windsurf", "devin", "Devin"]) {
|
|
82
|
+
out.push(join(config, appName, "User", "globalStorage", "state.vscdb"));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Backward-compatible: return the first candidate (legacy Windsurf path).
|
|
90
|
+
* @param {Object} [deps]
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
export function getDbPath(deps) {
|
|
94
|
+
return getDbPathCandidates(deps)[0];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Linux/WSL Devin CLI credential candidates.
|
|
99
|
+
* @param {Object} [deps]
|
|
100
|
+
* @param {string} [deps.platform]
|
|
101
|
+
* @param {string} [deps.home]
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
export function getCliCredentialPathCandidates(deps = {}) {
|
|
105
|
+
const plat = deps.platform ?? platform();
|
|
106
|
+
const home = deps.home ?? homedir();
|
|
107
|
+
if (plat !== "linux") return [];
|
|
108
|
+
return [join(home, ".local", "share", "devin", "credentials.toml")];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Credential sources in lookup order: CLI toml first, then SQLite DBs.
|
|
113
|
+
* @param {Object} [deps]
|
|
114
|
+
* @returns {{ type: "toml" | "sqlite", path: string }[]}
|
|
115
|
+
*/
|
|
116
|
+
export function getCredentialSources(deps = {}) {
|
|
117
|
+
const tomlSources = getCliCredentialPathCandidates(deps).map((path) => ({
|
|
118
|
+
type: "toml",
|
|
119
|
+
path,
|
|
120
|
+
}));
|
|
121
|
+
const sqliteSources = getDbPathCandidates(deps).map((path) => ({
|
|
122
|
+
type: "sqlite",
|
|
123
|
+
path,
|
|
124
|
+
}));
|
|
125
|
+
return [...tomlSources, ...sqliteSources];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extract an API key from Devin CLI credentials.toml content.
|
|
130
|
+
* @param {string} text
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
export function extractApiKeyFromToml(text) {
|
|
134
|
+
for (const field of TOML_API_KEY_FIELDS) {
|
|
135
|
+
const match = text.match(
|
|
136
|
+
new RegExp(`^\\s*${field}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|([^\\s#]+))`, "m"),
|
|
137
|
+
);
|
|
138
|
+
const value = (match?.[1] || match?.[2] || match?.[3] || "").trim();
|
|
139
|
+
if (value) return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const fallback = text.match(/\bsk-[A-Za-z0-9_-]+\b/);
|
|
143
|
+
return fallback ? fallback[0] : "";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} key
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
function isPreferredApiKey(key) {
|
|
151
|
+
return typeof key === "string" && key.startsWith("devin-session-token$");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} credentialsPath
|
|
156
|
+
* @returns {{ api_key?: string, db_path: string, source_type: string, error?: string, hint?: string }}
|
|
157
|
+
*/
|
|
158
|
+
function extractKeyFromToml(credentialsPath) {
|
|
159
|
+
if (!existsSync(credentialsPath)) {
|
|
160
|
+
return {
|
|
161
|
+
error: `Devin CLI credentials not found: ${credentialsPath}`,
|
|
162
|
+
hint: "Run `devin login` inside WSL/Linux, then retry.",
|
|
163
|
+
db_path: credentialsPath,
|
|
164
|
+
source_type: "devin_cli_credentials",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let text;
|
|
169
|
+
try {
|
|
170
|
+
text = readFileSync(credentialsPath, "utf8");
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return {
|
|
173
|
+
error: `Failed to read Devin CLI credentials: ${e.message}`,
|
|
174
|
+
db_path: credentialsPath,
|
|
175
|
+
source_type: "devin_cli_credentials",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const apiKey = extractApiKeyFromToml(text);
|
|
180
|
+
if (!apiKey) {
|
|
181
|
+
return {
|
|
182
|
+
error: "Devin CLI credentials did not contain an API key",
|
|
183
|
+
hint: "Run `devin login` inside WSL/Linux, then retry.",
|
|
184
|
+
db_path: credentialsPath,
|
|
185
|
+
source_type: "devin_cli_credentials",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
api_key: apiKey,
|
|
191
|
+
db_path: credentialsPath,
|
|
192
|
+
source_type: "devin_cli_credentials",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {string} dbPath
|
|
198
|
+
* @returns {Promise<{ api_key?: string, db_path: string, source_type?: string, error?: string, hint?: string }>}
|
|
199
|
+
*/
|
|
200
|
+
async function readApiKeyFromDb(dbPath) {
|
|
201
|
+
if (!existsSync(dbPath)) {
|
|
202
|
+
return {
|
|
203
|
+
error: `Windsurf / Devin database not found: ${dbPath}`,
|
|
204
|
+
hint: "Ensure Windsurf or Devin is installed and logged in.",
|
|
205
|
+
db_path: dbPath,
|
|
206
|
+
source_type: "sqlite",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let db;
|
|
211
|
+
try {
|
|
212
|
+
const SQL = await initSqlJs();
|
|
213
|
+
const buf = readFileSync(dbPath);
|
|
214
|
+
db = new SQL.Database(buf);
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return {
|
|
217
|
+
error: `Failed to open database: ${e.message}`,
|
|
218
|
+
db_path: dbPath,
|
|
219
|
+
source_type: "sqlite",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const stmt = db.prepare("SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus'");
|
|
225
|
+
if (!stmt.step()) {
|
|
226
|
+
stmt.free();
|
|
227
|
+
return {
|
|
228
|
+
error: "windsurfAuthStatus record not found",
|
|
229
|
+
hint: "Ensure Windsurf or Devin is logged in.",
|
|
230
|
+
db_path: dbPath,
|
|
231
|
+
source_type: "sqlite",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const row = stmt.getAsObject();
|
|
236
|
+
stmt.free();
|
|
237
|
+
|
|
238
|
+
let data;
|
|
239
|
+
try {
|
|
240
|
+
data = JSON.parse(row.value);
|
|
241
|
+
} catch {
|
|
242
|
+
return {
|
|
243
|
+
error: "windsurfAuthStatus data parse failed",
|
|
244
|
+
db_path: dbPath,
|
|
245
|
+
source_type: "sqlite",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const apiKey = data.apiKey || "";
|
|
250
|
+
if (!apiKey) {
|
|
251
|
+
return {
|
|
252
|
+
error: "apiKey field is empty",
|
|
253
|
+
db_path: dbPath,
|
|
254
|
+
source_type: "sqlite",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
api_key: apiKey,
|
|
260
|
+
db_path: dbPath,
|
|
261
|
+
source_type: "sqlite",
|
|
262
|
+
};
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return {
|
|
265
|
+
error: `Extraction failed: ${e.message}`,
|
|
266
|
+
db_path: dbPath,
|
|
267
|
+
source_type: "sqlite",
|
|
268
|
+
};
|
|
269
|
+
} finally {
|
|
270
|
+
db.close();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Extract API Key from Windsurf / Devin credential sources.
|
|
276
|
+
*
|
|
277
|
+
* Auto-detect scans CLI toml (Linux/WSL) then SQLite candidates and prefers
|
|
278
|
+
* `devin-session-token$` keys. Explicit `dbPath` skips auto order
|
|
279
|
+
* (`.toml` → toml reader, otherwise SQLite).
|
|
280
|
+
*
|
|
281
|
+
* @param {string} [dbPath]
|
|
282
|
+
* @param {Object} [deps]
|
|
283
|
+
* @returns {Promise<{ api_key?: string, db_path: string, source_type?: string, error?: string, hint?: string, tried_paths?: string[] }>}
|
|
284
|
+
*/
|
|
285
|
+
export async function extractKey(dbPath, deps) {
|
|
286
|
+
// Explicit path: always hit that source (even if missing) for precise errors.
|
|
287
|
+
if (dbPath) {
|
|
288
|
+
const type = dbPath.endsWith(".toml") ? "toml" : "sqlite";
|
|
289
|
+
const result =
|
|
290
|
+
type === "toml" ? extractKeyFromToml(dbPath) : await readApiKeyFromDb(dbPath);
|
|
291
|
+
return { ...result, tried_paths: [dbPath] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const sources = getCredentialSources(deps);
|
|
295
|
+
const tried_paths = sources.map((s) => s.path);
|
|
296
|
+
const existing = sources.filter((s) => existsSync(s.path));
|
|
297
|
+
|
|
298
|
+
if (!existing.length) {
|
|
299
|
+
return {
|
|
300
|
+
error: `Windsurf / Devin credential source not found. Tried:\n${tried_paths.map((p) => ` - ${p}`).join("\n")}`,
|
|
301
|
+
hint: "Ensure Windsurf/Devin is installed and logged in, or run `devin login` on Linux/WSL.",
|
|
302
|
+
db_path: sources[0]?.path || "",
|
|
303
|
+
tried_paths,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let firstUsable = null;
|
|
308
|
+
const failures = [];
|
|
309
|
+
|
|
310
|
+
for (const source of existing) {
|
|
311
|
+
const result =
|
|
312
|
+
source.type === "toml"
|
|
313
|
+
? extractKeyFromToml(source.path)
|
|
314
|
+
: await readApiKeyFromDb(source.path);
|
|
315
|
+
|
|
316
|
+
if (result.api_key) {
|
|
317
|
+
if (isPreferredApiKey(result.api_key)) {
|
|
318
|
+
return { ...result, tried_paths };
|
|
319
|
+
}
|
|
320
|
+
if (!firstUsable) firstUsable = result;
|
|
321
|
+
} else {
|
|
322
|
+
failures.push(result);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (firstUsable) return { ...firstUsable, tried_paths };
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
error: `No usable apiKey found. Tried:\n${failures.map((r) => ` - ${r.db_path}: ${r.error}`).join("\n")}`,
|
|
330
|
+
hint: "Ensure Windsurf/Devin is logged in, or run `devin login` on Linux/WSL.",
|
|
331
|
+
db_path: existing[0]?.path || sources[0]?.path || "",
|
|
332
|
+
tried_paths,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
export const PROJECT_PATH_REQUIRED_MESSAGE =
|
|
6
|
+
"project_path is required. Pass the absolute path to the project root directory.";
|
|
7
|
+
|
|
8
|
+
export const projectPathSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.trim()
|
|
11
|
+
.min(1, PROJECT_PATH_REQUIRED_MESSAGE);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validate the project root path provided to fast_context_search.
|
|
15
|
+
* Returns null when valid, otherwise an MCP-friendly error string.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} projectPath
|
|
18
|
+
* @param {(path: string) => import("node:fs").Stats} [statFn]
|
|
19
|
+
* @returns {string|null}
|
|
20
|
+
*/
|
|
21
|
+
export function validateProjectPath(projectPath, statFn = statSync) {
|
|
22
|
+
if (!projectPath) {
|
|
23
|
+
return `Error: ${PROJECT_PATH_REQUIRED_MESSAGE}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!isAbsolute(projectPath)) {
|
|
27
|
+
return `Error: project_path must be an absolute path, got: ${projectPath}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const st = statFn(projectPath);
|
|
32
|
+
if (!st.isDirectory()) {
|
|
33
|
+
return `Error: project_path is not a directory: ${projectPath}`;
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error?.code === "ENOENT") {
|
|
37
|
+
return `Error: project_path does not exist: ${projectPath}`;
|
|
38
|
+
}
|
|
39
|
+
if (error?.code === "EACCES" || error?.code === "EPERM") {
|
|
40
|
+
return `Error: cannot access project_path (${error.code}): ${projectPath}`;
|
|
41
|
+
}
|
|
42
|
+
const reason = error?.message ? `${error.code || "UNKNOWN"}: ${error.message}` : String(error);
|
|
43
|
+
return `Error: failed to validate project_path: ${reason}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
package/src/protobuf.mjs
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-written Protobuf encoder/decoder + Connect-RPC frame handling.
|
|
3
|
+
*
|
|
4
|
+
* Matches the Windsurf wire format exactly.
|
|
5
|
+
* Python bytearray → Node.js Buffer
|
|
6
|
+
* struct.pack(">I", len) → buf.writeUInt32BE
|
|
7
|
+
* gzip.compress/decompress → zlib.gzipSync/gunzipSync
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
11
|
+
|
|
12
|
+
// ─── Protobuf Encoder ──────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export class ProtobufEncoder {
|
|
15
|
+
constructor() {
|
|
16
|
+
/** @type {Buffer[]} */
|
|
17
|
+
this._chunks = [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Encode an unsigned varint into a Buffer.
|
|
22
|
+
* @param {number} value
|
|
23
|
+
* @returns {Buffer}
|
|
24
|
+
*/
|
|
25
|
+
_varint(value) {
|
|
26
|
+
const bytes = [];
|
|
27
|
+
while (value > 0x7f) {
|
|
28
|
+
bytes.push((value & 0x7f) | 0x80);
|
|
29
|
+
value >>>= 7;
|
|
30
|
+
}
|
|
31
|
+
bytes.push(value & 0x7f);
|
|
32
|
+
return Buffer.from(bytes);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Encode a field tag.
|
|
37
|
+
* @param {number} field
|
|
38
|
+
* @param {number} wire
|
|
39
|
+
* @returns {Buffer}
|
|
40
|
+
*/
|
|
41
|
+
_tag(field, wire) {
|
|
42
|
+
return this._varint((field << 3) | wire);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Write a varint field.
|
|
47
|
+
* @param {number} field
|
|
48
|
+
* @param {number} value
|
|
49
|
+
* @returns {ProtobufEncoder}
|
|
50
|
+
*/
|
|
51
|
+
writeVarint(field, value) {
|
|
52
|
+
this._chunks.push(this._tag(field, 0), this._varint(value));
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Write a length-delimited string field.
|
|
58
|
+
* @param {number} field
|
|
59
|
+
* @param {string} value
|
|
60
|
+
* @returns {ProtobufEncoder}
|
|
61
|
+
*/
|
|
62
|
+
writeString(field, value) {
|
|
63
|
+
const data = Buffer.from(value, "utf-8");
|
|
64
|
+
this._chunks.push(this._tag(field, 2), this._varint(data.length), data);
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write a length-delimited bytes field.
|
|
70
|
+
* @param {number} field
|
|
71
|
+
* @param {Buffer|Uint8Array} value
|
|
72
|
+
* @returns {ProtobufEncoder}
|
|
73
|
+
*/
|
|
74
|
+
writeBytes(field, value) {
|
|
75
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
76
|
+
this._chunks.push(this._tag(field, 2), this._varint(buf.length), buf);
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write a nested message field.
|
|
82
|
+
* @param {number} field
|
|
83
|
+
* @param {ProtobufEncoder} sub
|
|
84
|
+
* @returns {ProtobufEncoder}
|
|
85
|
+
*/
|
|
86
|
+
writeMessage(field, sub) {
|
|
87
|
+
const data = sub.toBuffer();
|
|
88
|
+
this._chunks.push(this._tag(field, 2), this._varint(data.length), data);
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return the encoded bytes as a Buffer.
|
|
94
|
+
* @returns {Buffer}
|
|
95
|
+
*/
|
|
96
|
+
toBuffer() {
|
|
97
|
+
return Buffer.concat(this._chunks);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Varint Decode ─────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Decode a varint from a buffer at the given offset.
|
|
105
|
+
* @param {Buffer} buf
|
|
106
|
+
* @param {number} offset
|
|
107
|
+
* @returns {[number, number]} [value, newOffset]
|
|
108
|
+
*/
|
|
109
|
+
export function decodeVarint(buf, offset) {
|
|
110
|
+
let value = 0;
|
|
111
|
+
let shift = 0;
|
|
112
|
+
while (offset < buf.length) {
|
|
113
|
+
const b = buf[offset++];
|
|
114
|
+
value |= (b & 0x7f) << shift;
|
|
115
|
+
shift += 7;
|
|
116
|
+
if (!(b & 0x80)) break;
|
|
117
|
+
}
|
|
118
|
+
return [value, offset];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Protobuf String Extraction ────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract all UTF-8 strings (length > 5) from raw protobuf data
|
|
125
|
+
* by parsing wire types. Matches Python proto_extract_strings().
|
|
126
|
+
* @param {Buffer} data
|
|
127
|
+
* @returns {string[]}
|
|
128
|
+
*/
|
|
129
|
+
export function extractStrings(data) {
|
|
130
|
+
const strings = [];
|
|
131
|
+
let i = 0;
|
|
132
|
+
while (i < data.length) {
|
|
133
|
+
// Read tag varint
|
|
134
|
+
let tag = 0;
|
|
135
|
+
let shift = 0;
|
|
136
|
+
while (i < data.length) {
|
|
137
|
+
const b = data[i++];
|
|
138
|
+
tag |= (b & 0x7f) << shift;
|
|
139
|
+
shift += 7;
|
|
140
|
+
if (!(b & 0x80)) break;
|
|
141
|
+
}
|
|
142
|
+
const wire = tag & 0x7;
|
|
143
|
+
if (wire === 0) {
|
|
144
|
+
// Varint — skip
|
|
145
|
+
while (i < data.length) {
|
|
146
|
+
const b = data[i++];
|
|
147
|
+
if (!(b & 0x80)) break;
|
|
148
|
+
}
|
|
149
|
+
} else if (wire === 1) {
|
|
150
|
+
// 64-bit fixed
|
|
151
|
+
i += 8;
|
|
152
|
+
} else if (wire === 2) {
|
|
153
|
+
// Length-delimited
|
|
154
|
+
let length = 0;
|
|
155
|
+
shift = 0;
|
|
156
|
+
while (i < data.length) {
|
|
157
|
+
const b = data[i++];
|
|
158
|
+
length |= (b & 0x7f) << shift;
|
|
159
|
+
shift += 7;
|
|
160
|
+
if (!(b & 0x80)) break;
|
|
161
|
+
}
|
|
162
|
+
if (i + length <= data.length) {
|
|
163
|
+
const raw = data.subarray(i, i + length);
|
|
164
|
+
try {
|
|
165
|
+
const text = raw.toString("utf-8");
|
|
166
|
+
if (text.length > 5) {
|
|
167
|
+
strings.push(text);
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Not valid UTF-8, skip
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
i += length;
|
|
174
|
+
} else if (wire === 5) {
|
|
175
|
+
// 32-bit fixed
|
|
176
|
+
i += 4;
|
|
177
|
+
} else {
|
|
178
|
+
// Unknown wire type — stop
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return strings;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Connect-RPC Frame Encode/Decode ───────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Encode protobuf bytes into a gzip-compressed Connect-RPC frame.
|
|
189
|
+
* Frame format: 1-byte flags + 4-byte big-endian length + payload
|
|
190
|
+
* @param {Buffer} protoBytes
|
|
191
|
+
* @param {boolean} [compress=true]
|
|
192
|
+
* @returns {Buffer}
|
|
193
|
+
*/
|
|
194
|
+
export function connectFrameEncode(protoBytes, compress = true) {
|
|
195
|
+
let payload;
|
|
196
|
+
let flags;
|
|
197
|
+
if (compress) {
|
|
198
|
+
payload = gzipSync(protoBytes);
|
|
199
|
+
flags = 1; // gzip compressed
|
|
200
|
+
} else {
|
|
201
|
+
payload = protoBytes;
|
|
202
|
+
flags = 0;
|
|
203
|
+
}
|
|
204
|
+
const header = Buffer.alloc(5);
|
|
205
|
+
header[0] = flags;
|
|
206
|
+
header.writeUInt32BE(payload.length, 1);
|
|
207
|
+
return Buffer.concat([header, payload]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Decode Connect-RPC frames from raw response data.
|
|
212
|
+
* Handles gzip-compressed frames (flags 1 or 3).
|
|
213
|
+
* @param {Buffer} data
|
|
214
|
+
* @returns {Buffer[]}
|
|
215
|
+
*/
|
|
216
|
+
export function connectFrameDecode(data) {
|
|
217
|
+
const frames = [];
|
|
218
|
+
let i = 0;
|
|
219
|
+
while (i + 5 <= data.length) {
|
|
220
|
+
const flags = data[i];
|
|
221
|
+
const length = data.readUInt32BE(i + 1);
|
|
222
|
+
i += 5;
|
|
223
|
+
let payload = data.subarray(i, i + length);
|
|
224
|
+
i += length;
|
|
225
|
+
if (flags === 1 || flags === 3) {
|
|
226
|
+
try {
|
|
227
|
+
payload = gunzipSync(payload);
|
|
228
|
+
} catch {
|
|
229
|
+
// Decompression failed — use raw payload
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
frames.push(Buffer.from(payload));
|
|
233
|
+
}
|
|
234
|
+
return frames;
|
|
235
|
+
}
|