@hydranium/data-server 1.0.0-next.10
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 +91 -0
- package/lib/data-server.d.ts +614 -0
- package/lib/data-server.d.ts.map +1 -0
- package/lib/data-server.js +1003 -0
- package/lib/data-server.js.map +1 -0
- package/lib/default-diagnostics.browser.d.ts +18 -0
- package/lib/default-diagnostics.browser.d.ts.map +1 -0
- package/lib/default-diagnostics.browser.js +36 -0
- package/lib/default-diagnostics.browser.js.map +1 -0
- package/lib/default-diagnostics.d.ts +36 -0
- package/lib/default-diagnostics.d.ts.map +1 -0
- package/lib/default-diagnostics.js +38 -0
- package/lib/default-diagnostics.js.map +1 -0
- package/lib/diagnostics-provider.d.ts +65 -0
- package/lib/diagnostics-provider.d.ts.map +1 -0
- package/lib/diagnostics-provider.js +10 -0
- package/lib/diagnostics-provider.js.map +1 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +15 -0
- package/lib/index.js.map +1 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.d.ts.map +1 -0
- package/lib/node/index.js +16 -0
- package/lib/node/index.js.map +1 -0
- package/lib/node/node-diagnostics-provider.d.ts +18 -0
- package/lib/node/node-diagnostics-provider.d.ts.map +1 -0
- package/lib/node/node-diagnostics-provider.js +68 -0
- package/lib/node/node-diagnostics-provider.js.map +1 -0
- package/lib/testing/data-server-harness.d.ts +85 -0
- package/lib/testing/data-server-harness.d.ts.map +1 -0
- package/lib/testing/data-server-harness.js +47 -0
- package/lib/testing/data-server-harness.js.map +1 -0
- package/lib/testing/index.d.ts +11 -0
- package/lib/testing/index.d.ts.map +1 -0
- package/lib/testing/index.js +10 -0
- package/lib/testing/index.js.map +1 -0
- package/package.json +93 -0
- package/src/data-server.ts +1282 -0
- package/src/default-diagnostics.browser.ts +41 -0
- package/src/default-diagnostics.ts +40 -0
- package/src/diagnostics-provider.ts +70 -0
- package/src/index.ts +15 -0
- package/src/node/index.ts +17 -0
- package/src/node/node-diagnostics-provider.ts +82 -0
- package/src/testing/data-server-harness.ts +147 -0
- package/src/testing/index.ts +19 -0
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
import { createRpcProxy, DisposableCollection, isDocumentSource, isElementSource, isSyntheticSource, ReferenceSource } from '@hydranium/protocol';
|
|
10
|
+
import { DATA_SERVER_DIAGNOSTICS_METHODS, DATA_SERVER_PROTOCOL_METHODS, DATA_SERVER_WIRE_PREFIX } from '@hydranium/protocol/data';
|
|
11
|
+
import { REVERT_ON_CLOSE_CLIENT_ID, UNKNOWN_CLIENT_ID } from '@hydranium/core';
|
|
12
|
+
import { defaultDataServerDiagnostics } from './default-diagnostics.js';
|
|
13
|
+
import { DocumentState, UriUtils } from '@hydranium/langium';
|
|
14
|
+
/**
|
|
15
|
+
* Domain separator between text and diagnostics inputs of
|
|
16
|
+
* {@link DataServer.computeDocumentFingerprint}'s hash. A single NUL byte is
|
|
17
|
+
* sufficient: the JSON-stringified diagnostics never contain a NUL byte, so
|
|
18
|
+
* the boundary is unambiguous.
|
|
19
|
+
*/
|
|
20
|
+
const FINGERPRINT_SEPARATOR = '\0';
|
|
21
|
+
/**
|
|
22
|
+
* Portable, non-cryptographic 64-bit fingerprint hash (cyrb53), returning a
|
|
23
|
+
* 16-char hex digest.
|
|
24
|
+
*
|
|
25
|
+
* **A cryptographic hash via `node:crypto` is the wrong trade**: the
|
|
26
|
+
* fingerprint only needs a stable signal that `(text, diagnostics)` changed
|
|
27
|
+
* between emissions, and a `node:*` import would cost
|
|
28
|
+
* `@hydranium/data-server` its browser-portability. Parts are fed
|
|
29
|
+
* incrementally, char by char, so multi-MB document text is never
|
|
30
|
+
* concatenated into one string.
|
|
31
|
+
*/
|
|
32
|
+
function fingerprintHash(parts) {
|
|
33
|
+
let h1 = 0xdeadbeef;
|
|
34
|
+
let h2 = 0x41c6ce57;
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
for (let i = 0; i < part.length; i++) {
|
|
37
|
+
const ch = part.charCodeAt(i);
|
|
38
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
39
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
|
|
43
|
+
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
44
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
|
|
45
|
+
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
46
|
+
const high = (h2 >>> 0).toString(16).padStart(8, '0');
|
|
47
|
+
const low = (h1 >>> 0).toString(16).padStart(8, '0');
|
|
48
|
+
return high + low;
|
|
49
|
+
}
|
|
50
|
+
/** Hash a document's raw text + diagnostics — the `'text-diagnostics'` strategy. */
|
|
51
|
+
function textDiagnosticsFingerprint(document) {
|
|
52
|
+
return fingerprintHash([document.textDocument.getText(), FINGERPRINT_SEPARATOR, JSON.stringify(document.diagnostics ?? [])]);
|
|
53
|
+
}
|
|
54
|
+
/** Hash an encoded transfer document's root + diagnostics — the `'transfer-document'` strategy. */
|
|
55
|
+
function transferDocumentFingerprint(transferDocument) {
|
|
56
|
+
// null, not undefined: `JSON.stringify(undefined)` yields undefined rather
|
|
57
|
+
// than a string, putting a non-string into the hash inputs.
|
|
58
|
+
return fingerprintHash([
|
|
59
|
+
JSON.stringify(transferDocument.root ?? null),
|
|
60
|
+
FINGERPRINT_SEPARATOR,
|
|
61
|
+
JSON.stringify(transferDocument.diagnostics)
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `logAfterMs` threshold passed to {@link Tracer.time} around
|
|
66
|
+
* {@link DataServer.computeDocumentFingerprint}. Below this the timing
|
|
67
|
+
* pair is suppressed so steady-state edits don't flood the log; above it
|
|
68
|
+
* the pathological cases (multi-MB files, massive diagnostic batches)
|
|
69
|
+
* surface naturally via the framework's standard timing pattern.
|
|
70
|
+
*/
|
|
71
|
+
const FINGERPRINT_LOG_AFTER_MS = 5;
|
|
72
|
+
/**
|
|
73
|
+
* Typed-RPC protocol head for the hydranium framework. The data-server
|
|
74
|
+
* is a PEER of `@hydranium/core/lsp` and `@hydranium/glsp-server` —
|
|
75
|
+
* all three heads coordinate through shared services in
|
|
76
|
+
* `@hydranium/core` (multi-client text documents, self-save
|
|
77
|
+
* registry, document-builder phases, project manager, ModelService
|
|
78
|
+
* facade) and never depend on each other at the package level.
|
|
79
|
+
*
|
|
80
|
+
* **No abstract grammar hooks**. The data-server has no grammar-specific
|
|
81
|
+
* abstract methods: `serialize` / `parseModel` live on
|
|
82
|
+
* {@link ModelService} (the in-process facade), where the `update` /
|
|
83
|
+
* `save` lifecycle owns the transfer-model round-trip. Adopters that want
|
|
84
|
+
* to customise serialisation rebind `services.model.ModelService` with a
|
|
85
|
+
* subclass; adopters that need a typed-overlay encoder rebind
|
|
86
|
+
* `services.model.TransferEncoder` with a subclass exposing the typed
|
|
87
|
+
* `TTransferMap`.
|
|
88
|
+
*
|
|
89
|
+
* **What still subclasses**. The data-server is concrete by default;
|
|
90
|
+
* adopters subclass ONLY when they need to decorate wire returns or
|
|
91
|
+
* notifications. The usual adoption path is DI rebinds alone.
|
|
92
|
+
*
|
|
93
|
+
* Lifecycle: the constructor registers framework request handlers (plus
|
|
94
|
+
* any names supplied via {@link DataServerOptions.additionalMethods}) and
|
|
95
|
+
* builds the {@link DataClientProtocol} notification proxy on the same
|
|
96
|
+
* connection in one {@link createRpcProxy} call (binding `this` as its
|
|
97
|
+
* `localTarget`) under the configured namespace, and subscribes one
|
|
98
|
+
* `DocumentBuilder.onDocumentPhase` listener per configured phase that
|
|
99
|
+
* dispatches subscription events via `clientProxy.onDocumentUpdated`.
|
|
100
|
+
*
|
|
101
|
+
* The data-server requires the full {@link ServerSharedServices}
|
|
102
|
+
* shape — `HydraniumTextDocuments` for client-attributed updates,
|
|
103
|
+
* `WritableFileSystemProvider` for save, `SelfSaveRegistry` for
|
|
104
|
+
* save-echo suppression, `ProjectManager` for project listing,
|
|
105
|
+
* `ModelService` for the lifecycle delegate, `TransferEncoder` for
|
|
106
|
+
* wire envelope construction — all of which the framework's
|
|
107
|
+
* `createServerSharedModule` binds by default.
|
|
108
|
+
*
|
|
109
|
+
* **No per-head shared module.** The data-server head does not surface a
|
|
110
|
+
* `createDataServerSharedModule` factory because it contributes no
|
|
111
|
+
* shared-tier bindings: it reads exclusively from
|
|
112
|
+
* {@link ServerSharedServices}. An empty factory would only mislead
|
|
113
|
+
* adopters into composing it as though it were the canonical adoption
|
|
114
|
+
* path.
|
|
115
|
+
*/
|
|
116
|
+
export class DataServer {
|
|
117
|
+
connection;
|
|
118
|
+
services;
|
|
119
|
+
/**
|
|
120
|
+
* Framework defaults, exposed so an adopter can spread them when
|
|
121
|
+
* extending rather than restate a value the framework may change.
|
|
122
|
+
*/
|
|
123
|
+
static DEFAULT_OPTIONS = {
|
|
124
|
+
subscriptionPhase: DocumentState.Validated
|
|
125
|
+
};
|
|
126
|
+
options;
|
|
127
|
+
/** Subscription bookkeeping: URI → set of clientIds that subscribed for that URI. */
|
|
128
|
+
subscriptions = new Map();
|
|
129
|
+
/**
|
|
130
|
+
* Open-document bookkeeping: URI → set of clientIds that opened it over THIS
|
|
131
|
+
* connection.
|
|
132
|
+
*
|
|
133
|
+
* Kept because the document store releases a client's hold only from an
|
|
134
|
+
* explicit close, and a client that dies without closing would otherwise keep
|
|
135
|
+
* the document open forever — resident, and with its last-close revert
|
|
136
|
+
* suppressed. The store has no notion of which connection a clientId reached
|
|
137
|
+
* it over, so the connection-scoped set has to live here; teaching it a client
|
|
138
|
+
* identity is the more robust and much larger alternative.
|
|
139
|
+
*/
|
|
140
|
+
openedDocuments = new Map();
|
|
141
|
+
/** Typed client proxy — sends `data-server/on*` notifications back over the same wire. */
|
|
142
|
+
clientProxy;
|
|
143
|
+
disposables = new DisposableCollection();
|
|
144
|
+
/**
|
|
145
|
+
* Snapshot of the most recent `DocumentBuilder.onUpdate` event. Drives
|
|
146
|
+
* `reason` discrimination on outbound `onDocumentUpdated` notifications:
|
|
147
|
+
* a URI in the `changed` list emits `'changed'`, in `deleted` emits
|
|
148
|
+
* `'deleted'`, otherwise `'rebuilt'` (cascade rebuild from a dependent
|
|
149
|
+
* URI's change). The same mechanism `AstDocumentManager.onUpdate` uses on
|
|
150
|
+
* the LSP side, so reason fidelity stays consistent between heads.
|
|
151
|
+
*/
|
|
152
|
+
lastBuildUpdate;
|
|
153
|
+
/**
|
|
154
|
+
* Per-URI hash of the last emitted (text + diagnostics) state,
|
|
155
|
+
* used by {@link dispatchPhaseEvent} to suppress duplicate
|
|
156
|
+
* `onDocumentUpdated` notifications for rebuilds that produce no
|
|
157
|
+
* observable change since the last emit.
|
|
158
|
+
*
|
|
159
|
+
* Two scenarios routinely produce such rebuilds:
|
|
160
|
+
* 1. **Refresh-triggered rebuilds.** When a second client attaches to a
|
|
161
|
+
* document already open in another client,
|
|
162
|
+
* `HydraniumTextDocuments.refreshContent` fires `onDidChangeContent`
|
|
163
|
+
* purely to re-trigger the build pipeline — the text and diagnostics
|
|
164
|
+
* are identical to the prior emit.
|
|
165
|
+
* 2. **Cascade rebuilds with no diagnostic delta.** Langium rebuilds a
|
|
166
|
+
* document when a dependency changes; if the rebuild produces the
|
|
167
|
+
* same diagnostics, there is nothing new to communicate to subscribers.
|
|
168
|
+
*
|
|
169
|
+
* Without this filter, both scenarios reach subscribers as wire-side
|
|
170
|
+
* `'changed'` events. A subscriber that interprets `'changed'` as
|
|
171
|
+
* "another client wrote new content" and resets its in-memory root to
|
|
172
|
+
* the server view then misreads the spurious event as a concurrent
|
|
173
|
+
* third-party write and loses the user's edits.
|
|
174
|
+
*
|
|
175
|
+
* Initialised on {@link watchModelDocument} to the current
|
|
176
|
+
* document fingerprint so the FIRST phase event after a fresh
|
|
177
|
+
* subscription is also de-duplicated against the state subscribers
|
|
178
|
+
* obtained via `getModelDocument` — they do not need a redundant phase
|
|
179
|
+
* notification for the state they just fetched.
|
|
180
|
+
*
|
|
181
|
+
* Cleared in {@link unwatchModelDocument} when the last
|
|
182
|
+
* subscriber for a URI leaves so memory does not accumulate.
|
|
183
|
+
*
|
|
184
|
+
* Stored as a cyrb53 hex digest (16 chars per URI) rather than the raw
|
|
185
|
+
* text + diagnostics serialisation so the memory cost is constant in
|
|
186
|
+
* document size. See {@link computeDocumentFingerprint} for the inputs.
|
|
187
|
+
*/
|
|
188
|
+
lastEmittedFingerprint = new Map();
|
|
189
|
+
/**
|
|
190
|
+
* URIs whose LAST client just closed. The update handler rebuilds such a
|
|
191
|
+
* document from its disk content (discarding unsaved in-session edits),
|
|
192
|
+
* but {@link dispatchPhaseEvent} gates on the subscription map — and the
|
|
193
|
+
* last close typically also removed the last watcher, so consumers that
|
|
194
|
+
* only ever fetch via `getModelDocument` would keep showing the
|
|
195
|
+
* discarded state forever. Marked on the last-close transition (see
|
|
196
|
+
* {@link subscribeToTextDocumentCloses}) and consumed by
|
|
197
|
+
* {@link dispatchPhaseEvent}, which broadcasts the following rebuild's
|
|
198
|
+
* phase event even without a subscription — de-duplicated against
|
|
199
|
+
* {@link lastEmittedFingerprint} where an entry survives, so a close
|
|
200
|
+
* whose disk state equals the last emitted state stays silent.
|
|
201
|
+
*/
|
|
202
|
+
pendingRevertBroadcasts = new Set();
|
|
203
|
+
tracer;
|
|
204
|
+
/** The in-flight interactive profile capture, held between {@link startProfiling} and {@link stopProfiling}. */
|
|
205
|
+
activeProfile;
|
|
206
|
+
/** Set once {@link dispose} has run, so a capture that starts after teardown is stopped instead of leaked. */
|
|
207
|
+
disposed = false;
|
|
208
|
+
/** Per-method RPC latency collector, when the head opted in via {@link DataServerOptions.latency}. */
|
|
209
|
+
latency;
|
|
210
|
+
/** Encoder pulled from DI. Adopters rebind `services.model.TransferEncoder` to a typed-overlay subclass. */
|
|
211
|
+
encoder;
|
|
212
|
+
/**
|
|
213
|
+
* In-process workspace facade — the lifecycle delegate `get` / `update` / `save` go through.
|
|
214
|
+
* The facade's AstDocument diagnostic shape is intentionally typed `unknown` here: adopters
|
|
215
|
+
* carry LSP-shape diagnostics in their AstDocument (e.g. an adopter's LSP-shape diagnostic type)
|
|
216
|
+
* while the wire shape stays `TDiagnostic extends TransferDiagnostic`. The encoder's
|
|
217
|
+
* `astDocumentToTransferDocument` accepts both shapes (wire-shape or LSP-shape) and projects
|
|
218
|
+
* to wire shape on the return — see `TransferEncoder.astDocumentToTransferDocument`.
|
|
219
|
+
*/
|
|
220
|
+
modelService;
|
|
221
|
+
constructor(connection, services, options = {}) {
|
|
222
|
+
this.connection = connection;
|
|
223
|
+
this.services = services;
|
|
224
|
+
this.options = this.resolveOptions(options);
|
|
225
|
+
this.tracer = this.services.Tracer.for(options.logName ?? 'DataServer').trace('instantiated');
|
|
226
|
+
// DI-bound: adopters rebind `services.model.TransferEncoder` /
|
|
227
|
+
// `services.model.ModelService` with their own subclasses. Slot types use the
|
|
228
|
+
// framework upper bounds; the casts below narrow to this instance's generics.
|
|
229
|
+
const { TransferEncoder: encoder, ModelService: modelService } = this.services.model;
|
|
230
|
+
if (!encoder || !modelService) {
|
|
231
|
+
throw new Error('DataServer requires `services.model.TransferEncoder` and `services.model.ModelService` to be bound. ' +
|
|
232
|
+
'Did you compose `createServerSharedModule(ctx)` into your shared module?');
|
|
233
|
+
}
|
|
234
|
+
this.encoder = encoder;
|
|
235
|
+
this.modelService = modelService;
|
|
236
|
+
const excluded = new Set(this.options.excludedMethods);
|
|
237
|
+
const registeredMethods = [
|
|
238
|
+
...DATA_SERVER_PROTOCOL_METHODS,
|
|
239
|
+
...DATA_SERVER_DIAGNOSTICS_METHODS,
|
|
240
|
+
...this.options.additionalMethods
|
|
241
|
+
].filter(name => !excluded.has(name));
|
|
242
|
+
// One call builds the outbound notification proxy AND registers the
|
|
243
|
+
// inbound handlers. A separate `bindRpcMethods` call per slice would
|
|
244
|
+
// force a subclass to re-bind in its own constructor to contribute
|
|
245
|
+
// methods alongside the framework's.
|
|
246
|
+
this.latency = options.latency;
|
|
247
|
+
this.clientProxy = createRpcProxy(connection, {
|
|
248
|
+
methodNamespace: this.options.methodNamespace,
|
|
249
|
+
localTarget: this,
|
|
250
|
+
localMethods: registeredMethods,
|
|
251
|
+
latency: this.latency
|
|
252
|
+
});
|
|
253
|
+
this.disposables.push(this.services.workspace.DocumentBuilder.onUpdate((changed, deleted) => {
|
|
254
|
+
this.lastBuildUpdate = { changed, deleted };
|
|
255
|
+
}));
|
|
256
|
+
this.subscribeToDocumentBuilder();
|
|
257
|
+
this.subscribeToTextDocumentSaves();
|
|
258
|
+
this.subscribeToTextDocumentCloses();
|
|
259
|
+
this.subscribeToProjectManager();
|
|
260
|
+
// Self-register teardown so an adopter that keeps no reference to the
|
|
261
|
+
// server still releases per-connection state, with no lifecycle hook of
|
|
262
|
+
// its own. See `dispose`.
|
|
263
|
+
this.disposables.push(connection.onClose(() => this.dispose()));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Release everything the constructor wired up — the bound protocol
|
|
267
|
+
* handlers and every listener — and clear the subscription map and the
|
|
268
|
+
* per-URI emission-fingerprint cache, so a long-lived shared services
|
|
269
|
+
* bundle does not retain per-connection memory after the connection
|
|
270
|
+
* closes. Also closes every document still open over this connection (see
|
|
271
|
+
* {@link closeOpenDocuments}), which is the SHARED store's state rather than
|
|
272
|
+
* this server's and so outlives the connection unless released here.
|
|
273
|
+
*
|
|
274
|
+
* Idempotent: subsequent calls are no-ops. Self-fires on
|
|
275
|
+
* `connection.onClose` so adopters who don't hold a reference still
|
|
276
|
+
* get per-connection cleanup; adopters that DO hold a reference may
|
|
277
|
+
* call `dispose()` directly for early teardown.
|
|
278
|
+
*/
|
|
279
|
+
dispose() {
|
|
280
|
+
this.disposed = true;
|
|
281
|
+
// Release an in-flight interactive capture so the process-wide inspector
|
|
282
|
+
// singleton is not left active after the connection closes.
|
|
283
|
+
if (this.activeProfile) {
|
|
284
|
+
const capture = this.activeProfile;
|
|
285
|
+
this.activeProfile = undefined;
|
|
286
|
+
void capture.stop({}).catch(() => undefined);
|
|
287
|
+
}
|
|
288
|
+
// AFTER `disposables.dispose()`, deliberately: closing a document fires
|
|
289
|
+
// `onDidClose`, and this server's own close listener would otherwise mark a
|
|
290
|
+
// revert broadcast for a connection that is already gone.
|
|
291
|
+
this.disposables.dispose();
|
|
292
|
+
this.closeOpenDocuments();
|
|
293
|
+
this.subscriptions.clear();
|
|
294
|
+
this.lastEmittedFingerprint.clear();
|
|
295
|
+
this.pendingRevertBroadcasts.clear();
|
|
296
|
+
}
|
|
297
|
+
// ============================================================
|
|
298
|
+
// DataServerProtocol implementation — delegates to ModelService.
|
|
299
|
+
// ============================================================
|
|
300
|
+
//
|
|
301
|
+
// The lifecycle methods (`get` / `update` / `save` / `ready`) forward
|
|
302
|
+
// straight to `ModelService` and encode the returned `AstDocument` on
|
|
303
|
+
// the wire boundary via `encoder.astDocumentToTransferDocument`.
|
|
304
|
+
// Adopters customising lifecycle behaviour (normalisation, supersession,
|
|
305
|
+
// settled-phase choice, etc.) override on the ModelService subclass —
|
|
306
|
+
// no override on DataServer is needed.
|
|
307
|
+
async openModelDocument(args) {
|
|
308
|
+
// Register the editor session (idempotent — `ModelService.open` refreshes
|
|
309
|
+
// an already-open document rather than re-opening), then return the built
|
|
310
|
+
// state at the configured target phase. The one-shot snapshot; subsequent
|
|
311
|
+
// build-phase events arrive via `watchModelDocument`.
|
|
312
|
+
await this.modelService.open(args);
|
|
313
|
+
// Record the hold so `dispose` can release it for a client that never
|
|
314
|
+
// closes. Keyed by the URI as given, because that is what `close` takes.
|
|
315
|
+
let holders = this.openedDocuments.get(args.uri);
|
|
316
|
+
if (!holders) {
|
|
317
|
+
holders = new Set();
|
|
318
|
+
this.openedDocuments.set(args.uri, holders);
|
|
319
|
+
}
|
|
320
|
+
holders.add(args.clientId);
|
|
321
|
+
const document = await this.getModelDocument({ uri: args.uri });
|
|
322
|
+
// Project the authoritative client-facing version onto the open snapshot.
|
|
323
|
+
// `getModelDocument` encodes the freshly-built AST snapshot, whose version
|
|
324
|
+
// is the `LangiumDocument`'s own `textDocument.version`. That lags the
|
|
325
|
+
// multi-client synced version whenever the open seeded the synced document
|
|
326
|
+
// with a caller-supplied version id — and the synced version is what
|
|
327
|
+
// `ModelService.update`'s optimistic-concurrency gate compares
|
|
328
|
+
// `baseVersion` against, so reporting the snapshot's would make the
|
|
329
|
+
// caller's first tagged write self-conflict. A genuine concurrent edit
|
|
330
|
+
// still trips the gate and is reconciled by the caller's replay rather
|
|
331
|
+
// than predicted here.
|
|
332
|
+
return { ...document, version: this.services.workspace.TextDocuments.version(args.uri) };
|
|
333
|
+
}
|
|
334
|
+
async closeModelDocument(args) {
|
|
335
|
+
// Closing a session also releases its watch for (uri, clientId) — a
|
|
336
|
+
// forgotten unwatch would otherwise leak phase-event dispatch until the
|
|
337
|
+
// connection closes. Idempotent: a close without a prior watch is a no-op.
|
|
338
|
+
await this.unwatchModelDocument({ uri: args.uri, clientId: args.clientId });
|
|
339
|
+
this.forgetOpenDocument(args.uri, args.clientId);
|
|
340
|
+
await this.modelService.close(args);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Drop the recorded hold for `(uri, clientId)` so {@link dispose} does not
|
|
344
|
+
* close it a second time. Idempotent.
|
|
345
|
+
*/
|
|
346
|
+
forgetOpenDocument(uri, clientId) {
|
|
347
|
+
const holders = this.openedDocuments.get(uri);
|
|
348
|
+
if (!holders) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
holders.delete(clientId);
|
|
352
|
+
if (holders.size === 0) {
|
|
353
|
+
this.openedDocuments.delete(uri);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Close every document still open over this connection, for the clients that
|
|
358
|
+
* opened it here.
|
|
359
|
+
*
|
|
360
|
+
* The document store releases a per-URI hold only from an explicit close, so
|
|
361
|
+
* without this a client that dies mid-session keeps its documents open for the
|
|
362
|
+
* lifetime of the process: `isOpenInAnyClient` stays true, the document stays
|
|
363
|
+
* resident, and the last-close revert never runs. A long-lived multi-client
|
|
364
|
+
* head is the configuration where a dead client is normal rather than
|
|
365
|
+
* exceptional, so the leak accumulates there.
|
|
366
|
+
*
|
|
367
|
+
* Runs from {@link dispose}, which is synchronous, so each close is fired and
|
|
368
|
+
* its failure swallowed — a teardown must not reject, and a URI whose close
|
|
369
|
+
* fails is no worse off than it was before this drain existed.
|
|
370
|
+
*/
|
|
371
|
+
closeOpenDocuments() {
|
|
372
|
+
const held = [...this.openedDocuments];
|
|
373
|
+
this.openedDocuments.clear();
|
|
374
|
+
for (const [uri, clientIds] of held) {
|
|
375
|
+
for (const clientId of clientIds) {
|
|
376
|
+
void Promise.resolve(this.modelService.close({ uri, clientId })).catch(() => undefined);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async getModelDocument(args) {
|
|
381
|
+
// Smart dispatch: a warm document (already in LangiumDocuments) is
|
|
382
|
+
// returned at its settle phase without a redundant build; a cold URI
|
|
383
|
+
// falls through to a fresh build. update/save already drive builds, so
|
|
384
|
+
// this read path does not need to force one, which would make every
|
|
385
|
+
// polling read pay for a rebuild.
|
|
386
|
+
//
|
|
387
|
+
// Defaults to the integrity-settled landmark (diagnostics may be absent,
|
|
388
|
+
// delivered asynchronously via the subscription channel). A one-shot /
|
|
389
|
+
// unsubscribed caller that needs diagnostics inline passes
|
|
390
|
+
// `includeDiagnostics: true` to settle at `Validated` instead.
|
|
391
|
+
const state = args.includeDiagnostics ? DocumentState.Validated : undefined;
|
|
392
|
+
const astDocument = await this.modelService.ensureDocumentState(args.uri, state);
|
|
393
|
+
return this.encoder.astDocumentToTransferDocument(astDocument);
|
|
394
|
+
}
|
|
395
|
+
async updateModelDocument(args) {
|
|
396
|
+
const astDocument = await this.modelService.update(args);
|
|
397
|
+
return this.encoder.astDocumentToTransferDocument(astDocument);
|
|
398
|
+
}
|
|
399
|
+
async saveModelDocument(args) {
|
|
400
|
+
const astDocument = await this.modelService.save(args);
|
|
401
|
+
return this.encoder.astDocumentToTransferDocument(astDocument);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Record a watch for `(uri, clientId)`. Subsequent phase events on `uri`
|
|
405
|
+
* fan out to the wired `clientProxy.onDocumentUpdated`. The
|
|
406
|
+
* bidirectional pattern means the event channel is the client
|
|
407
|
+
* notification surface, not a returned handle — this method only
|
|
408
|
+
* registers the URI in the dispatch table.
|
|
409
|
+
*
|
|
410
|
+
* Also baselines the per-URI emission fingerprint (see
|
|
411
|
+
* {@link lastEmittedFingerprint}) to the document's current state when
|
|
412
|
+
* the first watcher for the URI registers. This guarantees that any
|
|
413
|
+
* phase event firing immediately after the watch with no observable
|
|
414
|
+
* change is suppressed — watchers obtain initial state via
|
|
415
|
+
* `getModelDocument` (or {@link openModelDocument}) and do not need a
|
|
416
|
+
* redundant phase notification for that same state.
|
|
417
|
+
*/
|
|
418
|
+
async watchModelDocument(args) {
|
|
419
|
+
const uri = this.canonicalKey(args.uri);
|
|
420
|
+
let subscribers = this.subscriptions.get(uri);
|
|
421
|
+
if (!subscribers) {
|
|
422
|
+
subscribers = new Set();
|
|
423
|
+
this.subscriptions.set(uri, subscribers);
|
|
424
|
+
}
|
|
425
|
+
subscribers.add(args.clientId);
|
|
426
|
+
if (!this.lastEmittedFingerprint.has(uri)) {
|
|
427
|
+
const document = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
|
|
428
|
+
if (document) {
|
|
429
|
+
this.lastEmittedFingerprint.set(uri, this.computeDocumentFingerprint(document));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Remove a watch for `(uri, clientId)` previously created by
|
|
435
|
+
* {@link watchModelDocument}. Idempotent — unwatching twice is a no-op.
|
|
436
|
+
* Dispatch for `uri` stops once no watchers remain, at which point the
|
|
437
|
+
* per-URI emission fingerprint is also cleared so the next first-watch
|
|
438
|
+
* re-baselines against the then-current document state rather than a
|
|
439
|
+
* stale snapshot from the previous watch.
|
|
440
|
+
*/
|
|
441
|
+
async unwatchModelDocument(args) {
|
|
442
|
+
const uri = this.canonicalKey(args.uri);
|
|
443
|
+
const subscribers = this.subscriptions.get(uri);
|
|
444
|
+
if (!subscribers) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
subscribers.delete(args.clientId);
|
|
448
|
+
if (subscribers.size === 0) {
|
|
449
|
+
this.subscriptions.delete(uri);
|
|
450
|
+
this.lastEmittedFingerprint.delete(uri);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Canonicalise a URI string for use as a subscription / fingerprint
|
|
455
|
+
* map key, via the shared `DocumentUriPolicy`. Callers may send
|
|
456
|
+
* non-canonical URIs (drive-letter casing, percent-encoding differences,
|
|
457
|
+
* or a symlink path) over the wire; the dispatch side keys by
|
|
458
|
+
* `document.uri.toString()` from `LangiumDocuments`, so writer keys must
|
|
459
|
+
* canonicalise to the same form or events silently fail to deliver.
|
|
460
|
+
* Routing through the seam (rather than a bare `UriUtils.normalize`)
|
|
461
|
+
* means that when an adopter strengthens document identity — e.g.
|
|
462
|
+
* real-path (symlink) resolution — the data-server head's keys track
|
|
463
|
+
* it too, instead of carrying the same path-identity divergence the
|
|
464
|
+
* LSP head resolves.
|
|
465
|
+
*
|
|
466
|
+
* Adopters can still override for head-specific URI policy by subclassing.
|
|
467
|
+
*/
|
|
468
|
+
canonicalKey(uri) {
|
|
469
|
+
return this.services.workspace.DocumentUriPolicy.canonicalUri(uri);
|
|
470
|
+
}
|
|
471
|
+
async getProjects() {
|
|
472
|
+
// Pass-through: `ProjectManager<TProject>` already produces `TProject`
|
|
473
|
+
// (the shared services are parameterised over the same generic).
|
|
474
|
+
// The framework reads `id` for registry identity and `dependencies`
|
|
475
|
+
// for the visibility closure; every other field rides on the
|
|
476
|
+
// JSON-RPC envelope as adopter-defined wire metadata.
|
|
477
|
+
return this.services.workspace.ProjectManager.getProjects();
|
|
478
|
+
}
|
|
479
|
+
async getProjectForUri(args) {
|
|
480
|
+
// Pass-through: `ProjectManager.getProject(uri)` already returns the
|
|
481
|
+
// adopter's `TProject` shape. Membership logic belongs to the adopter's
|
|
482
|
+
// `ProjectManager`; the data-server forwards the URI without
|
|
483
|
+
// interpretation.
|
|
484
|
+
return this.services.workspace.ProjectManager.getProject(UriUtils.toUri(args.uri));
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Resolve once the data-server is ready to serve requests. Delegates
|
|
488
|
+
* to {@link ModelService.ready} so adopters that warm-load services
|
|
489
|
+
* (workspace indexing, etc.) override the `ModelService` slot in
|
|
490
|
+
* their shared module rather than this method on a DataServer
|
|
491
|
+
* subclass.
|
|
492
|
+
*/
|
|
493
|
+
async waitForReady() {
|
|
494
|
+
await this.modelService.ready;
|
|
495
|
+
}
|
|
496
|
+
// ============================================================
|
|
497
|
+
// ReferenceServerProtocol defaults — opt-in (not in DataServerProtocol).
|
|
498
|
+
// Resolve the language's reference services from each request's source
|
|
499
|
+
// (see resolveReferenceServices) and delegate.
|
|
500
|
+
// ============================================================
|
|
501
|
+
async findReferenceCandidates(ctx) {
|
|
502
|
+
// Candidates are derived from resolved cross-references, so the relevant document(s) must
|
|
503
|
+
// have finished the Linked phase — otherwise a query issued right after a model update races
|
|
504
|
+
// the asynchronous rebuild and returns stale candidates. Wait the source document
|
|
505
|
+
// specifically, but ONLY when a document is actually loaded at that URI: a synthetic source
|
|
506
|
+
// can address a URI with no document (a directory URI, typically), and a per-URI `waitUntil`
|
|
507
|
+
// there throws "No document found". Fall back to a global Linked settle, matching the
|
|
508
|
+
// id-based ElementSource (no URI) path.
|
|
509
|
+
const uri = isDocumentSource(ctx.source) || isSyntheticSource(ctx.source) ? UriUtils.toUri(ctx.source.uri) : undefined;
|
|
510
|
+
const waitUri = uri && this.services.workspace.LangiumDocuments.hasDocument(uri) ? uri : undefined;
|
|
511
|
+
await this.services.workspace.DocumentBuilder.waitUntil(DocumentState.Linked, waitUri);
|
|
512
|
+
return this.resolveReferenceServices(ctx.source).CandidateProvider.find(ctx);
|
|
513
|
+
}
|
|
514
|
+
async resolveReference(ref) {
|
|
515
|
+
const resolved = this.resolveReferenceServices(ref.source).CandidateProvider.resolveCandidate(ref);
|
|
516
|
+
if (!resolved) {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
const element = this.encoder.toTransfer(resolved.node);
|
|
520
|
+
return { ...resolved.candidate, element };
|
|
521
|
+
}
|
|
522
|
+
async findNextName(args) {
|
|
523
|
+
const uri = UriUtils.toUri(args.uri);
|
|
524
|
+
// Route through the same router as every other reference method.
|
|
525
|
+
// `args` carries a URI and an AST type, which is precisely a
|
|
526
|
+
// synthetic source — and the create-element flow driving this method is
|
|
527
|
+
// the one that produces directory URIs, on which a bare
|
|
528
|
+
// `getServices(uri)` throws while `findReferenceCandidates` succeeds.
|
|
529
|
+
const nameProvider = this.resolveReferenceServices(ReferenceSource.synthetic(args.uri, args.type)).NameProvider;
|
|
530
|
+
const tier = args.tier ?? 'project';
|
|
531
|
+
if (tier === 'public') {
|
|
532
|
+
return nameProvider.findNextProjectQualifiedName(args.type, args.proposal);
|
|
533
|
+
}
|
|
534
|
+
if (tier === 'local') {
|
|
535
|
+
// Document-scoped uniqueness: the document root is the container.
|
|
536
|
+
const document = await this.modelService.ensureDocumentState(args.uri);
|
|
537
|
+
return document.root ? nameProvider.findNextName(args.type, args.proposal, document.root) : args.proposal;
|
|
538
|
+
}
|
|
539
|
+
const project = this.services.workspace.ProjectManager.getProject(uri);
|
|
540
|
+
if (!project) {
|
|
541
|
+
// Project-tier uniqueness on a URI no project owns: the scope the
|
|
542
|
+
// caller asked about does not exist. Widen to workspace-wide, which is
|
|
543
|
+
// a strict superset — a name unique across every project is unique
|
|
544
|
+
// within any one of them, so this can only add a suffix, never miss a
|
|
545
|
+
// collision. Filtering on an empty project id instead would match no
|
|
546
|
+
// element, and so always answer "no collisions" with the bare
|
|
547
|
+
// proposal. Unreachable for adopters on the default
|
|
548
|
+
// `SingleProjectManager`, which owns every URI.
|
|
549
|
+
return nameProvider.findNextProjectQualifiedName(args.type, args.proposal);
|
|
550
|
+
}
|
|
551
|
+
return nameProvider.findNextDocumentQualifiedName(args.type, args.proposal, project.id);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Resolve the per-language `references` services for a reference source.
|
|
555
|
+
* Delegates the language choice to {@link resolveReferenceLanguage} and
|
|
556
|
+
* throws when no language owns the source — the reference heads have no
|
|
557
|
+
* meaningful empty answer (an empty candidate list reads to the client as
|
|
558
|
+
* "nothing matches" and hides the misrouting).
|
|
559
|
+
*/
|
|
560
|
+
resolveReferenceServices(source) {
|
|
561
|
+
const language = this.resolveReferenceLanguage(source);
|
|
562
|
+
if (!language) {
|
|
563
|
+
throw new Error('DataServer cannot resolve the language for a reference source whose URI matches no registered ' +
|
|
564
|
+
'language in a multi-language workspace. Override `fallbackReferenceLanguage` on your DataServer ' +
|
|
565
|
+
'subclass to name the language such sources belong to.');
|
|
566
|
+
}
|
|
567
|
+
return language.references;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Pick the language that owns a reference source, in order:
|
|
571
|
+
*
|
|
572
|
+
* 1. A URI-bearing source ({@link isDocumentSource}/{@link isSyntheticSource})
|
|
573
|
+
* whose URI resolves to a registered language routes by URI.
|
|
574
|
+
* 2. A single-language workspace always routes to that one language — so
|
|
575
|
+
* single-language adopters never reach the steps below, and never pay
|
|
576
|
+
* for them.
|
|
577
|
+
* 3. An {@link isElementSource} source carries no URI, so in a
|
|
578
|
+
* multi-language workspace it is routed via the document that holds the
|
|
579
|
+
* element (see {@link findElementDocumentUri}).
|
|
580
|
+
* 4. A source carrying an AST type is routed by that type when exactly one
|
|
581
|
+
* registered grammar can produce it (see
|
|
582
|
+
* {@link resolveReferenceLanguageByType}, over the registry's own type
|
|
583
|
+
* index). This is what resolves a create-element flow's synthetic source
|
|
584
|
+
* on a bare directory URI without asking the adopter.
|
|
585
|
+
* 5. Anything still unresolved falls to {@link fallbackReferenceLanguage},
|
|
586
|
+
* which is adopter policy.
|
|
587
|
+
*/
|
|
588
|
+
resolveReferenceLanguage(source) {
|
|
589
|
+
const registry = this.services.ServiceRegistry;
|
|
590
|
+
// `getServicesFor` (non-throwing, one ladder walk) gates the URI lookup:
|
|
591
|
+
// an extensionless / unregistered URI falls through to the steps below
|
|
592
|
+
// rather than throwing "no services for the extension ''".
|
|
593
|
+
const uri = isDocumentSource(source) || isSyntheticSource(source) ? UriUtils.toUri(source.uri) : undefined;
|
|
594
|
+
const byUri = uri && registry.getServicesFor(uri);
|
|
595
|
+
if (byUri) {
|
|
596
|
+
return byUri;
|
|
597
|
+
}
|
|
598
|
+
const all = registry.all;
|
|
599
|
+
if (all.length === 1) {
|
|
600
|
+
return all[0];
|
|
601
|
+
}
|
|
602
|
+
if (isElementSource(source)) {
|
|
603
|
+
const documentUri = this.findElementDocumentUri(source);
|
|
604
|
+
const byDocument = documentUri && registry.getServicesFor(documentUri);
|
|
605
|
+
if (byDocument) {
|
|
606
|
+
return byDocument;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const byType = this.resolveReferenceLanguageByType(source);
|
|
610
|
+
return byType ?? this.fallbackReferenceLanguage(source);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Route a reference source by the AST type it carries — a
|
|
614
|
+
* {@link isSyntheticSource}'s `type` (the transient node being created) or an
|
|
615
|
+
* {@link isElementSource}'s optional narrowing `type`.
|
|
616
|
+
*
|
|
617
|
+
* Answers only when EXACTLY ONE registered grammar can produce the type.
|
|
618
|
+
* Several can when the type comes from a grammar both import, and then the
|
|
619
|
+
* type genuinely does not identify a language — that is a fall-through to
|
|
620
|
+
* adopter policy, not a coin toss. Note this asks which grammar can *produce*
|
|
621
|
+
* the type, not which mentions it: a grammar that merely cross-references a
|
|
622
|
+
* type can never hold a node of it.
|
|
623
|
+
*/
|
|
624
|
+
resolveReferenceLanguageByType(source) {
|
|
625
|
+
const type = isSyntheticSource(source) ? source.type : isElementSource(source) ? source.type : undefined;
|
|
626
|
+
// The registry owns the type index — it is the one place that knows when
|
|
627
|
+
// the registered set changed, so unlike a private memo here it cannot go
|
|
628
|
+
// stale on a language registered after the first lookup.
|
|
629
|
+
return type ? this.services.ServiceRegistry.soleServicesByType(type) : undefined;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Locate the document that holds the element an {@link ElementSource}
|
|
633
|
+
* addresses, via the index's O(1) name lookup — `name` is the qualified
|
|
634
|
+
* name the `NameProvider` wrote into the index, and the optional `type`
|
|
635
|
+
* disambiguates names that repeat across types (honouring grammar
|
|
636
|
+
* subtyping through `AstReflection.isSubtype`).
|
|
637
|
+
*
|
|
638
|
+
* Only reached on the multi-language, name-based path (step 3 of
|
|
639
|
+
* {@link resolveReferenceLanguage}); single-language adopters return at
|
|
640
|
+
* step 2.
|
|
641
|
+
*
|
|
642
|
+
* Abstains when the name matches elements in more than one DOCUMENT — the
|
|
643
|
+
* index spans every language and is filled in build order, so "the
|
|
644
|
+
* first match" would be file-watch order rather than an answer. Step 3
|
|
645
|
+
* then falls through to the type-based step 4, which abstains on ties
|
|
646
|
+
* in the same way, and finally to adopter policy.
|
|
647
|
+
*/
|
|
648
|
+
findElementDocumentUri(source) {
|
|
649
|
+
const matches = this.services.workspace.IndexManager.getElementsByName(source.name, source.type);
|
|
650
|
+
const [first] = matches;
|
|
651
|
+
if (!first) {
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
// Routing only asks WHICH DOCUMENT, so several descriptions of the same
|
|
655
|
+
// document are not ambiguous — one element is routinely indexed several
|
|
656
|
+
// times, once per visibility tier and once more for a wrapper root
|
|
657
|
+
// beside its semantic root. Only matches that disagree on the document
|
|
658
|
+
// are ambiguous.
|
|
659
|
+
return matches.every(match => match.documentUri.toString() === first.documentUri.toString()) ? first.documentUri : undefined;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Language to serve reference queries whose source names no language of
|
|
663
|
+
* its own — a synthetic source addressing a URI with no (or an
|
|
664
|
+
* unregistered) extension, or an element id absent from the index.
|
|
665
|
+
*
|
|
666
|
+
* Returns `undefined` by default, which makes
|
|
667
|
+
* {@link resolveReferenceServices} throw. Only reachable in a
|
|
668
|
+
* multi-language workspace, where choosing among the registered languages
|
|
669
|
+
* is adopter policy: override and return the language such sources belong
|
|
670
|
+
* to.
|
|
671
|
+
*/
|
|
672
|
+
fallbackReferenceLanguage(_source) {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
// ============================================================
|
|
676
|
+
// Internal plumbing
|
|
677
|
+
// ============================================================
|
|
678
|
+
/**
|
|
679
|
+
* Build a {@link TransferDocument} envelope from the current document state,
|
|
680
|
+
* delegating root + diagnostic encoding to {@link encoder} (see
|
|
681
|
+
* `TransferEncoder.toTransferDocument` for the walk).
|
|
682
|
+
*
|
|
683
|
+
* The encoder field's generic-map binding is widened to
|
|
684
|
+
* `Record<string, TransferElement>` at the framework-default level, and an
|
|
685
|
+
* adopter supplying a typed-overlay encoder narrows the runtime shape to
|
|
686
|
+
* its wire types. The cast on the return is where that invariant — the
|
|
687
|
+
* adopter's `TTransfer` matches its encoder's overlay — is asserted, at a
|
|
688
|
+
* single boundary point rather than spread across the callers.
|
|
689
|
+
*/
|
|
690
|
+
envelope(uri) {
|
|
691
|
+
// Resolve through the model service's canonicalizing gateway rather than
|
|
692
|
+
// reaching into `LangiumDocuments` directly, so a divergent (symlink) URI
|
|
693
|
+
// still finds the document the build keys by its real path — and so the
|
|
694
|
+
// data-server never has to remember to canonicalize this lookup itself.
|
|
695
|
+
const document = this.modelService.getDocument(uri.toString());
|
|
696
|
+
if (!document) {
|
|
697
|
+
// No document — a shaped envelope rather than a throw, so the caller
|
|
698
|
+
// decides policy at its own layer; `root` is optional on the envelope
|
|
699
|
+
// so the compiler forces that decision. Adopters preferring to throw
|
|
700
|
+
// override `envelope`.
|
|
701
|
+
return {
|
|
702
|
+
uri: uri.toString(),
|
|
703
|
+
version: 0,
|
|
704
|
+
root: undefined,
|
|
705
|
+
diagnostics: []
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
return this.encoder.toTransferDocument(document);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Subscribe one listener at the configured {@link DataServerOptions.subscriptionPhase}.
|
|
712
|
+
* The listener dispatches subscription events for every matching URI. A
|
|
713
|
+
* single listener (rather than one per subscription) keeps the cost flat
|
|
714
|
+
* regardless of subscriber count.
|
|
715
|
+
*/
|
|
716
|
+
subscribeToDocumentBuilder() {
|
|
717
|
+
this.disposables.push(this.services.workspace.DocumentBuilder.onDocumentPhase(this.options.subscriptionPhase, (document, cancelToken) => this.dispatchPhaseEvent(document, cancelToken)));
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Subscribe to the universal save event on `HydraniumTextDocuments` so a
|
|
721
|
+
* single `onDocumentSaved` wire notification fires for ANY save of a
|
|
722
|
+
* subscribed URI — regardless of whether the save originated from the
|
|
723
|
+
* data-server's RPC `saveModelDocument`, the LSP head's text-editor save,
|
|
724
|
+
* or any other client writing through `notifyDidSaveTextDocument`.
|
|
725
|
+
*
|
|
726
|
+
* Architectural symmetry with {@link subscribeToDocumentBuilder}: every
|
|
727
|
+
* subscribed client sees every state change to documents they care about,
|
|
728
|
+
* regardless of which client triggered it. Firing `onDocumentSaved` only
|
|
729
|
+
* from the data-server's own RPC path is a bug, not an optimisation: an
|
|
730
|
+
* LSP-driven save then lands on disk without notifying the subscribed
|
|
731
|
+
* clients, which never clear their dirty state.
|
|
732
|
+
*/
|
|
733
|
+
subscribeToTextDocumentSaves() {
|
|
734
|
+
this.disposables.push(this.services.workspace.TextDocuments.onDidSave(event => this.dispatchSaveEvent(event)));
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Mark the last-close transition per URI (see
|
|
738
|
+
* {@link pendingRevertBroadcasts}). The listener consults
|
|
739
|
+
* `isOpenInAnyClient` AFTER the store decremented the closing client's
|
|
740
|
+
* hold, so a `false` answer means this close was the last one.
|
|
741
|
+
*/
|
|
742
|
+
subscribeToTextDocumentCloses() {
|
|
743
|
+
const textDocuments = this.services.workspace.TextDocuments;
|
|
744
|
+
this.disposables.push(textDocuments.onDidClose(event => {
|
|
745
|
+
if (!textDocuments.isOpenInAnyClient(event.document.uri)) {
|
|
746
|
+
this.pendingRevertBroadcasts.add(this.canonicalKey(event.document.uri));
|
|
747
|
+
}
|
|
748
|
+
}));
|
|
749
|
+
}
|
|
750
|
+
/** Fan out a save event for the document's URI, gated by the subscription map. */
|
|
751
|
+
dispatchSaveEvent(event) {
|
|
752
|
+
// The save event arrives under the CLIENT URI the text store keys by (e.g. a
|
|
753
|
+
// symlink path S); the subscription map and `dispatchPhaseEvent` key by the
|
|
754
|
+
// CANONICAL identity R. Canonicalize before both the gate and the envelope so
|
|
755
|
+
// a save of a symlinked file isn't silently dropped (and the envelope resolves
|
|
756
|
+
// the R-keyed document rather than missing into an empty one).
|
|
757
|
+
const uri = this.canonicalKey(event.document.uri);
|
|
758
|
+
if (!this.subscriptions.has(uri)) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const response = this.envelope(UriUtils.toUri(uri));
|
|
762
|
+
const wireEvent = {
|
|
763
|
+
document: response,
|
|
764
|
+
sourceClientId: event.clientId
|
|
765
|
+
};
|
|
766
|
+
this.clientProxy.onDocumentSaved(wireEvent);
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Fan out a phase event for the document's URI by calling
|
|
770
|
+
* `clientProxy.onDocumentUpdated`. The proxy lowers the call to a
|
|
771
|
+
* `data-server/onDocumentUpdated` wire notification; the paired client
|
|
772
|
+
* (bound via the client `createRpcProxy`'s `localTarget`/`localMethods`)
|
|
773
|
+
* routes it to its handler. Adopters fan a single inbound onDocumentUpdated
|
|
774
|
+
* out to multiple local subscribers with an `Emitter<T>` — the
|
|
775
|
+
* framework deliberately does NOT promise multi-listener semantics.
|
|
776
|
+
*
|
|
777
|
+
* The dispatch is guarded by two filters:
|
|
778
|
+
* 1. **Subscription map**: events for URIs no subscriber registered for
|
|
779
|
+
* are NOT sent over the wire (bandwidth scales with subscribed URIs,
|
|
780
|
+
* not phase events).
|
|
781
|
+
* 2. **Emission fingerprint**: events for rebuilds that produce no
|
|
782
|
+
* observable change since the last emit are suppressed. See
|
|
783
|
+
* {@link lastEmittedFingerprint} for the rationale.
|
|
784
|
+
*/
|
|
785
|
+
dispatchPhaseEvent(document, cancelToken) {
|
|
786
|
+
if (cancelToken.isCancellationRequested) {
|
|
787
|
+
// Build preempted by a concurrent write lock (or other cancel source) —
|
|
788
|
+
// the subscription event is stale by the time it would fire. Skip
|
|
789
|
+
// emission so RPC subscribers don't surface intermediate states.
|
|
790
|
+
// A pending revert mark is deliberately NOT consumed here: the
|
|
791
|
+
// follow-up build re-fires this phase and broadcasts then.
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const uri = document.uri.toString();
|
|
795
|
+
// Consume the last-close mark even when subscriptions exist — the
|
|
796
|
+
// regular dispatch below serves those watchers, and the mark's author
|
|
797
|
+
// attribution is more precise than the post-close `UNKNOWN_CLIENT_ID`
|
|
798
|
+
// the author lookup would yield.
|
|
799
|
+
const revertedOnClose = this.pendingRevertBroadcasts.delete(uri);
|
|
800
|
+
if (!this.subscriptions.has(uri) && !revertedOnClose) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const fingerprint = this.computeDocumentFingerprint(document);
|
|
804
|
+
if (this.lastEmittedFingerprint.get(uri) === fingerprint) {
|
|
805
|
+
// A rebuild that produced no observable change since the last emit —
|
|
806
|
+
// suppressed so RPC subscribers don't see a no-op broadcast. Logged at
|
|
807
|
+
// debug so a *needed* re-broadcast wrongly suppressed by this dedup
|
|
808
|
+
// (the failure mode the fingerprint strategy must avoid) is visible.
|
|
809
|
+
this.tracer.withUri(uri).debug(`Suppress onDocumentUpdated v${document.textDocument.version}: fingerprint unchanged`);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
this.lastEmittedFingerprint.set(uri, fingerprint);
|
|
813
|
+
const event = {
|
|
814
|
+
document: this.envelope(document.uri),
|
|
815
|
+
sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID : this.resolveSourceClientId(document),
|
|
816
|
+
reason: this.resolveUpdateReason(document.uri)
|
|
817
|
+
};
|
|
818
|
+
this.tracer
|
|
819
|
+
.withUri(uri)
|
|
820
|
+
.debug(`Emit onDocumentUpdated v${event.document.version} (reason=${event.reason}, sourceClientId=${event.sourceClientId})`);
|
|
821
|
+
this.clientProxy.onDocumentUpdated(event);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Fingerprint of the document's observable state, used to de-dup
|
|
825
|
+
* `onDocumentUpdated` emissions. The {@link FingerprintStrategy} option
|
|
826
|
+
* selects what is hashed (default `'transfer-document'` — see the type), and
|
|
827
|
+
* {@link additionalFingerprintInputs} folds in any extra adopter signal.
|
|
828
|
+
*
|
|
829
|
+
* The default `'transfer-document'` strategy goes through the encoder's
|
|
830
|
+
* {@link TransferEncoder.toTransferDocument}, which is cached per build —
|
|
831
|
+
* so within one phase event the fingerprint and the subsequently-emitted
|
|
832
|
+
* `envelope` share a single encode walk.
|
|
833
|
+
*
|
|
834
|
+
* Wrapped in {@link Tracer.time} against {@link FINGERPRINT_LOG_AFTER_MS}.
|
|
835
|
+
*/
|
|
836
|
+
computeDocumentFingerprint(document) {
|
|
837
|
+
return this.tracer.withUri(document.uri.toString()).time('Compute fingerprint', () => {
|
|
838
|
+
const base = this.options.fingerprintStrategy === 'text-diagnostics'
|
|
839
|
+
? textDiagnosticsFingerprint(document)
|
|
840
|
+
: transferDocumentFingerprint(this.encoder.toTransferDocument(document));
|
|
841
|
+
const extra = this.additionalFingerprintInputs(document);
|
|
842
|
+
return extra.length === 0 ? base : fingerprintHash([base, FINGERPRINT_SEPARATOR, ...extra.map(value => JSON.stringify(value))]);
|
|
843
|
+
}, 'debug', { logAfterMs: FINGERPRINT_LOG_AFTER_MS });
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Extra inputs folded into {@link computeDocumentFingerprint} alongside the
|
|
847
|
+
* chosen {@link FingerprintStrategy}. Default: none. Override to contribute a
|
|
848
|
+
* signal that lives outside the document's text / root / diagnostics — each
|
|
849
|
+
* entry need only be stable across equivalent emissions and
|
|
850
|
+
* JSON-serialisable.
|
|
851
|
+
*/
|
|
852
|
+
additionalFingerprintInputs(_document) {
|
|
853
|
+
return [];
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Discriminate the reason for a phase-event-driven update notification.
|
|
857
|
+
* Uses the most recent `DocumentBuilder.onUpdate` snapshot:
|
|
858
|
+
* - URI in the `deleted` list → `'deleted'`
|
|
859
|
+
* - URI in the `changed` list → `'changed'` (the URI was passed to
|
|
860
|
+
* `documentBuilder.update(changed, deleted)`, which spans `didChange`
|
|
861
|
+
* text-document events and programmatic `update([uri], [])` calls).
|
|
862
|
+
* - Otherwise → `'rebuilt'` (cascade re-derivation: this URI was rebuilt
|
|
863
|
+
* because something it depends on changed; its own text wasn't flagged).
|
|
864
|
+
*
|
|
865
|
+
* `'saved'` is NOT emitted from this code path — saves take the dedicated
|
|
866
|
+
* `DataClientProtocol.onDocumentSaved` channel; adopters that want a
|
|
867
|
+
* unified update stream synthesise `'saved'` in their bridge layer.
|
|
868
|
+
*/
|
|
869
|
+
resolveUpdateReason(uri) {
|
|
870
|
+
const last = this.lastBuildUpdate;
|
|
871
|
+
if (last?.deleted.some(deleted => UriUtils.equals(deleted, uri))) {
|
|
872
|
+
return 'deleted';
|
|
873
|
+
}
|
|
874
|
+
if (last?.changed.some(changed => UriUtils.equals(changed, uri))) {
|
|
875
|
+
return 'changed';
|
|
876
|
+
}
|
|
877
|
+
return 'rebuilt';
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Subscribe to the project tier's change channel and re-fan registry
|
|
881
|
+
* diffs into per-project wire notifications. The internal
|
|
882
|
+
* `ProjectChangeEvent` carries arrays of added/updated ids plus a
|
|
883
|
+
* removed list of `{ id, snapshot }` pairs; each affected project
|
|
884
|
+
* emits one wire `onProjectsChanged` event so clients react
|
|
885
|
+
* one-at-a-time without walking arrays. `'removed'` dispatches carry
|
|
886
|
+
* the pre-removal snapshot bundled in
|
|
887
|
+
* {@link ProjectChangeEvent.removed} because the registry entry is
|
|
888
|
+
* already gone by the time the event fires.
|
|
889
|
+
*
|
|
890
|
+
* Adopters that warm-load services may want to defer this subscription
|
|
891
|
+
* until {@link waitForReady} resolves (to avoid replaying the initial
|
|
892
|
+
* discovery as a burst of `'added'` events). Override
|
|
893
|
+
* {@link subscribeToProjectManager} on the subclass to gate.
|
|
894
|
+
*/
|
|
895
|
+
subscribeToProjectManager() {
|
|
896
|
+
this.disposables.push(this.services.workspace.ProjectManager.onProjectsChanged(event => this.dispatchProjectChangeEvent(event)));
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Fan one internal {@link ProjectChangeEvent} out to per-project wire
|
|
900
|
+
* notifications. Each `removed` entry pairs the id with the pre-removal
|
|
901
|
+
* snapshot needed for the `'removed'` wire payload.
|
|
902
|
+
*/
|
|
903
|
+
dispatchProjectChangeEvent(event) {
|
|
904
|
+
for (const id of event.added) {
|
|
905
|
+
const project = this.services.workspace.ProjectManager.getProjectById(id);
|
|
906
|
+
if (project) {
|
|
907
|
+
this.clientProxy.onProjectsChanged({ project, reason: 'added' });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
for (const id of event.updated) {
|
|
911
|
+
const project = this.services.workspace.ProjectManager.getProjectById(id);
|
|
912
|
+
if (project) {
|
|
913
|
+
this.clientProxy.onProjectsChanged({ project, reason: 'updated' });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
for (const entry of event.removed) {
|
|
917
|
+
this.clientProxy.onProjectsChanged({ project: entry.snapshot, reason: 'removed' });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Resolve the wire-level `sourceClientId` for `document`'s events — the client
|
|
922
|
+
* that authored its current version, from the version-author history on
|
|
923
|
+
* `HydraniumTextDocuments`. The protocol-level counterpart of the internal
|
|
924
|
+
* `AstDocumentManager.getAuthor`: a framework-internal rebuild has no author,
|
|
925
|
+
* so this surfaces the {@link UNKNOWN_CLIENT_ID} presentation default. Adopters
|
|
926
|
+
* that rebuild through non-text-document channels override to derive a
|
|
927
|
+
* source id of their own.
|
|
928
|
+
*/
|
|
929
|
+
resolveSourceClientId(document) {
|
|
930
|
+
const author = this.services.workspace.TextDocuments.getAuthor(document.textDocument.uri, document.textDocument.version);
|
|
931
|
+
return author ?? UNKNOWN_CLIENT_ID;
|
|
932
|
+
}
|
|
933
|
+
// --- Diagnostics (DataServerDiagnosticsProtocol) ---------------------------
|
|
934
|
+
// Computed in THIS process: the data-server child holds the model store, so
|
|
935
|
+
// these snapshots reflect the heap that actually carries the workspace
|
|
936
|
+
// AST/CST — the process that OOMs. Each is also emitted through the tracer so
|
|
937
|
+
// it reaches the server's log sink (e.g. a pod's stdout) and not only the
|
|
938
|
+
// RPC caller.
|
|
939
|
+
async dumpServerState(args) {
|
|
940
|
+
const snapshot = await this.options.diagnostics.dumpServerState(this.services, args);
|
|
941
|
+
this.tracer.info(snapshot);
|
|
942
|
+
return snapshot;
|
|
943
|
+
}
|
|
944
|
+
async writeHeapSnapshot(args) {
|
|
945
|
+
const filePath = await this.options.diagnostics.writeHeapSnapshot(args);
|
|
946
|
+
this.tracer.info(`Heap snapshot written to ${filePath}`);
|
|
947
|
+
return filePath;
|
|
948
|
+
}
|
|
949
|
+
async dumpPodMemory() {
|
|
950
|
+
const snapshot = await this.options.diagnostics.dumpPodMemory();
|
|
951
|
+
this.tracer.info(snapshot);
|
|
952
|
+
return snapshot;
|
|
953
|
+
}
|
|
954
|
+
async startProfiling(args) {
|
|
955
|
+
// The provider is singleton-guarded (one inspector session per process);
|
|
956
|
+
// a second start rejects there. Hold the capture so stopProfiling can end it.
|
|
957
|
+
const capture = await this.options.diagnostics.startProfiling(args);
|
|
958
|
+
// The connection may have closed (firing dispose) while start() was still
|
|
959
|
+
// awaiting begin(); dispose saw no activeProfile yet, so stop it here rather
|
|
960
|
+
// than leave the inspector singleton wedged for the process lifetime.
|
|
961
|
+
if (this.disposed) {
|
|
962
|
+
await capture.stop({}).catch(() => undefined);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
this.activeProfile = capture;
|
|
966
|
+
this.tracer.info('Profiling started');
|
|
967
|
+
}
|
|
968
|
+
async stopProfiling(args) {
|
|
969
|
+
if (!this.activeProfile) {
|
|
970
|
+
throw new Error('No profiling capture is active; call startProfiling first.');
|
|
971
|
+
}
|
|
972
|
+
const capture = this.activeProfile;
|
|
973
|
+
this.activeProfile = undefined;
|
|
974
|
+
// Writing the artefacts, folding the window into `server-summary.json` and
|
|
975
|
+
// formatting the report all happen inside the capture: each needs the
|
|
976
|
+
// filesystem, and the report shape they pass between them is Node-side.
|
|
977
|
+
const formatted = await capture.stop(args);
|
|
978
|
+
this.tracer.info(formatted);
|
|
979
|
+
return formatted;
|
|
980
|
+
}
|
|
981
|
+
async getLatency() {
|
|
982
|
+
return this.latency?.report() ?? { windowMs: 0, methods: [] };
|
|
983
|
+
}
|
|
984
|
+
/** Resolve a partial options object into a fully-defaulted form. */
|
|
985
|
+
resolveOptions(partial) {
|
|
986
|
+
const additionalMethods = partial.additionalMethods ?? [];
|
|
987
|
+
const builtIn = [...DATA_SERVER_PROTOCOL_METHODS, ...DATA_SERVER_DIAGNOSTICS_METHODS];
|
|
988
|
+
const overlap = additionalMethods.filter(name => builtIn.includes(name));
|
|
989
|
+
if (overlap.length > 0) {
|
|
990
|
+
throw new Error(`DataServer.additionalMethods overlaps with built-in data-server methods: ${overlap.join(', ')}. ` +
|
|
991
|
+
'Adopter protocols must not redeclare framework method names; rename the adopter method or remove it from additionalMethods.');
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
subscriptionPhase: partial.subscriptionPhase ?? DataServer.DEFAULT_OPTIONS.subscriptionPhase,
|
|
995
|
+
fingerprintStrategy: partial.fingerprintStrategy ?? 'transfer-document',
|
|
996
|
+
methodNamespace: partial.methodNamespace ?? DATA_SERVER_WIRE_PREFIX,
|
|
997
|
+
additionalMethods,
|
|
998
|
+
excludedMethods: partial.excludedMethods ?? [],
|
|
999
|
+
diagnostics: partial.diagnostics ?? defaultDataServerDiagnostics()
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
//# sourceMappingURL=data-server.js.map
|