@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.
- package/lib/client/data-connection.d.ts +28 -66
- package/lib/client/data-connection.d.ts.map +1 -1
- package/lib/client/data-connection.js +41 -112
- package/lib/client/data-connection.js.map +1 -1
- package/lib/client/data-session.d.ts +168 -93
- package/lib/client/data-session.d.ts.map +1 -1
- package/lib/client/data-session.js +291 -147
- package/lib/client/data-session.js.map +1 -1
- package/lib/data/data-protocol-methods.d.ts +2 -2
- package/lib/data/data-protocol-methods.d.ts.map +1 -1
- package/lib/data/data-protocol-methods.js +4 -0
- package/lib/data/data-protocol-methods.js.map +1 -1
- package/lib/data/data-server-protocol.d.ts +66 -30
- package/lib/data/data-server-protocol.d.ts.map +1 -1
- package/lib/data/requests.d.ts +67 -11
- package/lib/data/requests.d.ts.map +1 -1
- package/lib/errors.d.ts +66 -4
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +96 -5
- package/lib/errors.js.map +1 -1
- package/lib/messages/index.d.ts +1 -0
- package/lib/messages/index.d.ts.map +1 -1
- package/lib/messages/index.js +4 -1
- package/lib/messages/index.js.map +1 -1
- package/lib/model-server.d.ts +13 -1
- package/lib/model-server.d.ts.map +1 -1
- package/lib/model-server.js.map +1 -1
- package/lib/model-service/based-on.d.ts +2 -0
- package/lib/model-service/based-on.d.ts.map +1 -1
- package/lib/model-service/based-on.js +3 -0
- package/lib/model-service/based-on.js.map +1 -1
- package/lib/model-service/index.d.ts +0 -1
- package/lib/model-service/index.d.ts.map +1 -1
- package/lib/model-service/index.js +3 -5
- package/lib/model-service/index.js.map +1 -1
- package/lib/patch-merge.d.ts +28 -27
- package/lib/patch-merge.d.ts.map +1 -1
- package/lib/patch-merge.js +66 -22
- package/lib/patch-merge.js.map +1 -1
- package/lib/transfer-document.d.ts +2 -3
- package/lib/transfer-document.d.ts.map +1 -1
- package/lib/transfer-document.js.map +1 -1
- package/package.json +1 -1
- package/src/client/data-connection.ts +69 -119
- package/src/client/data-session.ts +348 -177
- package/src/data/data-protocol-methods.ts +4 -0
- package/src/data/data-server-protocol.ts +74 -30
- package/src/data/requests.ts +71 -11
- package/src/errors.ts +101 -4
- package/src/messages/index.ts +1 -0
- package/src/model-server.ts +13 -5
- package/src/model-service/based-on.ts +3 -0
- package/src/model-service/index.ts +3 -5
- package/src/patch-merge.ts +90 -37
- package/src/transfer-document.ts +2 -3
- package/lib/model-service/args.d.ts +0 -65
- package/lib/model-service/args.d.ts.map +0 -1
- package/lib/model-service/args.js +0 -11
- package/lib/model-service/args.js.map +0 -1
- 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
|
|
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<
|
|
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<
|
|
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
|
|
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 [
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* it
|
|
109
|
-
*
|
|
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
|
|
114
|
-
*
|
|
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
|
|
123
|
+
createSession(label = 'session', clientId?: string): DataSession<TTransfer, TServer> {
|
|
134
124
|
this.assertLive();
|
|
135
|
-
|
|
136
|
-
|
|
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.
|
|
142
|
-
|
|
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 =
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
215
|
-
* on a connection it sees close, so
|
|
216
|
-
*
|
|
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>>();
|