@lensmap/language-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.md +37 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +90 -0
- package/dist/preview/assets/playground-BSWSEt-v.js +215 -0
- package/dist/preview/assets/preview-CJQs7HAo.js +1 -0
- package/dist/preview/assets/preview-fhnbHE6T.css +1 -0
- package/dist/preview/assets/styles-B1m3hcTM.css +1 -0
- package/dist/preview/assets/styles-CWsrvT4d.js +190 -0
- package/dist/preview/index.html +15 -0
- package/dist/preview/preview.html +17 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +658 -0
- package/package.json +43 -3
package/dist/server.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { createServer, } from "node:http";
|
|
4
|
+
import { extname, resolve, sep } from "node:path";
|
|
5
|
+
import { ConnectionTypeSchema, connectionWording, DocumentSession, positionToOffset, } from "@lensmap/document";
|
|
6
|
+
import { CodeActionKind, CompletionItemKind, createConnection, StreamMessageReader, StreamMessageWriter, TextDocumentSyncKind, } from "vscode-languageserver/node";
|
|
7
|
+
class HttpError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(status, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
const equalSecret = (value, secret) => {
|
|
16
|
+
const left = Buffer.from(value ?? "");
|
|
17
|
+
const right = Buffer.from(secret);
|
|
18
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
19
|
+
};
|
|
20
|
+
function position(value) {
|
|
21
|
+
if (!record(value) ||
|
|
22
|
+
!Number.isSafeInteger(value.line) ||
|
|
23
|
+
!Number.isSafeInteger(value.character) ||
|
|
24
|
+
value.line < 0 ||
|
|
25
|
+
value.character < 0)
|
|
26
|
+
throw new HttpError(400, "Expected a zero-based UTF-16 source position.");
|
|
27
|
+
return { line: value.line, character: value.character };
|
|
28
|
+
}
|
|
29
|
+
function text(value) {
|
|
30
|
+
if (typeof value !== "string" || !value)
|
|
31
|
+
throw new HttpError(400, "A document URI and thought key are required.");
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function contentPatch(value) {
|
|
35
|
+
if (!record(value) ||
|
|
36
|
+
Object.keys(value).length === 0 ||
|
|
37
|
+
Object.entries(value).some(([key, item]) => !["summary", "full_text"].includes(key) ||
|
|
38
|
+
(item !== null && typeof item !== "string")))
|
|
39
|
+
throw new HttpError(400, "Patch must contain summary and/or full_text, each a string or null.");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
async function body(request) {
|
|
43
|
+
let length = 0;
|
|
44
|
+
const parts = [];
|
|
45
|
+
if (request.headers["content-type"]?.split(";", 1)[0].trim() !==
|
|
46
|
+
"application/json")
|
|
47
|
+
throw new HttpError(415, "Use application/json.");
|
|
48
|
+
for await (const part of request) {
|
|
49
|
+
const bytes = Buffer.from(part);
|
|
50
|
+
length += bytes.length;
|
|
51
|
+
if (length > 1024 * 1024)
|
|
52
|
+
throw new HttpError(413, "Request body is too large.");
|
|
53
|
+
parts.push(bytes);
|
|
54
|
+
}
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(Buffer.concat(parts).toString("utf8"));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new HttpError(400, "Malformed JSON.");
|
|
61
|
+
}
|
|
62
|
+
if (!record(parsed))
|
|
63
|
+
throw new HttpError(400, "Expected a JSON object.");
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
function json(response, status, value) {
|
|
67
|
+
response.writeHead(status, {
|
|
68
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
69
|
+
});
|
|
70
|
+
response.end(JSON.stringify(value));
|
|
71
|
+
}
|
|
72
|
+
/** One editor-owned process; source never comes from HTTP-supplied file paths. */
|
|
73
|
+
export async function startServer(options) {
|
|
74
|
+
// The library's raw-stream compatibility overload calls process.exit on EOF,
|
|
75
|
+
// before our HTTP cleanup. Readers/writers leave lifecycle ownership here.
|
|
76
|
+
const connection = createConnection(new StreamMessageReader(options.input), new StreamMessageWriter(options.output));
|
|
77
|
+
const entries = new Map();
|
|
78
|
+
const streams = new Set();
|
|
79
|
+
const observers = new Set();
|
|
80
|
+
const apiToken = randomBytes(32).toString("hex");
|
|
81
|
+
const bootstrapToken = randomBytes(32).toString("hex");
|
|
82
|
+
const cookieToken = randomBytes(32).toString("hex");
|
|
83
|
+
const cookieName = `lensmap_${randomBytes(8).toString("hex")}`;
|
|
84
|
+
const assets = await realpath(options.assets);
|
|
85
|
+
let activeUri = null;
|
|
86
|
+
let sequence = 0;
|
|
87
|
+
let closed = false;
|
|
88
|
+
let shutdown = false;
|
|
89
|
+
let canApply = false;
|
|
90
|
+
let canShowDocument = false;
|
|
91
|
+
let writeQueue = Promise.resolve();
|
|
92
|
+
let closePromise;
|
|
93
|
+
let origin = "";
|
|
94
|
+
let previewURL = "";
|
|
95
|
+
const envelope = () => ({
|
|
96
|
+
type: "session",
|
|
97
|
+
documents: [...entries.values()].map(({ session }) => session.snapshot()),
|
|
98
|
+
activeUri,
|
|
99
|
+
});
|
|
100
|
+
const emit = (message) => {
|
|
101
|
+
const frame = `data: ${JSON.stringify(message)}\n\n`;
|
|
102
|
+
for (const stream of streams) {
|
|
103
|
+
// A disconnected/background client must not accumulate unbounded snapshots.
|
|
104
|
+
if (!stream.write(frame)) {
|
|
105
|
+
stream.end();
|
|
106
|
+
streams.delete(stream);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const entryFor = (uri) => {
|
|
111
|
+
const entry = entries.get(uri);
|
|
112
|
+
if (!entry)
|
|
113
|
+
throw new HttpError(404, "Document is not attached to this editor session.");
|
|
114
|
+
return entry;
|
|
115
|
+
};
|
|
116
|
+
const current = (uri) => {
|
|
117
|
+
const entry = entryFor(uri);
|
|
118
|
+
const snapshot = entry.session.snapshot();
|
|
119
|
+
if (snapshot.pending || !snapshot.valid)
|
|
120
|
+
throw new HttpError(409, "Current source has not produced valid bindings.");
|
|
121
|
+
return entry;
|
|
122
|
+
};
|
|
123
|
+
const publish = (entry) => {
|
|
124
|
+
const snapshot = entry.session.snapshot();
|
|
125
|
+
emit({ type: "snapshot", snapshot });
|
|
126
|
+
if (!snapshot.pending)
|
|
127
|
+
void connection.sendDiagnostics({
|
|
128
|
+
uri: snapshot.uri,
|
|
129
|
+
version: snapshot.revision,
|
|
130
|
+
diagnostics: snapshot.diagnostics.map((item) => ({
|
|
131
|
+
...item,
|
|
132
|
+
severity: 1,
|
|
133
|
+
source: "lensmap",
|
|
134
|
+
})),
|
|
135
|
+
});
|
|
136
|
+
for (const observer of observers)
|
|
137
|
+
observer();
|
|
138
|
+
};
|
|
139
|
+
const analyze = (entry) => {
|
|
140
|
+
if (entry.timer)
|
|
141
|
+
clearTimeout(entry.timer);
|
|
142
|
+
entry.timer = undefined;
|
|
143
|
+
entry.session.analyze();
|
|
144
|
+
publish(entry);
|
|
145
|
+
};
|
|
146
|
+
const waitForReadback = (entry, base, expected) => new Promise((done) => {
|
|
147
|
+
const matches = () => !closed &&
|
|
148
|
+
entries.get(entry.session.snapshot().uri) === entry &&
|
|
149
|
+
entry.session.revision > base &&
|
|
150
|
+
entry.session.source === expected;
|
|
151
|
+
if (matches()) {
|
|
152
|
+
done(true);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const finish = (result) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
observers.delete(check);
|
|
158
|
+
done(result);
|
|
159
|
+
};
|
|
160
|
+
const check = () => {
|
|
161
|
+
if (closed || matches())
|
|
162
|
+
finish(matches());
|
|
163
|
+
};
|
|
164
|
+
const timer = setTimeout(() => finish(matches()), options.readbackTimeoutMs ?? 1500);
|
|
165
|
+
observers.add(check);
|
|
166
|
+
});
|
|
167
|
+
const focus = (uri, at, explicit, revision) => {
|
|
168
|
+
const entry = entryFor(uri);
|
|
169
|
+
if (revision !== undefined && revision !== entry.session.revision)
|
|
170
|
+
throw new HttpError(409, "Source changed after this command was prepared.");
|
|
171
|
+
const key = entry.session.locate(at) ?? null;
|
|
172
|
+
activeUri = uri;
|
|
173
|
+
if (explicit) {
|
|
174
|
+
if (!key)
|
|
175
|
+
throw new HttpError(409, "No current resolvable thought at this caret.");
|
|
176
|
+
emit({ type: "focus", uri, key, sequence: ++sequence });
|
|
177
|
+
}
|
|
178
|
+
else
|
|
179
|
+
emit({
|
|
180
|
+
type: "caret",
|
|
181
|
+
uri,
|
|
182
|
+
key,
|
|
183
|
+
revision: entry.session.revision,
|
|
184
|
+
sequence: ++sequence,
|
|
185
|
+
});
|
|
186
|
+
return key;
|
|
187
|
+
};
|
|
188
|
+
const reveal = async (uri, key) => {
|
|
189
|
+
const entry = current(uri);
|
|
190
|
+
const range = entry.session.definition(key);
|
|
191
|
+
if (!range)
|
|
192
|
+
throw new HttpError(404, "Thought has no current source definition.");
|
|
193
|
+
if (!options.reveal)
|
|
194
|
+
throw new HttpError(503, "Source reveal is unavailable in this host.");
|
|
195
|
+
await options.reveal({ uri, range, source: entry.session.source });
|
|
196
|
+
return { status: "revealed" };
|
|
197
|
+
};
|
|
198
|
+
const edit = (request) => {
|
|
199
|
+
const uri = text(request.uri);
|
|
200
|
+
const key = text(request.key);
|
|
201
|
+
const patch = contentPatch(request.patch);
|
|
202
|
+
if (!Number.isSafeInteger(request.baseRevision))
|
|
203
|
+
throw new HttpError(400, "A baseRevision is required.");
|
|
204
|
+
const operation = async () => {
|
|
205
|
+
const entry = current(uri);
|
|
206
|
+
const base = request.baseRevision;
|
|
207
|
+
if (entry.session.revision !== base)
|
|
208
|
+
throw new HttpError(409, "Base revision is no longer current. Read again before retrying.");
|
|
209
|
+
if (!canApply)
|
|
210
|
+
throw new HttpError(409, "Editor does not support workspace edits.");
|
|
211
|
+
let prepared;
|
|
212
|
+
try {
|
|
213
|
+
prepared = entry.session.prepareContentEdit(key, patch);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
throw new HttpError(400, error instanceof Error ? error.message : "Content edit is invalid.");
|
|
217
|
+
}
|
|
218
|
+
if (prepared.source === entry.session.source)
|
|
219
|
+
return { status: "confirmed", revision: base };
|
|
220
|
+
// Versioned LSP edits are best-effort in Zed, NOT atomic compare-and-swap.
|
|
221
|
+
const result = await connection.workspace.applyEdit({
|
|
222
|
+
label: "Lensmap: edit thought content",
|
|
223
|
+
edit: {
|
|
224
|
+
documentChanges: [
|
|
225
|
+
{ textDocument: { uri, version: base }, edits: prepared.edits },
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
if (!result.applied)
|
|
230
|
+
return { status: "rejected", revision: entry.session.revision };
|
|
231
|
+
const confirmed = await waitForReadback(entry, base, prepared.source);
|
|
232
|
+
if (confirmed && entry.session.snapshot().pending)
|
|
233
|
+
analyze(entry);
|
|
234
|
+
return {
|
|
235
|
+
status: confirmed ? "confirmed" : "unconfirmed",
|
|
236
|
+
revision: entry.session.revision,
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
const result = writeQueue.then(operation);
|
|
240
|
+
writeQueue = result.catch(() => undefined);
|
|
241
|
+
return result;
|
|
242
|
+
};
|
|
243
|
+
const http = createServer((request, response) => {
|
|
244
|
+
response.setHeader("Cache-Control", "no-store");
|
|
245
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
246
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
247
|
+
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
|
|
248
|
+
const handle = async () => {
|
|
249
|
+
if (closed || shutdown)
|
|
250
|
+
throw new HttpError(503, "Editor session is closed.");
|
|
251
|
+
if (request.headers.host !== new URL(origin).host ||
|
|
252
|
+
(request.headers.origin !== undefined &&
|
|
253
|
+
request.headers.origin !== origin))
|
|
254
|
+
throw new HttpError(403, "Untrusted Host or Origin.");
|
|
255
|
+
const url = new URL(request.url ?? "/", origin);
|
|
256
|
+
const method = request.method ?? "GET";
|
|
257
|
+
if (url.pathname === "/api/auth" && method === "POST") {
|
|
258
|
+
if (request.headers.origin !== origin)
|
|
259
|
+
throw new HttpError(403, "Same-origin bootstrap required.");
|
|
260
|
+
const payload = await body(request);
|
|
261
|
+
if (typeof payload.token !== "string" ||
|
|
262
|
+
!equalSecret(payload.token, bootstrapToken))
|
|
263
|
+
throw new HttpError(401, "Invalid preview authorization.");
|
|
264
|
+
response.setHeader("Set-Cookie", `${cookieName}=${cookieToken}; HttpOnly; SameSite=Strict; Path=/api`);
|
|
265
|
+
json(response, 200, { status: "authorized" });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (url.pathname.startsWith("/api/")) {
|
|
269
|
+
const bearer = request.headers.authorization?.startsWith("Bearer ")
|
|
270
|
+
? request.headers.authorization.slice(7)
|
|
271
|
+
: undefined;
|
|
272
|
+
const cookie = request.headers.cookie
|
|
273
|
+
?.split(";")
|
|
274
|
+
.map((part) => part.trim())
|
|
275
|
+
.find((part) => part.startsWith(`${cookieName}=`))
|
|
276
|
+
?.slice(cookieName.length + 1);
|
|
277
|
+
const bearerOK = equalSecret(bearer, apiToken);
|
|
278
|
+
if (!bearerOK && !equalSecret(cookie, cookieToken))
|
|
279
|
+
throw new HttpError(401, "Preview authorization required.");
|
|
280
|
+
if (method !== "GET" && !bearerOK && request.headers.origin !== origin)
|
|
281
|
+
throw new HttpError(403, "Same-origin mutation required.");
|
|
282
|
+
if (url.pathname === "/api/session" && method === "GET") {
|
|
283
|
+
json(response, 200, envelope());
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (url.pathname === "/api/events" && method === "GET") {
|
|
287
|
+
response.writeHead(200, {
|
|
288
|
+
"Content-Type": "text/event-stream",
|
|
289
|
+
Connection: "keep-alive",
|
|
290
|
+
});
|
|
291
|
+
streams.add(response);
|
|
292
|
+
response.write(`data: ${JSON.stringify(envelope())}\n\n`);
|
|
293
|
+
request.on("close", () => streams.delete(response));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (url.pathname === "/api/document" && method === "GET") {
|
|
297
|
+
json(response, 200, entryFor(text(url.searchParams.get("uri"))).session.snapshot());
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (url.pathname === "/api/thought" && method === "GET") {
|
|
301
|
+
const uri = text(url.searchParams.get("uri"));
|
|
302
|
+
const key = text(url.searchParams.get("key"));
|
|
303
|
+
const session = entryFor(uri).session;
|
|
304
|
+
const snapshot = session.snapshot();
|
|
305
|
+
const thought = snapshot.graph?.nodes.find((node) => node.key === key);
|
|
306
|
+
if (!thought)
|
|
307
|
+
throw new HttpError(404, "Unknown thought.");
|
|
308
|
+
json(response, 200, {
|
|
309
|
+
uri,
|
|
310
|
+
revision: snapshot.revision,
|
|
311
|
+
graphRevision: snapshot.graphRevision,
|
|
312
|
+
valid: snapshot.valid,
|
|
313
|
+
pending: snapshot.pending,
|
|
314
|
+
thought,
|
|
315
|
+
connections: snapshot.graph?.edges.filter((edge) => edge.from === key || edge.to === key),
|
|
316
|
+
range: session.definition(key) ?? null,
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (method === "POST" &&
|
|
321
|
+
[
|
|
322
|
+
"/api/content",
|
|
323
|
+
"/api/reveal",
|
|
324
|
+
"/api/centre",
|
|
325
|
+
"/api/open-destination",
|
|
326
|
+
].includes(url.pathname)) {
|
|
327
|
+
const payload = await body(request);
|
|
328
|
+
if (url.pathname === "/api/content") {
|
|
329
|
+
const result = await edit(payload);
|
|
330
|
+
json(response, result.status === "confirmed"
|
|
331
|
+
? 200
|
|
332
|
+
: result.status === "rejected"
|
|
333
|
+
? 409
|
|
334
|
+
: 202, result);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (url.pathname === "/api/reveal") {
|
|
338
|
+
json(response, 200, await reveal(text(payload.uri), text(payload.key)));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (url.pathname === "/api/open-destination") {
|
|
342
|
+
if (Object.keys(payload).some((key) => !["uri", "key", "baseRevision", "attachmentId"].includes(key)))
|
|
343
|
+
throw new HttpError(400, "Provide attached thought identity, not an opening URL.");
|
|
344
|
+
const uri = text(payload.uri);
|
|
345
|
+
const snapshot = current(uri).session.snapshot();
|
|
346
|
+
if (payload.baseRevision !== snapshot.revision ||
|
|
347
|
+
typeof payload.attachmentId !== "string" ||
|
|
348
|
+
payload.attachmentId !== snapshot.attachmentId)
|
|
349
|
+
throw new HttpError(409, "The source or attachment changed; select the portal again.");
|
|
350
|
+
const destination = snapshot.graph?.nodes.find((node) => node.key === text(payload.key))?.destination;
|
|
351
|
+
if (destination?.kind !== "external_link")
|
|
352
|
+
throw new HttpError(404, "No external destination on this thought.");
|
|
353
|
+
if (Array.from(destination.url).some((c) => c.charCodeAt(0) <= 32 || c.charCodeAt(0) === 127))
|
|
354
|
+
throw new HttpError(400, "The destination address contains invalid whitespace or control characters.");
|
|
355
|
+
let target;
|
|
356
|
+
try {
|
|
357
|
+
target = new URL(destination.url, uri);
|
|
358
|
+
decodeURI(target.pathname);
|
|
359
|
+
decodeURIComponent(target.hash.slice(1));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
throw new HttpError(400, "The destination address is invalid.");
|
|
363
|
+
}
|
|
364
|
+
if (!["http:", "https:", "file:"].includes(target.protocol) ||
|
|
365
|
+
target.username ||
|
|
366
|
+
target.password)
|
|
367
|
+
throw new HttpError(400, "This destination scheme or embedded credentials are unsupported.");
|
|
368
|
+
if (!canShowDocument)
|
|
369
|
+
throw new HttpError(501, "This editor does not provide a destination opener.");
|
|
370
|
+
// Let the editor handle URLs and files using its own preferences.
|
|
371
|
+
// The preview-launching OS opener is intentionally not reused.
|
|
372
|
+
const result = await connection.window.showDocument({
|
|
373
|
+
uri: target.href,
|
|
374
|
+
external: false,
|
|
375
|
+
takeFocus: true,
|
|
376
|
+
});
|
|
377
|
+
if (!result.success)
|
|
378
|
+
throw new HttpError(502, "The editor could not open this destination.");
|
|
379
|
+
json(response, 200, { opened: true });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const key = focus(text(payload.uri), position(payload.position), true, typeof payload.baseRevision === "number"
|
|
383
|
+
? payload.baseRevision
|
|
384
|
+
: undefined);
|
|
385
|
+
json(response, 200, { key });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
throw new HttpError(404, "Unknown API endpoint.");
|
|
389
|
+
}
|
|
390
|
+
if (method !== "GET" ||
|
|
391
|
+
(url.pathname !== "/preview.html" &&
|
|
392
|
+
url.pathname !== "/" &&
|
|
393
|
+
!url.pathname.startsWith("/assets/")))
|
|
394
|
+
throw new HttpError(404, "Not found.");
|
|
395
|
+
const pathname = url.pathname === "/" ? "/preview.html" : url.pathname;
|
|
396
|
+
let path;
|
|
397
|
+
try {
|
|
398
|
+
path = await realpath(resolve(assets, `.${decodeURIComponent(pathname)}`));
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
throw new HttpError(404, "Asset not found.");
|
|
402
|
+
}
|
|
403
|
+
if (!path.startsWith(assets + sep) || !(await stat(path)).isFile())
|
|
404
|
+
throw new HttpError(404, "Asset not found.");
|
|
405
|
+
const types = {
|
|
406
|
+
".html": "text/html; charset=utf-8",
|
|
407
|
+
".js": "text/javascript; charset=utf-8",
|
|
408
|
+
".css": "text/css; charset=utf-8",
|
|
409
|
+
".svg": "image/svg+xml",
|
|
410
|
+
".png": "image/png",
|
|
411
|
+
".woff2": "font/woff2",
|
|
412
|
+
};
|
|
413
|
+
response.writeHead(200, {
|
|
414
|
+
"Content-Type": types[extname(path)] ?? "application/octet-stream",
|
|
415
|
+
});
|
|
416
|
+
response.end(await readFile(path));
|
|
417
|
+
};
|
|
418
|
+
void handle().catch((error) => {
|
|
419
|
+
if (response.headersSent) {
|
|
420
|
+
response.end();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
json(response, error instanceof HttpError ? error.status : 500, {
|
|
424
|
+
error: error instanceof HttpError
|
|
425
|
+
? error.message
|
|
426
|
+
: "Bridge operation failed.",
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
http.requestTimeout = 15_000;
|
|
431
|
+
await new Promise((done, reject) => {
|
|
432
|
+
http.once("error", reject);
|
|
433
|
+
http.listen(0, "127.0.0.1", () => {
|
|
434
|
+
http.off("error", reject);
|
|
435
|
+
done();
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
const address = http.address();
|
|
439
|
+
if (!address || typeof address === "string")
|
|
440
|
+
throw new Error("Loopback endpoint did not bind.");
|
|
441
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
442
|
+
previewURL = `${origin}/preview.html#token=${bootstrapToken}`;
|
|
443
|
+
const closeHttp = () => {
|
|
444
|
+
if (closePromise)
|
|
445
|
+
return closePromise;
|
|
446
|
+
closed = true;
|
|
447
|
+
for (const entry of entries.values())
|
|
448
|
+
if (entry.timer)
|
|
449
|
+
clearTimeout(entry.timer);
|
|
450
|
+
for (const stream of streams)
|
|
451
|
+
stream.end();
|
|
452
|
+
streams.clear();
|
|
453
|
+
for (const observer of [...observers])
|
|
454
|
+
observer();
|
|
455
|
+
closePromise = new Promise((done) => {
|
|
456
|
+
http.close(() => done());
|
|
457
|
+
http.closeAllConnections();
|
|
458
|
+
});
|
|
459
|
+
return closePromise;
|
|
460
|
+
};
|
|
461
|
+
const close = async () => {
|
|
462
|
+
await closeHttp();
|
|
463
|
+
connection.dispose();
|
|
464
|
+
};
|
|
465
|
+
connection.onInitialize((params) => {
|
|
466
|
+
canApply = params.capabilities.workspace?.applyEdit === true;
|
|
467
|
+
canShowDocument =
|
|
468
|
+
params.capabilities.window?.showDocument?.support === true;
|
|
469
|
+
return {
|
|
470
|
+
capabilities: {
|
|
471
|
+
positionEncoding: "utf-16",
|
|
472
|
+
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
473
|
+
documentHighlightProvider: true,
|
|
474
|
+
definitionProvider: true,
|
|
475
|
+
completionProvider: { triggerCharacters: [":", " "] },
|
|
476
|
+
codeActionProvider: true,
|
|
477
|
+
executeCommandProvider: {
|
|
478
|
+
commands: [
|
|
479
|
+
"lensmap.openPreview",
|
|
480
|
+
"lensmap.centrePreview",
|
|
481
|
+
"lensmap.session",
|
|
482
|
+
],
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
serverInfo: { name: "Lensmap", version: "0.0.0" },
|
|
486
|
+
};
|
|
487
|
+
});
|
|
488
|
+
connection.onDidOpenTextDocument(({ textDocument: document }) => {
|
|
489
|
+
if (shutdown ||
|
|
490
|
+
closed ||
|
|
491
|
+
(!document.uri.endsWith(".lensmap") && document.languageId !== "lensmap"))
|
|
492
|
+
return;
|
|
493
|
+
const previous = entries.get(document.uri);
|
|
494
|
+
if (previous?.timer)
|
|
495
|
+
clearTimeout(previous.timer);
|
|
496
|
+
const entry = {
|
|
497
|
+
session: new DocumentSession(document.uri, document.text, document.version),
|
|
498
|
+
};
|
|
499
|
+
entries.set(document.uri, entry);
|
|
500
|
+
activeUri = document.uri;
|
|
501
|
+
emit(envelope());
|
|
502
|
+
publish(entry);
|
|
503
|
+
});
|
|
504
|
+
connection.onDidChangeTextDocument(({ textDocument: document, contentChanges }) => {
|
|
505
|
+
if (shutdown || closed)
|
|
506
|
+
return;
|
|
507
|
+
const entry = entries.get(document.uri);
|
|
508
|
+
if (!entry || document.version <= entry.session.revision)
|
|
509
|
+
return;
|
|
510
|
+
let source = entry.session.source;
|
|
511
|
+
try {
|
|
512
|
+
for (const change of contentChanges) {
|
|
513
|
+
if ("range" in change) {
|
|
514
|
+
const start = positionToOffset(source, change.range.start);
|
|
515
|
+
const end = positionToOffset(source, change.range.end);
|
|
516
|
+
if (end < start)
|
|
517
|
+
return;
|
|
518
|
+
source = source.slice(0, start) + change.text + source.slice(end);
|
|
519
|
+
}
|
|
520
|
+
else
|
|
521
|
+
source = change.text;
|
|
522
|
+
}
|
|
523
|
+
entry.session.receive(source, document.version);
|
|
524
|
+
publish(entry);
|
|
525
|
+
if (entry.timer)
|
|
526
|
+
clearTimeout(entry.timer);
|
|
527
|
+
entry.timer = setTimeout(() => {
|
|
528
|
+
if (entries.get(document.uri) === entry && !closed)
|
|
529
|
+
analyze(entry);
|
|
530
|
+
}, options.debounceMs ?? 90);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
void connection.sendDiagnostics({
|
|
534
|
+
uri: document.uri,
|
|
535
|
+
version: document.version,
|
|
536
|
+
diagnostics: [
|
|
537
|
+
{
|
|
538
|
+
message: "Invalid source synchronization range; reopen this document to resynchronize.",
|
|
539
|
+
range: {
|
|
540
|
+
start: { line: 0, character: 0 },
|
|
541
|
+
end: { line: 0, character: 0 },
|
|
542
|
+
},
|
|
543
|
+
severity: 1,
|
|
544
|
+
source: "lensmap",
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
connection.onDidCloseTextDocument(({ textDocument }) => {
|
|
551
|
+
const entry = entries.get(textDocument.uri);
|
|
552
|
+
if (entry?.timer)
|
|
553
|
+
clearTimeout(entry.timer);
|
|
554
|
+
entries.delete(textDocument.uri);
|
|
555
|
+
if (activeUri === textDocument.uri)
|
|
556
|
+
activeUri = entries.keys().next().value ?? null;
|
|
557
|
+
emit(envelope());
|
|
558
|
+
for (const observer of observers)
|
|
559
|
+
observer();
|
|
560
|
+
void connection.sendDiagnostics({ uri: textDocument.uri, diagnostics: [] });
|
|
561
|
+
});
|
|
562
|
+
connection.onDocumentHighlight(({ textDocument, position: at }) => {
|
|
563
|
+
const entry = entries.get(textDocument.uri);
|
|
564
|
+
if (!entry)
|
|
565
|
+
return [];
|
|
566
|
+
focus(textDocument.uri, at, false);
|
|
567
|
+
return entry.session.highlights(at).map((range) => ({ range, kind: 1 }));
|
|
568
|
+
});
|
|
569
|
+
connection.onDefinition(({ textDocument, position: at }) => {
|
|
570
|
+
const session = entries.get(textDocument.uri)?.session;
|
|
571
|
+
const key = session?.locate(at);
|
|
572
|
+
const range = key ? session?.definition(key) : undefined;
|
|
573
|
+
return range ? { uri: textDocument.uri, range } : null;
|
|
574
|
+
});
|
|
575
|
+
connection.onCompletion(({ textDocument, position: at }) => {
|
|
576
|
+
const session = entries.get(textDocument.uri)?.session;
|
|
577
|
+
if (!session)
|
|
578
|
+
return [];
|
|
579
|
+
const line = session.source.split(/\r?\n/)[at.line]?.slice(0, at.character) ?? "";
|
|
580
|
+
if (/^\s*(?:-\s*)?type:\s*[^#]*$/.test(line))
|
|
581
|
+
return ConnectionTypeSchema.options.map((type) => ({
|
|
582
|
+
label: type,
|
|
583
|
+
kind: CompletionItemKind.EnumMember,
|
|
584
|
+
detail: connectionWording(type, "outgoing"),
|
|
585
|
+
}));
|
|
586
|
+
if (!session.snapshot().valid || session.snapshot().pending)
|
|
587
|
+
return [];
|
|
588
|
+
if (!/^\s*(?:-\s*)?node_id:\s*[^#]*$/.test(line))
|
|
589
|
+
return [];
|
|
590
|
+
return (session.snapshot().graph?.nodes.flatMap((node) => node.id === undefined
|
|
591
|
+
? []
|
|
592
|
+
: [
|
|
593
|
+
{
|
|
594
|
+
label: node.id,
|
|
595
|
+
kind: CompletionItemKind.Reference,
|
|
596
|
+
detail: node.title,
|
|
597
|
+
},
|
|
598
|
+
]) ?? []);
|
|
599
|
+
});
|
|
600
|
+
connection.onCodeAction(({ textDocument, range }) => entries.has(textDocument.uri)
|
|
601
|
+
? [
|
|
602
|
+
{
|
|
603
|
+
title: "Lensmap: Open preview",
|
|
604
|
+
kind: CodeActionKind.Empty,
|
|
605
|
+
command: { title: "Open preview", command: "lensmap.openPreview" },
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
title: "Lensmap: Centre preview here",
|
|
609
|
+
kind: CodeActionKind.Empty,
|
|
610
|
+
command: {
|
|
611
|
+
title: "Centre preview",
|
|
612
|
+
command: "lensmap.centrePreview",
|
|
613
|
+
arguments: [
|
|
614
|
+
{
|
|
615
|
+
uri: textDocument.uri,
|
|
616
|
+
position: range.start,
|
|
617
|
+
baseRevision: entryFor(textDocument.uri).session.revision,
|
|
618
|
+
},
|
|
619
|
+
],
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
]
|
|
623
|
+
: []);
|
|
624
|
+
connection.onExecuteCommand(async (params) => {
|
|
625
|
+
if (params.command === "lensmap.session")
|
|
626
|
+
return { origin, previewURL, apiToken };
|
|
627
|
+
if (params.command === "lensmap.openPreview") {
|
|
628
|
+
await options.open?.(previewURL);
|
|
629
|
+
return { url: previewURL };
|
|
630
|
+
}
|
|
631
|
+
if (params.command === "lensmap.centrePreview") {
|
|
632
|
+
const payload = params.arguments?.[0];
|
|
633
|
+
if (!record(payload))
|
|
634
|
+
throw new Error("Current caret arguments are required.");
|
|
635
|
+
return {
|
|
636
|
+
key: focus(text(payload.uri), position(payload.position), true, typeof payload.baseRevision === "number"
|
|
637
|
+
? payload.baseRevision
|
|
638
|
+
: undefined),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
throw new Error("Unknown Lensmap command.");
|
|
642
|
+
});
|
|
643
|
+
connection.onShutdown(async () => {
|
|
644
|
+
shutdown = true;
|
|
645
|
+
await closeHttp();
|
|
646
|
+
});
|
|
647
|
+
connection.onExit(() => {
|
|
648
|
+
void close();
|
|
649
|
+
});
|
|
650
|
+
options.input.once("end", () => {
|
|
651
|
+
void close();
|
|
652
|
+
});
|
|
653
|
+
options.input.once("close", () => {
|
|
654
|
+
void close();
|
|
655
|
+
});
|
|
656
|
+
connection.listen();
|
|
657
|
+
return { origin, previewURL, apiToken, close };
|
|
658
|
+
}
|