@lsproxy/proxy 0.2.0 → 1.0.0
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 +3 -3
- package/dist/main.js +2 -2
- package/dist/main.js.map +1 -1
- package/package.json +31 -5
- package/CHANGELOG.md +0 -23
- package/src/backend-pool.test.ts +0 -101
- package/src/backend-pool.ts +0 -151
- package/src/client-session.ts +0 -199
- package/src/document-state.test.ts +0 -64
- package/src/document-state.ts +0 -92
- package/src/index.ts +0 -1
- package/src/main.ts +0 -35
- package/src/proxy-server.ts +0 -158
- package/src/socket-path.test.ts +0 -34
- package/src/socket-path.ts +0 -15
- package/tsconfig.json +0 -12
- package/tsconfig.tsbuildinfo +0 -1
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 `
|
|
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
|
-
|
|
17
|
+
lsproxy textDocument hover src/foo.ts 12:7
|
|
18
18
|
│
|
|
19
19
|
├─ checks ~/.lsproxy/<hash(root)>.sock
|
|
20
20
|
│
|
|
21
|
-
├─ (first call) spawns
|
|
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('[
|
|
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(`[
|
|
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,
|
|
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.
|
|
4
|
-
"description": "Per-root LSP
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
-
"
|
|
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/
|
|
19
|
-
"@lspeasy/
|
|
44
|
+
"@lspeasy/client": "3.1.1",
|
|
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
|
package/src/backend-pool.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/backend-pool.ts
DELETED
|
@@ -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
|
-
}
|
package/src/client-session.ts
DELETED
|
@@ -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
|
-
});
|