@lsproxy/proxy 0.2.0 → 1.0.1

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 CHANGED
@@ -7,18 +7,18 @@ to it instead of spawning a fresh language server on every invocation.
7
7
  ## Why
8
8
 
9
9
  An LSP server's `initialize` handshake and project indexing typically take 1–15
10
- seconds. Paying that cost on every `lspeasy` call makes scripting impractical.
10
+ seconds. Paying that cost on every `lsproxy` call makes scripting impractical.
11
11
  `@lsproxy/proxy` keeps the server alive between calls so subsequent invocations
12
12
  attach in under 100ms.
13
13
 
14
14
  ## How it works
15
15
 
16
16
  ```
17
- lspeasy textDocument hover src/foo.ts 12:7
17
+ lsproxy textDocument hover src/foo.ts 12:7
18
18
 
19
19
  ├─ checks ~/.lsproxy/<hash(root)>.sock
20
20
 
21
- ├─ (first call) spawns lsproxy daemon, awaits socket
21
+ ├─ (first call) spawns lsps daemon, awaits socket
22
22
 
23
23
  └─ connects via SocketTransport ──► ClientSession
24
24
 
package/dist/main.js CHANGED
@@ -15,7 +15,7 @@ const { values } = parseArgs({
15
15
  strict: true
16
16
  });
17
17
  if (!values['root']) {
18
- process.stderr.write('[lsproxy] fatal: --root is required\n');
18
+ process.stderr.write('[lsps] fatal: --root is required\n');
19
19
  process.exit(1);
20
20
  }
21
21
  const root = resolve(values['root']);
@@ -27,7 +27,7 @@ const server = new ProxyServer({
27
27
  lazyCloseMs: Number(values['lazy-close-delay'])
28
28
  });
29
29
  server.start().catch((err) => {
30
- process.stderr.write(`[lsproxy] fatal: ${err.message}\n`);
30
+ process.stderr.write(`[lsps] fatal: ${err.message}\n`);
31
31
  process.exit(1);
32
32
  });
33
33
  //# sourceMappingURL=main.js.map
package/dist/main.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AACA,yBAAyB;AACzB,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC3B,OAAO,EAAE;QACP,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QACxB,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QAC1B,cAAc,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE;QACtD,sBAAsB,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE;QAC7D,kBAAkB,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE;KAC1D;IACD,gBAAgB,EAAE,KAAK;IACvB,MAAM,EAAE,IAAI;CACb,CAAC,CAAC;AAEH,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;IACpB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,uCAAuC,CAAC,CAAC;IAC9D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AACD,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;AACrC,MAAM,MAAM,GAAG,IAAI,WAAW,CAAC;IAC7B,IAAI;IACJ,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,SAAS,IAAI,EAAE,cAAc,EAAE,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;IAC3E,aAAa,EAAE,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IAC7C,aAAa,EAAE,MAAM,CAAC,MAAM,CAAC,sBAAsB,CAAC,CAAC;IACrD,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;CAChD,CAAC,CAAC;AAEH,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,GAAU,EAAE,EAAE;IAClC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,oBAAoB,GAAG,CAAC,OAAO,IAAI,CAAC,CAAC;IAC1D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AACA,yBAAyB;AACzB,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC3B,OAAO,EAAE;QACP,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QACxB,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QAC1B,cAAc,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE;QACtD,sBAAsB,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE;QAC7D,kBAAkB,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE;KAC1D;IACD,gBAAgB,EAAE,KAAK;IACvB,MAAM,EAAE,IAAI;CACb,CAAC,CAAC;AAEH,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;IACpB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,oCAAoC,CAAC,CAAC;IAC3D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AACD,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;AACrC,MAAM,MAAM,GAAG,IAAI,WAAW,CAAC;IAC7B,IAAI;IACJ,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,SAAS,IAAI,EAAE,cAAc,EAAE,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;IAC3E,aAAa,EAAE,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IAC7C,aAAa,EAAE,MAAM,CAAC,MAAM,CAAC,sBAAsB,CAAC,CAAC;IACrD,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;CAChD,CAAC,CAAC;AAEH,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,GAAU,EAAE,EAAE;IAClC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,iBAAiB,GAAG,CAAC,OAAO,IAAI,CAAC,CAAC;IACvD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,10 +1,29 @@
1
1
  {
2
2
  "name": "@lsproxy/proxy",
3
- "version": "0.2.0",
4
- "description": "Per-root LSP proxy daemon — holds warm language server connections for @lsproxy/cli",
3
+ "version": "1.0.1",
4
+ "description": "Per-root LSP multiplexing daemon — holds warm language server connections for @lsproxy/cli",
5
+ "private": false,
6
+ "keywords": [
7
+ "lsp",
8
+ "language-server-protocol",
9
+ "lsp-proxy",
10
+ "language-server",
11
+ "cli"
12
+ ],
13
+ "homepage": "https://github.com/pradeepmouli/lspeasy#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/pradeepmouli/lspeasy/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Pradeep Mouli <pmouli@mac.com> (https://github.com/pradeepmouli)",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/pradeepmouli/lspeasy.git",
22
+ "directory": "apps/proxy"
23
+ },
5
24
  "type": "module",
6
25
  "bin": {
7
- "lsproxy": "./dist/main.js"
26
+ "lsps": "./dist/main.js"
8
27
  },
9
28
  "exports": {
10
29
  ".": {
@@ -14,9 +33,16 @@
14
33
  },
15
34
  "main": "./dist/index.js",
16
35
  "types": "./dist/index.d.ts",
36
+ "files": [
37
+ "dist",
38
+ "README.md"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
17
43
  "dependencies": {
18
- "@lspeasy/core": "2.3.0",
19
- "@lspeasy/client": "3.1.1"
44
+ "@lspeasy/client": "3.1.2",
45
+ "@lspeasy/core": "2.3.0"
20
46
  },
21
47
  "devDependencies": {
22
48
  "typescript": "^6.0.3"
package/CHANGELOG.md DELETED
@@ -1,23 +0,0 @@
1
- # @lsproxy/proxy
2
-
3
- ## 0.2.0
4
-
5
- ### Minor Changes
6
-
7
- - 03fd44c: feat(cli): language-agnostic and capability-agnostic CLI
8
-
9
- - `lsp.json` discovery: walks `<root>/lsp.json`, `<root>/.claude/lsp.json`, `<root>/.github/lsp.json`, `~/.claude/lsp.json`; `--server` overrides
10
- - Runtime Zod → Commander translation: builds namespace/subcommand tree from `LSPSchemas × ServerCapabilities` so only what the connected server advertises is exposed
11
- - Two-pass parse: `util.parseArgs` extracts globals, then Commander dispatches after capability detection
12
- - Generic `lspeasy call <method> --params <json>` fallback always available
13
- - Write operations (rename, formatting) now apply `WorkspaceEdit` results to disk; `--dry-run` shows planned changes without writing
14
- - `workspace/symbol` gets a `<query>` positional via new `'query'` arg pattern
15
-
16
- `@lspeasy/core`: expanded `LSPSchemas` to cover all standard text-document and workspace capabilities
17
-
18
- ### Patch Changes
19
-
20
- - Updated dependencies [0eb1694]
21
- - Updated dependencies [03fd44c]
22
- - @lspeasy/core@2.3.0
23
- - @lspeasy/client@3.1.1
@@ -1,101 +0,0 @@
1
- // apps/proxy/src/backend-pool.test.ts
2
- import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
3
- import { BackendPool } from './backend-pool.js';
4
- import { discoverServerByLanguageId } from '@lspeasy/core';
5
-
6
- vi.mock('@lspeasy/core', async (importActual) => {
7
- const actual = await importActual<typeof import('@lspeasy/core')>();
8
- return {
9
- ...actual,
10
- discoverServerByLanguageId: vi.fn(),
11
- discoverExtensionMap: vi.fn(() => ({ '.ts': 'typescript' }))
12
- };
13
- });
14
-
15
- vi.mock('@lspeasy/client', () => ({
16
- LSPClient: vi.fn().mockImplementation(function () {
17
- return {
18
- connect: vi.fn().mockResolvedValue(undefined),
19
- disconnect: vi.fn().mockResolvedValue(undefined),
20
- getServerCapabilities: vi.fn().mockReturnValue({ hoverProvider: true }),
21
- sendRequest: vi.fn().mockResolvedValue(null),
22
- sendNotification: vi.fn().mockResolvedValue(undefined)
23
- };
24
- })
25
- }));
26
-
27
- vi.mock('node:child_process', () => ({
28
- spawn: vi.fn().mockReturnValue({
29
- stdout: { on: vi.fn() },
30
- stdin: { on: vi.fn() },
31
- on: vi.fn(),
32
- kill: vi.fn()
33
- })
34
- }));
35
-
36
- describe('BackendPool', () => {
37
- beforeEach(() => {
38
- (discoverServerByLanguageId as ReturnType<typeof vi.fn>).mockReturnValue({
39
- serverCommand: '"typescript-language-server" "--stdio"',
40
- languageId: 'typescript'
41
- });
42
- });
43
-
44
- afterEach(() => {
45
- vi.restoreAllMocks();
46
- vi.useRealTimers();
47
- });
48
-
49
- it('creates a backend on first ensureBackend call', async () => {
50
- const pool = new BackendPool('/project');
51
- const client = await pool.ensureBackend('typescript');
52
- expect(client).toBeDefined();
53
- });
54
-
55
- it('returns the same client on subsequent calls', async () => {
56
- const pool = new BackendPool('/project');
57
- const c1 = await pool.ensureBackend('typescript');
58
- const c2 = await pool.ensureBackend('typescript');
59
- expect(c1).toBe(c2);
60
- });
61
-
62
- it('throws when no server is configured for languageId', async () => {
63
- (discoverServerByLanguageId as ReturnType<typeof vi.fn>).mockReturnValue(null);
64
- const pool = new BackendPool('/project');
65
- await expect(pool.ensureBackend('python')).rejects.toThrow('python');
66
- });
67
-
68
- it('getLanguageIdForExtension returns the languageId for a known extension', () => {
69
- const pool = new BackendPool('/project');
70
- expect(pool.getLanguageIdForExtension('.ts')).toBe('typescript');
71
- });
72
-
73
- it('stopAll disconnects live backends', async () => {
74
- const pool = new BackendPool('/project');
75
- const client = (await pool.ensureBackend('typescript')) as any;
76
- await pool.stopAll();
77
- expect(client.disconnect).toHaveBeenCalled();
78
- });
79
-
80
- it('fires stopBackend after backendIdleMs', async () => {
81
- vi.useFakeTimers();
82
- const pool = new BackendPool('/project', { backendIdleMs: 1000 });
83
- const client = (await pool.ensureBackend('typescript')) as any;
84
- vi.advanceTimersByTime(1000);
85
- await vi.runAllTimersAsync();
86
- expect(client.disconnect).toHaveBeenCalled();
87
- });
88
-
89
- it('resets idle timer on repeated ensureBackend calls', async () => {
90
- vi.useFakeTimers();
91
- const pool = new BackendPool('/project', { backendIdleMs: 1000 });
92
- await pool.ensureBackend('typescript');
93
- vi.advanceTimersByTime(500);
94
- const client = (await pool.ensureBackend('typescript')) as any;
95
- vi.advanceTimersByTime(600); // only 600ms since last call — should not fire yet
96
- expect(client.disconnect).not.toHaveBeenCalled();
97
- vi.advanceTimersByTime(400); // now 1000ms after last call — fires
98
- await vi.runAllTimersAsync();
99
- expect(client.disconnect).toHaveBeenCalled();
100
- });
101
- });
@@ -1,151 +0,0 @@
1
- // apps/proxy/src/backend-pool.ts
2
- import { spawn } from 'node:child_process';
3
- import { resolve, basename } from 'node:path';
4
- import type { Readable, Writable } from 'node:stream';
5
- import { pathToFileURL } from 'node:url';
6
- import { LSPClient } from '@lspeasy/client';
7
- import {
8
- tokenizeCommand,
9
- discoverServerByLanguageId,
10
- discoverExtensionMap,
11
- type ClientCapabilities
12
- } from '@lspeasy/core';
13
- import { StdioTransport } from '@lspeasy/core/node';
14
-
15
- // Advertise full capabilities so backends expose their complete ServerCapabilities.
16
- const CLIENT_CAPABILITIES = {
17
- textDocument: {
18
- synchronization: { dynamicRegistration: false },
19
- definition: { dynamicRegistration: false },
20
- declaration: { dynamicRegistration: false },
21
- typeDefinition: { dynamicRegistration: false },
22
- implementation: { dynamicRegistration: false },
23
- references: { dynamicRegistration: false },
24
- documentSymbol: { dynamicRegistration: false },
25
- hover: { dynamicRegistration: false },
26
- completion: { dynamicRegistration: false },
27
- signatureHelp: { dynamicRegistration: false },
28
- rename: { dynamicRegistration: false },
29
- codeAction: { dynamicRegistration: false },
30
- formatting: { dynamicRegistration: false },
31
- rangeFormatting: { dynamicRegistration: false }
32
- },
33
- workspace: {
34
- applyEdit: true,
35
- executeCommand: { dynamicRegistration: false },
36
- symbol: { dynamicRegistration: false }
37
- }
38
- } satisfies Partial<ClientCapabilities>;
39
-
40
- export interface BackendPoolOptions {
41
- backendIdleMs?: number;
42
- }
43
-
44
- interface BackendEntry {
45
- client: LSPClient;
46
- proc: ReturnType<typeof spawn>;
47
- idleTimer?: ReturnType<typeof setTimeout>;
48
- }
49
-
50
- export class BackendPool {
51
- private readonly backends = new Map<string, BackendEntry>();
52
- private readonly starting = new Map<string, Promise<LSPClient>>();
53
- private readonly extensionMap: Record<string, string>;
54
- private readonly backendIdleMs: number;
55
-
56
- constructor(
57
- private readonly root: string,
58
- opts: BackendPoolOptions = {}
59
- ) {
60
- this.backendIdleMs = opts.backendIdleMs ?? 600_000;
61
- this.extensionMap = discoverExtensionMap(root);
62
- }
63
-
64
- getLanguageIdForExtension(ext: string): string | undefined {
65
- return this.extensionMap[ext];
66
- }
67
-
68
- getBackend(languageId: string): LSPClient | undefined {
69
- return this.backends.get(languageId)?.client;
70
- }
71
-
72
- async ensureBackend(languageId: string): Promise<LSPClient> {
73
- const existing = this.backends.get(languageId);
74
- if (existing) {
75
- this.resetIdleTimer(languageId, existing);
76
- return existing.client;
77
- }
78
-
79
- // Deduplicate concurrent startup calls for the same languageId
80
- const inflight = this.starting.get(languageId);
81
- if (inflight) return inflight;
82
-
83
- const promise = this.startBackend(languageId);
84
- this.starting.set(languageId, promise);
85
- try {
86
- return await promise;
87
- } finally {
88
- this.starting.delete(languageId);
89
- }
90
- }
91
-
92
- private async startBackend(languageId: string): Promise<LSPClient> {
93
- const discovered = discoverServerByLanguageId(this.root, languageId);
94
- if (!discovered) {
95
- throw new Error(`No LSP server configured for languageId "${languageId}"`);
96
- }
97
-
98
- const [cmd, ...args] = tokenizeCommand(discovered.serverCommand);
99
- if (!cmd) throw new Error('Empty server command');
100
-
101
- const proc = spawn(cmd, args, { cwd: this.root, stdio: 'pipe' });
102
- const transport = new StdioTransport({
103
- input: proc.stdout as Readable,
104
- output: proc.stdin as Writable
105
- });
106
-
107
- const rootDir = resolve(this.root);
108
- const rootUri = pathToFileURL(rootDir).href;
109
- const client = new LSPClient({
110
- name: 'lsproxy',
111
- version: '0.1.0',
112
- capabilities: CLIENT_CAPABILITIES as ClientCapabilities,
113
- rootUri,
114
- workspaceFolders: [{ uri: rootUri, name: basename(rootDir) }]
115
- });
116
-
117
- await client.connect(transport);
118
-
119
- const entry: BackendEntry = { client, proc };
120
- this.backends.set(languageId, entry);
121
- this.resetIdleTimer(languageId, entry);
122
- return client;
123
- }
124
-
125
- async stopBackend(languageId: string): Promise<void> {
126
- const entry = this.backends.get(languageId);
127
- if (!entry) return;
128
- if (entry.idleTimer) {
129
- clearTimeout(entry.idleTimer);
130
- delete entry.idleTimer;
131
- }
132
- this.backends.delete(languageId);
133
- try {
134
- await entry.client.disconnect();
135
- } catch {
136
- // ignore
137
- }
138
- entry.proc.kill();
139
- }
140
-
141
- async stopAll(): Promise<void> {
142
- await Promise.all([...this.backends.keys()].map((id) => this.stopBackend(id)));
143
- }
144
-
145
- private resetIdleTimer(languageId: string, entry: BackendEntry): void {
146
- if (entry.idleTimer) {
147
- clearTimeout(entry.idleTimer);
148
- }
149
- entry.idleTimer = setTimeout(() => this.stopBackend(languageId), this.backendIdleMs);
150
- }
151
- }
@@ -1,199 +0,0 @@
1
- // apps/proxy/src/client-session.ts
2
- import { extname } from 'node:path';
3
- import type { Transport } from '@lspeasy/core';
4
- import type { LSPClient } from '@lspeasy/client';
5
- import type { BackendPool } from './backend-pool.js';
6
- import type { DocumentStateManager } from './document-state.js';
7
-
8
- export interface ClientSessionOptions {
9
- sessionId: string;
10
- transport: Transport;
11
- pool: BackendPool;
12
- docState: DocumentStateManager;
13
- root: string;
14
- onEnd: (sessionId: string) => void;
15
- }
16
-
17
- type RawMsg = {
18
- jsonrpc: '2.0';
19
- id?: string | number | null;
20
- method?: string;
21
- params?: unknown;
22
- result?: unknown;
23
- error?: unknown;
24
- };
25
-
26
- export class ClientSession {
27
- private readonly id: string;
28
- private readonly transport: Transport;
29
- private readonly pool: BackendPool;
30
- private readonly docState: DocumentStateManager;
31
- private readonly onEnd: (sessionId: string) => void;
32
- private languageId = 'plaintext';
33
- private requestIdCounter = 0;
34
- private readonly pendingClientRequests = new Map<string | number, (result: unknown) => void>();
35
- private applyEditDisposable: { dispose(): void } | undefined;
36
-
37
- constructor(opts: ClientSessionOptions) {
38
- this.id = opts.sessionId;
39
- this.transport = opts.transport;
40
- this.pool = opts.pool;
41
- this.docState = opts.docState;
42
- this.onEnd = opts.onEnd;
43
-
44
- this.transport.onMessage((msg) => this.handleMessage(msg as unknown as RawMsg));
45
- this.transport.onClose(() => this.handleClose());
46
- this.transport.onError((e) => process.stderr.write(`[session:${this.id}] ${e.message}\n`));
47
- }
48
-
49
- private async handleMessage(msg: RawMsg): Promise<void> {
50
- const isRequest = msg.id !== undefined && msg.method !== undefined;
51
- const isNotification = msg.id === undefined && msg.method !== undefined;
52
- // Response to a request we sent (e.g. workspace/applyEdit forwarded to CLI)
53
- const isResponse = msg.id !== undefined && msg.method === undefined && msg.result !== undefined;
54
-
55
- if (isResponse) {
56
- const resolve = this.pendingClientRequests.get(msg.id as string | number);
57
- if (resolve) {
58
- this.pendingClientRequests.delete(msg.id as string | number);
59
- resolve(msg.result);
60
- }
61
- return;
62
- }
63
-
64
- try {
65
- if (isRequest) {
66
- const result = await this.handleRequest(msg);
67
- await this.transport.send({
68
- jsonrpc: '2.0',
69
- id: msg.id as string | number,
70
- result
71
- });
72
- } else if (isNotification) {
73
- await this.handleNotification(msg);
74
- }
75
- } catch (err) {
76
- if (isRequest) {
77
- await this.transport.send({
78
- jsonrpc: '2.0',
79
- id: msg.id as string | number,
80
- error: { code: -32603, message: String(err) }
81
- });
82
- }
83
- }
84
- }
85
-
86
- private async forwardToClient(method: string, params: unknown): Promise<unknown> {
87
- const id = ++this.requestIdCounter;
88
- return new Promise((resolve) => {
89
- this.pendingClientRequests.set(id, resolve);
90
- void this.transport.send({ jsonrpc: '2.0', id, method, params });
91
- });
92
- }
93
-
94
- private async handleRequest(msg: RawMsg): Promise<unknown> {
95
- if (msg.method === 'initialize') {
96
- return this.handleInitialize(msg.params as Record<string, unknown>);
97
- }
98
- if (msg.method === 'shutdown') {
99
- // Ack without tearing down the shared backend
100
- return null;
101
- }
102
- const backend = this.backendForMsg(msg);
103
- return (backend.sendRequest as (m: string, p: unknown) => Promise<unknown>)(
104
- msg.method!,
105
- msg.params
106
- );
107
- }
108
-
109
- private async handleInitialize(params: Record<string, unknown>): Promise<unknown> {
110
- const initOpts = params['initializationOptions'] as Record<string, unknown> | undefined;
111
- this.languageId = (initOpts?.['languageId'] as string | undefined) ?? 'plaintext';
112
- const backend = await this.pool.ensureBackend(this.languageId);
113
-
114
- // Forward workspace/applyEdit from backend to this CLI session.
115
- // The registration overwrites any prior session's handler — acceptable because
116
- // applyEdit only fires during executeCommand, which is driven by an active session.
117
- this.applyEditDisposable?.dispose();
118
- this.applyEditDisposable = (
119
- backend.onRequest as (m: string, h: (p: unknown) => Promise<unknown>) => { dispose(): void }
120
- )('workspace/applyEdit', (p) => this.forwardToClient('workspace/applyEdit', p));
121
-
122
- return { capabilities: backend.getServerCapabilities() ?? {} };
123
- }
124
-
125
- private async handleNotification(msg: RawMsg): Promise<void> {
126
- // Session lifecycle — not forwarded to the shared backend
127
- if (msg.method === 'initialized' || msg.method === 'exit') return;
128
-
129
- if (msg.method === 'textDocument/didOpen') {
130
- const p = msg.params as Record<string, unknown>;
131
- const td = p['textDocument'] as Record<string, unknown>;
132
- const uri = td['uri'] as string;
133
- const content = td['text'] as string;
134
- const langId = td['languageId'] as string;
135
- const action = this.docState.onDidOpen(this.id, uri, content, langId);
136
-
137
- const backend = await this.pool.ensureBackend(langId || this.languageIdForUri(uri));
138
-
139
- if (action === 'open') {
140
- await (backend.sendNotification as (m: string, p: unknown) => Promise<void>)(
141
- 'textDocument/didOpen',
142
- p
143
- );
144
- } else if (action === 'change') {
145
- await (backend.sendNotification as (m: string, p: unknown) => Promise<void>)(
146
- 'textDocument/didChange',
147
- {
148
- textDocument: { uri, version: (td['version'] as number) + 1 },
149
- contentChanges: [{ text: content }]
150
- }
151
- );
152
- }
153
- // 'skip' → no-op
154
- return;
155
- }
156
-
157
- if (msg.method === 'textDocument/didClose') {
158
- const p = msg.params as Record<string, unknown>;
159
- const td = p['textDocument'] as Record<string, unknown>;
160
- const uri = td['uri'] as string;
161
- this.docState.onDidClose(this.id, uri);
162
- return;
163
- }
164
-
165
- // Forward all other notifications (didChange, willSave, etc.)
166
- const backend = this.backendForMsg(msg);
167
- await (backend.sendNotification as (m: string, p: unknown) => Promise<void>)(
168
- msg.method!,
169
- msg.params
170
- );
171
- }
172
-
173
- private backendForMsg(msg: RawMsg): LSPClient {
174
- // Prefer routing by URI extension; fall back to session's primary languageId
175
- const params = msg.params as Record<string, unknown> | undefined;
176
- const td = params?.['textDocument'] as Record<string, unknown> | undefined;
177
- const uri = td?.['uri'] as string | undefined;
178
- const langId = uri ? (this.languageIdForUri(uri) ?? this.languageId) : this.languageId;
179
- const backend = this.pool.getBackend(langId) ?? this.pool.getBackend(this.languageId);
180
- if (!backend) throw new Error(`No backend available for languageId "${langId}"`);
181
- return backend;
182
- }
183
-
184
- private languageIdForUri(uri: string): string {
185
- try {
186
- const ext = extname(new URL(uri).pathname);
187
- return this.pool.getLanguageIdForExtension(ext) ?? this.languageId;
188
- } catch {
189
- return this.languageId;
190
- }
191
- }
192
-
193
- private handleClose(): void {
194
- this.applyEditDisposable?.dispose();
195
- this.applyEditDisposable = undefined;
196
- this.docState.onSessionEnd(this.id);
197
- this.onEnd(this.id);
198
- }
199
- }
@@ -1,64 +0,0 @@
1
- // apps/proxy/src/document-state.test.ts
2
- import { describe, it, expect, vi, afterEach } from 'vitest';
3
- import { DocumentStateManager } from './document-state.js';
4
-
5
- describe('DocumentStateManager', () => {
6
- afterEach(() => vi.useRealTimers());
7
-
8
- it('returns "open" for a new URI', () => {
9
- const m = new DocumentStateManager();
10
- expect(m.onDidOpen('session-1', 'file:///a.ts', 'hello', 'typescript')).toBe('open');
11
- });
12
-
13
- it('returns "skip" when a second session opens the same URI with same content', () => {
14
- const m = new DocumentStateManager();
15
- m.onDidOpen('session-1', 'file:///a.ts', 'hello', 'typescript');
16
- expect(m.onDidOpen('session-2', 'file:///a.ts', 'hello', 'typescript')).toBe('skip');
17
- });
18
-
19
- it('returns "change" when a second session opens URI with different content', () => {
20
- const m = new DocumentStateManager();
21
- m.onDidOpen('session-1', 'file:///a.ts', 'hello', 'typescript');
22
- expect(m.onDidOpen('session-2', 'file:///a.ts', 'world', 'typescript')).toBe('change');
23
- });
24
-
25
- it('onSessionEnd returns URIs that had last session closed', () => {
26
- const m = new DocumentStateManager();
27
- m.onDidOpen('s1', 'file:///a.ts', 'code', 'typescript');
28
- m.onDidOpen('s1', 'file:///b.ts', 'code', 'typescript');
29
- m.onDidOpen('s2', 'file:///a.ts', 'code', 'typescript');
30
- const uris = m.onSessionEnd('s1');
31
- // b.ts lost its only session, a.ts still has s2
32
- expect(uris).toEqual(['file:///b.ts']);
33
- });
34
-
35
- it('cancels lazy-close timer when URI is reopened', () => {
36
- vi.useFakeTimers();
37
- const closeCallback = vi.fn();
38
- const m = new DocumentStateManager({ lazyCloseMs: 1000, onClose: closeCallback });
39
- m.onDidOpen('s1', 'file:///a.ts', 'x', 'typescript');
40
- m.onSessionEnd('s1'); // schedules lazy close
41
- m.onDidOpen('s2', 'file:///a.ts', 'x', 'typescript'); // should cancel timer
42
- vi.advanceTimersByTime(2000);
43
- expect(closeCallback).not.toHaveBeenCalled();
44
- });
45
-
46
- it('fires onClose callback after lazyCloseMs when no sessions remain', () => {
47
- vi.useFakeTimers();
48
- const closeCallback = vi.fn();
49
- const m = new DocumentStateManager({ lazyCloseMs: 500, onClose: closeCallback });
50
- m.onDidOpen('s1', 'file:///a.ts', 'x', 'typescript');
51
- m.onSessionEnd('s1');
52
- vi.advanceTimersByTime(600);
53
- expect(closeCallback).toHaveBeenCalledWith('file:///a.ts');
54
- });
55
-
56
- it('getContent returns undefined after lazy eviction fires', () => {
57
- vi.useFakeTimers();
58
- const m = new DocumentStateManager({ lazyCloseMs: 500 });
59
- m.onDidOpen('s1', 'file:///a.ts', 'content', 'typescript');
60
- m.onSessionEnd('s1');
61
- vi.advanceTimersByTime(600);
62
- expect(m.getContent('file:///a.ts')).toBeUndefined();
63
- });
64
- });