@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/src/document-state.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
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
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { socketPath, pidPath } from './socket-path.js';
|
package/src/main.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
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
|
-
});
|
package/src/proxy-server.ts
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
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
|
-
}
|
package/src/socket-path.test.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
});
|
package/src/socket-path.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
}
|