@workweave/router 0.2.10 → 0.2.11

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.
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Which language server serves which file, and the pool of live ones.
3
+ *
4
+ * The registry is a data table rather than a class hierarchy: adding a language
5
+ * is one row, and every behavioral difference between servers is already
6
+ * expressible as data (binary, root markers, install hint).
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { abortable, LspClient, spawnTransport, type LspClientOptions } from "./lsp-client.js";
13
+
14
+ export interface ServerBinary {
15
+ command: string;
16
+ args: string[];
17
+ }
18
+
19
+ export interface ServerInstall {
20
+ /** Toolchain binary that must already exist for the install to be possible. */
21
+ requires: string;
22
+ command: string[];
23
+ }
24
+
25
+ export interface LanguageServerSpec {
26
+ id: string;
27
+ /** Model/user-facing language name ("go"), distinct from the server id ("gopls"). */
28
+ language: string;
29
+ /** Lowercased file extension (with dot) to LSP languageId. */
30
+ languages: Record<string, string>;
31
+ /** Tried in order; the first one on PATH wins. */
32
+ binaries: ServerBinary[];
33
+ rootMarkers: string[];
34
+ install: ServerInstall;
35
+ /**
36
+ * Where the install command drops its binary when that directory is not on
37
+ * PATH ("~" expands to the home dir). A function of the environment because
38
+ * the toolchains honor overrides (GOBIN / GOPATH / CARGO_HOME) — hardcoding
39
+ * the defaults would lose a successful install to a non-default location.
40
+ */
41
+ fallbackDirs(env: NodeJS.ProcessEnv): string[];
42
+ }
43
+
44
+ /** `go install` target: $GOBIN, else <first GOPATH element>/bin, else ~/go/bin. */
45
+ export function goBinDirs(env: NodeJS.ProcessEnv): string[] {
46
+ const dirs: string[] = [];
47
+ if (env.GOBIN?.trim()) dirs.push(env.GOBIN.trim());
48
+ const gopathFirst = env.GOPATH?.split(path.delimiter)[0]?.trim();
49
+ dirs.push(gopathFirst ? path.join(gopathFirst, "bin") : "~/go/bin");
50
+ return dirs;
51
+ }
52
+
53
+ /** rustup/cargo bin dir: $CARGO_HOME/bin, else ~/.cargo/bin. */
54
+ export function cargoBinDirs(env: NodeJS.ProcessEnv): string[] {
55
+ const cargoHome = env.CARGO_HOME?.trim();
56
+ return [cargoHome ? path.join(cargoHome, "bin") : "~/.cargo/bin"];
57
+ }
58
+
59
+ export const LSP_SERVERS: LanguageServerSpec[] = [
60
+ {
61
+ id: "gopls",
62
+ language: "go",
63
+ languages: { ".go": "go" },
64
+ binaries: [{ command: "gopls", args: ["serve"] }],
65
+ rootMarkers: ["go.work", "go.mod"],
66
+ install: { requires: "go", command: ["go", "install", "golang.org/x/tools/gopls@latest"] },
67
+ fallbackDirs: goBinDirs,
68
+ },
69
+ {
70
+ id: "typescript",
71
+ language: "typescript",
72
+ languages: {
73
+ ".ts": "typescript",
74
+ ".tsx": "typescriptreact",
75
+ ".mts": "typescript",
76
+ ".cts": "typescript",
77
+ ".js": "javascript",
78
+ ".jsx": "javascriptreact",
79
+ ".mjs": "javascript",
80
+ ".cjs": "javascript",
81
+ },
82
+ binaries: [{ command: "typescript-language-server", args: ["--stdio"] }],
83
+ rootMarkers: ["tsconfig.json", "jsconfig.json", "package.json", ".git"],
84
+ install: { requires: "npm", command: ["npm", "i", "-g", "typescript-language-server", "typescript"] },
85
+ fallbackDirs: () => [],
86
+ },
87
+ {
88
+ id: "pyright",
89
+ language: "python",
90
+ languages: { ".py": "python", ".pyi": "python" },
91
+ binaries: [
92
+ { command: "pyright-langserver", args: ["--stdio"] },
93
+ { command: "basedpyright-langserver", args: ["--stdio"] },
94
+ ],
95
+ rootMarkers: ["pyrightconfig.json", "pyproject.toml", "setup.py", "requirements.txt", ".git"],
96
+ install: { requires: "npm", command: ["npm", "i", "-g", "pyright"] },
97
+ fallbackDirs: () => [],
98
+ },
99
+ {
100
+ id: "rust-analyzer",
101
+ language: "rust",
102
+ languages: { ".rs": "rust" },
103
+ binaries: [{ command: "rust-analyzer", args: [] }],
104
+ rootMarkers: ["Cargo.toml"],
105
+ install: { requires: "rustup", command: ["rustup", "component", "add", "rust-analyzer"] },
106
+ fallbackDirs: cargoBinDirs,
107
+ },
108
+ ];
109
+
110
+ export function specForLanguage(language: string): LanguageServerSpec | undefined {
111
+ return LSP_SERVERS.find((spec) => spec.language === language.toLowerCase());
112
+ }
113
+
114
+ export function installCommandText(spec: LanguageServerSpec): string {
115
+ return spec.install.command.join(" ");
116
+ }
117
+
118
+ /**
119
+ * Markers that positively indicate the language is present in a directory.
120
+ * `.git` is a root-walk fallback, not evidence of any language.
121
+ */
122
+ export function detectMarkers(spec: LanguageServerSpec): string[] {
123
+ return spec.rootMarkers.filter((marker) => marker !== ".git");
124
+ }
125
+
126
+ export function specForFile(filePath: string): LanguageServerSpec | undefined {
127
+ const extension = path.extname(filePath).toLowerCase();
128
+ if (!extension) return undefined;
129
+ return LSP_SERVERS.find((spec) => extension in spec.languages);
130
+ }
131
+
132
+ export function languageIdFor(spec: LanguageServerSpec, filePath: string): string {
133
+ return spec.languages[path.extname(filePath).toLowerCase()] ?? "plaintext";
134
+ }
135
+
136
+ export function supportedExtensions(): string[] {
137
+ return LSP_SERVERS.flatMap((spec) => Object.keys(spec.languages)).sort();
138
+ }
139
+
140
+ export type ExistsFn = (target: string) => boolean;
141
+
142
+ /** Nearest ancestor holding a marker wins; `fallbackCwd` when the file sits outside any project. */
143
+ export function findWorkspaceRoot(filePath: string, markers: string[], fallbackCwd: string, exists: ExistsFn = fs.existsSync): string {
144
+ let current = path.dirname(path.resolve(filePath));
145
+ while (true) {
146
+ for (const marker of markers) {
147
+ if (exists(path.join(current, marker))) return current;
148
+ }
149
+ const parent = path.dirname(current);
150
+ if (parent === current) return fallbackCwd;
151
+ current = parent;
152
+ }
153
+ }
154
+
155
+ export type WhichFn = (command: string) => string | undefined;
156
+
157
+ function isExecutable(candidate: string): boolean {
158
+ try {
159
+ if (!fs.statSync(candidate).isFile()) return false;
160
+ // Windows has no X_OK bit; presence on PATH with a known extension is the test.
161
+ if (process.platform !== "win32") fs.accessSync(candidate, fs.constants.X_OK);
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /** Hand-rolled PATH scan — the package ships raw TS with peer deps only, so no `which` dependency. */
169
+ export function defaultWhich(command: string): string | undefined {
170
+ if (command.includes("/") || command.includes(path.sep)) return isExecutable(command) ? command : undefined;
171
+ const extensions = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
172
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
173
+ if (!dir) continue;
174
+ for (const extension of extensions) {
175
+ const candidate = path.join(dir, command + extension);
176
+ if (isExecutable(candidate)) return candidate;
177
+ }
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ function expandHome(dir: string): string {
183
+ return dir.startsWith("~/") ? path.join(os.homedir(), dir.slice(2)) : dir;
184
+ }
185
+
186
+ export function resolveBinary(
187
+ spec: LanguageServerSpec,
188
+ which: WhichFn = defaultWhich,
189
+ env: NodeJS.ProcessEnv = process.env,
190
+ ): ServerBinary | undefined {
191
+ for (const binary of spec.binaries) {
192
+ // Fallback candidates are absolute, which defaultWhich checks directly;
193
+ // routing them through `which` keeps a single injectable seam.
194
+ const candidates = [binary.command, ...spec.fallbackDirs(env).map((dir) => path.join(expandHome(dir), binary.command))];
195
+ for (const candidate of candidates) {
196
+ const resolved = which(candidate);
197
+ if (resolved) return { command: resolved, args: binary.args };
198
+ }
199
+ }
200
+ return undefined;
201
+ }
202
+
203
+ export function missingServerText(spec: LanguageServerSpec): string {
204
+ const names = spec.binaries.map((binary) => binary.command).join(" or ");
205
+ return [
206
+ `No language server for this file type: ${names} is not on PATH.`,
207
+ `The user can enable it (install: ${installCommandText(spec)}), or ask the assistant to via the lsp_enable tool.`,
208
+ "Until then, use grep/read for this file instead.",
209
+ ].join("\n");
210
+ }
211
+
212
+ export interface PoolOptions extends LspClientOptions {
213
+ maxServers: number;
214
+ idleMs: number;
215
+ }
216
+
217
+ export interface PoolDeps {
218
+ createClient?(spec: LanguageServerSpec, binary: ServerBinary, root: string, options: LspClientOptions): LspClient;
219
+ now?(): number;
220
+ }
221
+
222
+ interface PoolEntry {
223
+ client: LspClient;
224
+ ready: Promise<LspClient>;
225
+ lastUsed: number;
226
+ /** False until initialize succeeds — pending entries are exempt from idle and LRU eviction. */
227
+ initialized: boolean;
228
+ idleTimer?: NodeJS.Timeout;
229
+ }
230
+
231
+ function defaultCreateClient(
232
+ _spec: LanguageServerSpec,
233
+ binary: ServerBinary,
234
+ root: string,
235
+ options: LspClientOptions,
236
+ ): LspClient {
237
+ return new LspClient(root, spawnTransport(binary.command, binary.args, root), options);
238
+ }
239
+
240
+ /**
241
+ * Live servers keyed by workspace root + language. Servers are expensive to
242
+ * start (gopls indexes a module) and cheap to keep, so they are spawned lazily,
243
+ * shared by every caller including broker-connected subagents, and reclaimed on
244
+ * idle or LRU pressure.
245
+ */
246
+ export class LspServerPool {
247
+ private readonly entries = new Map<string, PoolEntry>();
248
+ private readonly createClient: NonNullable<PoolDeps["createClient"]>;
249
+ private readonly now: () => number;
250
+
251
+ constructor(
252
+ private readonly options: PoolOptions,
253
+ deps: PoolDeps = {},
254
+ ) {
255
+ this.createClient = deps.createClient ?? defaultCreateClient;
256
+ this.now = deps.now ?? Date.now;
257
+ }
258
+
259
+ get size(): number {
260
+ return this.entries.size;
261
+ }
262
+
263
+ async acquire(spec: LanguageServerSpec, binary: ServerBinary, root: string, signal?: AbortSignal): Promise<LspClient> {
264
+ const key = `${root} ${spec.id}`;
265
+ const existing = this.entries.get(key);
266
+ if (existing && existing.client.dead) {
267
+ this.forget(key, existing);
268
+ existing.client.killNow();
269
+ }
270
+
271
+ let entry = this.entries.get(key);
272
+ if (!entry) {
273
+ const client = this.createClient(spec, binary, root, this.options);
274
+ const created: PoolEntry = { client, ready: Promise.resolve(client), lastUsed: this.now(), initialized: false };
275
+ created.ready = client.initialize().then(() => {
276
+ // Only now does the entry become evictable: an idle timer or LRU pass
277
+ // during the (up to warmup-length) handshake would dispose a client
278
+ // its original caller is still awaiting.
279
+ created.initialized = true;
280
+ if (this.entries.get(key) === created) this.armIdleTimer(key, created);
281
+ return client;
282
+ });
283
+ this.entries.set(key, created);
284
+ // A failed handshake must not stay cached as a poisoned promise.
285
+ created.ready.catch(() => {
286
+ if (this.entries.get(key) === created) this.forget(key, created);
287
+ client.killNow();
288
+ });
289
+ entry = created;
290
+ }
291
+
292
+ entry.lastUsed = this.now();
293
+ if (entry.initialized) this.armIdleTimer(key, entry);
294
+ this.evictOverCap(key);
295
+ return abortable(entry.ready, signal);
296
+ }
297
+
298
+ async shutdownAll(): Promise<void> {
299
+ const entries = [...this.entries.entries()];
300
+ this.entries.clear();
301
+ await Promise.all(
302
+ entries.map(async ([, entry]) => {
303
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
304
+ try {
305
+ await entry.client.dispose();
306
+ } catch {
307
+ /* one stuck server must not block the rest of shutdown */
308
+ }
309
+ }),
310
+ );
311
+ }
312
+
313
+ /** Emergency sweep for `process.on("exit")`, where nothing async can run. */
314
+ killAllSync(): void {
315
+ for (const [, entry] of this.entries) {
316
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
317
+ entry.client.killNow();
318
+ }
319
+ this.entries.clear();
320
+ }
321
+
322
+ private forget(key: string, entry: PoolEntry): void {
323
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
324
+ if (this.entries.get(key) === entry) this.entries.delete(key);
325
+ }
326
+
327
+ private armIdleTimer(key: string, entry: PoolEntry): void {
328
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
329
+ entry.idleTimer = setTimeout(() => {
330
+ // A slow request (or diagnostics wait) can outlive a short idle window;
331
+ // "idle" means no in-flight work, not merely no recent acquire.
332
+ if (entry.client.busy && !entry.client.dead) {
333
+ this.armIdleTimer(key, entry);
334
+ return;
335
+ }
336
+ this.forget(key, entry);
337
+ void entry.client.dispose();
338
+ }, this.options.idleMs);
339
+ entry.idleTimer.unref?.();
340
+ }
341
+
342
+ private evictOverCap(keepKey: string): void {
343
+ while (this.entries.size > Math.max(1, this.options.maxServers)) {
344
+ let oldestKey: string | undefined;
345
+ let oldestEntry: PoolEntry | undefined;
346
+ for (const [key, entry] of this.entries) {
347
+ if (key === keepKey) continue;
348
+ // Still initializing = someone is awaiting it. The cap is soft during
349
+ // warmup; the overflow is reclaimed on the next settled acquire.
350
+ if (!entry.initialized) continue;
351
+ if (!oldestEntry || entry.lastUsed < oldestEntry.lastUsed) {
352
+ oldestKey = key;
353
+ oldestEntry = entry;
354
+ }
355
+ }
356
+ if (!oldestKey || !oldestEntry) return;
357
+ this.forget(oldestKey, oldestEntry);
358
+ void oldestEntry.client.dispose();
359
+ }
360
+ }
361
+ }