@lsproxy/proxy 0.2.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/backend-pool.d.ts +20 -0
- package/dist/backend-pool.d.ts.map +1 -0
- package/dist/backend-pool.js +123 -0
- package/dist/backend-pool.js.map +1 -0
- package/dist/client-session.d.ts +32 -0
- package/dist/client-session.d.ts.map +1 -0
- package/dist/client-session.js +150 -0
- package/dist/client-session.js.map +1 -0
- package/dist/document-state.d.ts +17 -0
- package/dist/document-state.d.ts.map +1 -0
- package/dist/document-state.js +69 -0
- package/dist/document-state.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +33 -0
- package/dist/main.js.map +1 -0
- package/dist/proxy-server.d.ts +28 -0
- package/dist/proxy-server.d.ts.map +1 -0
- package/dist/proxy-server.js +146 -0
- package/dist/proxy-server.js.map +1 -0
- package/dist/socket-path.d.ts +3 -0
- package/dist/socket-path.d.ts.map +1 -0
- package/dist/socket-path.js +13 -0
- package/dist/socket-path.js.map +1 -0
- package/package.json +32 -0
- package/src/backend-pool.test.ts +101 -0
- package/src/backend-pool.ts +151 -0
- package/src/client-session.ts +199 -0
- package/src/document-state.test.ts +64 -0
- package/src/document-state.ts +92 -0
- package/src/index.ts +1 -0
- package/src/main.ts +35 -0
- package/src/proxy-server.ts +158 -0
- package/src/socket-path.test.ts +34 -0
- package/src/socket-path.ts +15 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// apps/proxy/src/document-state.ts
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export type DidOpenAction = 'open' | 'change' | 'skip';
|
|
5
|
+
|
|
6
|
+
export interface DocumentStateOptions {
|
|
7
|
+
lazyCloseMs?: number;
|
|
8
|
+
onClose?: (uri: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface DocEntry {
|
|
12
|
+
languageId: string;
|
|
13
|
+
content: string;
|
|
14
|
+
contentHash: string;
|
|
15
|
+
openSessions: Set<string>;
|
|
16
|
+
closeTimer?: ReturnType<typeof setTimeout>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DocumentStateManager {
|
|
20
|
+
private readonly docs = new Map<string, DocEntry>();
|
|
21
|
+
private readonly lazyCloseMs: number;
|
|
22
|
+
private readonly onClose: ((uri: string) => void) | undefined;
|
|
23
|
+
|
|
24
|
+
constructor(opts: DocumentStateOptions = {}) {
|
|
25
|
+
this.lazyCloseMs = opts.lazyCloseMs ?? 300_000;
|
|
26
|
+
this.onClose = opts.onClose;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
onDidOpen(sessionId: string, uri: string, content: string, languageId: string): DidOpenAction {
|
|
30
|
+
const hash = createHash('sha256').update(content).digest('hex');
|
|
31
|
+
const entry = this.docs.get(uri);
|
|
32
|
+
|
|
33
|
+
if (!entry) {
|
|
34
|
+
this.docs.set(uri, {
|
|
35
|
+
languageId,
|
|
36
|
+
content,
|
|
37
|
+
contentHash: hash,
|
|
38
|
+
openSessions: new Set([sessionId])
|
|
39
|
+
});
|
|
40
|
+
return 'open';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (entry.closeTimer !== undefined) {
|
|
44
|
+
clearTimeout(entry.closeTimer);
|
|
45
|
+
delete entry.closeTimer;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
entry.openSessions.add(sessionId);
|
|
49
|
+
|
|
50
|
+
if (entry.contentHash === hash) return 'skip';
|
|
51
|
+
|
|
52
|
+
entry.content = content;
|
|
53
|
+
entry.contentHash = hash;
|
|
54
|
+
return 'change';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onDidClose(sessionId: string, uri: string): void {
|
|
58
|
+
const entry = this.docs.get(uri);
|
|
59
|
+
if (!entry) return;
|
|
60
|
+
entry.openSessions.delete(sessionId);
|
|
61
|
+
if (entry.openSessions.size === 0) {
|
|
62
|
+
this.scheduleLazyClose(uri, entry);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Returns URIs where this session was the last opener (informational).
|
|
67
|
+
// The actual backend didClose fires via onClose after lazyCloseMs — do NOT
|
|
68
|
+
// forward didClose immediately based on this return value.
|
|
69
|
+
onSessionEnd(sessionId: string): string[] {
|
|
70
|
+
const toClose: string[] = [];
|
|
71
|
+
for (const [uri, entry] of this.docs) {
|
|
72
|
+
if (!entry.openSessions.has(sessionId)) continue;
|
|
73
|
+
entry.openSessions.delete(sessionId);
|
|
74
|
+
if (entry.openSessions.size === 0) {
|
|
75
|
+
toClose.push(uri);
|
|
76
|
+
this.scheduleLazyClose(uri, entry);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return toClose;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getContent(uri: string): string | undefined {
|
|
83
|
+
return this.docs.get(uri)?.content;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private scheduleLazyClose(uri: string, entry: DocEntry): void {
|
|
87
|
+
entry.closeTimer = setTimeout(() => {
|
|
88
|
+
this.docs.delete(uri);
|
|
89
|
+
this.onClose?.(uri);
|
|
90
|
+
}, this.lazyCloseMs);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { socketPath, pidPath } from './socket-path.js';
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// apps/proxy/src/main.ts
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { ProxyServer } from './proxy-server.js';
|
|
6
|
+
|
|
7
|
+
const { values } = parseArgs({
|
|
8
|
+
options: {
|
|
9
|
+
root: { type: 'string' },
|
|
10
|
+
socket: { type: 'string' },
|
|
11
|
+
'idle-timeout': { type: 'string', default: '1800000' },
|
|
12
|
+
'backend-idle-timeout': { type: 'string', default: '600000' },
|
|
13
|
+
'lazy-close-delay': { type: 'string', default: '300000' }
|
|
14
|
+
},
|
|
15
|
+
allowPositionals: false,
|
|
16
|
+
strict: true
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (!values['root']) {
|
|
20
|
+
process.stderr.write('[lsproxy] fatal: --root is required\n');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const root = resolve(values['root']);
|
|
24
|
+
const server = new ProxyServer({
|
|
25
|
+
root,
|
|
26
|
+
...(values['socket'] !== undefined && { socketOverride: values['socket'] }),
|
|
27
|
+
idleTimeoutMs: Number(values['idle-timeout']),
|
|
28
|
+
backendIdleMs: Number(values['backend-idle-timeout']),
|
|
29
|
+
lazyCloseMs: Number(values['lazy-close-delay'])
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
server.start().catch((err: Error) => {
|
|
33
|
+
process.stderr.write(`[lsproxy] fatal: ${err.message}\n`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// apps/proxy/src/proxy-server.ts
|
|
2
|
+
import { createServer, createConnection, type Server, type Socket } from 'node:net';
|
|
3
|
+
import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { socketToTransport } from '@lspeasy/core/node';
|
|
6
|
+
import { BackendPool, type BackendPoolOptions } from './backend-pool.js';
|
|
7
|
+
import { DocumentStateManager } from './document-state.js';
|
|
8
|
+
import { ClientSession } from './client-session.js';
|
|
9
|
+
import { socketPath, pidPath } from './socket-path.js';
|
|
10
|
+
|
|
11
|
+
export interface ProxyServerOptions extends BackendPoolOptions {
|
|
12
|
+
root: string;
|
|
13
|
+
socketOverride?: string;
|
|
14
|
+
idleTimeoutMs?: number;
|
|
15
|
+
lazyCloseMs?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ProxyServer {
|
|
19
|
+
private readonly root: string;
|
|
20
|
+
private readonly sockPath: string;
|
|
21
|
+
private readonly pidFilePath: string;
|
|
22
|
+
private readonly idleTimeoutMs: number;
|
|
23
|
+
private readonly pool: BackendPool;
|
|
24
|
+
private readonly docState: DocumentStateManager;
|
|
25
|
+
private server: Server | undefined;
|
|
26
|
+
private readonly sessions = new Map<string, ClientSession>();
|
|
27
|
+
private readonly activeSockets = new Set<Socket>();
|
|
28
|
+
private idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
29
|
+
private sessionCounter = 0;
|
|
30
|
+
|
|
31
|
+
constructor(opts: ProxyServerOptions) {
|
|
32
|
+
this.root = opts.root;
|
|
33
|
+
this.sockPath = opts.socketOverride ?? socketPath(opts.root);
|
|
34
|
+
this.pidFilePath = opts.socketOverride
|
|
35
|
+
? opts.socketOverride.endsWith('.sock')
|
|
36
|
+
? opts.socketOverride.slice(0, -5) + '.pid'
|
|
37
|
+
: opts.socketOverride + '.pid'
|
|
38
|
+
: pidPath(opts.root);
|
|
39
|
+
this.idleTimeoutMs = opts.idleTimeoutMs ?? 1_800_000;
|
|
40
|
+
this.pool = new BackendPool(opts.root, {
|
|
41
|
+
...(opts.backendIdleMs !== undefined && { backendIdleMs: opts.backendIdleMs })
|
|
42
|
+
});
|
|
43
|
+
this.docState = new DocumentStateManager({
|
|
44
|
+
...(opts.lazyCloseMs !== undefined && { lazyCloseMs: opts.lazyCloseMs }),
|
|
45
|
+
onClose: (uri) => this.lazyCloseUri(uri)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async start(): Promise<void> {
|
|
50
|
+
mkdirSync(dirname(this.sockPath), { recursive: true });
|
|
51
|
+
|
|
52
|
+
const srv = createServer((socket) => {
|
|
53
|
+
this.activeSockets.add(socket);
|
|
54
|
+
socket.on('close', () => this.activeSockets.delete(socket));
|
|
55
|
+
const sessionId = `s${++this.sessionCounter}`;
|
|
56
|
+
const transport = socketToTransport(socket);
|
|
57
|
+
const session = new ClientSession({
|
|
58
|
+
sessionId,
|
|
59
|
+
transport,
|
|
60
|
+
pool: this.pool,
|
|
61
|
+
docState: this.docState,
|
|
62
|
+
root: this.root,
|
|
63
|
+
onEnd: (id) => this.onSessionEnd(id)
|
|
64
|
+
});
|
|
65
|
+
this.sessions.set(sessionId, session);
|
|
66
|
+
this.resetIdleTimer();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.server = srv;
|
|
70
|
+
await new Promise<void>((resolve, reject) => {
|
|
71
|
+
srv.on('error', async (err: NodeJS.ErrnoException) => {
|
|
72
|
+
if (err.code === 'EADDRINUSE') {
|
|
73
|
+
const live = await this.isSocketLive(this.sockPath);
|
|
74
|
+
if (live) {
|
|
75
|
+
process.stderr.write('[lsproxy] socket already in use — another daemon is running\n');
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
// Stale socket from a crashed daemon — remove and retry once
|
|
79
|
+
unlinkSync(this.sockPath);
|
|
80
|
+
srv.listen(this.sockPath, resolve);
|
|
81
|
+
} else {
|
|
82
|
+
reject(err);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
srv.listen(this.sockPath, resolve);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
writeFileSync(this.pidFilePath, String(process.pid), 'utf8');
|
|
89
|
+
process.stderr.write(`[lsproxy] listening on ${this.sockPath}\n`);
|
|
90
|
+
|
|
91
|
+
process.on('SIGTERM', () => this.stop());
|
|
92
|
+
this.resetIdleTimer();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async stop(): Promise<void> {
|
|
96
|
+
if (this.idleTimer) {
|
|
97
|
+
clearTimeout(this.idleTimer);
|
|
98
|
+
this.idleTimer = undefined;
|
|
99
|
+
}
|
|
100
|
+
await this.pool.stopAll();
|
|
101
|
+
for (const sock of this.activeSockets) sock.destroy();
|
|
102
|
+
await new Promise<void>((resolve) => {
|
|
103
|
+
if (!this.server) {
|
|
104
|
+
resolve();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.server.close(() => resolve());
|
|
108
|
+
});
|
|
109
|
+
if (existsSync(this.sockPath)) unlinkSync(this.sockPath);
|
|
110
|
+
if (existsSync(this.pidFilePath)) unlinkSync(this.pidFilePath);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private onSessionEnd(sessionId: string): void {
|
|
114
|
+
this.sessions.delete(sessionId);
|
|
115
|
+
this.resetIdleTimer();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private resetIdleTimer(): void {
|
|
119
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
120
|
+
if (this.sessions.size > 0) return;
|
|
121
|
+
this.idleTimer = setTimeout(() => {
|
|
122
|
+
process.stderr.write('[lsproxy] idle timeout — shutting down\n');
|
|
123
|
+
this.stop().then(() => process.exit(0));
|
|
124
|
+
}, this.idleTimeoutMs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private isSocketLive(sockPath: string): Promise<boolean> {
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
const sock = createConnection({ path: sockPath });
|
|
130
|
+
sock.once('connect', () => {
|
|
131
|
+
sock.destroy();
|
|
132
|
+
resolve(true);
|
|
133
|
+
});
|
|
134
|
+
sock.once('error', () => resolve(false));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async lazyCloseUri(uri: string): Promise<void> {
|
|
139
|
+
const ext = (() => {
|
|
140
|
+
try {
|
|
141
|
+
return new URL(uri).pathname.split('.').pop() ?? '';
|
|
142
|
+
} catch {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
const langId = ext ? this.pool.getLanguageIdForExtension(`.${ext}`) : undefined;
|
|
147
|
+
const backend = langId ? this.pool.getBackend(langId) : undefined;
|
|
148
|
+
if (!backend) return;
|
|
149
|
+
try {
|
|
150
|
+
await (backend.sendNotification as (m: string, p: unknown) => Promise<void>)(
|
|
151
|
+
'textDocument/didClose',
|
|
152
|
+
{ textDocument: { uri } }
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
// ignore
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { socketPath, pidPath } from './socket-path.js';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, basename } from 'node:path';
|
|
5
|
+
|
|
6
|
+
describe('socketPath + pidPath', () => {
|
|
7
|
+
it('produces a stable path for a given root', () => {
|
|
8
|
+
expect(socketPath('/home/user/myproject')).toBe(socketPath('/home/user/myproject'));
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('produces different paths for different roots', () => {
|
|
12
|
+
expect(socketPath('/project/a')).not.toBe(socketPath('/project/b'));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('path ends with .sock', () => {
|
|
16
|
+
expect(socketPath('/project')).toMatch(/\.sock$/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('path is inside ~/.lsproxy/', () => {
|
|
20
|
+
const base = join(homedir(), '.lsproxy');
|
|
21
|
+
expect(socketPath('/project')).toContain(base);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('pidPath matches socketPath with .pid extension', () => {
|
|
25
|
+
const root = '/project';
|
|
26
|
+
expect(pidPath(root)).toBe(socketPath(root).replace('.sock', '.pid'));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('hash is 12 characters long', () => {
|
|
30
|
+
const path = socketPath('/project');
|
|
31
|
+
const hash = basename(path, '.sock');
|
|
32
|
+
expect(hash).toHaveLength(12);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
function rootHash(root: string): string {
|
|
6
|
+
return createHash('sha1').update(resolve(root)).digest('hex').slice(0, 12);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function socketPath(root: string): string {
|
|
10
|
+
return join(homedir(), '.lsproxy', `${rootHash(root)}.sock`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function pidPath(root: string): string {
|
|
14
|
+
return join(homedir(), '.lsproxy', `${rootHash(root)}.pid`);
|
|
15
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"composite": true,
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"rootDir": "./src",
|
|
7
|
+
"declarationMap": true
|
|
8
|
+
},
|
|
9
|
+
"references": [{ "path": "../../packages/core" }, { "path": "../../packages/client" }],
|
|
10
|
+
"include": ["src/**/*"],
|
|
11
|
+
"exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.spec.ts"]
|
|
12
|
+
}
|