@langify-org/dsh-code-server 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.ja.md +64 -0
- package/README.md +63 -2
- package/README.zh.md +64 -0
- package/cordis.patch.yml +4 -0
- package/dist/client.cjs +708 -0
- package/dist/index.mjs +839 -0
- package/extension/dsh-code-bridge.vsix +0 -0
- package/package.json +86 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
import { chmod, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { createServer } from "node:net";
|
|
9
|
+
import { STATUS_CODES, get, request } from "node:http";
|
|
10
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
11
|
+
import { clientRequestSchema } from "@deepseek-ai/dsh-client-connection";
|
|
12
|
+
import { SessionId } from "@deepseek-ai/dsh-session/types";
|
|
13
|
+
import { parseFileAddress } from "@deepseek-ai/dsh-util-workspace-path";
|
|
14
|
+
// src/rpc.ts
|
|
15
|
+
/** DSH's shared RPC channel, and the prefix of the endpoints this plugin owns on it. */
|
|
16
|
+
const RPC_CHANNEL = "/api";
|
|
17
|
+
const RPC_PREFIX = "langify-code-server/";
|
|
18
|
+
/**
|
|
19
|
+
* Why the extension could not open a file. `outcome_unknown` means the editor
|
|
20
|
+
* was already asked and may still show the file later.
|
|
21
|
+
*/
|
|
22
|
+
const OPEN_ERROR_CODES = [
|
|
23
|
+
"invalid_path",
|
|
24
|
+
"deadline_exceeded",
|
|
25
|
+
"outcome_unknown",
|
|
26
|
+
"open_failed",
|
|
27
|
+
"show_failed"
|
|
28
|
+
];
|
|
29
|
+
/** Failures the host finds itself. */
|
|
30
|
+
const HOST_ERROR_CODES = [
|
|
31
|
+
"invalid_request",
|
|
32
|
+
"session_not_found",
|
|
33
|
+
"session_migration_required",
|
|
34
|
+
"not_a_directory",
|
|
35
|
+
"not_a_file",
|
|
36
|
+
"not_a_local_file",
|
|
37
|
+
"different_host",
|
|
38
|
+
"code_server_not_found",
|
|
39
|
+
"code_server_failed",
|
|
40
|
+
"unknown_binding",
|
|
41
|
+
"not_ready",
|
|
42
|
+
"binding_disconnected",
|
|
43
|
+
"binding_expired",
|
|
44
|
+
"binding_closed",
|
|
45
|
+
"internal_error"
|
|
46
|
+
];
|
|
47
|
+
const id = z.string().min(1).max(256).refine((value) => !value.includes("\0"));
|
|
48
|
+
const position$1 = z.int().min(1).max(1e6);
|
|
49
|
+
const binding = {
|
|
50
|
+
sessionId: id,
|
|
51
|
+
bindingId: z.uuid()
|
|
52
|
+
};
|
|
53
|
+
const requestSchemas = {
|
|
54
|
+
create: z.strictObject({
|
|
55
|
+
sessionId: id,
|
|
56
|
+
ownerId: id
|
|
57
|
+
}),
|
|
58
|
+
watch: z.strictObject({
|
|
59
|
+
...binding,
|
|
60
|
+
revision: z.int().min(-1)
|
|
61
|
+
}),
|
|
62
|
+
close: z.strictObject(binding),
|
|
63
|
+
open: z.strictObject({
|
|
64
|
+
...binding,
|
|
65
|
+
address: z.string().min(1).max(16384),
|
|
66
|
+
line: position$1.optional()
|
|
67
|
+
})
|
|
68
|
+
};
|
|
69
|
+
z.strictObject({
|
|
70
|
+
bindingId: z.uuid(),
|
|
71
|
+
cwd: z.string(),
|
|
72
|
+
url: z.string()
|
|
73
|
+
}), z.strictObject({
|
|
74
|
+
state: z.enum([
|
|
75
|
+
"pending",
|
|
76
|
+
"ready",
|
|
77
|
+
"disconnected",
|
|
78
|
+
"expired",
|
|
79
|
+
"closed"
|
|
80
|
+
]),
|
|
81
|
+
revision: z.int().nonnegative(),
|
|
82
|
+
reconnect: z.boolean()
|
|
83
|
+
}), z.strictObject({ state: z.literal("closed") }), z.strictObject({
|
|
84
|
+
path: z.string(),
|
|
85
|
+
line: position$1
|
|
86
|
+
});
|
|
87
|
+
const METHODS = [
|
|
88
|
+
"create",
|
|
89
|
+
"watch",
|
|
90
|
+
"close",
|
|
91
|
+
"open"
|
|
92
|
+
];
|
|
93
|
+
/** Environment variable of the code-server DSH starts, naming the bridge socket every window shares. */
|
|
94
|
+
const SOCKET_ENV = "DSH_CODE_BRIDGE_SOCKET";
|
|
95
|
+
/**
|
|
96
|
+
* Key in the editor window's URL fragment that names its binding. The fragment
|
|
97
|
+
* never reaches a server; the extension reads it back through `asExternalUri`.
|
|
98
|
+
*/
|
|
99
|
+
const BINDING_FRAGMENT = "dsh-binding";
|
|
100
|
+
const absolutePath = z.string().max(4096).refine((path) => isAbsolute(path) && !path.includes("\0"));
|
|
101
|
+
const position = z.int().min(1).max(1e6);
|
|
102
|
+
const requestId = z.uuid();
|
|
103
|
+
const helloSchema = z.strictObject({
|
|
104
|
+
type: z.literal("hello"),
|
|
105
|
+
bindingId: z.uuid(),
|
|
106
|
+
cwd: absolutePath
|
|
107
|
+
});
|
|
108
|
+
z.discriminatedUnion("type", [z.strictObject({
|
|
109
|
+
type: z.literal("accepted"),
|
|
110
|
+
bindingId: z.uuid()
|
|
111
|
+
}), z.strictObject({
|
|
112
|
+
type: z.literal("rejected"),
|
|
113
|
+
code: z.string(),
|
|
114
|
+
message: z.string()
|
|
115
|
+
})]);
|
|
116
|
+
const openRequestSchema = z.strictObject({
|
|
117
|
+
type: z.literal("open"),
|
|
118
|
+
requestId,
|
|
119
|
+
path: absolutePath,
|
|
120
|
+
line: position,
|
|
121
|
+
deadline: z.int().nonnegative()
|
|
122
|
+
});
|
|
123
|
+
const openResultSchema = z.discriminatedUnion("ok", [z.strictObject({
|
|
124
|
+
type: z.literal("result"),
|
|
125
|
+
requestId,
|
|
126
|
+
ok: z.literal(true),
|
|
127
|
+
path: absolutePath,
|
|
128
|
+
line: position
|
|
129
|
+
}), z.strictObject({
|
|
130
|
+
type: z.literal("result"),
|
|
131
|
+
requestId,
|
|
132
|
+
ok: z.literal(false),
|
|
133
|
+
error: z.strictObject({
|
|
134
|
+
code: z.enum(OPEN_ERROR_CODES),
|
|
135
|
+
message: z.string().max(2048)
|
|
136
|
+
})
|
|
137
|
+
})]);
|
|
138
|
+
z.union([
|
|
139
|
+
openRequestSchema,
|
|
140
|
+
z.strictObject({
|
|
141
|
+
type: z.literal("cwd-changed"),
|
|
142
|
+
cwd: absolutePath
|
|
143
|
+
}),
|
|
144
|
+
z.strictObject({
|
|
145
|
+
type: z.literal("notice"),
|
|
146
|
+
code: z.enum([...OPEN_ERROR_CODES, ...HOST_ERROR_CODES]),
|
|
147
|
+
message: z.string().max(2048)
|
|
148
|
+
})
|
|
149
|
+
]);
|
|
150
|
+
const editorFrameSchema = z.union([openResultSchema, z.strictObject({ type: z.literal("reconnect") })]);
|
|
151
|
+
/** Serialize one frame. */
|
|
152
|
+
function frame(value) {
|
|
153
|
+
return `${JSON.stringify(value)}\n`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Split a stream into JSON frames. Stops at the first frame that is too large
|
|
157
|
+
* or not UTF-8 JSON and reports it once; the caller closes the stream.
|
|
158
|
+
*/
|
|
159
|
+
function readFrames(stream, onFrame, onInvalid) {
|
|
160
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
161
|
+
let buffered = Buffer.alloc(0);
|
|
162
|
+
let failed = false;
|
|
163
|
+
stream.on("data", (chunk) => {
|
|
164
|
+
if (failed) return;
|
|
165
|
+
try {
|
|
166
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
167
|
+
for (let end = buffered.indexOf(10); end !== -1; end = buffered.indexOf(10)) {
|
|
168
|
+
if (end + 1 > 65536) throw new Error("frame exceeds 64 KiB");
|
|
169
|
+
const text = decoder.decode(buffered.subarray(0, end));
|
|
170
|
+
buffered = buffered.subarray(end + 1);
|
|
171
|
+
onFrame(JSON.parse(text));
|
|
172
|
+
if (stream.destroyed) return;
|
|
173
|
+
}
|
|
174
|
+
if (buffered.length >= 65536) throw new Error("frame exceeds 64 KiB");
|
|
175
|
+
} catch (error) {
|
|
176
|
+
failed = true;
|
|
177
|
+
onInvalid(error instanceof Error ? error : new Error(String(error)));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// src/host/errors.ts
|
|
182
|
+
/** A failure the page can explain: `code` selects its translated message. */
|
|
183
|
+
var CodeServerError = class extends Error {
|
|
184
|
+
code;
|
|
185
|
+
constructor(code, message, options) {
|
|
186
|
+
super(message, options);
|
|
187
|
+
this.code = code;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
// src/host/bridge.ts
|
|
191
|
+
/** An unclaimed binding expires this long after creation. */
|
|
192
|
+
const CONNECT_TIMEOUT_MS = 6e4;
|
|
193
|
+
/** A binding expires this long after the page last watched it. */
|
|
194
|
+
const LEASE_MS = 12e4;
|
|
195
|
+
/** An open request without an answer by then has an unknown outcome. */
|
|
196
|
+
const OPEN_TIMEOUT_MS = 15e3;
|
|
197
|
+
/** The longest a watch waits for a change. */
|
|
198
|
+
const WATCH_MS = 2e4;
|
|
199
|
+
const HELLO_TIMEOUT_MS = 5e3;
|
|
200
|
+
const finalError = {
|
|
201
|
+
disconnected: "binding_disconnected",
|
|
202
|
+
expired: "binding_expired",
|
|
203
|
+
closed: "binding_closed"
|
|
204
|
+
};
|
|
205
|
+
var Bridge = class Bridge {
|
|
206
|
+
#bindings = /* @__PURE__ */ new Map();
|
|
207
|
+
#sockets = /* @__PURE__ */ new Set();
|
|
208
|
+
#server;
|
|
209
|
+
/** Where extensions connect; every code-server window learns it from its environment. */
|
|
210
|
+
socketPath;
|
|
211
|
+
constructor(server, socketPath) {
|
|
212
|
+
this.#server = server;
|
|
213
|
+
this.socketPath = socketPath;
|
|
214
|
+
server.on("connection", (socket) => this.#accept(socket));
|
|
215
|
+
}
|
|
216
|
+
/** Listen on `socketPath`, which must sit in a directory only this user can enter. */
|
|
217
|
+
static async listen(socketPath) {
|
|
218
|
+
const server = createServer();
|
|
219
|
+
await new Promise((resolve, reject) => {
|
|
220
|
+
server.once("error", reject);
|
|
221
|
+
server.listen(socketPath, () => {
|
|
222
|
+
server.off("error", reject);
|
|
223
|
+
resolve();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
await chmod(socketPath, 384);
|
|
227
|
+
return new Bridge(server, socketPath);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Start a binding for a window that will open `cwd`, a canonical path the
|
|
231
|
+
* extension must match, and return its id.
|
|
232
|
+
*/
|
|
233
|
+
create(sessionId, ownerId, cwd) {
|
|
234
|
+
for (const binding of this.#bindings.values()) if (binding.sessionId === sessionId && binding.ownerId === ownerId) this.#finish(binding, "closed");
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const binding = {
|
|
237
|
+
id: randomUUID(),
|
|
238
|
+
sessionId,
|
|
239
|
+
ownerId,
|
|
240
|
+
cwd,
|
|
241
|
+
connectBy: now + CONNECT_TIMEOUT_MS,
|
|
242
|
+
state: "pending",
|
|
243
|
+
revision: 0,
|
|
244
|
+
reconnect: false,
|
|
245
|
+
announcedCwd: cwd,
|
|
246
|
+
leaseUntil: now + LEASE_MS,
|
|
247
|
+
opens: /* @__PURE__ */ new Map(),
|
|
248
|
+
watchers: /* @__PURE__ */ new Set()
|
|
249
|
+
};
|
|
250
|
+
this.#bindings.set(binding.id, binding);
|
|
251
|
+
this.#schedule(binding);
|
|
252
|
+
return binding.id;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Extend a live binding's lease, then wait until its revision differs from
|
|
256
|
+
* `revision` or {@link WATCH_MS} passes, and report it.
|
|
257
|
+
*/
|
|
258
|
+
async watch(sessionId, bindingId, revision, signal) {
|
|
259
|
+
const binding = this.#get(sessionId, bindingId);
|
|
260
|
+
if (binding.state === "pending" || binding.state === "ready") {
|
|
261
|
+
binding.leaseUntil = Date.now() + LEASE_MS;
|
|
262
|
+
this.#schedule(binding);
|
|
263
|
+
}
|
|
264
|
+
if (binding.revision === revision) await new Promise((resolve) => {
|
|
265
|
+
const done = () => {
|
|
266
|
+
clearTimeout(timer);
|
|
267
|
+
binding.watchers.delete(done);
|
|
268
|
+
signal.removeEventListener("abort", done);
|
|
269
|
+
resolve();
|
|
270
|
+
};
|
|
271
|
+
const timer = setTimeout(done, WATCH_MS);
|
|
272
|
+
binding.watchers.add(done);
|
|
273
|
+
signal.addEventListener("abort", done, { once: true });
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
state: binding.state,
|
|
277
|
+
revision: binding.revision,
|
|
278
|
+
reconnect: binding.reconnect
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
close(sessionId, bindingId) {
|
|
282
|
+
const binding = this.#bindings.get(bindingId);
|
|
283
|
+
if (binding?.sessionId === sessionId) this.#finish(binding, "closed");
|
|
284
|
+
}
|
|
285
|
+
/** Tell a connected editor that the session's working folder is now `cwd`, once per new folder. */
|
|
286
|
+
announceCwd(sessionId, bindingId, cwd) {
|
|
287
|
+
const binding = this.#get(sessionId, bindingId);
|
|
288
|
+
if (cwd === binding.announcedCwd) return;
|
|
289
|
+
binding.announcedCwd = cwd;
|
|
290
|
+
if (cwd !== binding.cwd) this.#send(binding, {
|
|
291
|
+
type: "cwd-changed",
|
|
292
|
+
cwd
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/** Show a failure the host found for this window in its editor, when it is connected. */
|
|
296
|
+
report(sessionId, bindingId, error) {
|
|
297
|
+
const binding = this.#bindings.get(bindingId);
|
|
298
|
+
if (binding?.sessionId !== sessionId) return;
|
|
299
|
+
this.#send(binding, {
|
|
300
|
+
type: "notice",
|
|
301
|
+
code: error.code,
|
|
302
|
+
message: error.message
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Ask the window to show a file. Never retried: a lost answer is
|
|
307
|
+
* `outcome_unknown`. The editor reports its own failures; a timeout found
|
|
308
|
+
* here is reported to it too.
|
|
309
|
+
*/
|
|
310
|
+
open(sessionId, bindingId, location) {
|
|
311
|
+
const binding = this.#get(sessionId, bindingId);
|
|
312
|
+
const peer = binding.peer;
|
|
313
|
+
if (binding.state === "pending" || peer === void 0) throw new CodeServerError("not_ready", "The editor window has not connected yet");
|
|
314
|
+
if (binding.state !== "ready") throw new CodeServerError(finalError[binding.state], `The binding is ${binding.state}`);
|
|
315
|
+
const requestId = randomUUID();
|
|
316
|
+
const request = {
|
|
317
|
+
type: "open",
|
|
318
|
+
requestId,
|
|
319
|
+
...location,
|
|
320
|
+
deadline: Date.now() + OPEN_TIMEOUT_MS
|
|
321
|
+
};
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
const timer = setTimeout(() => {
|
|
324
|
+
binding.opens.delete(requestId);
|
|
325
|
+
const error = new CodeServerError("outcome_unknown", "No answer from the editor window");
|
|
326
|
+
this.#send(binding, {
|
|
327
|
+
type: "notice",
|
|
328
|
+
code: error.code,
|
|
329
|
+
message: error.message
|
|
330
|
+
});
|
|
331
|
+
reject(error);
|
|
332
|
+
}, OPEN_TIMEOUT_MS);
|
|
333
|
+
binding.opens.set(requestId, {
|
|
334
|
+
resolve,
|
|
335
|
+
reject,
|
|
336
|
+
timer
|
|
337
|
+
});
|
|
338
|
+
peer.write(frame(request));
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
async dispose() {
|
|
342
|
+
for (const binding of this.#bindings.values()) {
|
|
343
|
+
this.#finish(binding, "closed");
|
|
344
|
+
clearTimeout(binding.timer);
|
|
345
|
+
}
|
|
346
|
+
this.#bindings.clear();
|
|
347
|
+
const closed = new Promise((resolve) => this.#server.close(() => resolve()));
|
|
348
|
+
for (const socket of this.#sockets) socket.destroy();
|
|
349
|
+
await closed;
|
|
350
|
+
}
|
|
351
|
+
#get(sessionId, bindingId) {
|
|
352
|
+
const binding = this.#bindings.get(bindingId);
|
|
353
|
+
if (binding?.sessionId !== sessionId) throw new CodeServerError("unknown_binding", "No such binding for this session");
|
|
354
|
+
return binding;
|
|
355
|
+
}
|
|
356
|
+
#send(binding, message) {
|
|
357
|
+
if (binding.state === "ready") binding.peer?.write(frame(message));
|
|
358
|
+
}
|
|
359
|
+
/** Wake the page's watch. */
|
|
360
|
+
#changed(binding) {
|
|
361
|
+
binding.revision += 1;
|
|
362
|
+
for (const wake of binding.watchers) wake();
|
|
363
|
+
}
|
|
364
|
+
/** Arm the binding's single timer for its next deadline. */
|
|
365
|
+
#schedule(binding) {
|
|
366
|
+
clearTimeout(binding.timer);
|
|
367
|
+
const deadline = binding.state === "pending" ? Math.min(binding.connectBy, binding.leaseUntil) : binding.leaseUntil;
|
|
368
|
+
binding.timer = setTimeout(() => this.#finish(binding, "expired"), deadline - Date.now());
|
|
369
|
+
}
|
|
370
|
+
/** Enter a final state once; the record stays for a lease so watches can read it. */
|
|
371
|
+
#finish(binding, state) {
|
|
372
|
+
if (binding.state !== "pending" && binding.state !== "ready") return;
|
|
373
|
+
binding.state = state;
|
|
374
|
+
binding.peer?.destroy();
|
|
375
|
+
for (const open of binding.opens.values()) {
|
|
376
|
+
clearTimeout(open.timer);
|
|
377
|
+
open.reject(new CodeServerError("outcome_unknown", `The binding was ${state} before the editor answered`));
|
|
378
|
+
}
|
|
379
|
+
binding.opens.clear();
|
|
380
|
+
clearTimeout(binding.timer);
|
|
381
|
+
binding.timer = setTimeout(() => this.#bindings.delete(binding.id), LEASE_MS);
|
|
382
|
+
this.#changed(binding);
|
|
383
|
+
}
|
|
384
|
+
#accept(socket) {
|
|
385
|
+
let binding;
|
|
386
|
+
let refused = false;
|
|
387
|
+
const helloTimer = setTimeout(() => socket.destroy(), HELLO_TIMEOUT_MS);
|
|
388
|
+
const reject = (code, message) => {
|
|
389
|
+
refused = true;
|
|
390
|
+
socket.end(frame({
|
|
391
|
+
type: "rejected",
|
|
392
|
+
code,
|
|
393
|
+
message
|
|
394
|
+
}));
|
|
395
|
+
};
|
|
396
|
+
this.#sockets.add(socket);
|
|
397
|
+
socket.on("error", () => socket.destroy());
|
|
398
|
+
socket.on("close", () => {
|
|
399
|
+
this.#sockets.delete(socket);
|
|
400
|
+
clearTimeout(helloTimer);
|
|
401
|
+
if (binding?.peer === socket) this.#finish(binding, "disconnected");
|
|
402
|
+
});
|
|
403
|
+
readFrames(socket, (value) => {
|
|
404
|
+
if (refused) return;
|
|
405
|
+
if (binding === void 0) {
|
|
406
|
+
clearTimeout(helloTimer);
|
|
407
|
+
const hello = helloSchema.safeParse(value);
|
|
408
|
+
if (!hello.success) return reject("invalid_hello", "Expected a hello frame");
|
|
409
|
+
const candidate = this.#bindings.get(hello.data.bindingId);
|
|
410
|
+
if (candidate?.state !== "pending") return reject("binding_unavailable", "The binding cannot be claimed");
|
|
411
|
+
if (candidate.cwd !== hello.data.cwd) return reject("cwd_mismatch", "The window opened another folder");
|
|
412
|
+
binding = candidate;
|
|
413
|
+
binding.peer = socket;
|
|
414
|
+
binding.state = "ready";
|
|
415
|
+
this.#schedule(binding);
|
|
416
|
+
socket.write(frame({
|
|
417
|
+
type: "accepted",
|
|
418
|
+
bindingId: binding.id
|
|
419
|
+
}));
|
|
420
|
+
this.#changed(binding);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const message = editorFrameSchema.safeParse(value);
|
|
424
|
+
if (!message.success) return void socket.destroy();
|
|
425
|
+
if (message.data.type === "reconnect") {
|
|
426
|
+
binding.reconnect = true;
|
|
427
|
+
this.#changed(binding);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const result = message.data;
|
|
431
|
+
const open = binding.opens.get(result.requestId);
|
|
432
|
+
if (open === void 0) return;
|
|
433
|
+
binding.opens.delete(result.requestId);
|
|
434
|
+
clearTimeout(open.timer);
|
|
435
|
+
if (result.ok) open.resolve({
|
|
436
|
+
path: result.path,
|
|
437
|
+
line: result.line
|
|
438
|
+
});
|
|
439
|
+
else open.reject(new CodeServerError(result.error.code, result.error.message));
|
|
440
|
+
}, () => socket.destroy());
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
// src/host/code-server.ts
|
|
444
|
+
const STARTUP_TIMEOUT_MS = 3e4;
|
|
445
|
+
const OUTPUT = { maxBytes: 65536 };
|
|
446
|
+
var CodeServer = class {
|
|
447
|
+
#subprocess;
|
|
448
|
+
#options;
|
|
449
|
+
#socketPath;
|
|
450
|
+
#extension;
|
|
451
|
+
#onStarted;
|
|
452
|
+
#starting;
|
|
453
|
+
#child;
|
|
454
|
+
#disposed = false;
|
|
455
|
+
/**
|
|
456
|
+
* @param extension - VSIX installed before every start.
|
|
457
|
+
* @param onStarted - called once per process; its disposer runs when the process exits.
|
|
458
|
+
*/
|
|
459
|
+
constructor(subprocess, options, socketPath, extension, onStarted) {
|
|
460
|
+
this.#subprocess = subprocess;
|
|
461
|
+
this.#options = options;
|
|
462
|
+
this.#socketPath = socketPath;
|
|
463
|
+
this.#extension = extension;
|
|
464
|
+
this.#onStarted = onStarted;
|
|
465
|
+
}
|
|
466
|
+
/** The running server, started on first use and again after it exits or fails to start. */
|
|
467
|
+
ensure() {
|
|
468
|
+
this.#starting ??= this.#start().catch((error) => {
|
|
469
|
+
this.#starting = void 0;
|
|
470
|
+
throw error;
|
|
471
|
+
});
|
|
472
|
+
return this.#starting;
|
|
473
|
+
}
|
|
474
|
+
async dispose() {
|
|
475
|
+
this.#disposed = true;
|
|
476
|
+
const child = this.#child;
|
|
477
|
+
this.#child = void 0;
|
|
478
|
+
this.#starting = void 0;
|
|
479
|
+
child?.terminate();
|
|
480
|
+
await child?.waitForExit();
|
|
481
|
+
}
|
|
482
|
+
async #start() {
|
|
483
|
+
let command;
|
|
484
|
+
try {
|
|
485
|
+
command = await this.#subprocess.resolveExecutable(this.#options.command);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
throw new CodeServerError("code_server_not_found", `Cannot find ${this.#options.command}`, { cause: error });
|
|
488
|
+
}
|
|
489
|
+
const directories = [...this.#options.userDataDir === void 0 ? [] : ["--user-data-dir", this.#options.userDataDir], ...this.#options.extensionsDir === void 0 ? [] : ["--extensions-dir", this.#options.extensionsDir]];
|
|
490
|
+
const version = await this.#run([command, "--version"]);
|
|
491
|
+
const commit = /^\S+ ([0-9a-f]{40}) /.exec(version)?.[1];
|
|
492
|
+
if (commit === void 0) throw new CodeServerError("code_server_failed", `Unrecognized code-server version: ${version}`);
|
|
493
|
+
await this.#run([
|
|
494
|
+
command,
|
|
495
|
+
...directories,
|
|
496
|
+
"--install-extension",
|
|
497
|
+
this.#extension,
|
|
498
|
+
"--force"
|
|
499
|
+
]);
|
|
500
|
+
await rm(this.#socketPath, { force: true });
|
|
501
|
+
if (this.#disposed) throw new CodeServerError("code_server_failed", "The plugin has stopped");
|
|
502
|
+
const child = this.#subprocess.spawn({
|
|
503
|
+
argv: [
|
|
504
|
+
command,
|
|
505
|
+
...directories,
|
|
506
|
+
"--socket",
|
|
507
|
+
this.#socketPath,
|
|
508
|
+
"--socket-mode",
|
|
509
|
+
"600",
|
|
510
|
+
"--auth",
|
|
511
|
+
"none",
|
|
512
|
+
"--disable-telemetry",
|
|
513
|
+
"--disable-update-check",
|
|
514
|
+
"--disable-proxy"
|
|
515
|
+
],
|
|
516
|
+
cwd: homedir(),
|
|
517
|
+
env: this.#options.env,
|
|
518
|
+
graceMs: 5e3,
|
|
519
|
+
stdio: {
|
|
520
|
+
stdin: "ignore",
|
|
521
|
+
stdout: OUTPUT,
|
|
522
|
+
stderr: OUTPUT
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
this.#child = child;
|
|
526
|
+
const running = {
|
|
527
|
+
socketPath: this.#socketPath,
|
|
528
|
+
commit
|
|
529
|
+
};
|
|
530
|
+
const stopped = this.#onStarted(running);
|
|
531
|
+
const exited = () => {
|
|
532
|
+
stopped();
|
|
533
|
+
if (this.#child !== child) return;
|
|
534
|
+
this.#child = void 0;
|
|
535
|
+
this.#starting = void 0;
|
|
536
|
+
};
|
|
537
|
+
child.done.then(exited, exited);
|
|
538
|
+
await waitUntilListening(this.#socketPath, child);
|
|
539
|
+
return running;
|
|
540
|
+
}
|
|
541
|
+
/** Run a short code-server CLI command and return its stdout. */
|
|
542
|
+
async #run(argv) {
|
|
543
|
+
const child = this.#subprocess.spawn({
|
|
544
|
+
argv,
|
|
545
|
+
cwd: homedir(),
|
|
546
|
+
graceMs: 1e3,
|
|
547
|
+
signal: AbortSignal.timeout(12e4),
|
|
548
|
+
stdio: {
|
|
549
|
+
stdin: "ignore",
|
|
550
|
+
stdout: OUTPUT,
|
|
551
|
+
stderr: OUTPUT
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
const outcome = await child.done;
|
|
555
|
+
await child.waitForExit();
|
|
556
|
+
if (outcome.exitCode !== 0) throw new CodeServerError("code_server_failed", `${argv.join(" ")} failed: ${output(child, "stderr")}`);
|
|
557
|
+
return output(child, "stdout");
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
function output(child, stream) {
|
|
561
|
+
return child.collected[stream]?.readFrom(0).text.trim() ?? "";
|
|
562
|
+
}
|
|
563
|
+
async function waitUntilListening(socketPath, child) {
|
|
564
|
+
let exited = false;
|
|
565
|
+
const stop = () => {
|
|
566
|
+
exited = true;
|
|
567
|
+
};
|
|
568
|
+
child.done.then(stop, stop);
|
|
569
|
+
for (const deadline = Date.now() + STARTUP_TIMEOUT_MS; Date.now() < deadline; await setTimeout$1(100)) {
|
|
570
|
+
if (exited) throw new CodeServerError("code_server_failed", `code-server exited: ${output(child, "stderr")}`);
|
|
571
|
+
if (await healthy(socketPath)) return;
|
|
572
|
+
}
|
|
573
|
+
child.terminate();
|
|
574
|
+
throw new CodeServerError("code_server_failed", "code-server did not start within 30 seconds");
|
|
575
|
+
}
|
|
576
|
+
function healthy(socketPath) {
|
|
577
|
+
return new Promise((resolve) => {
|
|
578
|
+
get({
|
|
579
|
+
socketPath,
|
|
580
|
+
path: "/healthz"
|
|
581
|
+
}, (response) => {
|
|
582
|
+
response.resume();
|
|
583
|
+
resolve(response.statusCode === 200);
|
|
584
|
+
}).on("error", () => resolve(false));
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
// src/host/relay.ts
|
|
588
|
+
/** Path prefix code-server is served under, relative to the DSH page. */
|
|
589
|
+
const RELAY_PREFIX = "/code-server";
|
|
590
|
+
/** DSH's credentials are not code-server's business. */
|
|
591
|
+
const PRIVATE_HEADERS = /* @__PURE__ */ new Set(["cookie", "authorization"]);
|
|
592
|
+
function upstreamHeaders(headers) {
|
|
593
|
+
return Object.fromEntries(Object.entries(headers).filter(([name]) => !PRIVATE_HEADERS.has(name)));
|
|
594
|
+
}
|
|
595
|
+
function upstreamPath(url = "/") {
|
|
596
|
+
const path = url.slice(12);
|
|
597
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
598
|
+
}
|
|
599
|
+
/** Handler for HTTP requests below {@link RELAY_PREFIX}. */
|
|
600
|
+
function relayRequests(admission, start) {
|
|
601
|
+
return async (req, res) => {
|
|
602
|
+
const rejection = admission({ headers: req.headers });
|
|
603
|
+
if (rejection !== void 0) {
|
|
604
|
+
res.writeHead(rejection).end();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
let server;
|
|
608
|
+
try {
|
|
609
|
+
server = await start();
|
|
610
|
+
} catch (error) {
|
|
611
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
612
|
+
res.end(error instanceof Error ? error.message : "code-server is unavailable");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const upstream = request({
|
|
616
|
+
socketPath: server.socketPath,
|
|
617
|
+
method: req.method,
|
|
618
|
+
path: upstreamPath(req.url),
|
|
619
|
+
headers: upstreamHeaders(req.headers)
|
|
620
|
+
}, (response) => {
|
|
621
|
+
res.writeHead(response.statusCode ?? 502, response.statusMessage, response.headers);
|
|
622
|
+
response.pipe(res);
|
|
623
|
+
});
|
|
624
|
+
upstream.on("error", () => {
|
|
625
|
+
if (res.headersSent) res.destroy();
|
|
626
|
+
else res.writeHead(502).end();
|
|
627
|
+
});
|
|
628
|
+
res.on("close", () => upstream.destroy());
|
|
629
|
+
req.pipe(upstream);
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/** Handler for code-server's WebSocket upgrades, which arrive on one exact path. */
|
|
633
|
+
function relayUpgrades(admission, socketPath) {
|
|
634
|
+
return (req, socket, head) => {
|
|
635
|
+
const rejection = admission({ headers: req.headers });
|
|
636
|
+
if (rejection !== void 0) {
|
|
637
|
+
socket.end(`HTTP/1.1 ${rejection} ${STATUS_CODES[rejection] ?? ""}\r\n\r\n`);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const upstream = request({
|
|
641
|
+
socketPath,
|
|
642
|
+
method: req.method,
|
|
643
|
+
path: upstreamPath(req.url),
|
|
644
|
+
headers: upstreamHeaders(req.headers)
|
|
645
|
+
});
|
|
646
|
+
upstream.on("upgrade", (response, upstreamSocket, upstreamHead) => {
|
|
647
|
+
const lines = [`HTTP/1.1 ${response.statusCode ?? 101} ${response.statusMessage ?? ""}`];
|
|
648
|
+
for (let index = 0; index < response.rawHeaders.length; index += 2) lines.push(`${response.rawHeaders[index] ?? ""}: ${response.rawHeaders[index + 1] ?? ""}`);
|
|
649
|
+
socket.write(`${lines.join("\r\n")}\r\n\r\n`);
|
|
650
|
+
if (upstreamHead.length > 0) socket.write(upstreamHead);
|
|
651
|
+
if (head.length > 0) upstreamSocket.write(head);
|
|
652
|
+
upstreamSocket.pipe(socket).pipe(upstreamSocket);
|
|
653
|
+
upstreamSocket.on("error", () => socket.destroy());
|
|
654
|
+
upstreamSocket.on("close", () => socket.destroy());
|
|
655
|
+
socket.on("close", () => upstreamSocket.destroy());
|
|
656
|
+
});
|
|
657
|
+
upstream.on("response", (response) => {
|
|
658
|
+
response.resume();
|
|
659
|
+
socket.end(`HTTP/1.1 ${response.statusCode ?? 502} ${response.statusMessage ?? ""}\r\n\r\n`);
|
|
660
|
+
});
|
|
661
|
+
upstream.on("error", () => socket.destroy());
|
|
662
|
+
socket.on("error", () => upstream.destroy());
|
|
663
|
+
upstream.end();
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
// src/host/handlers.ts
|
|
667
|
+
/**
|
|
668
|
+
* One exact route per method on DSH's shared RPC channel. DSH authenticates the
|
|
669
|
+
* browser before a request reaches these routes.
|
|
670
|
+
*/
|
|
671
|
+
function rpcRoutes(services, bridge, codeServer) {
|
|
672
|
+
const handlers = {
|
|
673
|
+
async create({ sessionId, ownerId }, signal) {
|
|
674
|
+
const { cwd } = await sessionFolders(services, sessionId, signal);
|
|
675
|
+
const folder = await localPath(services, cwd, cwd, "directory", signal);
|
|
676
|
+
await codeServer.ensure();
|
|
677
|
+
signal.throwIfAborted();
|
|
678
|
+
const bindingId = bridge.create(sessionId, ownerId, folder);
|
|
679
|
+
return {
|
|
680
|
+
bindingId,
|
|
681
|
+
cwd: folder,
|
|
682
|
+
url: editorUrl(bindingId, folder)
|
|
683
|
+
};
|
|
684
|
+
},
|
|
685
|
+
async watch({ sessionId, bindingId, revision }, signal) {
|
|
686
|
+
const { cwd } = await sessionFolders(services, sessionId, signal);
|
|
687
|
+
bridge.announceCwd(sessionId, bindingId, await realpath(cwd).catch(() => cwd));
|
|
688
|
+
return bridge.watch(sessionId, bindingId, revision, signal);
|
|
689
|
+
},
|
|
690
|
+
async close({ sessionId, bindingId }) {
|
|
691
|
+
bridge.close(sessionId, bindingId);
|
|
692
|
+
return { state: "closed" };
|
|
693
|
+
},
|
|
694
|
+
async open({ sessionId, bindingId, address, line = 1 }, signal) {
|
|
695
|
+
let path;
|
|
696
|
+
try {
|
|
697
|
+
path = await filePath(services, sessionId, address, signal);
|
|
698
|
+
} catch (error) {
|
|
699
|
+
bridge.report(sessionId, bindingId, asCodeServerError(error));
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
return bridge.open(sessionId, bindingId, {
|
|
703
|
+
path,
|
|
704
|
+
line
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
async function dispatch(method, payload, signal) {
|
|
709
|
+
const request = requestSchemas[method].safeParse(payload);
|
|
710
|
+
if (!request.success) throw new CodeServerError("invalid_request", z.prettifyError(request.error));
|
|
711
|
+
return handlers[method](request.data, signal);
|
|
712
|
+
}
|
|
713
|
+
async function answer(method, payload, signal) {
|
|
714
|
+
try {
|
|
715
|
+
return {
|
|
716
|
+
ok: true,
|
|
717
|
+
value: await dispatch(method, payload, signal)
|
|
718
|
+
};
|
|
719
|
+
} catch (error) {
|
|
720
|
+
const { code, message } = asCodeServerError(error);
|
|
721
|
+
return {
|
|
722
|
+
ok: false,
|
|
723
|
+
error: {
|
|
724
|
+
code,
|
|
725
|
+
message,
|
|
726
|
+
details: {}
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return METHODS.map((method) => ({
|
|
732
|
+
path: `${RPC_CHANNEL}/${RPC_PREFIX}${method}`,
|
|
733
|
+
methods: ["POST"],
|
|
734
|
+
requestBody: "buffered",
|
|
735
|
+
async fetch(request) {
|
|
736
|
+
if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("JSON required", { status: 415 });
|
|
737
|
+
const body = await request.json().catch(() => void 0);
|
|
738
|
+
const envelope = clientRequestSchema.safeParse(body);
|
|
739
|
+
if (!envelope.success || envelope.data.method !== `langify-code-server/${method}`) return new Response("Invalid RPC envelope", { status: 400 });
|
|
740
|
+
const response = {
|
|
741
|
+
type: "server-response",
|
|
742
|
+
rpcId: envelope.data.rpcId,
|
|
743
|
+
result: await answer(method, envelope.data.payload, request.signal)
|
|
744
|
+
};
|
|
745
|
+
return Response.json(response);
|
|
746
|
+
}
|
|
747
|
+
}));
|
|
748
|
+
}
|
|
749
|
+
/** The iframe URL, relative to the DSH page; its fragment tells the window's extension its binding. */
|
|
750
|
+
function editorUrl(bindingId, cwd) {
|
|
751
|
+
const query = new URLSearchParams({ folder: cwd });
|
|
752
|
+
return `${RELAY_PREFIX.slice(1)}/?${query.toString()}#${BINDING_FRAGMENT}=${bindingId}`;
|
|
753
|
+
}
|
|
754
|
+
function asCodeServerError(error) {
|
|
755
|
+
if (error instanceof CodeServerError) return error;
|
|
756
|
+
return new CodeServerError("internal_error", error instanceof Error ? error.message : String(error), { cause: error });
|
|
757
|
+
}
|
|
758
|
+
/** The local file a `dsh-resource://file/...` address names. */
|
|
759
|
+
async function filePath(services, sessionId, address, signal) {
|
|
760
|
+
const file = parseFileAddress(address);
|
|
761
|
+
if (file === void 0) throw new CodeServerError("not_a_local_file", "Not a local file link");
|
|
762
|
+
const { original } = await sessionFolders(services, file.scope === "session" ? file.sessionId : sessionId, signal);
|
|
763
|
+
return localPath(services, file.path, original, "file", signal);
|
|
764
|
+
}
|
|
765
|
+
async function sessionFolders(services, id, signal) {
|
|
766
|
+
const sessionId = SessionId(id);
|
|
767
|
+
const fallback = services.workingDirectory.defaultDirectory;
|
|
768
|
+
const live = services.sessions.get(sessionId);
|
|
769
|
+
if (live !== void 0) return {
|
|
770
|
+
cwd: services.workingDirectory.get(live),
|
|
771
|
+
original: live.header.cwd ?? fallback
|
|
772
|
+
};
|
|
773
|
+
const projection = await services.sessionController.projections({ sessionId }, signal);
|
|
774
|
+
if (projection === null) throw new CodeServerError("session_not_found", "No such session");
|
|
775
|
+
if (projection.kind !== "sequenced") throw new CodeServerError("session_migration_required", "Open the conversation once to migrate it");
|
|
776
|
+
const saved = await services.sessionPersistence.stat(sessionId, { signal });
|
|
777
|
+
if (saved === void 0) throw new CodeServerError("session_not_found", "No such session");
|
|
778
|
+
const original = saved.header.cwd ?? fallback;
|
|
779
|
+
return {
|
|
780
|
+
cwd: projection.values.workingDirectory ?? original,
|
|
781
|
+
original
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
/** Resolve a path through DSH's file system and require it on this host, canonicalized. */
|
|
785
|
+
async function localPath(services, path, base, type, signal) {
|
|
786
|
+
const target = await services.fs.resolve(path, {
|
|
787
|
+
cwd: base,
|
|
788
|
+
signal
|
|
789
|
+
});
|
|
790
|
+
if ((await services.fs.stat(target, signal))?.type !== type) throw new CodeServerError(type === "file" ? "not_a_file" : "not_a_directory", `${path} is not a ${type}`);
|
|
791
|
+
const processPath = services.fs.processPath(target);
|
|
792
|
+
if (services.fs.processPathFromHostPath(processPath) !== processPath) throw new CodeServerError("different_host", "The session runs on another file system");
|
|
793
|
+
return realpath(processPath);
|
|
794
|
+
}
|
|
795
|
+
// src/host/services.ts
|
|
796
|
+
const inject = [
|
|
797
|
+
"connection",
|
|
798
|
+
"webServer",
|
|
799
|
+
"subprocess",
|
|
800
|
+
"fs",
|
|
801
|
+
"sessions",
|
|
802
|
+
"workingDirectory",
|
|
803
|
+
"sessionController",
|
|
804
|
+
"sessionPersistence"
|
|
805
|
+
];
|
|
806
|
+
// src/host/index.ts
|
|
807
|
+
const name = "langify-code-server";
|
|
808
|
+
const Config = Schema.object({
|
|
809
|
+
command: Schema.string().default("code-server").description("code-server executable name or path."),
|
|
810
|
+
userDataDir: Schema.string().default("").description("code-server's --user-data-dir. Empty uses its default."),
|
|
811
|
+
extensionsDir: Schema.string().default("").description("code-server's --extensions-dir. Empty uses its default.")
|
|
812
|
+
});
|
|
813
|
+
async function apply(ctx, config) {
|
|
814
|
+
const runtimeDir = await mkdtemp(join(process.env.XDG_RUNTIME_DIR ?? tmpdir(), "dsh-code-server-"));
|
|
815
|
+
ctx.effect(() => () => rm(runtimeDir, {
|
|
816
|
+
recursive: true,
|
|
817
|
+
force: true
|
|
818
|
+
}), "code-server: runtime directory");
|
|
819
|
+
const bridge = await Bridge.listen(join(runtimeDir, "bridge.sock"));
|
|
820
|
+
ctx.effect(() => () => bridge.dispose(), "code-server: bridge");
|
|
821
|
+
const admission = (request) => ctx.connection.requestRejection(request);
|
|
822
|
+
const codeServer = new CodeServer(ctx.subprocess, {
|
|
823
|
+
command: config.command,
|
|
824
|
+
userDataDir: config.userDataDir || void 0,
|
|
825
|
+
extensionsDir: config.extensionsDir || void 0,
|
|
826
|
+
env: { [SOCKET_ENV]: bridge.socketPath }
|
|
827
|
+
}, join(runtimeDir, "code-server.sock"), fileURLToPath(new URL("../extension/dsh-code-bridge.vsix", import.meta.url)), ({ socketPath, commit }) => ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
828
|
+
path: `${RELAY_PREFIX}/stable-${commit}`,
|
|
829
|
+
handler: relayUpgrades(admission, socketPath)
|
|
830
|
+
}), "code-server: WebSocket relay"));
|
|
831
|
+
ctx.effect(() => () => codeServer.dispose(), "code-server: process");
|
|
832
|
+
ctx.effect(() => ctx.webServer.register({
|
|
833
|
+
kind: "prefix",
|
|
834
|
+
path: RELAY_PREFIX,
|
|
835
|
+
handler: relayRequests(admission, () => codeServer.ensure())
|
|
836
|
+
}), "code-server: HTTP relay");
|
|
837
|
+
for (const route of rpcRoutes(ctx, bridge, codeServer)) ctx.effect(() => ctx.connection.fetch.register(route), `code-server: ${route.path}`);
|
|
838
|
+
}
|
|
839
|
+
export { Config, apply, inject, name };
|