@diffexai/diffex-client 0.2.0
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/CHANGELOG.md +11 -0
- package/LICENSE +230 -0
- package/NOTICE +6 -0
- package/README.md +109 -0
- package/THIRD-PARTY-NOTICES.txt +2 -0
- package/dist/client.d.ts +24 -0
- package/dist/client.js +384 -0
- package/dist/cloud.d.ts +57 -0
- package/dist/cloud.js +188 -0
- package/dist/connection.d.ts +23 -0
- package/dist/connection.js +203 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +45 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/promise.d.ts +7 -0
- package/dist/promise.js +10 -0
- package/dist/session-handle.d.ts +53 -0
- package/dist/session-handle.js +50 -0
- package/dist/state.d.ts +21 -0
- package/dist/state.js +144 -0
- package/dist/transport.d.ts +16 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.js +1 -0
- package/dist/unix.d.ts +7 -0
- package/dist/unix.js +150 -0
- package/dist/websocket.d.ts +42 -0
- package/dist/websocket.js +124 -0
- package/distribution-components.json +16 -0
- package/distribution-files.json +102 -0
- package/package.json +70 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { encodeClientMessage, ProtocolValidationError, } from "@diffexai/diffex-protocol";
|
|
2
|
+
import { Connection } from "./connection.js";
|
|
3
|
+
import { DiffexClientDisposedError, DiffexDisconnectedError, DiffexServerError, DiffexSessionDetachedError, DiffexSessionOwnershipError, toError, } from "./errors.js";
|
|
4
|
+
import { createPromiseResolvers } from "./promise.js";
|
|
5
|
+
import { SessionHandle, } from "./session-handle.js";
|
|
6
|
+
import { ClientState } from "./state.js";
|
|
7
|
+
export class DiffexClient {
|
|
8
|
+
#options;
|
|
9
|
+
#connection;
|
|
10
|
+
#state;
|
|
11
|
+
#pendingRequests = new Map();
|
|
12
|
+
#sessionLeaseCounts = new Map();
|
|
13
|
+
#exclusiveSessionLeases = new Map();
|
|
14
|
+
#sessionLeaseGenerations = new Map();
|
|
15
|
+
#sessionAttachments = new Map();
|
|
16
|
+
#sessionDetachments = new Map();
|
|
17
|
+
#sessionCleanupRequired = new Set();
|
|
18
|
+
#sessionReconciliations = new Map();
|
|
19
|
+
#connectionStateListeners = new Set();
|
|
20
|
+
#requestSequence = 0;
|
|
21
|
+
#disposed = false;
|
|
22
|
+
#disposePromise;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.#options = options;
|
|
25
|
+
this.#state = new ClientState(options.onListenerError);
|
|
26
|
+
this.#connection = new Connection({
|
|
27
|
+
transportFactory: options.transportFactory,
|
|
28
|
+
maxFrameLength: options.maxFrameLength,
|
|
29
|
+
onHandshake: (snapshot) => this.#state.applyServerSnapshot(snapshot),
|
|
30
|
+
onMessage: (message) => this.#handleMessage(message),
|
|
31
|
+
onStateChange: (change) => this.#handleConnectionStateChange(change),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
get disposed() {
|
|
35
|
+
return this.#disposed;
|
|
36
|
+
}
|
|
37
|
+
get connectionState() {
|
|
38
|
+
return this.#connection.state;
|
|
39
|
+
}
|
|
40
|
+
get connected() {
|
|
41
|
+
return this.#connection.state === "connected";
|
|
42
|
+
}
|
|
43
|
+
get snapshot() {
|
|
44
|
+
return this.#state.snapshot;
|
|
45
|
+
}
|
|
46
|
+
static async connect(options) {
|
|
47
|
+
const client = new DiffexClient(options);
|
|
48
|
+
try {
|
|
49
|
+
await client.connect();
|
|
50
|
+
return client;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
await client.dispose();
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
connect() {
|
|
58
|
+
if (this.#disposed)
|
|
59
|
+
return Promise.reject(new DiffexClientDisposedError());
|
|
60
|
+
if (this.#connection.state === "disconnected")
|
|
61
|
+
this.#state.reset();
|
|
62
|
+
return this.#connection.connect();
|
|
63
|
+
}
|
|
64
|
+
reconnect() {
|
|
65
|
+
return this.connect();
|
|
66
|
+
}
|
|
67
|
+
disconnect(reason = "Client disconnected") {
|
|
68
|
+
this.#connection.disconnect(reason);
|
|
69
|
+
}
|
|
70
|
+
subscribe(listener) {
|
|
71
|
+
this.#assertNotDisposed();
|
|
72
|
+
return this.#state.subscribe(listener);
|
|
73
|
+
}
|
|
74
|
+
onEvent(listener) {
|
|
75
|
+
this.#assertNotDisposed();
|
|
76
|
+
return this.#state.onEvent(listener);
|
|
77
|
+
}
|
|
78
|
+
onConnectionStateChange(listener) {
|
|
79
|
+
this.#assertNotDisposed();
|
|
80
|
+
this.#connectionStateListeners.add(listener);
|
|
81
|
+
return () => this.#connectionStateListeners.delete(listener);
|
|
82
|
+
}
|
|
83
|
+
async listSessions() {
|
|
84
|
+
return (await this.#request({ command: "list" })).sessions;
|
|
85
|
+
}
|
|
86
|
+
async createSession(options = {}) {
|
|
87
|
+
const result = await this.#request({ command: "create", ...options });
|
|
88
|
+
const token = this.#reserveSessionLease(result.session.id, "exclusive");
|
|
89
|
+
return this.#createSessionLease(result.session.id, token);
|
|
90
|
+
}
|
|
91
|
+
async attachSession(sessionId) {
|
|
92
|
+
return this.acquireSession(sessionId, { mode: "shared" });
|
|
93
|
+
}
|
|
94
|
+
async acquireSession(sessionId, options) {
|
|
95
|
+
this.#assertNotDisposed();
|
|
96
|
+
const token = this.#reserveSessionLease(sessionId, options.mode);
|
|
97
|
+
try {
|
|
98
|
+
const detachment = this.#sessionDetachments.get(sessionId);
|
|
99
|
+
if (detachment)
|
|
100
|
+
await detachment.catch(() => { });
|
|
101
|
+
const reconciled = this.#sessionCleanupRequired.has(sessionId)
|
|
102
|
+
? await this.#reconcileSessionCleanup(sessionId)
|
|
103
|
+
: false;
|
|
104
|
+
if (reconciled || !this.#state.isSessionAttached(sessionId)) {
|
|
105
|
+
let attachment = this.#sessionAttachments.get(sessionId);
|
|
106
|
+
if (!attachment) {
|
|
107
|
+
attachment = this.#attachSession(sessionId);
|
|
108
|
+
this.#sessionAttachments.set(sessionId, attachment);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await attachment;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
if (this.#sessionAttachments.get(sessionId) === attachment)
|
|
115
|
+
this.#sessionAttachments.delete(sessionId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return this.#createSessionLease(sessionId, token);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.#releaseSessionLease(sessionId, token);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async #attachSession(sessionId) {
|
|
126
|
+
const previous = this.#state.forgetSessionSnapshot(sessionId);
|
|
127
|
+
try {
|
|
128
|
+
await this.#request({ command: "attach", sessionId });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (previous)
|
|
132
|
+
this.#state.restoreSessionSnapshot(previous);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
#request(command) {
|
|
137
|
+
if (this.#disposed)
|
|
138
|
+
return Promise.reject(new DiffexClientDisposedError());
|
|
139
|
+
if (!this.connected)
|
|
140
|
+
return Promise.reject(new DiffexDisconnectedError());
|
|
141
|
+
const id = `request-${++this.#requestSequence}`;
|
|
142
|
+
const { promise, resolve, reject } = createPromiseResolvers();
|
|
143
|
+
this.#pendingRequests.set(id, { command, resolve, reject });
|
|
144
|
+
let frame;
|
|
145
|
+
try {
|
|
146
|
+
frame = encodeClientMessage({ type: "request", id, request: command }, { maxFrameLength: this.#connection.maxFrameLength });
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
this.#takePendingRequest(id)?.reject(toError(error));
|
|
150
|
+
return promise;
|
|
151
|
+
}
|
|
152
|
+
this.#connection.send(frame);
|
|
153
|
+
return promise;
|
|
154
|
+
}
|
|
155
|
+
#createSessionLease(sessionId, token) {
|
|
156
|
+
const generation = this.#sessionLeaseGenerations.get(sessionId) ?? 0;
|
|
157
|
+
this.#sessionLeaseGenerations.set(sessionId, generation);
|
|
158
|
+
let state = "active";
|
|
159
|
+
let releasePromise;
|
|
160
|
+
const refreshState = () => {
|
|
161
|
+
if ((state === "active" || state === "releasing") &&
|
|
162
|
+
this.#sessionLeaseGenerations.get(sessionId) !== generation) {
|
|
163
|
+
state = "invalidated";
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const isActive = () => {
|
|
167
|
+
refreshState();
|
|
168
|
+
return state === "active" && this.#state.isSessionAttached(sessionId);
|
|
169
|
+
};
|
|
170
|
+
const assertActive = () => {
|
|
171
|
+
this.#assertNotDisposed();
|
|
172
|
+
if (!this.connected)
|
|
173
|
+
throw new DiffexDisconnectedError();
|
|
174
|
+
if (!isActive())
|
|
175
|
+
throw new DiffexSessionDetachedError(sessionId);
|
|
176
|
+
};
|
|
177
|
+
const release = (relinquishOnFailure) => {
|
|
178
|
+
refreshState();
|
|
179
|
+
if (state === "released" || state === "invalidated")
|
|
180
|
+
return Promise.resolve();
|
|
181
|
+
if (releasePromise)
|
|
182
|
+
return releasePromise;
|
|
183
|
+
assertActive();
|
|
184
|
+
state = "releasing";
|
|
185
|
+
releasePromise = (async () => {
|
|
186
|
+
const count = this.#sessionLeaseCounts.get(sessionId) ?? 0;
|
|
187
|
+
if (count <= 1) {
|
|
188
|
+
const detachment = this.#request({ command: "detach", sessionId }).then(() => undefined);
|
|
189
|
+
this.#sessionDetachments.set(sessionId, detachment);
|
|
190
|
+
try {
|
|
191
|
+
await detachment;
|
|
192
|
+
this.#releaseSessionLease(sessionId, token);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
if (this.#sessionDetachments.get(sessionId) === detachment) {
|
|
196
|
+
this.#sessionDetachments.delete(sessionId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
this.#releaseSessionLease(sessionId, token);
|
|
202
|
+
}
|
|
203
|
+
state = "released";
|
|
204
|
+
})().catch((error) => {
|
|
205
|
+
refreshState();
|
|
206
|
+
if (state === "invalidated")
|
|
207
|
+
return;
|
|
208
|
+
if (relinquishOnFailure) {
|
|
209
|
+
this.#releaseSessionLease(sessionId, token);
|
|
210
|
+
this.#sessionCleanupRequired.add(sessionId);
|
|
211
|
+
state = "released";
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
state = "active";
|
|
215
|
+
releasePromise = undefined;
|
|
216
|
+
}
|
|
217
|
+
throw error;
|
|
218
|
+
});
|
|
219
|
+
return releasePromise;
|
|
220
|
+
};
|
|
221
|
+
const callbacks = {
|
|
222
|
+
isAttached: isActive,
|
|
223
|
+
getSnapshot: () => (isActive() ? this.#state.getSessionSnapshot(sessionId) : undefined),
|
|
224
|
+
subscribe: (listener) => {
|
|
225
|
+
assertActive();
|
|
226
|
+
return this.#state.subscribeSession(sessionId, (snapshot) => {
|
|
227
|
+
if (isActive())
|
|
228
|
+
listener(snapshot);
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
onEvent: (listener) => {
|
|
232
|
+
assertActive();
|
|
233
|
+
return this.#state.onSessionEvent(sessionId, (event) => {
|
|
234
|
+
if (isActive() || event.type === "session_removed")
|
|
235
|
+
listener(event);
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
detach: () => release(false),
|
|
239
|
+
dispose: () => release(true),
|
|
240
|
+
request: (command) => {
|
|
241
|
+
assertActive();
|
|
242
|
+
return this.#request(command);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
return new SessionHandle(sessionId, callbacks);
|
|
246
|
+
}
|
|
247
|
+
#handleMessage(message) {
|
|
248
|
+
if (message.type === "event") {
|
|
249
|
+
if (message.event.type === "session_removed")
|
|
250
|
+
this.#invalidateSessionLeases(message.event.sessionId);
|
|
251
|
+
this.#state.applyEvent(message.event);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const pending = this.#takePendingRequest(message.id);
|
|
255
|
+
if (!pending) {
|
|
256
|
+
this.#connection.fail(new ProtocolValidationError("Response has no matching request"));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!message.ok) {
|
|
260
|
+
pending.reject(new DiffexServerError(message.error));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (message.result.command !== pending.command.command) {
|
|
264
|
+
const error = new ProtocolValidationError(`Response command ${message.result.command} does not match ${pending.command.command}`);
|
|
265
|
+
pending.reject(error);
|
|
266
|
+
this.#connection.fail(error);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.#state.applyResult(message.result);
|
|
270
|
+
pending.resolve(message.result);
|
|
271
|
+
}
|
|
272
|
+
#handleConnectionStateChange(change) {
|
|
273
|
+
if (change.state === "disconnected") {
|
|
274
|
+
this.#state.clearAttachments();
|
|
275
|
+
this.#invalidateAllSessionLeases();
|
|
276
|
+
this.#rejectPendingRequests(change.error ?? new DiffexDisconnectedError());
|
|
277
|
+
}
|
|
278
|
+
this.#notifyConnectionStateListeners(change);
|
|
279
|
+
}
|
|
280
|
+
#takePendingRequest(id) {
|
|
281
|
+
const request = this.#pendingRequests.get(id);
|
|
282
|
+
if (request)
|
|
283
|
+
this.#pendingRequests.delete(id);
|
|
284
|
+
return request;
|
|
285
|
+
}
|
|
286
|
+
#rejectPendingRequests(error) {
|
|
287
|
+
const requests = [...this.#pendingRequests.values()];
|
|
288
|
+
this.#pendingRequests.clear();
|
|
289
|
+
for (const request of requests)
|
|
290
|
+
request.reject(error);
|
|
291
|
+
}
|
|
292
|
+
dispose() {
|
|
293
|
+
if (this.#disposePromise)
|
|
294
|
+
return this.#disposePromise;
|
|
295
|
+
this.#disposed = true;
|
|
296
|
+
this.#disposePromise = Promise.resolve();
|
|
297
|
+
const error = new DiffexClientDisposedError();
|
|
298
|
+
this.#rejectPendingRequests(error);
|
|
299
|
+
this.#connection.disconnect(error);
|
|
300
|
+
this.#state.dispose();
|
|
301
|
+
this.#invalidateAllSessionLeases();
|
|
302
|
+
this.#connectionStateListeners.clear();
|
|
303
|
+
return this.#disposePromise;
|
|
304
|
+
}
|
|
305
|
+
[Symbol.asyncDispose]() {
|
|
306
|
+
return this.dispose();
|
|
307
|
+
}
|
|
308
|
+
#assertNotDisposed() {
|
|
309
|
+
if (this.#disposed)
|
|
310
|
+
throw new DiffexClientDisposedError();
|
|
311
|
+
}
|
|
312
|
+
async #reconcileSessionCleanup(sessionId) {
|
|
313
|
+
if (!this.#sessionCleanupRequired.has(sessionId))
|
|
314
|
+
return false;
|
|
315
|
+
let reconciliation = this.#sessionReconciliations.get(sessionId);
|
|
316
|
+
if (!reconciliation) {
|
|
317
|
+
reconciliation = this.#request({ command: "detach", sessionId })
|
|
318
|
+
.then(() => undefined)
|
|
319
|
+
.then(() => {
|
|
320
|
+
this.#sessionCleanupRequired.delete(sessionId);
|
|
321
|
+
})
|
|
322
|
+
.finally(() => {
|
|
323
|
+
this.#sessionReconciliations.delete(sessionId);
|
|
324
|
+
});
|
|
325
|
+
this.#sessionReconciliations.set(sessionId, reconciliation);
|
|
326
|
+
}
|
|
327
|
+
await reconciliation;
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
#reserveSessionLease(sessionId, mode) {
|
|
331
|
+
const count = this.#sessionLeaseCounts.get(sessionId) ?? 0;
|
|
332
|
+
if (mode === "exclusive" && count > 0) {
|
|
333
|
+
throw new DiffexSessionOwnershipError(sessionId, `Session ${sessionId} already has an active lease`);
|
|
334
|
+
}
|
|
335
|
+
if (mode === "shared" && this.#exclusiveSessionLeases.has(sessionId)) {
|
|
336
|
+
throw new DiffexSessionOwnershipError(sessionId, `Session ${sessionId} has an exclusive lease`);
|
|
337
|
+
}
|
|
338
|
+
const token = { mode };
|
|
339
|
+
this.#sessionLeaseCounts.set(sessionId, count + 1);
|
|
340
|
+
if (mode === "exclusive")
|
|
341
|
+
this.#exclusiveSessionLeases.set(sessionId, token);
|
|
342
|
+
return token;
|
|
343
|
+
}
|
|
344
|
+
#releaseSessionLease(sessionId, token) {
|
|
345
|
+
const count = this.#sessionLeaseCounts.get(sessionId) ?? 0;
|
|
346
|
+
if (count <= 1)
|
|
347
|
+
this.#sessionLeaseCounts.delete(sessionId);
|
|
348
|
+
else
|
|
349
|
+
this.#sessionLeaseCounts.set(sessionId, count - 1);
|
|
350
|
+
if (this.#exclusiveSessionLeases.get(sessionId) === token)
|
|
351
|
+
this.#exclusiveSessionLeases.delete(sessionId);
|
|
352
|
+
}
|
|
353
|
+
#invalidateSessionLeases(sessionId) {
|
|
354
|
+
this.#sessionLeaseCounts.delete(sessionId);
|
|
355
|
+
this.#exclusiveSessionLeases.delete(sessionId);
|
|
356
|
+
this.#sessionCleanupRequired.delete(sessionId);
|
|
357
|
+
this.#sessionLeaseGenerations.set(sessionId, (this.#sessionLeaseGenerations.get(sessionId) ?? 0) + 1);
|
|
358
|
+
}
|
|
359
|
+
#invalidateAllSessionLeases() {
|
|
360
|
+
for (const sessionId of this.#sessionLeaseCounts.keys())
|
|
361
|
+
this.#invalidateSessionLeases(sessionId);
|
|
362
|
+
this.#sessionCleanupRequired.clear();
|
|
363
|
+
}
|
|
364
|
+
#notifyConnectionStateListeners(change) {
|
|
365
|
+
for (const listener of this.#connectionStateListeners) {
|
|
366
|
+
try {
|
|
367
|
+
listener(change);
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
this.#reportListenerError(error);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
#reportListenerError(error) {
|
|
375
|
+
if (!this.#options.onListenerError)
|
|
376
|
+
return;
|
|
377
|
+
try {
|
|
378
|
+
this.#options.onListenerError(toError(error));
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// Diagnostics cannot affect protocol or transport state.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
package/dist/cloud.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ByteTransportFactory } from "./transport.ts";
|
|
2
|
+
import { type WebSocketConstructor } from "./websocket.ts";
|
|
3
|
+
export type CloudSessionStatus = "creating" | "running" | "stopping" | "stopped" | "failed" | "deleting" | "deleted";
|
|
4
|
+
export interface CloudSession {
|
|
5
|
+
session_id: string;
|
|
6
|
+
status: CloudSessionStatus;
|
|
7
|
+
created_at: string;
|
|
8
|
+
updated_at: string;
|
|
9
|
+
}
|
|
10
|
+
export interface CloudRuntimeAttachment {
|
|
11
|
+
url: string;
|
|
12
|
+
expires_at: string;
|
|
13
|
+
protocol_version: number;
|
|
14
|
+
}
|
|
15
|
+
interface CloudFetchResponse {
|
|
16
|
+
readonly ok: boolean;
|
|
17
|
+
readonly status: number;
|
|
18
|
+
json(): Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
export type CloudFetch = (input: string, init: {
|
|
21
|
+
method: string;
|
|
22
|
+
headers: Record<string, string>;
|
|
23
|
+
body?: string;
|
|
24
|
+
}) => Promise<CloudFetchResponse>;
|
|
25
|
+
export interface DiffexCloudClientOptions {
|
|
26
|
+
baseUrl: string | URL;
|
|
27
|
+
accessToken: string | (() => string | Promise<string>);
|
|
28
|
+
fetch?: CloudFetch;
|
|
29
|
+
}
|
|
30
|
+
export interface CloudTransportOptions {
|
|
31
|
+
maxPendingBytes?: number;
|
|
32
|
+
webSocket?: WebSocketConstructor;
|
|
33
|
+
}
|
|
34
|
+
export declare class DiffexCloudApiError extends Error {
|
|
35
|
+
readonly status: number;
|
|
36
|
+
readonly code: string;
|
|
37
|
+
readonly requestId?: string;
|
|
38
|
+
constructor(input: {
|
|
39
|
+
status: number;
|
|
40
|
+
code: string;
|
|
41
|
+
message: string;
|
|
42
|
+
requestId?: string;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Authenticated control-plane client for hosted Diffex session lifecycle and attachment. */
|
|
46
|
+
export declare class DiffexCloudClient {
|
|
47
|
+
#private;
|
|
48
|
+
constructor(options: DiffexCloudClientOptions);
|
|
49
|
+
createSession(): Promise<CloudSession>;
|
|
50
|
+
listSessions(): Promise<CloudSession[]>;
|
|
51
|
+
getSession(sessionId: string): Promise<CloudSession>;
|
|
52
|
+
createAttachment(sessionId: string): Promise<CloudRuntimeAttachment>;
|
|
53
|
+
stopSession(sessionId: string): Promise<CloudSession>;
|
|
54
|
+
deleteSession(sessionId: string): Promise<void>;
|
|
55
|
+
createTransportFactory(sessionId: string, options?: CloudTransportOptions): ByteTransportFactory;
|
|
56
|
+
}
|
|
57
|
+
export {};
|
package/dist/cloud.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from "@diffexai/diffex-protocol";
|
|
2
|
+
import { createWebSocketTransportFactory } from "./websocket.js";
|
|
3
|
+
export class DiffexCloudApiError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
requestId;
|
|
7
|
+
constructor(input) {
|
|
8
|
+
super(input.message);
|
|
9
|
+
this.name = "DiffexCloudApiError";
|
|
10
|
+
this.status = input.status;
|
|
11
|
+
this.code = input.code;
|
|
12
|
+
this.requestId = input.requestId;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Authenticated control-plane client for hosted Diffex session lifecycle and attachment. */
|
|
16
|
+
export class DiffexCloudClient {
|
|
17
|
+
#baseUrl;
|
|
18
|
+
#accessToken;
|
|
19
|
+
#fetch;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.#baseUrl = normalizedBaseUrl(options.baseUrl);
|
|
22
|
+
this.#accessToken = options.accessToken;
|
|
23
|
+
this.#fetch = options.fetch ?? requiredFetch();
|
|
24
|
+
}
|
|
25
|
+
async createSession() {
|
|
26
|
+
const value = await this.#request("POST", "/v1/cloud/sessions", {});
|
|
27
|
+
return sessionResponse(value);
|
|
28
|
+
}
|
|
29
|
+
async listSessions() {
|
|
30
|
+
const value = record(await this.#request("GET", "/v1/cloud/sessions"));
|
|
31
|
+
if (!Array.isArray(value.sessions))
|
|
32
|
+
throw invalidResponse();
|
|
33
|
+
return value.sessions.map(cloudSession);
|
|
34
|
+
}
|
|
35
|
+
async getSession(sessionId) {
|
|
36
|
+
return sessionResponse(await this.#request("GET", sessionPath(sessionId)));
|
|
37
|
+
}
|
|
38
|
+
async createAttachment(sessionId) {
|
|
39
|
+
const value = record(await this.#request("POST", `${sessionPath(sessionId)}/attach`, {}));
|
|
40
|
+
return cloudAttachment(value.attachment);
|
|
41
|
+
}
|
|
42
|
+
async stopSession(sessionId) {
|
|
43
|
+
return sessionResponse(await this.#request("POST", `${sessionPath(sessionId)}/stop`, {}));
|
|
44
|
+
}
|
|
45
|
+
async deleteSession(sessionId) {
|
|
46
|
+
await this.#request("DELETE", sessionPath(sessionId));
|
|
47
|
+
}
|
|
48
|
+
createTransportFactory(sessionId, options = {}) {
|
|
49
|
+
assertSessionId(sessionId);
|
|
50
|
+
return createWebSocketTransportFactory({
|
|
51
|
+
url: async () => (await this.createAttachment(sessionId)).url,
|
|
52
|
+
...(options.maxPendingBytes === undefined ? {} : { maxPendingBytes: options.maxPendingBytes }),
|
|
53
|
+
...(options.webSocket === undefined ? {} : { webSocket: options.webSocket }),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async #request(method, path, body) {
|
|
57
|
+
const token = await resolveAccessToken(this.#accessToken);
|
|
58
|
+
const response = await this.#fetch(new URL(path, this.#baseUrl).toString(), {
|
|
59
|
+
method,
|
|
60
|
+
headers: {
|
|
61
|
+
authorization: `Bearer ${token}`,
|
|
62
|
+
accept: "application/json",
|
|
63
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
64
|
+
},
|
|
65
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
66
|
+
});
|
|
67
|
+
if (response.status === 204)
|
|
68
|
+
return undefined;
|
|
69
|
+
const value = await response.json().catch(() => undefined);
|
|
70
|
+
if (!response.ok)
|
|
71
|
+
throw apiError(response.status, value);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function normalizedBaseUrl(input) {
|
|
76
|
+
const value = new URL(input);
|
|
77
|
+
if (value.username ||
|
|
78
|
+
value.password ||
|
|
79
|
+
(value.protocol !== "https:" && !(value.protocol === "http:" && isLoopbackHostname(value.hostname)))) {
|
|
80
|
+
throw new TypeError("Diffex cloud baseUrl must use HTTPS outside localhost");
|
|
81
|
+
}
|
|
82
|
+
value.pathname = value.pathname.endsWith("/") ? value.pathname : `${value.pathname}/`;
|
|
83
|
+
value.search = "";
|
|
84
|
+
value.hash = "";
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
function requiredFetch() {
|
|
88
|
+
const candidate = globalThis.fetch;
|
|
89
|
+
if (!candidate)
|
|
90
|
+
throw new Error("fetch is not available in this runtime");
|
|
91
|
+
return (input, init) => candidate(input, init);
|
|
92
|
+
}
|
|
93
|
+
async function resolveAccessToken(input) {
|
|
94
|
+
const value = typeof input === "function" ? await input() : input;
|
|
95
|
+
const token = value.trim();
|
|
96
|
+
if (!token)
|
|
97
|
+
throw new Error("Diffex cloud access token is empty");
|
|
98
|
+
return token;
|
|
99
|
+
}
|
|
100
|
+
function sessionPath(sessionId) {
|
|
101
|
+
assertSessionId(sessionId);
|
|
102
|
+
return `/v1/cloud/sessions/${encodeURIComponent(sessionId)}`;
|
|
103
|
+
}
|
|
104
|
+
function assertSessionId(sessionId) {
|
|
105
|
+
if (!sessionId.trim())
|
|
106
|
+
throw new TypeError("Diffex cloud session ID is required");
|
|
107
|
+
}
|
|
108
|
+
function sessionResponse(input) {
|
|
109
|
+
return cloudSession(record(input).session);
|
|
110
|
+
}
|
|
111
|
+
function cloudSession(input) {
|
|
112
|
+
const value = record(input);
|
|
113
|
+
const sessionId = stringValue(value.session_id);
|
|
114
|
+
const status = stringValue(value.status);
|
|
115
|
+
const createdAt = isoTimestamp(value.created_at);
|
|
116
|
+
const updatedAt = isoTimestamp(value.updated_at);
|
|
117
|
+
if (!isCloudSessionStatus(status))
|
|
118
|
+
throw invalidResponse();
|
|
119
|
+
return { session_id: sessionId, status, created_at: createdAt, updated_at: updatedAt };
|
|
120
|
+
}
|
|
121
|
+
function cloudAttachment(input) {
|
|
122
|
+
const value = record(input);
|
|
123
|
+
const url = stringValue(value.url);
|
|
124
|
+
const parsedUrl = new URL(url);
|
|
125
|
+
if (parsedUrl.username ||
|
|
126
|
+
parsedUrl.password ||
|
|
127
|
+
(parsedUrl.protocol !== "wss:" && !(parsedUrl.protocol === "ws:" && isLoopbackHostname(parsedUrl.hostname)))) {
|
|
128
|
+
throw invalidResponse();
|
|
129
|
+
}
|
|
130
|
+
const protocolVersion = value.protocol_version;
|
|
131
|
+
if (protocolVersion !== PROTOCOL_VERSION) {
|
|
132
|
+
throw new DiffexCloudApiError({
|
|
133
|
+
status: 409,
|
|
134
|
+
code: "unsupported_protocol_version",
|
|
135
|
+
message: `Diffex cloud requires protocol version ${String(protocolVersion)}; this client supports ${String(PROTOCOL_VERSION)}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
url,
|
|
140
|
+
expires_at: isoTimestamp(value.expires_at),
|
|
141
|
+
protocol_version: Number(protocolVersion),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function isLoopbackHostname(hostname) {
|
|
145
|
+
const normalized = hostname.toLowerCase().replace(/^\[(.*)\]$/u, "$1");
|
|
146
|
+
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
|
147
|
+
}
|
|
148
|
+
function isCloudSessionStatus(value) {
|
|
149
|
+
return ["creating", "running", "stopping", "stopped", "failed", "deleting", "deleted"].includes(value);
|
|
150
|
+
}
|
|
151
|
+
function apiError(status, input) {
|
|
152
|
+
const error = optionalRecord(optionalRecord(input)?.error);
|
|
153
|
+
return new DiffexCloudApiError({
|
|
154
|
+
status,
|
|
155
|
+
code: typeof error?.code === "string" ? error.code : "request_failed",
|
|
156
|
+
message: typeof error?.message === "string" ? error.message : `Diffex cloud request failed (${status})`,
|
|
157
|
+
...(typeof error?.requestId === "string" ? { requestId: error.requestId } : {}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function record(input) {
|
|
161
|
+
const value = optionalRecord(input);
|
|
162
|
+
if (!value)
|
|
163
|
+
throw invalidResponse();
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
function optionalRecord(input) {
|
|
167
|
+
return typeof input === "object" && input !== null && !Array.isArray(input)
|
|
168
|
+
? input
|
|
169
|
+
: undefined;
|
|
170
|
+
}
|
|
171
|
+
function stringValue(input) {
|
|
172
|
+
if (typeof input !== "string" || !input)
|
|
173
|
+
throw invalidResponse();
|
|
174
|
+
return input;
|
|
175
|
+
}
|
|
176
|
+
function isoTimestamp(input) {
|
|
177
|
+
const value = stringValue(input);
|
|
178
|
+
if (Number.isNaN(Date.parse(value)))
|
|
179
|
+
throw invalidResponse();
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
function invalidResponse() {
|
|
183
|
+
return new DiffexCloudApiError({
|
|
184
|
+
status: 502,
|
|
185
|
+
code: "invalid_response",
|
|
186
|
+
message: "Diffex cloud returned an invalid response",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ServerMessage, type ServerSnapshot } from "@diffexai/diffex-protocol";
|
|
2
|
+
import type { ByteTransportFactory } from "./transport.ts";
|
|
3
|
+
import type { ConnectionState, ConnectionStateChange } from "./types.ts";
|
|
4
|
+
interface ConnectionOptions {
|
|
5
|
+
transportFactory: ByteTransportFactory;
|
|
6
|
+
maxFrameLength?: number;
|
|
7
|
+
onHandshake(snapshot: ServerSnapshot): void;
|
|
8
|
+
onMessage(message: Exclude<ServerMessage, {
|
|
9
|
+
type: "hello" | "hello_error";
|
|
10
|
+
}>): void;
|
|
11
|
+
onStateChange(change: ConnectionStateChange): void;
|
|
12
|
+
}
|
|
13
|
+
export declare class Connection {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(options: ConnectionOptions);
|
|
16
|
+
get state(): ConnectionState;
|
|
17
|
+
get maxFrameLength(): number;
|
|
18
|
+
connect(): Promise<ServerSnapshot>;
|
|
19
|
+
disconnect(reason?: string | Error): void;
|
|
20
|
+
fail(error: Error): void;
|
|
21
|
+
send(frame: Uint8Array): void;
|
|
22
|
+
}
|
|
23
|
+
export {};
|