@hydranium/protocol 1.0.0-next.117 → 1.0.0-next.123

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 (60) hide show
  1. package/lib/client/data-connection.d.ts +28 -66
  2. package/lib/client/data-connection.d.ts.map +1 -1
  3. package/lib/client/data-connection.js +41 -112
  4. package/lib/client/data-connection.js.map +1 -1
  5. package/lib/client/data-session.d.ts +168 -93
  6. package/lib/client/data-session.d.ts.map +1 -1
  7. package/lib/client/data-session.js +291 -147
  8. package/lib/client/data-session.js.map +1 -1
  9. package/lib/data/data-protocol-methods.d.ts +2 -2
  10. package/lib/data/data-protocol-methods.d.ts.map +1 -1
  11. package/lib/data/data-protocol-methods.js +4 -0
  12. package/lib/data/data-protocol-methods.js.map +1 -1
  13. package/lib/data/data-server-protocol.d.ts +66 -30
  14. package/lib/data/data-server-protocol.d.ts.map +1 -1
  15. package/lib/data/requests.d.ts +67 -11
  16. package/lib/data/requests.d.ts.map +1 -1
  17. package/lib/errors.d.ts +66 -4
  18. package/lib/errors.d.ts.map +1 -1
  19. package/lib/errors.js +96 -5
  20. package/lib/errors.js.map +1 -1
  21. package/lib/messages/index.d.ts +1 -0
  22. package/lib/messages/index.d.ts.map +1 -1
  23. package/lib/messages/index.js +4 -1
  24. package/lib/messages/index.js.map +1 -1
  25. package/lib/model-server.d.ts +13 -1
  26. package/lib/model-server.d.ts.map +1 -1
  27. package/lib/model-server.js.map +1 -1
  28. package/lib/model-service/based-on.d.ts +2 -0
  29. package/lib/model-service/based-on.d.ts.map +1 -1
  30. package/lib/model-service/based-on.js +3 -0
  31. package/lib/model-service/based-on.js.map +1 -1
  32. package/lib/model-service/index.d.ts +0 -1
  33. package/lib/model-service/index.d.ts.map +1 -1
  34. package/lib/model-service/index.js +3 -5
  35. package/lib/model-service/index.js.map +1 -1
  36. package/lib/patch-merge.d.ts +28 -27
  37. package/lib/patch-merge.d.ts.map +1 -1
  38. package/lib/patch-merge.js +66 -22
  39. package/lib/patch-merge.js.map +1 -1
  40. package/lib/transfer-document.d.ts +2 -3
  41. package/lib/transfer-document.d.ts.map +1 -1
  42. package/lib/transfer-document.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/client/data-connection.ts +69 -119
  45. package/src/client/data-session.ts +348 -177
  46. package/src/data/data-protocol-methods.ts +4 -0
  47. package/src/data/data-server-protocol.ts +74 -30
  48. package/src/data/requests.ts +71 -11
  49. package/src/errors.ts +101 -4
  50. package/src/messages/index.ts +1 -0
  51. package/src/model-server.ts +13 -5
  52. package/src/model-service/based-on.ts +3 -0
  53. package/src/model-service/index.ts +3 -5
  54. package/src/patch-merge.ts +90 -37
  55. package/src/transfer-document.ts +2 -3
  56. package/lib/model-service/args.d.ts +0 -65
  57. package/lib/model-service/args.d.ts.map +0 -1
  58. package/lib/model-service/args.js +0 -11
  59. package/lib/model-service/args.js.map +0 -1
  60. package/src/model-service/args.ts +0 -69
@@ -19,11 +19,14 @@ import {
19
19
  import type { TransferElement } from '../transfer-element';
20
20
  import { DataEvents } from './data-events';
21
21
  import type { DataPort } from './data-port';
22
- import { DataSession } from './data-session';
22
+ import { DataSession, type DataSessionFactory } from './data-session';
23
23
  import { RpcConnection, type RpcConnectionLifecycle } from './rpc-connection';
24
24
 
25
25
  /** Options for {@link DataConnection}. */
26
- export interface DataConnectionOptions extends RpcConnectionLifecycle {
26
+ export interface DataConnectionOptions<
27
+ TTransfer extends TransferElement = TransferElement,
28
+ TServer extends DataServerProtocol<TTransfer, DiagnosticOf<TServer>> = DataServerProtocol<TTransfer>
29
+ > extends RpcConnectionLifecycle {
27
30
  /**
28
31
  * Wire namespace the server is addressed under. Defaults to the
29
32
  * framework's {@link DATA_SERVER_WIRE_PREFIX}, which is what an unmodified
@@ -32,10 +35,16 @@ export interface DataConnectionOptions extends RpcConnectionLifecycle {
32
35
  * "Unhandled method" rather than failing at wire-up.
33
36
  */
34
37
  readonly methodNamespace?: string;
38
+ /** Builds the sessions `createSession` hands out. Defaults to a plain {@link DataSession}. */
39
+ readonly sessionFactory?: DataSessionFactory<TTransfer, TServer>;
35
40
  }
36
41
 
37
42
  /** {@link DataConnectionOptions} for a client that does not speak {@link DataClientProtocol}. */
38
- export interface DataConnectionOptionsWithMethods<TClient extends object> extends DataConnectionOptions {
43
+ export interface DataConnectionOptionsWithMethods<
44
+ TClient extends object,
45
+ TTransfer extends TransferElement = TransferElement,
46
+ TServer extends DataServerProtocol<TTransfer, DiagnosticOf<TServer>> = DataServerProtocol<TTransfer>
47
+ > extends DataConnectionOptions<TTransfer, TServer> {
39
48
  /**
40
49
  * Method names of the client to bind as inbound handlers. Declare it
41
50
  * `as const satisfies ReadonlyArray<keyof YourClient & string>` so the list
@@ -52,10 +61,14 @@ export interface DataConnectionOptionsWithMethods<TClient extends object> extend
52
61
  * request/response-only client binding the default list fails at wire-up. The
53
62
  * conditional turns that into a compile error.
54
63
  */
55
- export type DataConnectionArgs<TTransfer extends TransferElement, TClient extends object> =
64
+ export type DataConnectionArgs<
65
+ TTransfer extends TransferElement,
66
+ TClient extends object,
67
+ TServer extends DataServerProtocol<TTransfer, DiagnosticOf<TServer>> = DataServerProtocol<TTransfer>
68
+ > =
56
69
  TClient extends DataClientProtocol<TTransfer>
57
- ? [options?: DataConnectionOptions & Partial<DataConnectionOptionsWithMethods<TClient>>]
58
- : [options: DataConnectionOptionsWithMethods<TClient>];
70
+ ? [options?: DataConnectionOptions<TTransfer, TServer> & Partial<DataConnectionOptionsWithMethods<TClient, TTransfer, TServer>>]
71
+ : [options: DataConnectionOptionsWithMethods<TClient, TTransfer, TServer>];
59
72
 
60
73
  /**
61
74
  * A {@link RpcConnection} to the data head, carrying as many participants as
@@ -63,7 +76,7 @@ export type DataConnectionArgs<TTransfer extends TransferElement, TClient extend
63
76
  *
64
77
  * Document operations live on the participants rather than here: they carry a
65
78
  * `clientId`, which identifies a participant rather than a wire, and the server
66
- * keys its holds and watches per `(uri, clientId)`. Two parties sharing one
79
+ * keys its opens and watches per `(uri, clientId)`. Two parties sharing one
67
80
  * identity cannot tell each other's writes from their own echoes.
68
81
  *
69
82
  * Generic over the transfer root so this file names no grammar. An adopter
@@ -76,21 +89,12 @@ export class DataConnection<
76
89
  TClient extends object = DataClientProtocol<TTransfer>
77
90
  > extends RpcConnection<TServer, TClient> {
78
91
  protected readonly sessions = new Set<DataSession<TTransfer, TServer>>();
79
- /**
80
- * Holds a disposed session could not release, keyed `clientId` + `uri` so a
81
- * repeated handover does not queue the same close twice.
82
- *
83
- * Retried on this connection's own activity rather than on a timer: every
84
- * session operation asks for the proxy, so a shared connection with anything
85
- * else going on supplies the occasions, and one with nothing going on has
86
- * nobody whose documents the stale hold could affect.
87
- */
88
- protected readonly orphanedHolds = new Map<string, { readonly uri: string; readonly clientId: string }>();
89
- /** Guards {@link releaseOrphanedHolds} against re-entering through its own proxy lookup. */
90
- protected releasingOrphans = false;
92
+ protected readonly sessionFactory: DataSessionFactory<TTransfer, TServer>;
91
93
 
92
- constructor(port: DataPort, client: TClient, ...rest: DataConnectionArgs<TTransfer, TClient>) {
93
- const [options = {}] = rest as [(DataConnectionOptions & Partial<DataConnectionOptionsWithMethods<TClient>>)?];
94
+ constructor(port: DataPort, client: TClient, ...rest: DataConnectionArgs<TTransfer, TClient, TServer>) {
95
+ const [options = {}] = rest as [
96
+ (DataConnectionOptions<TTransfer, TServer> & Partial<DataConnectionOptionsWithMethods<TClient, TTransfer, TServer>>)?
97
+ ];
94
98
  super(port, client, {
95
99
  methodNamespace: options.methodNamespace ?? DATA_SERVER_WIRE_PREFIX,
96
100
  // The default is reachable only where `TClient` satisfies
@@ -99,131 +103,77 @@ export class DataConnection<
99
103
  clientMethods: options.clientMethods ?? (DATA_CLIENT_PROTOCOL_METHODS as unknown as readonly (keyof TClient & string)[]),
100
104
  lifecycle: options
101
105
  });
106
+ this.sessionFactory =
107
+ options.sessionFactory ?? ((clientId, host, label) => new DataSession<TTransfer, TServer>(clientId, host, label));
102
108
  }
103
109
 
104
110
  /**
105
- * Mint a participant on this connection under `clientId`.
106
- *
107
- * `clientId` must be distinct per participant and stable for its lifetime:
108
- * it keys the server's per-`(uri, clientId)` hold and watch, and it is the
109
- * echo key an inbound `onDocumentUpdated` is matched against.
111
+ * Start a participant on this connection, registered with the server under a
112
+ * fresh id, `label` plus `#` plus a random UUID, or under `clientId` when
113
+ * given. Synchronous: the registration is sent at once, and the session's
114
+ * calls wait for it. The server refuses an id live anywhere in its process,
115
+ * and every call of that session then rejects with a
116
+ * `DuplicateClientIdError` code.
110
117
  *
111
118
  * Throws for an id in {@link FRAMEWORK_CLIENT_IDS} — those are authors the
112
119
  * SERVER emits rather than participants, so a session holding one would read
113
- * the framework's own broadcasts as its own echoes and drop them. Nothing
114
- * about that fails on its own: the document simply stops following, which
115
- * looks like a dead connection.
116
- *
117
- * Throws, too, for an id a LIVE session on this connection already holds.
118
- * Per-document membership is a set of client ids, so two participants
119
- * sharing one collapse to a single hold and the first close releases it
120
- * under the survivor, which then stops receiving updates for a document it
121
- * is still showing. A constant bound once per participant KIND — one per
122
- * widget class rather than per instance — satisfies the type and violates
123
- * this.
124
- *
125
- * And throws for an id whose previous session left a hold this connection is
126
- * still trying to release. That release closes `(uri, clientId)`, so an id
127
- * reissued while it is outstanding can have the new participant's hold closed
128
- * out from under it. The id frees itself as soon as the release lands.
129
- *
130
- * The checks span this connection only, so a head several connections reach
131
- * can still be addressed twice under one id.
120
+ * the framework's own broadcasts as its own echoes and drop them — and for
121
+ * an id a live session on this connection already holds.
132
122
  */
133
- createSession(clientId: string): DataSession<TTransfer, TServer> {
123
+ createSession(label = 'session', clientId?: string): DataSession<TTransfer, TServer> {
134
124
  this.assertLive();
135
- if (FRAMEWORK_CLIENT_IDS.includes(clientId)) {
136
- throw new Error(`clientId '${clientId}' is reserved by the framework and cannot identify a participant`);
137
- }
138
- if ([...this.sessions].some(session => session.clientId === clientId)) {
139
- throw new Error(`clientId '${clientId}' already identifies a live participant on this connection`);
125
+ const id = clientId ?? `${label}#${globalThis.crypto.randomUUID()}`;
126
+ if (FRAMEWORK_CLIENT_IDS.includes(id)) {
127
+ throw new Error(`clientId '${id}' is reserved by the framework and cannot identify a participant`);
140
128
  }
141
- if ([...this.orphanedHolds.values()].some(hold => hold.clientId === clientId)) {
142
- // A retry is still outstanding for this id, and it closes
143
- // `(uri, clientId)` — which the server keys one hold per. Handing the
144
- // id over now lets that close land on the NEW participant's hold
145
- // instead, taking a document away from a session that opened it
146
- // successfully. Nothing about that fails on its own: the document
147
- // simply stops following, and the close that did it was issued for a
148
- // participant already gone.
149
- //
150
- // Attempted first, so an id whose release has become possible is free
151
- // by the next call rather than waiting for other traffic.
152
- void this.releaseOrphanedHolds();
153
- throw new Error(`clientId '${clientId}' has an unreleased hold on this connection and cannot identify a new participant yet`);
129
+ if ([...this.sessions].some(session => session.clientId === id)) {
130
+ throw new Error(`clientId '${id}' already identifies a live participant on this connection`);
154
131
  }
155
- const session = new DataSession<TTransfer, TServer>(clientId, {
156
- connected: async () => {
157
- const server = await this.connected();
158
- // Unawaited: a session's own operation must not wait on, or fail
159
- // for, the release of a hold another participant abandoned.
160
- void this.releaseOrphanedHolds();
161
- return server;
132
+ const session = this.sessionFactory(
133
+ id,
134
+ {
135
+ connected: () => this.connected(),
136
+ releaseSession: released => this.sessions.delete(released),
137
+ reportError: (error, reported) => this.reportError(error, reported)
162
138
  },
163
- releaseSession: released => this.sessions.delete(released),
164
- orphanHold: (uri, holder) => this.orphanHold(uri, holder)
165
- });
139
+ label
140
+ );
166
141
  this.sessions.add(session);
142
+ // A failure reaches the session's own calls, which wait for the same
143
+ // registration; caught here only so it is not also reported unhandled.
144
+ session.connected().catch(() => undefined);
167
145
  return session;
168
146
  }
169
147
 
170
148
  /**
171
- * Take over a `(uri, clientId)` hold a disposed session could not release.
172
- *
173
- * One attempt is made straight away, since whatever refused the session's
174
- * close may already be over; a refusal leaves the entry for the next
175
- * operation on this connection to retry.
176
- */
177
- protected orphanHold(uri: string, clientId: string): void {
178
- this.orphanedHolds.set(`${clientId}\u0000${uri}`, { uri, clientId });
179
- void this.releaseOrphanedHolds();
180
- }
181
-
182
- /**
183
- * Try to close every hold handed over by a disposed session, keeping the ones
184
- * the server still refuses.
185
- *
186
- * Errors are swallowed per entry: this runs off another participant's
187
- * operation, which has no interest in an unrelated release and no caller to
188
- * surface it to. A hold that survives every attempt is released by the
189
- * server's connection-close cleanup.
149
+ * After the transport dropped, reconnect on the next macrotask for the
150
+ * sessions with documents open, so they re-watch and follow their documents
151
+ * again without waiting for a call of their own; a session with nothing
152
+ * open restores on its next call. Nothing is scheduled once this connection
153
+ * is disposed, which drops its generation too.
190
154
  */
191
- protected async releaseOrphanedHolds(): Promise<void> {
192
- if (this.releasingOrphans || this.orphanedHolds.size === 0 || this.disposed) {
193
- return;
194
- }
195
- this.releasingOrphans = true;
196
- try {
197
- const server = await this.connected();
198
- for (const [key, hold] of [...this.orphanedHolds]) {
199
- try {
200
- await server.closeModelDocument({ uri: hold.uri, clientId: hold.clientId });
201
- this.orphanedHolds.delete(key);
202
- } catch {
203
- // Kept for the next occasion.
155
+ protected override dropGeneration(): void {
156
+ const dropped = this.generation !== undefined;
157
+ super.dropGeneration();
158
+ if (dropped && !this.disposed) {
159
+ setTimeout(() => {
160
+ if (!this.disposed) {
161
+ this.sessions.forEach(session => session.reconnect());
204
162
  }
205
- }
206
- } catch {
207
- // No proxy to release through; the entries wait for one.
208
- } finally {
209
- this.releasingOrphans = false;
163
+ }, 0);
210
164
  }
211
165
  }
212
166
 
213
167
  /**
214
- * Sessions are detached rather than disposed: the server releases every hold
215
- * on a connection it sees close, so closing each document first sends
216
- * requests over a connection this call is about to dispose.
168
+ * Sessions are detached rather than disposed: the server ends every session
169
+ * on a connection it sees close, so ending each one first sends requests
170
+ * over a connection this call is about to dispose.
217
171
  */
218
172
  override dispose(): void {
219
173
  for (const session of [...this.sessions]) {
220
174
  session.detach();
221
175
  }
222
176
  this.sessions.clear();
223
- // Dropped rather than closed, for the same reason the sessions detach: the
224
- // server releases every hold on a connection it sees close, and the close
225
- // would travel over the connection being disposed.
226
- this.orphanedHolds.clear();
227
177
  super.dispose();
228
178
  }
229
179
  }
@@ -250,7 +200,7 @@ export class DataConnectionWithEvents<
250
200
  /** Server pushes, fanned out to as many local listeners as the host has. */
251
201
  readonly events: DataEvents<TTransfer, DiagnosticOf<TServer>, ProjectOf<TServer>>;
252
202
 
253
- constructor(port: DataPort, options?: DataConnectionOptions) {
203
+ constructor(port: DataPort, options?: DataConnectionOptions<TTransfer, TServer>) {
254
204
  // Built as a local because `this` is unavailable before `super`, then
255
205
  // read back onto the field.
256
206
  const events = new DataEvents<TTransfer, DiagnosticOf<TServer>, ProjectOf<TServer>>();