@hydranium/protocol 1.0.0-next.12 → 1.0.0-next.127

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.
Files changed (172) hide show
  1. package/README.md +35 -1
  2. package/lib/client/data-connection.d.ts +145 -0
  3. package/lib/client/data-connection.d.ts.map +1 -0
  4. package/lib/client/data-connection.js +205 -0
  5. package/lib/client/data-connection.js.map +1 -0
  6. package/lib/client/data-events.d.ts +13 -1
  7. package/lib/client/data-events.d.ts.map +1 -1
  8. package/lib/client/data-events.js +21 -0
  9. package/lib/client/data-events.js.map +1 -1
  10. package/lib/client/data-port.d.ts +17 -22
  11. package/lib/client/data-port.d.ts.map +1 -1
  12. package/lib/client/data-session.d.ts +301 -80
  13. package/lib/client/data-session.d.ts.map +1 -1
  14. package/lib/client/data-session.js +425 -107
  15. package/lib/client/data-session.js.map +1 -1
  16. package/lib/client/index.d.ts +13 -8
  17. package/lib/client/index.d.ts.map +1 -1
  18. package/lib/client/index.js +13 -8
  19. package/lib/client/index.js.map +1 -1
  20. package/lib/client/message-relay.d.ts +9 -3
  21. package/lib/client/message-relay.d.ts.map +1 -1
  22. package/lib/client/message-relay.js +11 -5
  23. package/lib/client/message-relay.js.map +1 -1
  24. package/lib/client/post-message-transport.d.ts +64 -3
  25. package/lib/client/post-message-transport.d.ts.map +1 -1
  26. package/lib/client/post-message-transport.js +175 -1
  27. package/lib/client/post-message-transport.js.map +1 -1
  28. package/lib/client/rpc-connection.d.ts +149 -0
  29. package/lib/client/rpc-connection.d.ts.map +1 -0
  30. package/lib/client/rpc-connection.js +179 -0
  31. package/lib/client/rpc-connection.js.map +1 -0
  32. package/lib/client-ids.d.ts +41 -0
  33. package/lib/client-ids.d.ts.map +1 -0
  34. package/lib/client-ids.js +44 -0
  35. package/lib/client-ids.js.map +1 -0
  36. package/lib/clock.d.ts +38 -0
  37. package/lib/clock.d.ts.map +1 -1
  38. package/lib/clock.js +36 -1
  39. package/lib/clock.js.map +1 -1
  40. package/lib/data/data-protocol-methods.d.ts +4 -4
  41. package/lib/data/data-protocol-methods.d.ts.map +1 -1
  42. package/lib/data/data-protocol-methods.js +11 -1
  43. package/lib/data/data-protocol-methods.js.map +1 -1
  44. package/lib/data/data-server-protocol.d.ts +110 -31
  45. package/lib/data/data-server-protocol.d.ts.map +1 -1
  46. package/lib/data/events.d.ts +84 -3
  47. package/lib/data/events.d.ts.map +1 -1
  48. package/lib/data/requests.d.ts +67 -11
  49. package/lib/data/requests.d.ts.map +1 -1
  50. package/lib/errors.d.ts +102 -12
  51. package/lib/errors.d.ts.map +1 -1
  52. package/lib/errors.js +136 -18
  53. package/lib/errors.js.map +1 -1
  54. package/lib/glsp-save-model-actions.d.ts +50 -0
  55. package/lib/glsp-save-model-actions.d.ts.map +1 -0
  56. package/lib/glsp-save-model-actions.js +28 -0
  57. package/lib/glsp-save-model-actions.js.map +1 -0
  58. package/lib/index.d.ts +3 -0
  59. package/lib/index.d.ts.map +1 -1
  60. package/lib/index.js +6 -0
  61. package/lib/index.js.map +1 -1
  62. package/lib/latency-collector.d.ts +8 -4
  63. package/lib/latency-collector.d.ts.map +1 -1
  64. package/lib/latency-collector.js.map +1 -1
  65. package/lib/messages/index.d.ts +29 -0
  66. package/lib/messages/index.d.ts.map +1 -0
  67. package/lib/messages/index.js +56 -0
  68. package/lib/messages/index.js.map +1 -0
  69. package/lib/messages/primitives.d.ts +188 -0
  70. package/lib/messages/primitives.d.ts.map +1 -0
  71. package/lib/messages/primitives.js +161 -0
  72. package/lib/messages/primitives.js.map +1 -0
  73. package/lib/model-server.d.ts +44 -13
  74. package/lib/model-server.d.ts.map +1 -1
  75. package/lib/model-server.js +4 -2
  76. package/lib/model-server.js.map +1 -1
  77. package/lib/model-service/based-on.d.ts +57 -0
  78. package/lib/model-service/based-on.d.ts.map +1 -0
  79. package/lib/model-service/based-on.js +37 -0
  80. package/lib/model-service/based-on.js.map +1 -0
  81. package/lib/model-service/index.d.ts +1 -1
  82. package/lib/model-service/index.d.ts.map +1 -1
  83. package/lib/model-service/index.js +4 -5
  84. package/lib/model-service/index.js.map +1 -1
  85. package/lib/patch-merge.d.ts +28 -27
  86. package/lib/patch-merge.d.ts.map +1 -1
  87. package/lib/patch-merge.js +66 -22
  88. package/lib/patch-merge.js.map +1 -1
  89. package/lib/profile-session.d.ts +8 -4
  90. package/lib/profile-session.d.ts.map +1 -1
  91. package/lib/profile-session.js.map +1 -1
  92. package/lib/rpc/bind-rpc-methods.d.ts +29 -3
  93. package/lib/rpc/bind-rpc-methods.d.ts.map +1 -1
  94. package/lib/rpc/bind-rpc-methods.js +22 -3
  95. package/lib/rpc/bind-rpc-methods.js.map +1 -1
  96. package/lib/rpc/create-rpc-proxy.d.ts +7 -0
  97. package/lib/rpc/create-rpc-proxy.d.ts.map +1 -1
  98. package/lib/rpc/create-rpc-proxy.js +6 -1
  99. package/lib/rpc/create-rpc-proxy.js.map +1 -1
  100. package/lib/testing/catalogue-audit.d.ts +80 -0
  101. package/lib/testing/catalogue-audit.d.ts.map +1 -0
  102. package/lib/testing/catalogue-audit.js +94 -0
  103. package/lib/testing/catalogue-audit.js.map +1 -0
  104. package/lib/testing/data-doubles.d.ts +13 -12
  105. package/lib/testing/data-doubles.d.ts.map +1 -1
  106. package/lib/testing/data-doubles.js +19 -5
  107. package/lib/testing/data-doubles.js.map +1 -1
  108. package/lib/testing/fake-clock.d.ts +9 -1
  109. package/lib/testing/fake-clock.d.ts.map +1 -1
  110. package/lib/testing/fake-clock.js +54 -45
  111. package/lib/testing/fake-clock.js.map +1 -1
  112. package/lib/testing/index.d.ts +1 -0
  113. package/lib/testing/index.d.ts.map +1 -1
  114. package/lib/testing/index.js +4 -1
  115. package/lib/testing/index.js.map +1 -1
  116. package/lib/testing/node/index.d.ts +1 -0
  117. package/lib/testing/node/index.d.ts.map +1 -1
  118. package/lib/testing/node/index.js +2 -2
  119. package/lib/testing/node/index.js.map +1 -1
  120. package/lib/testing/node/message-port-pair.d.ts +25 -0
  121. package/lib/testing/node/message-port-pair.d.ts.map +1 -0
  122. package/lib/testing/node/message-port-pair.js +26 -0
  123. package/lib/testing/node/message-port-pair.js.map +1 -0
  124. package/lib/transfer-diagnostic.d.ts +33 -0
  125. package/lib/transfer-diagnostic.d.ts.map +1 -1
  126. package/lib/transfer-diagnostic.js +23 -0
  127. package/lib/transfer-diagnostic.js.map +1 -1
  128. package/lib/transfer-document.d.ts +22 -5
  129. package/lib/transfer-document.d.ts.map +1 -1
  130. package/lib/transfer-document.js +14 -1
  131. package/lib/transfer-document.js.map +1 -1
  132. package/package.json +11 -2
  133. package/src/client/data-connection.ts +282 -0
  134. package/src/client/data-events.ts +33 -1
  135. package/src/client/data-port.ts +17 -23
  136. package/src/client/data-session.ts +549 -126
  137. package/src/client/index.ts +13 -8
  138. package/src/client/message-relay.ts +29 -7
  139. package/src/client/post-message-transport.ts +219 -4
  140. package/src/client/rpc-connection.ts +244 -0
  141. package/src/client-ids.ts +45 -0
  142. package/src/clock.ts +56 -0
  143. package/src/data/data-protocol-methods.ts +11 -3
  144. package/src/data/data-server-protocol.ts +131 -31
  145. package/src/data/events.ts +90 -3
  146. package/src/data/requests.ts +71 -11
  147. package/src/errors.ts +154 -20
  148. package/src/glsp-save-model-actions.ts +59 -0
  149. package/src/index.ts +6 -0
  150. package/src/latency-collector.ts +8 -3
  151. package/src/messages/index.ts +36 -0
  152. package/src/messages/primitives.ts +271 -0
  153. package/src/model-server.ts +47 -18
  154. package/src/model-service/based-on.ts +63 -0
  155. package/src/model-service/index.ts +4 -5
  156. package/src/patch-merge.ts +90 -37
  157. package/src/profile-session.ts +9 -4
  158. package/src/rpc/bind-rpc-methods.ts +49 -4
  159. package/src/rpc/create-rpc-proxy.ts +14 -1
  160. package/src/testing/catalogue-audit.ts +111 -0
  161. package/src/testing/data-doubles.ts +42 -17
  162. package/src/testing/fake-clock.ts +62 -47
  163. package/src/testing/index.ts +4 -1
  164. package/src/testing/node/index.ts +2 -2
  165. package/src/testing/node/message-port-pair.ts +40 -0
  166. package/src/transfer-diagnostic.ts +40 -0
  167. package/src/transfer-document.ts +29 -6
  168. package/lib/model-service/args.d.ts +0 -64
  169. package/lib/model-service/args.d.ts.map +0 -1
  170. package/lib/model-service/args.js +0 -11
  171. package/lib/model-service/args.js.map +0 -1
  172. package/src/model-service/args.ts +0 -67
package/src/errors.ts CHANGED
@@ -8,6 +8,20 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  import { ResponseError } from 'vscode-jsonrpc';
11
+ import { defineMessage, type HydraniumMessageData, messageData } from './messages/primitives';
12
+
13
+ /**
14
+ * The catalogue declaration behind {@link ConflictError}'s sentence.
15
+ *
16
+ * Its English must keep containing {@link CONFLICT_ERROR_MESSAGE_MARKER}: the
17
+ * marker is tier 3 of {@link isConflictError}'s ladder, and it matches on text.
18
+ * That tier only ever works untranslated, which is why it is the last resort
19
+ * behind the numeric code rather than the primary check.
20
+ */
21
+ export const STALE_BASED_UPDATE = defineMessage(
22
+ 'hydranium/protocol/stale-based-update',
23
+ 'Stale-based update for {uri}: expected v{expectedVersion}, server is at v{actualVersion}'
24
+ );
11
25
 
12
26
  /**
13
27
  * Application-specific JSON-RPC error code for {@link ConflictError}.
@@ -24,16 +38,16 @@ export const CONFLICT_ERROR_CODE = 1001;
24
38
  * Structured payload carried in {@link ConflictError.data}, and the only place
25
39
  * a post-RPC caller can read the version mismatch from.
26
40
  */
27
- export interface ConflictErrorData {
41
+ export interface ConflictErrorData extends HydraniumMessageData {
28
42
  readonly uri: string;
29
43
  /** The based-on version the caller authored against. */
30
- readonly expected: number;
44
+ readonly expectedVersion: number;
31
45
  /** The server's current text-document version at the time of the throw. */
32
- readonly actual: number;
46
+ readonly actualVersion: number;
33
47
  }
34
48
 
35
49
  /**
36
- * Thrown by `ModelService.update` / `ModelService.save` when the caller-
50
+ * Thrown by a client session's `update` / `save` when the caller-
37
51
  * supplied based-on version no longer matches the server's current text-
38
52
  * document version for the same URI — i.e. the snapshot the caller
39
53
  * authored against has been superseded by an intervening edit.
@@ -46,11 +60,9 @@ export interface ConflictErrorData {
46
60
  * `err.data` (the instance is reconstructed as a generic
47
61
  * `ResponseError`, so subclass getters / fields do not survive).
48
62
  *
49
- * Detection is opt-in via the optional `baseVersion` field on
50
- * `TransferUpdateArgs` / `TransferSaveArgs`; callers that omit the field get
51
- * no gating. This mirrors LSP's `OptionalVersionedTextDocumentIdentifier`
52
- * posture, so headless / CLI / batch tooling with no meaningful based-on
53
- * version can opt out explicitly.
63
+ * Detection is driven by the required `basedOn` field of every write
64
+ * request; a caller with no meaningful based-on version passes
65
+ * `'anything'` and gets no gating.
54
66
  *
55
67
  * Three reasonable adopter recovery strategies:
56
68
  *
@@ -64,18 +76,19 @@ export interface ConflictErrorData {
64
76
  * No auto-retry or auto-merge ships by default.
65
77
  */
66
78
  export class ConflictError extends ResponseError<ConflictErrorData> {
67
- constructor(uri: string, expected: number, actual: number) {
68
- super(CONFLICT_ERROR_CODE, `Stale-based update for ${uri}: expected v${expected}, server is at v${actual}`, {
69
- uri,
70
- expected,
71
- actual
72
- });
79
+ constructor(uri: string, expectedVersion: number, actualVersion: number) {
80
+ const params = { uri, expectedVersion, actualVersion };
81
+ // The identity rides alongside the typed payload rather than replacing
82
+ // it: `isConflictError`'s name check is surface an adopter may bind, so
83
+ // adding the identity widens the payload rather than reshaping it.
84
+ super(CONFLICT_ERROR_CODE, STALE_BASED_UPDATE.format(params), { ...params, ...messageData(STALE_BASED_UPDATE, params) });
73
85
  this.name = 'ConflictError';
74
86
  // ResponseError's constructor calls `Object.setPrototypeOf(this,
75
87
  // ResponseError.prototype)` to keep its own prototype chain intact across
76
88
  // transpilation targets; that resets us to ResponseError, hiding the
77
89
  // ConflictError-specific getters. Restore the prototype here so
78
- // `err.uri` / `.expected` / `.actual` resolve through this class.
90
+ // `err.uri` / `.expectedVersion` / `.actualVersion` resolve through this
91
+ // class.
79
92
  Object.setPrototypeOf(this, ConflictError.prototype);
80
93
  }
81
94
 
@@ -83,13 +96,134 @@ export class ConflictError extends ResponseError<ConflictErrorData> {
83
96
  return this.data!.uri;
84
97
  }
85
98
 
86
- get expected(): number {
87
- return this.data!.expected;
99
+ /**
100
+ * The based-on version the caller authored against.
101
+ *
102
+ * Must not be renamed to `expected`, nor its sibling to `actual`: a test
103
+ * reporter reads an error carrying both as an assertion failure, and
104
+ * vitest's formatter then ASSIGNS to them, which throws on an accessor and
105
+ * replaces the real failure with a `TypeError`.
106
+ */
107
+ get expectedVersion(): number {
108
+ return this.data!.expectedVersion;
88
109
  }
89
110
 
90
- get actual(): number {
91
- return this.data!.actual;
111
+ /** The server's version at the time of the throw. Not `actual` — see {@link expectedVersion}. */
112
+ get actualVersion(): number {
113
+ return this.data!.actualVersion;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * JSON-RPC code for {@link SessionClosedError}, beside {@link CONFLICT_ERROR_CODE}
119
+ * and for the same reason: the code survives reconstruction, the class does not.
120
+ */
121
+ export const SESSION_CLOSED_ERROR_CODE = 1004;
122
+ /** JSON-RPC code for {@link DocumentNotOpenError}. */
123
+ export const DOCUMENT_NOT_OPEN_ERROR_CODE = 1005;
124
+ /** JSON-RPC code for {@link DuplicateClientIdError}. */
125
+ export const DUPLICATE_CLIENT_ID_ERROR_CODE = 1006;
126
+
127
+ /**
128
+ * The catalogue declaration behind {@link SessionClosedError}'s default
129
+ * sentence. It names no client id: the sentence can reach an end user, and the
130
+ * id stays in {@link SessionClosedErrorData.clientId} for whoever needs it.
131
+ */
132
+ export const SESSION_CLOSED = defineMessage('hydranium/protocol/session-closed', 'The editing session has ended.');
133
+
134
+ /** Structured payload carried in {@link SessionClosedError.data}. */
135
+ export interface SessionClosedErrorData extends HydraniumMessageData {
136
+ readonly clientId: string;
137
+ }
138
+
139
+ /**
140
+ * Thrown by every call on a client session after it ended, and by an open that
141
+ * was still in flight when its session ended: by the server for its sessions,
142
+ * and by a client-side `DataSession` once disposed, so a caller handles both
143
+ * the same way. A caller recognises it with {@link isSessionClosedError}, never
144
+ * by its sentence.
145
+ */
146
+ export class SessionClosedError extends ResponseError<SessionClosedErrorData> {
147
+ /**
148
+ * `message` replaces the default English, for a caller that knows more than
149
+ * that the session is gone. The identity stays {@link SESSION_CLOSED}'s, so a
150
+ * translating renderer renders the catalogue sentence in its place.
151
+ */
152
+ constructor(clientId: string, message = SESSION_CLOSED.format()) {
153
+ super(SESSION_CLOSED_ERROR_CODE, message, { clientId, ...messageData(SESSION_CLOSED) });
154
+ this.name = 'SessionClosedError';
155
+ // `ResponseError` resets the prototype to its own; see `ConflictError`.
156
+ Object.setPrototypeOf(this, SessionClosedError.prototype);
157
+ }
158
+
159
+ get clientId(): string {
160
+ return this.data!.clientId;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Thrown when a client session writes a document it does not have open.
166
+ *
167
+ * A session writes only what it has open, so this is the answer both to a write
168
+ * that never opened and to one whose open was closed underneath it — by the
169
+ * session itself, or by the document being deleted.
170
+ */
171
+ export class DocumentNotOpenError extends ResponseError<{ readonly uri: string; readonly clientId: string }> {
172
+ constructor(uri: string, clientId: string) {
173
+ super(DOCUMENT_NOT_OPEN_ERROR_CODE, `Document ${uri} is not open in client session ${clientId}`, { uri, clientId });
174
+ this.name = 'DocumentNotOpenError';
175
+ Object.setPrototypeOf(this, DocumentNotOpenError.prototype);
92
176
  }
177
+
178
+ get uri(): string {
179
+ return this.data!.uri;
180
+ }
181
+
182
+ get clientId(): string {
183
+ return this.data!.clientId;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Thrown when a client session is started under an id that is already live in
189
+ * the process, or that the framework reserves for itself.
190
+ *
191
+ * Ids are unique process-wide because the id is also the author label on every
192
+ * version and the key a client recognises its own echoes by; two participants
193
+ * sharing one would each take the other's writes for their own.
194
+ */
195
+ export class DuplicateClientIdError extends ResponseError<{ readonly clientId: string }> {
196
+ constructor(clientId: string) {
197
+ super(DUPLICATE_CLIENT_ID_ERROR_CODE, `Client id ${clientId} is already in use`, { clientId });
198
+ this.name = 'DuplicateClientIdError';
199
+ Object.setPrototypeOf(this, DuplicateClientIdError.prototype);
200
+ }
201
+
202
+ get clientId(): string {
203
+ return this.data!.clientId;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Whether `error` is a {@link SessionClosedError}: by name in-process, by code
209
+ * after an RPC, where it arrives as a plain `ResponseError`.
210
+ */
211
+ export function isSessionClosedError(error: unknown): error is SessionClosedError {
212
+ return hasErrorIdentity(error, 'SessionClosedError', SESSION_CLOSED_ERROR_CODE);
213
+ }
214
+
215
+ /** Whether `error` is a {@link DocumentNotOpenError}; see {@link isSessionClosedError}. */
216
+ export function isDocumentNotOpenError(error: unknown): error is DocumentNotOpenError {
217
+ return hasErrorIdentity(error, 'DocumentNotOpenError', DOCUMENT_NOT_OPEN_ERROR_CODE);
218
+ }
219
+
220
+ /** Whether `error` is a {@link DuplicateClientIdError}; see {@link isSessionClosedError}. */
221
+ export function isDuplicateClientIdError(error: unknown): error is DuplicateClientIdError {
222
+ return hasErrorIdentity(error, 'DuplicateClientIdError', DUPLICATE_CLIENT_ID_ERROR_CODE);
223
+ }
224
+
225
+ function hasErrorIdentity(error: unknown, name: string, code: number): boolean {
226
+ return error instanceof Error && (error.name === name || (error as Partial<ResponseError<unknown>>).code === code);
93
227
  }
94
228
 
95
229
  /** Marker substring present in every {@link ConflictError} message, used by
@@ -0,0 +1,59 @@
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
+
10
+ // Declared field by field rather than extending GLSP's action types: this
11
+ // package carries no GLSP dependency, and both GLSP heads already depend on it.
12
+ // The fields are GLSP's own, so both actions are assignable to GLSP's
13
+ // `RequestAction` and `ResponseAction`.
14
+
15
+ /**
16
+ * A save of a diagram's model that the server answers, with a
17
+ * {@link ModelSavedAction} once the save has finished or a GLSP
18
+ * `RejectAction` when it fails.
19
+ *
20
+ * It carries what GLSP's `SaveModelAction` carries, and the server saves it the
21
+ * same way. GLSP's `SaveModelAction` is not answered: the server's only
22
+ * reply is a dirty-state change, which the client drops when the dirty flag
23
+ * does not change, so a client cannot tell which save an answer belongs to or
24
+ * whether a save failed.
25
+ */
26
+ export interface RequestSaveModelAction {
27
+ kind: typeof RequestSaveModelAction.KIND;
28
+ /** The id the response echoes as its `responseId`. Empty until the dispatcher assigns one. */
29
+ requestId: string;
30
+ /** How long the receiver waits for its own handler, in milliseconds. */
31
+ timeout?: number;
32
+ /** The file to save to, as in GLSP's `SaveModelAction`; absent saves to the diagram's source. */
33
+ fileUri?: string;
34
+ /** GLSP's typing marker for the response. Never set. */
35
+ readonly _?: ModelSavedAction;
36
+ }
37
+
38
+ export namespace RequestSaveModelAction {
39
+ export const KIND = 'hydraniumRequestSaveModel';
40
+
41
+ export function create(options: { fileUri?: string; requestId?: string } = {}): RequestSaveModelAction {
42
+ return { kind: KIND, requestId: '', ...options };
43
+ }
44
+ }
45
+
46
+ /** The server's answer to a {@link RequestSaveModelAction} that saved. */
47
+ export interface ModelSavedAction {
48
+ kind: typeof ModelSavedAction.KIND;
49
+ /** The `requestId` of the save this answers. The server's dispatcher sets it. */
50
+ responseId: string;
51
+ }
52
+
53
+ export namespace ModelSavedAction {
54
+ export const KIND = 'hydraniumModelSaved';
55
+
56
+ export function create(options: { responseId?: string } = {}): ModelSavedAction {
57
+ return { kind: KIND, responseId: '', ...options };
58
+ }
59
+ }
package/src/index.ts CHANGED
@@ -16,14 +16,20 @@
16
16
 
17
17
  export * from './abstract-logger';
18
18
  export * from './client';
19
+ export * from './client-ids';
19
20
  export * from './clock';
20
21
  export * from './data';
21
22
  export * from './browser-runtime';
22
23
  export * from './debouncer';
24
+ export * from './glsp-save-model-actions';
23
25
  export * from './errors';
24
26
  export * from './host-diagnostics';
25
27
  export * from './logger';
26
28
  export * from './latency-collector';
29
+ // The primitives only. The `./messages` subpath additionally enumerates this
30
+ // package's own declarations, which the root barrel already re-exports through
31
+ // the modules that raise them.
32
+ export * from './messages/primitives';
27
33
  export * from './patch-merge';
28
34
  export * from './noop-logger';
29
35
  export * from './observable-value';
@@ -87,8 +87,13 @@ export interface LatencyReport {
87
87
  */
88
88
  export type LatencyRetention = { readonly kind: 'keep-all' } | { readonly kind: 'ring-buffer'; readonly maxSamplesPerMethod: number };
89
89
 
90
- /** Per-method state: retained samples (bounded in ring-buffer mode) plus lifetime totals. */
91
- interface MethodAccumulator {
90
+ /**
91
+ * Per-method state: retained samples (bounded in ring-buffer mode) plus lifetime
92
+ * totals. The mutable tally behind {@link MethodLatency}, which is the derived
93
+ * report row. Held by the `protected` {@link LatencyCollector.durations}, so a
94
+ * subclass recording its own samples has to name it.
95
+ */
96
+ export interface MethodLatencyTally {
92
97
  /** Retained durations for percentile estimation; capped in ring-buffer mode. Order is irrelevant (report sorts). */
93
98
  readonly samples: number[];
94
99
  /** Next slot to overwrite once the ring buffer is full (ring-buffer mode only). */
@@ -119,7 +124,7 @@ function percentile(sortedAscending: readonly number[], percent: number): number
119
124
  * deterministic on a fake clock.
120
125
  */
121
126
  export class LatencyCollector {
122
- protected readonly durations = new Map<string, MethodAccumulator>();
127
+ protected readonly durations = new Map<string, MethodLatencyTally>();
123
128
  protected window: Stopwatch;
124
129
 
125
130
  constructor(
@@ -0,0 +1,36 @@
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
+
10
+ /**
11
+ * The message-externalization mechanism, plus every user-facing message
12
+ * `@hydranium/protocol` itself raises.
13
+ *
14
+ * Declarations stay beside their call sites and are re-exported here, so this is
15
+ * enumeration rather than centralization: a code's package segment has to name
16
+ * the package that raises it, and a shared module would make that segment a lie
17
+ * for every message in it. Adding a message therefore touches the file that
18
+ * raises it and this list, and nothing else.
19
+ *
20
+ * A barrel makes every code and English default public API, so renaming a code
21
+ * is a breaking change. That was already true — an adopter's catalogue keys on
22
+ * these codes either way — but it is now in the type system rather than implicit.
23
+ * The English is a fallback, not a contract; the code is the contract.
24
+ */
25
+
26
+ export * from './primitives';
27
+
28
+ export { SESSION_CLOSED, STALE_BASED_UPDATE } from '../errors';
29
+ export { DATA_SERVER_CONNECT_FAILED, DATA_SERVER_NOT_READY } from '../client/rpc-connection';
30
+ export { DATA_SESSION_RESTORE_FAILED, DATA_SESSION_UNSAVED_LOST } from '../client/data-session';
31
+ export {
32
+ RELAY_REPLAY_FAILED,
33
+ RELAY_TRANSPORT_OPEN_FAILED,
34
+ RELAY_TRANSPORT_READ_FAILED,
35
+ RELAY_TRANSPORT_WRITE_FAILED
36
+ } from '../client/message-relay';
@@ -0,0 +1,271 @@
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
+
10
+ import { ResponseError } from 'vscode-jsonrpc';
11
+
12
+ /**
13
+ * The framework externalizes user-facing strings and SELECTS no locale: it
14
+ * relays the one its client declared and renders with whatever templates the
15
+ * adopter installed, defaulting to its English. Every such string carries a
16
+ * stable code beside that English, and exactly one side renders it — the side
17
+ * that knows the reading user's language.
18
+ *
19
+ * Which side that is depends on the message, not on the package. A server
20
+ * message is rendered by the server, at the one seam every carrier passes
21
+ * through, in the locale it was handed at init. A message the client tier raises
22
+ * is rendered there, because those fire when the server is unreachable. Nothing
23
+ * is rendered twice: two renders of one sentence are two authorities over it,
24
+ * and they diverge on the first reword.
25
+ *
26
+ * Codes are `hydranium/<unscoped-package>/<name>`. The package segment locates
27
+ * the declaration, so a message is declared in the package that raises it and a
28
+ * code never names a package it does not live in. `.` and `:` are forbidden in a
29
+ * segment: they are i18next's default key and namespace separators, where either
30
+ * silently becomes a nested lookup that misses.
31
+ */
32
+ export type MessageParams = Readonly<Record<string, string | number>>;
33
+
34
+ type Placeholder<S extends string> = S extends `${string}{${infer Name}}${infer Rest}` ? Name | Placeholder<Rest> : never;
35
+
36
+ export type ParamsOf<S extends string> = [Placeholder<S>] extends [never]
37
+ ? Record<never, never>
38
+ : Readonly<Record<Placeholder<S>, string | number>>;
39
+
40
+ /** Required exactly when the text has placeholders, absent when it does not. */
41
+ export type ParamsArg<S extends string> = [Placeholder<S>] extends [never] ? [] : [params: ParamsOf<S>];
42
+
43
+ /**
44
+ * Rejects a text argument already widened to `string`. Load-bearing rather than
45
+ * defensive: the whole compile-time guarantee is conditional on `S` inferring a
46
+ * literal, and for a concatenated or pre-widened text the placeholder set
47
+ * silently becomes empty, `format()` accepts no arguments, and the missing
48
+ * substitution surfaces only at runtime.
49
+ */
50
+ export type LiteralText<S extends string> = string extends S ? never : S;
51
+
52
+ export interface MessageDefinition<S extends string> {
53
+ readonly code: string;
54
+ readonly text: S;
55
+ format(...args: ParamsArg<S>): string;
56
+ }
57
+
58
+ /**
59
+ * Declare a message. Placeholder names are inferred from `text` rather than
60
+ * declared again in a type argument: a second spelling of every name is the
61
+ * repetition that drifts, since adding a placeholder to the sentence and not to
62
+ * the type compiles.
63
+ */
64
+ export function defineMessage<S extends string>(code: string, text: LiteralText<S>): MessageDefinition<S> {
65
+ const literal = text as S;
66
+ return { code, text: literal, format: (...args) => interpolate(literal, args[0] ?? {}) };
67
+ }
68
+
69
+ const PLACEHOLDER = /\{([^}]+)\}/g;
70
+
71
+ /**
72
+ * Substitute `{name}` tokens, leaving an unfilled token in place.
73
+ *
74
+ * It must not throw. This runs over an adopter's translation as well as our own
75
+ * text, so a typo in a foreign catalogue has to degrade to a slightly wrong
76
+ * sentence rather than raise inside a toast render. Re-scanning the result to
77
+ * detect an unfilled token is what an earlier form did, and it cannot work:
78
+ * `String.replace` does not rescan replacement text, so the check could not tell
79
+ * an unfilled placeholder from user data shaped like one — an element literally
80
+ * named `{separator}` crashed at the authoring site.
81
+ */
82
+ export function interpolate(template: string, params: MessageParams): string {
83
+ // Indexed rather than `key in params`: a hand-built or version-skewed
84
+ // identity can arrive with no params at all, and `in` throws on a non-object
85
+ // where a lookup degrades.
86
+ const lookup = params as Record<string, string | number | undefined> | undefined;
87
+ return template.replace(PLACEHOLDER, (match, key: string) => {
88
+ const value = lookup?.[key];
89
+ return value === undefined ? match : String(value);
90
+ });
91
+ }
92
+
93
+ export interface MessageIdentity {
94
+ readonly code: string;
95
+ readonly params: MessageParams;
96
+ }
97
+
98
+ /**
99
+ * Envelope for a protocol `data` field. Namespaced under one key so it co-exists
100
+ * with a carrier's own `data` conventions rather than occupying `data` itself.
101
+ */
102
+ export interface HydraniumMessageData {
103
+ readonly hydranium: MessageIdentity;
104
+ }
105
+
106
+ /** An identity plus its resolved English — everything a renderer needs, on any carrier. */
107
+ export interface ResolvedMessage extends MessageIdentity {
108
+ readonly text: string;
109
+ }
110
+
111
+ export function messageData<S extends string>(message: MessageDefinition<S>, ...args: ParamsArg<S>): HydraniumMessageData {
112
+ return { hydranium: { code: message.code, params: args[0] ?? {} } };
113
+ }
114
+
115
+ /**
116
+ * Identity plus resolved English, for a hand-off carrying a value rather than a
117
+ * protocol field. The result is structured-clone safe, so it survives a process
118
+ * hop where one intervenes and costs nothing where none does.
119
+ */
120
+ export function resolve<S extends string>(message: MessageDefinition<S>, ...args: ParamsArg<S>): ResolvedMessage {
121
+ return { code: message.code, text: message.format(...args), params: args[0] ?? {} };
122
+ }
123
+
124
+ /**
125
+ * Validates every field {@link MessageIdentity} declares, `params` included.
126
+ * A guard over foreign input that checks only `code` while declaring `params`
127
+ * non-optional hands `undefined` to the renderer, which fails with the worst
128
+ * polarity available: invisible in English, crashing only once a translation is
129
+ * loaded.
130
+ */
131
+ export function hasMessageIdentity(data: unknown): data is HydraniumMessageData {
132
+ if (typeof data !== 'object' || data === null || Array.isArray(data) || !('hydranium' in data)) {
133
+ return false;
134
+ }
135
+ const identity = (data as { hydranium: unknown }).hydranium;
136
+ if (typeof identity !== 'object' || identity === null || Array.isArray(identity)) {
137
+ return false;
138
+ }
139
+ const candidate = identity as Partial<MessageIdentity>;
140
+ return typeof candidate.code === 'string' && typeof candidate.params === 'object' && candidate.params !== null;
141
+ }
142
+
143
+ /**
144
+ * A type alias rather than a subclass. Only `code`, `message` and `data` cross
145
+ * the wire, so a subclass buys nothing there: `instanceof` does not survive
146
+ * reconstruction, and the subclass costs an `Object.setPrototypeOf` in every
147
+ * constructor purely to undo what `ResponseError`'s own constructor does.
148
+ */
149
+ export type HydraniumResponseError = ResponseError<HydraniumMessageData>;
150
+
151
+ /**
152
+ * The numeric `code` and the message's catalogue code are unrelated and both are
153
+ * needed: `ResponseError.code` is an `integer`, so it cannot hold a
154
+ * `hydranium/…` key, and it is what a caller switches on after reconstruction.
155
+ */
156
+ export function messageError<S extends string>(
157
+ code: number,
158
+ message: MessageDefinition<S>,
159
+ ...params: ParamsArg<S>
160
+ ): HydraniumResponseError {
161
+ return new ResponseError(code, message.format(...params), messageData(message, ...params));
162
+ }
163
+
164
+ /**
165
+ * The reading user's language, as the tag a client declared.
166
+ *
167
+ * Neither validated nor normalised anywhere: rejecting an unfamiliar tag would
168
+ * be selecting a locale, which the framework does not do.
169
+ *
170
+ * NOT a Langium language id, which names a grammar. The two are strings of the
171
+ * same shape reachable from the same services, so one used where the other
172
+ * belongs misses every lookup rather than failing.
173
+ */
174
+ export type Locale = string;
175
+
176
+ /**
177
+ * A host's templates for one locale, keyed by the whole message code.
178
+ *
179
+ * **Flat, not nested.** A code is one key here, `/` separators included; a host
180
+ * whose own catalogue format nests — Theia's does — flattens on the way in. A
181
+ * map that nests instead misses every lookup while type-checking, because the
182
+ * value this is indexed by is `ResolvedMessage.code`.
183
+ *
184
+ * **Partial by nature rather than by convention.** A code with no entry renders
185
+ * its English default, so a catalogue covering none of a package's codes is a
186
+ * valid catalogue and not a broken one — which is what lets an adopter translate
187
+ * as much or as little as they like.
188
+ *
189
+ * Deliberately NOT narrowed to the codes that exist, and a `Code extends string`
190
+ * parameter would not buy what it appears to: a catalogue is loaded as JSON, so
191
+ * it reaches a checked position as a variable rather than as an object literal,
192
+ * and excess-property checking — the only thing that would reject a mistyped
193
+ * key — does not run there. The gain is a compile error for a catalogue with no
194
+ * correct key at all; the cost is a code union to maintain by hand, since a
195
+ * declaration does not carry its code as a literal type.
196
+ */
197
+ export type MessageCatalogue = Readonly<Record<string, string>>;
198
+
199
+ export namespace MessageCatalogue {
200
+ /**
201
+ * One catalogue from several, for a renderer whose override supplies entries
202
+ * over the ones it inherits.
203
+ *
204
+ * **Later wins**, so a caller passes the inherited answer before its own.
205
+ *
206
+ * **No source at all answers `undefined`, not `{}`.** A renderer holds that
207
+ * answer as the locale having no catalogue and renders straight through; an
208
+ * empty object is a catalogue that misses every lookup, so returning one
209
+ * turns the opt-out into a per-message search that yields the same English.
210
+ */
211
+ export function merge(...catalogues: ReadonlyArray<MessageCatalogue | undefined>): MessageCatalogue | undefined {
212
+ const present = catalogues.filter((catalogue): catalogue is MessageCatalogue => catalogue !== undefined);
213
+ if (present.length <= 1) {
214
+ return present[0];
215
+ }
216
+ return Object.assign({}, ...present) as MessageCatalogue;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Render on the side that knows the reading user's locale. Omitting
222
+ * `translations` is how an adopter without i18n opts out, and yields the
223
+ * English.
224
+ */
225
+ export function renderFrameworkMessage(message: ResolvedMessage, translations?: MessageCatalogue): string {
226
+ const template = translations?.[message.code];
227
+ return template ? interpolate(template, message.params) : message.text;
228
+ }
229
+
230
+ export function resolvedFromResponseError(error: ResponseError<unknown>): ResolvedMessage | undefined {
231
+ return hasMessageIdentity(error.data) ? { ...error.data.hydranium, text: error.message } : undefined;
232
+ }
233
+
234
+ /**
235
+ * The detail half of a `{detail}` placeholder. A technical error string is safe
236
+ * to pass as a parameter for the same reason a number is: it is not itself
237
+ * translatable text, so it needs no code of its own. A PROSE fragment is not,
238
+ * and must become one code per value instead.
239
+ */
240
+ export function describeError(error: unknown): string {
241
+ return error instanceof Error ? error.message : String(error);
242
+ }
243
+
244
+ /**
245
+ * Recognises a declaration among a barrel's exports. The `format` check is what
246
+ * discriminates: a `code` + `text` pair alone admits any object that happens to
247
+ * carry both.
248
+ */
249
+ export function isMessageDeclaration(value: unknown): value is MessageDefinition<string> {
250
+ const candidate = value as { code?: unknown; text?: unknown; format?: unknown } | null;
251
+ return (
252
+ typeof candidate === 'object' &&
253
+ candidate !== null &&
254
+ typeof candidate.code === 'string' &&
255
+ typeof candidate.text === 'string' &&
256
+ typeof candidate.format === 'function'
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Every declaration a `./messages` barrel exports.
262
+ *
263
+ * A caller cannot get there with `Object.values(barrel).filter(isMessageDeclaration)`:
264
+ * a barrel's value type is a union of its declarations AND its functions, and
265
+ * `filter` will not narrow a function type down to a `MessageDefinition`, so the
266
+ * result stays the union and reading `.code` off it does not compile. Taking the
267
+ * barrel as an opaque object is what makes the one-liner work.
268
+ */
269
+ export function collectMessages(barrel: object): MessageDefinition<string>[] {
270
+ return (Object.values(barrel) as unknown[]).filter(isMessageDeclaration);
271
+ }