@biomejs/backend-jsonrpc 0.1.0-nightly.54e85a2
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/package.json +1 -0
- package/src/command.ts +36 -0
- package/src/index.ts +50 -0
- package/src/socket.ts +47 -0
- package/src/transport.ts +291 -0
- package/src/workspace.ts +1629 -0
- package/tests/transport.test.mjs +163 -0
- package/tests/workspace.test.mjs +45 -0
- package/tsconfig.json +103 -0
package/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"@biomejs/backend-jsonrpc","version":"0.1.0-nightly.54e85a2","main":"dist/index.js","scripts":{"test":"vitest","test:ci":"pnpm build && vitest --run","tsc":"tsc --noEmit","build":"tsc"},"homepage":"https://biomejs.dev","repository":{"type":"git","url":"https://github.com/biomejs/biome.git","directory":"npm/backend-jsonrpc"},"author":"Rome Tools Developers and Contributors","bugs":"https://github.com/biomejs/biome/issues","description":"Bindings to the JSON-RPC Workspace API of the Rome daemon","keywords":["JavaScript","TypeScript","format","lint","toolchain"],"engines":{"node":">=14.*"},"license":"MIT","devDependencies":{"@types/node":"^18.7.2","typescript":"^4.8.2","vite":"^3.0.8","vitest":"^0.22.0"},"optionalDependencies":{"@biomsjs/cli-win32-x64":"0.1.0-nightly.54e85a2","@biomsjs/cli-win32-arm64":"0.1.0-nightly.54e85a2","@biomsjs/cli-darwin-x64":"0.1.0-nightly.54e85a2","@biomsjs/cli-darwin-arm64":"0.1.0-nightly.54e85a2","@biomsjs/cli-linux-x64":"0.1.0-nightly.54e85a2","@biomsjs/cli-linux-arm64":"0.1.0-nightly.54e85a2"}}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets the path of the Rome binary for the current platform
|
|
3
|
+
*
|
|
4
|
+
* @returns Filesystem path to the binary, or null if no prebuilt distribution exists for the current platform
|
|
5
|
+
*/
|
|
6
|
+
export function getCommand(): string | null {
|
|
7
|
+
const { platform, arch } = process;
|
|
8
|
+
|
|
9
|
+
type PlatformPaths = {
|
|
10
|
+
[P in NodeJS.Platform]?: {
|
|
11
|
+
[A in NodeJS.Architecture]?: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const PLATFORMS: PlatformPaths = {
|
|
16
|
+
win32: {
|
|
17
|
+
x64: "@rometools/cli-win32-x64/rome.exe",
|
|
18
|
+
arm64: "@rometools/cli-win32-arm64/rome.exe",
|
|
19
|
+
},
|
|
20
|
+
darwin: {
|
|
21
|
+
x64: "@rometools/cli-darwin-x64/rome",
|
|
22
|
+
arm64: "@rometools/cli-darwin-arm64/rome",
|
|
23
|
+
},
|
|
24
|
+
linux: {
|
|
25
|
+
x64: "@rometools/cli-linux-x64/rome",
|
|
26
|
+
arm64: "@rometools/cli-linux-arm64/rome",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const binPath = PLATFORMS?.[platform]?.[arch];
|
|
31
|
+
if (!binPath) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return require.resolve(binPath);
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getCommand } from "./command";
|
|
2
|
+
import { createSocket } from "./socket";
|
|
3
|
+
import { Transport } from "./transport";
|
|
4
|
+
import {
|
|
5
|
+
createWorkspace as wrapTransport,
|
|
6
|
+
type Workspace,
|
|
7
|
+
type RomePath,
|
|
8
|
+
} from "./workspace";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create an instance of the Workspace client connected to a remote daemon
|
|
12
|
+
* instance through the JSON-RPC protocol
|
|
13
|
+
*
|
|
14
|
+
* @returns A Workspace client, or null if the underlying platform is not supported
|
|
15
|
+
*/
|
|
16
|
+
export async function createWorkspace(): Promise<Workspace | null> {
|
|
17
|
+
const command = getCommand();
|
|
18
|
+
if (!command) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return createWorkspaceWithBinary(command);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create an instance of the Workspace client connected to a remote daemon
|
|
27
|
+
* instance through the JSON-RPC protocol, using the provided command to spawn
|
|
28
|
+
* the daemon if necessary
|
|
29
|
+
*
|
|
30
|
+
* @param command Path to the Rome binary distribution
|
|
31
|
+
* @returns A Workspace client, or null if the underlying platform is not supported
|
|
32
|
+
*/
|
|
33
|
+
export async function createWorkspaceWithBinary(
|
|
34
|
+
command: string,
|
|
35
|
+
): Promise<Workspace> {
|
|
36
|
+
const socket = await createSocket(command);
|
|
37
|
+
const transport = new Transport(socket);
|
|
38
|
+
|
|
39
|
+
await transport.request("initialize", {
|
|
40
|
+
capabilities: {},
|
|
41
|
+
client_info: {
|
|
42
|
+
name: "@biomejs/backend-jsonrpc",
|
|
43
|
+
version: "0.10.1-next",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return wrapTransport(transport);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export * from "./workspace";
|
package/src/socket.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { connect, type Socket } from "net";
|
|
3
|
+
|
|
4
|
+
function getSocket(command: string): Promise<string> {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const process = spawn(command, ["__print_socket"], {
|
|
7
|
+
stdio: "pipe",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
process.on("error", reject);
|
|
11
|
+
|
|
12
|
+
let pipeName = "";
|
|
13
|
+
process.stdout.on("data", (data) => {
|
|
14
|
+
pipeName += data.toString("utf-8");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
process.on("exit", (code) => {
|
|
18
|
+
if (code === 0) {
|
|
19
|
+
resolve(pipeName.trimEnd());
|
|
20
|
+
} else {
|
|
21
|
+
reject(
|
|
22
|
+
new Error(
|
|
23
|
+
`Command '${command} __print_socket' exited with code ${code}`,
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ensure the Rome daemon server is running and create a Socket connected to the RPC channel
|
|
33
|
+
*
|
|
34
|
+
* @param command Path to the Rome daemon binary
|
|
35
|
+
* @returns Socket instance connected to the daemon
|
|
36
|
+
*/
|
|
37
|
+
export async function createSocket(command: string): Promise<Socket> {
|
|
38
|
+
const path = await getSocket(command);
|
|
39
|
+
const socket = connect(path);
|
|
40
|
+
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
socket.once("error", reject);
|
|
43
|
+
socket.once("ready", resolve);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return socket;
|
|
47
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
interface Socket {
|
|
2
|
+
on(event: "data", fn: (data: Buffer) => void): void;
|
|
3
|
+
write(data: Buffer): void;
|
|
4
|
+
destroy(): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
enum ReaderStateKind {
|
|
8
|
+
Header,
|
|
9
|
+
Body,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ReaderStateHeader {
|
|
13
|
+
readonly kind: ReaderStateKind.Header;
|
|
14
|
+
contentLength?: number;
|
|
15
|
+
contentType?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ReaderStateBody {
|
|
19
|
+
readonly kind: ReaderStateKind.Body;
|
|
20
|
+
readonly contentLength: number;
|
|
21
|
+
readonly contentType?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type ReaderState = ReaderStateHeader | ReaderStateBody;
|
|
25
|
+
|
|
26
|
+
interface JsonRpcRequest {
|
|
27
|
+
jsonrpc: "2.0";
|
|
28
|
+
id: number;
|
|
29
|
+
method: string;
|
|
30
|
+
params: any;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isJsonRpcRequest(message: JsonRpcMessage): message is JsonRpcRequest {
|
|
34
|
+
return (
|
|
35
|
+
"id" in message &&
|
|
36
|
+
typeof message.id === "number" &&
|
|
37
|
+
"method" in message &&
|
|
38
|
+
typeof message.method === "string" &&
|
|
39
|
+
"params" in message
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface JsonRpcNotification {
|
|
44
|
+
jsonrpc: "2.0";
|
|
45
|
+
method: string;
|
|
46
|
+
params: any;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isJsonRpcNotification(
|
|
50
|
+
message: JsonRpcMessage,
|
|
51
|
+
): message is JsonRpcNotification {
|
|
52
|
+
return (
|
|
53
|
+
!("id" in message) &&
|
|
54
|
+
"method" in message &&
|
|
55
|
+
typeof message.method === "string" &&
|
|
56
|
+
"params" in message
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type JsonRpcResponse =
|
|
61
|
+
| {
|
|
62
|
+
jsonrpc: "2.0";
|
|
63
|
+
id: number;
|
|
64
|
+
result: any;
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
jsonrpc: "2.0";
|
|
68
|
+
id: number;
|
|
69
|
+
error: any;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function isJsonRpcResponse(
|
|
73
|
+
message: JsonRpcMessage,
|
|
74
|
+
): message is JsonRpcResponse {
|
|
75
|
+
return (
|
|
76
|
+
"id" in message &&
|
|
77
|
+
typeof message.id === "number" &&
|
|
78
|
+
!("method" in message) &&
|
|
79
|
+
("result" in message || "error" in message)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
84
|
+
|
|
85
|
+
function isJsonRpcMessage(message: any): message is JsonRpcMessage {
|
|
86
|
+
return (
|
|
87
|
+
typeof message === "object" && message !== null && message.jsonrpc === "2.0"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface PendingRequest {
|
|
92
|
+
resolve(result: any): void;
|
|
93
|
+
reject(error: any): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const MIME_JSONRPC = "application/vscode-jsonrpc";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Implements the Rome daemon server JSON-RPC protocol over a Socket instance
|
|
100
|
+
*/
|
|
101
|
+
export class Transport {
|
|
102
|
+
/**
|
|
103
|
+
* Counter incremented for each outgoing request to generate a unique ID
|
|
104
|
+
*/
|
|
105
|
+
private nextRequestId = 0;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Storage for the promise resolver functions of pending requests,
|
|
109
|
+
* keyed by ID of the request
|
|
110
|
+
*/
|
|
111
|
+
private pendingRequests: Map<number, PendingRequest> = new Map();
|
|
112
|
+
|
|
113
|
+
constructor(private socket: Socket) {
|
|
114
|
+
socket.on("data", (data) => {
|
|
115
|
+
this.processIncoming(data);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Send a request to the remote server
|
|
121
|
+
*
|
|
122
|
+
* @param method Name of the remote method to call
|
|
123
|
+
* @param params Parameters object the remote method should be called with
|
|
124
|
+
* @return Promise resolving with the value returned by the remote method, or rejecting with an RPC error if the remote call failed
|
|
125
|
+
*/
|
|
126
|
+
request(method: string, params: any): Promise<any> {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const id = this.nextRequestId++;
|
|
129
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
130
|
+
this.sendMessage({
|
|
131
|
+
jsonrpc: "2.0",
|
|
132
|
+
id,
|
|
133
|
+
method,
|
|
134
|
+
params,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Send a notification message to the remote server
|
|
141
|
+
*
|
|
142
|
+
* @param method Name of the remote method to call
|
|
143
|
+
* @param params Parameters object the remote method should be called with
|
|
144
|
+
*/
|
|
145
|
+
notify(method: string, params: any) {
|
|
146
|
+
this.sendMessage({
|
|
147
|
+
jsonrpc: "2.0",
|
|
148
|
+
method,
|
|
149
|
+
params,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Destroy the internal socket instance for this Transport
|
|
155
|
+
*/
|
|
156
|
+
destroy() {
|
|
157
|
+
this.socket.destroy();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private sendMessage(message: JsonRpcMessage) {
|
|
161
|
+
const body = Buffer.from(JSON.stringify(message));
|
|
162
|
+
const headers = Buffer.from(
|
|
163
|
+
`Content-Length: ${body.length}\r\n` +
|
|
164
|
+
`Content-Type: ${MIME_JSONRPC};charset=utf-8\r\n` +
|
|
165
|
+
`\r\n`,
|
|
166
|
+
);
|
|
167
|
+
this.socket.write(Buffer.concat([headers, body]));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private pendingData = Buffer.from("");
|
|
171
|
+
private readerState: ReaderState = {
|
|
172
|
+
kind: ReaderStateKind.Header,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
private processIncoming(data: Buffer) {
|
|
176
|
+
this.pendingData = Buffer.concat([this.pendingData, data]);
|
|
177
|
+
|
|
178
|
+
while (this.pendingData.length > 0) {
|
|
179
|
+
if (this.readerState.kind === ReaderStateKind.Header) {
|
|
180
|
+
const lineBreakIndex = this.pendingData.indexOf("\n");
|
|
181
|
+
if (lineBreakIndex < 0) {
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const header = this.pendingData.subarray(0, lineBreakIndex + 1);
|
|
186
|
+
this.pendingData = this.pendingData.subarray(lineBreakIndex + 1);
|
|
187
|
+
this.processIncomingHeader(this.readerState, header.toString("utf-8"));
|
|
188
|
+
} else if (this.pendingData.length >= this.readerState.contentLength) {
|
|
189
|
+
const body = this.pendingData.subarray(
|
|
190
|
+
0,
|
|
191
|
+
this.readerState.contentLength,
|
|
192
|
+
);
|
|
193
|
+
this.pendingData = this.pendingData.subarray(
|
|
194
|
+
this.readerState.contentLength,
|
|
195
|
+
);
|
|
196
|
+
this.processIncomingBody(body);
|
|
197
|
+
|
|
198
|
+
this.readerState = {
|
|
199
|
+
kind: ReaderStateKind.Header,
|
|
200
|
+
};
|
|
201
|
+
} else {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private processIncomingHeader(readerState: ReaderStateHeader, line: string) {
|
|
208
|
+
if (line === "\r\n") {
|
|
209
|
+
const { contentLength, contentType } = readerState;
|
|
210
|
+
if (typeof contentLength !== "number") {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`incoming message from the remote workspace is missing the Content-Length header`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.readerState = {
|
|
217
|
+
kind: ReaderStateKind.Body,
|
|
218
|
+
contentLength,
|
|
219
|
+
contentType,
|
|
220
|
+
};
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const colonIndex = line.indexOf(":");
|
|
225
|
+
if (colonIndex < 0) {
|
|
226
|
+
throw new Error(`could not find colon token in "${line}"`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const headerName = line.substring(0, colonIndex);
|
|
230
|
+
const headerValue = line.substring(colonIndex + 1).trim();
|
|
231
|
+
|
|
232
|
+
switch (headerName) {
|
|
233
|
+
case "Content-Length": {
|
|
234
|
+
const value = parseInt(headerValue);
|
|
235
|
+
readerState.contentLength = value;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case "Content-Type": {
|
|
239
|
+
if (!headerValue.startsWith(MIME_JSONRPC)) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`invalid value for Content-Type expected "${MIME_JSONRPC}", got "${headerValue}"`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
readerState.contentType = headerValue;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
default:
|
|
249
|
+
console.warn(`ignoring unknown header "${headerName}"`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private processIncomingBody(buffer: Buffer) {
|
|
254
|
+
const data = buffer.toString("utf-8");
|
|
255
|
+
const body = JSON.parse(data);
|
|
256
|
+
|
|
257
|
+
if (isJsonRpcMessage(body)) {
|
|
258
|
+
if (isJsonRpcRequest(body)) {
|
|
259
|
+
// TODO: Not implemented at the moment
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (isJsonRpcNotification(body)) {
|
|
264
|
+
// TODO: Not implemented at the moment
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (isJsonRpcResponse(body)) {
|
|
269
|
+
const pendingRequest = this.pendingRequests.get(body.id);
|
|
270
|
+
if (pendingRequest) {
|
|
271
|
+
this.pendingRequests.delete(body.id);
|
|
272
|
+
const { resolve, reject } = pendingRequest;
|
|
273
|
+
if ("result" in body) {
|
|
274
|
+
resolve(body.result);
|
|
275
|
+
} else {
|
|
276
|
+
reject(body.error);
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`could not find any pending request matching RPC response ID ${body.id}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
throw new Error(
|
|
288
|
+
`failed to deserialize incoming message from remote workspace, "${data}" is not a valid JSON-RPC message body`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|