@abloatai/transaction 0.59.2 → 0.61.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/dist/auth/apiKey.d.ts +10 -11
- package/dist/auth/apiKey.d.ts.map +1 -1
- package/dist/auth/apiKey.js +15 -29
- package/dist/auth/apiKey.js.map +1 -1
- package/dist/auth/browserCredentialSafety.js +1 -1
- package/dist/auth/browserCredentialSafety.js.map +1 -1
- package/dist/auth/capability.d.ts +15 -15
- package/dist/auth/capabilityLifecycle.d.ts +3 -3
- package/dist/auth/capabilityLifecycle.d.ts.map +1 -1
- package/dist/auth/capabilityLifecycle.js +2 -1
- package/dist/auth/capabilityLifecycle.js.map +1 -1
- package/dist/auth/credentialEndpoint.js +1 -1
- package/dist/auth/credentialEndpoint.js.map +1 -1
- package/dist/auth/credentialResult.d.ts +2 -1
- package/dist/auth/credentialResult.d.ts.map +1 -1
- package/dist/auth/credentialResult.js.map +1 -1
- package/dist/auth/credentialSource.js.map +1 -1
- package/dist/auth/identity.js.map +1 -1
- package/dist/auth/schemas.d.ts +5 -5
- package/dist/auth/sessionIssuanceProtocol.d.ts +8 -8
- package/dist/auth/sessionIssuanceProtocol.d.ts.map +1 -1
- package/dist/branches.d.ts +38 -38
- package/dist/claims/admission.d.ts +4 -0
- package/dist/claims/admission.d.ts.map +1 -0
- package/dist/claims/admission.js +32 -0
- package/dist/claims/admission.js.map +1 -0
- package/dist/claims/awaitGrant.d.ts.map +1 -1
- package/dist/claims/awaitGrant.js +4 -38
- package/dist/claims/awaitGrant.js.map +1 -1
- package/dist/claims/conflict.d.ts +55 -0
- package/dist/claims/conflict.d.ts.map +1 -0
- package/dist/claims/conflict.js +71 -0
- package/dist/claims/conflict.js.map +1 -0
- package/dist/claims/contract.d.ts +39 -27
- package/dist/claims/contract.d.ts.map +1 -1
- package/dist/claims/contract.js +2 -0
- package/dist/claims/contract.js.map +1 -1
- package/dist/claims/eventContract.d.ts +18 -18
- package/dist/claims/events.d.ts +1 -1
- package/dist/claims/events.d.ts.map +1 -1
- package/dist/claims/index.d.ts +2 -0
- package/dist/claims/index.d.ts.map +1 -1
- package/dist/claims/index.js +1 -0
- package/dist/claims/index.js.map +1 -1
- package/dist/claims/routes.d.ts +9 -9
- package/dist/claims/routes.d.ts.map +1 -1
- package/dist/claims/trace.d.ts.map +1 -1
- package/dist/claims/trace.js.map +1 -1
- package/dist/client/ablo.d.ts +11 -8
- package/dist/client/ablo.d.ts.map +1 -1
- package/dist/client/ablo.js +5 -6
- package/dist/client/ablo.js.map +1 -1
- package/dist/client/resources/httpResources.d.ts +1 -169
- package/dist/client/resources/httpResources.d.ts.map +1 -1
- package/dist/client/resources/httpResources.js +1 -1
- package/dist/client/resources/httpResources.js.map +1 -1
- package/dist/client/resources/modelOperations.d.ts +6 -15
- package/dist/client/resources/modelOperations.d.ts.map +1 -1
- package/dist/client/resources/where.d.ts +12 -12
- package/dist/client/surface.d.ts +1 -1
- package/dist/client/surface.d.ts.map +1 -1
- package/dist/client/surface.js +2 -4
- package/dist/client/surface.js.map +1 -1
- package/dist/commit/confirmation/commitEnvelope.d.ts +7 -7
- package/dist/commit/confirmation/httpCommitEnvelope.d.ts +1 -1
- package/dist/commit/confirmation/pendingWrite.d.ts +5 -5
- package/dist/commit/contract.d.ts +249 -249
- package/dist/commit/contract.d.ts.map +1 -1
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +4 -5
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/schema.d.ts +64 -101
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +14 -69
- package/dist/coordination/schema.js.map +1 -1
- package/dist/environment.d.ts +3 -3
- package/dist/environment.d.ts.map +1 -1
- package/dist/environment.js.map +1 -1
- package/dist/errorCodes.d.ts +4 -5
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +15 -9
- package/dist/errorCodes.js.map +1 -1
- package/dist/errorObservation.d.ts +14 -14
- package/dist/errors.d.ts +17 -11
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +101 -5
- package/dist/errors.js.map +1 -1
- package/dist/keys/index.d.ts +1 -1
- package/dist/keys/index.d.ts.map +1 -1
- package/dist/keys/index.js.map +1 -1
- package/dist/observation/contract.d.ts +34 -34
- package/dist/observation/feedContract.d.ts +86 -86
- package/dist/observation/persistence/syncDeltaRow.d.ts +23 -23
- package/dist/observation/persistence/syncDeltaRow.d.ts.map +1 -1
- package/dist/pricing.d.ts +12 -12
- package/dist/schema/audit.js.map +1 -1
- package/dist/schema/ddl.js.map +1 -1
- package/dist/schema/ddlLock.js.map +1 -1
- package/dist/schema/deployment/contracts.d.ts +64 -64
- package/dist/schema/deployment/index.js.map +1 -1
- package/dist/schema/deployment/reconcile.js.map +1 -1
- package/dist/schema/diff.js.map +1 -1
- package/dist/schema/field.d.ts +1 -1
- package/dist/schema/field.d.ts.map +1 -1
- package/dist/schema/loadStrategy.d.ts +2 -2
- package/dist/schema/loadStrategy.d.ts.map +1 -1
- package/dist/schema/openapi.js.map +1 -1
- package/dist/schema/relation.d.ts +3 -3
- package/dist/schema/relation.d.ts.map +1 -1
- package/dist/schema/residency.d.ts +1 -1
- package/dist/schema/roles.d.ts +1 -1
- package/dist/schema/roles.d.ts.map +1 -1
- package/dist/schema/roles.js.map +1 -1
- package/dist/schema/schema.d.ts +1 -1
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/serialize.js.map +1 -1
- package/dist/schema/sugar.d.ts.map +1 -1
- package/dist/schema/tenancy.d.ts +2 -2
- package/dist/server/storageMode.d.ts +1 -1
- package/dist/sessions/client.d.ts +29 -0
- package/dist/sessions/client.d.ts.map +1 -0
- package/dist/sessions/client.js +69 -0
- package/dist/sessions/client.js.map +1 -0
- package/dist/sessions/contract.d.ts +97 -0
- package/dist/sessions/contract.d.ts.map +1 -0
- package/dist/sessions/contract.js +5 -0
- package/dist/sessions/contract.js.map +1 -0
- package/dist/{auth/sessionMint.d.ts → sessions/create.d.ts} +4 -4
- package/dist/sessions/create.d.ts.map +1 -0
- package/dist/{auth/sessionMint.js → sessions/create.js} +11 -10
- package/dist/sessions/create.js.map +1 -0
- package/dist/sessions/handler.d.ts +23 -0
- package/dist/sessions/handler.d.ts.map +1 -0
- package/dist/sessions/handler.js +55 -0
- package/dist/sessions/handler.js.map +1 -0
- package/dist/sessions/index.d.ts +14 -0
- package/dist/sessions/index.d.ts.map +1 -0
- package/dist/sessions/index.js +14 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/{transport/connection/credentialLifecycle.d.ts → sessions/lifecycle.d.ts} +6 -6
- package/dist/sessions/lifecycle.d.ts.map +1 -0
- package/dist/{transport/connection/credentialLifecycle.js → sessions/lifecycle.js} +5 -5
- package/dist/sessions/lifecycle.js.map +1 -0
- package/dist/sessions/source.d.ts +24 -0
- package/dist/sessions/source.d.ts.map +1 -0
- package/dist/sessions/source.js +56 -0
- package/dist/sessions/source.js.map +1 -0
- package/dist/source/adapters/adapterProfile.d.ts +6 -6
- package/dist/source/adapters/conformance.js.map +1 -1
- package/dist/source/adapters/contract.d.ts +14 -14
- package/dist/source/adapters/kysely.js.map +1 -1
- package/dist/source/adapters/memory.js.map +1 -1
- package/dist/source/adapters/prisma.js.map +1 -1
- package/dist/source/connector/client.d.ts.map +1 -1
- package/dist/source/connector/client.js.map +1 -1
- package/dist/source/connector/protocol.d.ts.map +1 -1
- package/dist/source/delivery/pushQueue.d.ts.map +1 -1
- package/dist/source/delivery/pushQueue.js.map +1 -1
- package/dist/source/endpoint/signing.d.ts +4 -4
- package/dist/source/endpoint/signing.d.ts.map +1 -1
- package/dist/source/outbox/contract.d.ts +4 -4
- package/dist/testing/fixtures/httpResponses.d.ts +2 -2
- package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
- package/dist/testing/fixtures/httpResponses.js.map +1 -1
- package/dist/transport/connection/index.d.ts +0 -1
- package/dist/transport/connection/index.d.ts.map +1 -1
- package/dist/transport/connection/index.js +0 -1
- package/dist/transport/connection/index.js.map +1 -1
- package/dist/transport/connection/manager.d.ts +1 -1
- package/dist/transport/connection/manager.d.ts.map +1 -1
- package/dist/transport/connection/manager.js.map +1 -1
- package/dist/transport/connection/networkProbe.d.ts +5 -18
- package/dist/transport/connection/networkProbe.d.ts.map +1 -1
- package/dist/transport/connection/networkProbe.js.map +1 -1
- package/dist/transport/http/claimWait.d.ts +9 -0
- package/dist/transport/http/claimWait.d.ts.map +1 -0
- package/dist/transport/http/claimWait.js +74 -0
- package/dist/transport/http/claimWait.js.map +1 -0
- package/dist/transport/http/client.d.ts +11 -19
- package/dist/transport/http/client.d.ts.map +1 -1
- package/dist/transport/http/client.js +15 -18
- package/dist/transport/http/client.js.map +1 -1
- package/dist/transport/http/contract.d.ts +57 -0
- package/dist/transport/http/contract.d.ts.map +1 -0
- package/dist/transport/http/contract.js +3 -0
- package/dist/transport/http/contract.js.map +1 -0
- package/dist/transport/http/index.d.ts +1 -0
- package/dist/transport/http/index.d.ts.map +1 -1
- package/dist/transport/http/index.js +1 -0
- package/dist/transport/http/index.js.map +1 -1
- package/dist/transport/http/options.d.ts +4 -1
- package/dist/transport/http/options.d.ts.map +1 -1
- package/dist/transport/http/transport.d.ts +1 -84
- package/dist/transport/http/transport.d.ts.map +1 -1
- package/dist/transport/http/transport.js +16 -134
- package/dist/transport/http/transport.js.map +1 -1
- package/dist/transport/websocket/commitFrames.d.ts +1 -2
- package/dist/transport/websocket/commitFrames.d.ts.map +1 -1
- package/dist/transport/websocket/commitFrames.js.map +1 -1
- package/dist/transport/websocket/frameHandlers.d.ts +0 -14
- package/dist/transport/websocket/frameHandlers.d.ts.map +1 -1
- package/dist/transport/websocket/frameHandlers.js +4 -71
- package/dist/transport/websocket/frameHandlers.js.map +1 -1
- package/dist/transport/websocket/heartbeat.d.ts.map +1 -1
- package/dist/transport/websocket/heartbeat.js.map +1 -1
- package/dist/transport/websocket/session.d.ts.map +1 -1
- package/dist/transport/websocket/session.js +137 -64
- package/dist/transport/websocket/session.js.map +1 -1
- package/dist/transport/websocket/sessionContract.d.ts +4 -15
- package/dist/transport/websocket/sessionContract.d.ts.map +1 -1
- package/dist/transport/websocket/transport.d.ts +6 -42
- package/dist/transport/websocket/transport.d.ts.map +1 -1
- package/dist/transport/websocket/transport.js +14 -101
- package/dist/transport/websocket/transport.js.map +1 -1
- package/dist/wire/accountResponses.d.ts +31 -31
- package/dist/wire/auth.d.ts +3 -3
- package/dist/wire/bootstrapReason.d.ts +2 -2
- package/dist/wire/dataSourceResponses.d.ts +2 -2
- package/dist/wire/dataSourceResponses.d.ts.map +1 -1
- package/dist/wire/frames.d.ts +8 -8
- package/dist/wire/inboundFrames.d.ts +60 -63
- package/dist/wire/inboundFrames.d.ts.map +1 -1
- package/dist/wire/inboundFrames.js +5 -6
- package/dist/wire/inboundFrames.js.map +1 -1
- package/dist/wire/listEnvelope.js.map +1 -1
- package/dist/wire/modelResponses.d.ts +2 -2
- package/dist/wire/modelShape.d.ts +4 -4
- package/dist/wire/pricing.d.ts +3 -3
- package/dist/wire/protocolVersion.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/auth/apiKey.ts +25 -44
- package/src/auth/browserCredentialSafety.ts +1 -1
- package/src/auth/capabilityLifecycle.ts +3 -2
- package/src/auth/credentialEndpoint.ts +1 -1
- package/src/auth/credentialResult.ts +2 -0
- package/src/claims/admission.ts +49 -0
- package/src/claims/awaitGrant.ts +3 -52
- package/src/claims/conflict.ts +103 -0
- package/src/claims/contract.ts +2 -0
- package/src/claims/events.ts +1 -2
- package/src/claims/index.ts +11 -0
- package/src/client/ablo.ts +15 -10
- package/src/client/resources/httpResources.ts +2 -174
- package/src/client/resources/modelOperations.ts +0 -10
- package/src/client/surface.ts +2 -4
- package/src/coordination/index.ts +4 -9
- package/src/coordination/schema.ts +18 -81
- package/src/errorCodes.ts +21 -13
- package/src/errors.ts +116 -4
- package/src/sessions/client.ts +121 -0
- package/src/sessions/contract.ts +116 -0
- package/src/{auth/sessionMint.ts → sessions/create.ts} +13 -12
- package/src/sessions/handler.ts +96 -0
- package/src/sessions/index.ts +13 -0
- package/src/{transport/connection/credentialLifecycle.ts → sessions/lifecycle.ts} +5 -5
- package/src/sessions/source.ts +94 -0
- package/src/transport/connection/index.ts +0 -1
- package/src/transport/connection/manager.ts +1 -1
- package/src/transport/http/claimWait.ts +108 -0
- package/src/transport/http/client.ts +27 -36
- package/src/transport/http/contract.ts +71 -0
- package/src/transport/http/index.ts +1 -0
- package/src/transport/http/options.ts +4 -1
- package/src/transport/http/transport.ts +22 -275
- package/src/transport/websocket/commitFrames.ts +0 -2
- package/src/transport/websocket/frameHandlers.ts +7 -102
- package/src/transport/websocket/session.ts +162 -73
- package/src/transport/websocket/sessionContract.ts +4 -13
- package/src/transport/websocket/transport.ts +15 -137
- package/src/wire/inboundFrames.ts +5 -6
- package/dist/auth/sessionMint.d.ts.map +0 -1
- package/dist/auth/sessionMint.js.map +0 -1
- package/dist/transport/connection/credentialLifecycle.d.ts.map +0 -1
- package/dist/transport/connection/credentialLifecycle.js.map +0 -1
|
@@ -2,8 +2,15 @@ import {
|
|
|
2
2
|
AbloClaimedError,
|
|
3
3
|
AbloConnectionError,
|
|
4
4
|
AbloSessionError,
|
|
5
|
-
|
|
5
|
+
isAccessCredentialExpiryCloseReason,
|
|
6
6
|
} from '../../errors.js';
|
|
7
|
+
import { claimAdmissionError } from '../../claims/admission.js';
|
|
8
|
+
import { classifyCredentialKind } from '../../auth/credentialKind.js';
|
|
9
|
+
import {
|
|
10
|
+
credentialToken,
|
|
11
|
+
type CredentialProviderResult,
|
|
12
|
+
} from '../../auth/credentialResult.js';
|
|
13
|
+
import { CredentialLifecycle } from '../../sessions/lifecycle.js';
|
|
7
14
|
import {
|
|
8
15
|
clientSyncDeltaSchema,
|
|
9
16
|
type ClientSyncDelta,
|
|
@@ -21,7 +28,6 @@ import type {
|
|
|
21
28
|
WebSocketObserveOptions,
|
|
22
29
|
WebSocketObservedDelta,
|
|
23
30
|
WebSocketPresence,
|
|
24
|
-
WebSocketJoinInput,
|
|
25
31
|
WebSocketSessionOptions,
|
|
26
32
|
OpenCollaborationEvents,
|
|
27
33
|
} from './sessionContract.js';
|
|
@@ -32,6 +38,10 @@ interface StoredPosition {
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
const MAX_BUFFERED_OBSERVATION_DELTAS = 1_024;
|
|
41
|
+
const ignoreObservedAcknowledgeFailure = (): undefined => undefined;
|
|
42
|
+
// Preserve the originating claim wait error when best-effort queue cleanup
|
|
43
|
+
// fails for the same disconnected transport.
|
|
44
|
+
const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
|
|
35
45
|
|
|
36
46
|
function parsePosition(value: string | null): StoredPosition {
|
|
37
47
|
if (!value) return { lastSyncId: 0, cursor: null };
|
|
@@ -125,17 +135,29 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
125
135
|
private closed = false;
|
|
126
136
|
private currentToken: string;
|
|
127
137
|
private acknowledgeLane: Promise<void> = Promise.resolve();
|
|
138
|
+
private terminalError: Error | null = null;
|
|
139
|
+
private readonly terminalListeners = new Set<(error: Error) => void>();
|
|
140
|
+
private readonly credentialLifecycle: CredentialLifecycle | null;
|
|
141
|
+
private pendingSessionError: Error | null = null;
|
|
128
142
|
|
|
129
143
|
readonly presence: WebSocketPresence;
|
|
130
144
|
readonly collaboration: WebSocketCollaboration<TEvents>;
|
|
131
145
|
|
|
132
|
-
constructor(
|
|
146
|
+
constructor(
|
|
147
|
+
options: WebSocketSessionOptions,
|
|
148
|
+
position: StoredPosition,
|
|
149
|
+
credential: CredentialProviderResult,
|
|
150
|
+
) {
|
|
133
151
|
this.options = options;
|
|
152
|
+
const token = credentialToken(credential);
|
|
153
|
+
if (!token) {
|
|
154
|
+
throw new AbloSessionError('The WebSocket transport requires an authenticated credential.');
|
|
155
|
+
}
|
|
134
156
|
this.currentToken = token;
|
|
135
157
|
this.cursorKey = options.cursorKey ?? 'websocket';
|
|
136
158
|
this.socket = new AgentWebSocket<TEvents>({
|
|
137
159
|
baseUrl: options.baseUrl,
|
|
138
|
-
kind: 'agent',
|
|
160
|
+
kind: classifyCredentialKind(token) === 'ephemeral' ? 'user' : 'agent',
|
|
139
161
|
getAuthToken: () => this.currentToken,
|
|
140
162
|
syncGroups: [...(options.syncGroups ?? [])],
|
|
141
163
|
collaborationEvents: [...(options.collaborationEvents ?? [])],
|
|
@@ -148,23 +170,73 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
148
170
|
this.collaboration = {
|
|
149
171
|
send: (event, payload) => this.socket.sendCollaborationEvent(event, payload),
|
|
150
172
|
} as WebSocketCollaboration<TEvents>;
|
|
151
|
-
this.socket.subscribe('session_error', () => {
|
|
152
|
-
|
|
173
|
+
this.socket.subscribe('session_error', (error) => {
|
|
174
|
+
this.handleSessionError(error);
|
|
153
175
|
});
|
|
176
|
+
this.credentialLifecycle = options.access.renewable
|
|
177
|
+
? new CredentialLifecycle({
|
|
178
|
+
setAuthToken: (freshToken) => {
|
|
179
|
+
this.currentToken = freshToken;
|
|
180
|
+
},
|
|
181
|
+
nudgeReconnect: () => {
|
|
182
|
+
if (this.closed || this.terminalError) return;
|
|
183
|
+
this.socket.clearSessionError();
|
|
184
|
+
this.socket.resetReconnectAttempts();
|
|
185
|
+
this.socket.connect();
|
|
186
|
+
},
|
|
187
|
+
reportSessionExpired: () => {
|
|
188
|
+
this.failTerminal(this.pendingSessionError ?? new AbloSessionError(
|
|
189
|
+
'The application session ended while renewing its Ablo session.',
|
|
190
|
+
));
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
: null;
|
|
194
|
+
if (this.credentialLifecycle) {
|
|
195
|
+
this.credentialLifecycle.start(() => this.loadCredential(), { proactiveInNode: true });
|
|
196
|
+
this.credentialLifecycle.accept(credential);
|
|
197
|
+
}
|
|
154
198
|
}
|
|
155
199
|
|
|
156
|
-
private async
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
private async loadCredential(): Promise<CredentialProviderResult> {
|
|
201
|
+
return this.options.access.credential();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private handleSessionError(error: Error): void {
|
|
205
|
+
if (
|
|
206
|
+
!this.credentialLifecycle
|
|
207
|
+
|| !isAccessCredentialExpiryCloseReason(error.message)
|
|
208
|
+
) {
|
|
209
|
+
this.failTerminal(error);
|
|
210
|
+
return;
|
|
167
211
|
}
|
|
212
|
+
this.pendingSessionError = error;
|
|
213
|
+
void this.credentialLifecycle
|
|
214
|
+
.recoverFromAuthRejection('access_credential_expiry')
|
|
215
|
+
.catch((recoveryError: unknown) => {
|
|
216
|
+
this.failTerminal(
|
|
217
|
+
recoveryError instanceof Error
|
|
218
|
+
? recoveryError
|
|
219
|
+
: new AbloConnectionError('The WebSocket credential recovery failed.'),
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private failTerminal(error: Error): void {
|
|
225
|
+
if (this.terminalError || this.closed) return;
|
|
226
|
+
this.terminalError = error;
|
|
227
|
+
this.socket.setSessionErrorDetected();
|
|
228
|
+
this.socket.disconnect();
|
|
229
|
+
for (const listener of this.terminalListeners) listener(error);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private onTerminal(listener: (error: Error) => void): () => void {
|
|
233
|
+
if (this.terminalError) {
|
|
234
|
+
const error = this.terminalError;
|
|
235
|
+
queueMicrotask(() => listener(error));
|
|
236
|
+
return () => undefined;
|
|
237
|
+
}
|
|
238
|
+
this.terminalListeners.add(listener);
|
|
239
|
+
return () => this.terminalListeners.delete(listener);
|
|
168
240
|
}
|
|
169
241
|
|
|
170
242
|
get connected(): boolean {
|
|
@@ -175,6 +247,7 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
175
247
|
if (this.closed) {
|
|
176
248
|
return Promise.reject(new AbloConnectionError('The WebSocket session is closed.'));
|
|
177
249
|
}
|
|
250
|
+
if (this.terminalError) return Promise.reject(this.terminalError);
|
|
178
251
|
if (this.socket.isConnected()) return Promise.resolve();
|
|
179
252
|
if (this.readyPromise) return this.readyPromise;
|
|
180
253
|
this.readyPromise = new Promise<void>((resolve, reject) => {
|
|
@@ -190,7 +263,7 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
190
263
|
cleanup();
|
|
191
264
|
resolve();
|
|
192
265
|
});
|
|
193
|
-
const failed = this.
|
|
266
|
+
const failed = this.onTerminal((error) => {
|
|
194
267
|
rejectConnection(error);
|
|
195
268
|
});
|
|
196
269
|
const mismatch = this.socket.subscribe('protocol_mismatch', () => {
|
|
@@ -223,14 +296,27 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
223
296
|
|
|
224
297
|
async claim(input: WebSocketClaimInput) {
|
|
225
298
|
await this.ready();
|
|
226
|
-
const timeoutMs = input.timeoutMs ?? 15_000;
|
|
227
299
|
return new Promise<import('../../coordination/schema.js').ClaimAcquired | import('../../coordination/schema.js').ClaimGranted>((resolve, reject) => {
|
|
228
300
|
const cleanups: (() => void)[] = [];
|
|
301
|
+
let finished = false;
|
|
229
302
|
const finish = (outcome: () => void): void => {
|
|
230
|
-
|
|
303
|
+
if (finished) return;
|
|
304
|
+
finished = true;
|
|
305
|
+
if (timeout) clearTimeout(timeout);
|
|
231
306
|
for (const cleanup of cleanups) cleanup();
|
|
232
307
|
outcome();
|
|
233
308
|
};
|
|
309
|
+
const abandonAndReject = (error: Error): void => {
|
|
310
|
+
if (finished) return;
|
|
311
|
+
finished = true;
|
|
312
|
+
if (timeout) clearTimeout(timeout);
|
|
313
|
+
for (const cleanup of cleanups) cleanup();
|
|
314
|
+
void this.release({
|
|
315
|
+
claimId: input.claimId,
|
|
316
|
+
entityType: input.entityType,
|
|
317
|
+
entityId: input.entityId,
|
|
318
|
+
}).catch(ignoreBestEffortClaimReleaseFailure).then(() => reject(error));
|
|
319
|
+
};
|
|
234
320
|
const matches = (event: { claimId: string }): boolean => event.claimId === input.claimId;
|
|
235
321
|
cleanups.push(this.socket.subscribe('claim_acquired', (event) => {
|
|
236
322
|
if (matches(event)) finish(() => resolve(event));
|
|
@@ -242,27 +328,12 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
242
328
|
if (!matches(event)) return;
|
|
243
329
|
const refusal = input.onQueued?.(event);
|
|
244
330
|
if (refusal) {
|
|
245
|
-
|
|
246
|
-
claimId: input.claimId,
|
|
247
|
-
entityType: input.entityType,
|
|
248
|
-
entityId: input.entityId,
|
|
249
|
-
});
|
|
250
|
-
finish(() => reject(refusal));
|
|
331
|
+
abandonAndReject(refusal);
|
|
251
332
|
}
|
|
252
333
|
}));
|
|
253
334
|
cleanups.push(this.socket.subscribe('claim_rejected', (event) => {
|
|
254
335
|
if (!matches(event)) return;
|
|
255
|
-
finish(() => reject(
|
|
256
|
-
event.message ?? `Claim ${event.claimId} was rejected.`,
|
|
257
|
-
{
|
|
258
|
-
code: event.reason === 'conflict' ? 'claim_conflict' : 'claim_rejected',
|
|
259
|
-
details: {
|
|
260
|
-
...(event.target ? { target: event.target } : {}),
|
|
261
|
-
...(event.heldBy ? { heldBy: event.heldBy } : {}),
|
|
262
|
-
...(event.heldByClaimId ? { heldByClaimId: event.heldByClaimId } : {}),
|
|
263
|
-
},
|
|
264
|
-
},
|
|
265
|
-
)));
|
|
336
|
+
finish(() => reject(claimAdmissionError(event)));
|
|
266
337
|
}));
|
|
267
338
|
cleanups.push(this.socket.subscribe('disconnected', () => {
|
|
268
339
|
finish(() => reject(new AbloConnectionError(
|
|
@@ -270,29 +341,21 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
270
341
|
)));
|
|
271
342
|
}));
|
|
272
343
|
const abort = (): void => {
|
|
273
|
-
|
|
274
|
-
claimId: input.claimId,
|
|
275
|
-
entityType: input.entityType,
|
|
276
|
-
entityId: input.entityId,
|
|
277
|
-
});
|
|
278
|
-
finish(() => reject(new AbloClaimedError(
|
|
344
|
+
abandonAndReject(new AbloClaimedError(
|
|
279
345
|
`The wait for claim ${input.claimId} was aborted.`,
|
|
280
346
|
{ code: 'claim_wait_aborted' },
|
|
281
|
-
))
|
|
347
|
+
));
|
|
282
348
|
};
|
|
283
349
|
input.signal?.addEventListener('abort', abort, { once: true });
|
|
284
350
|
cleanups.push(() => input.signal?.removeEventListener('abort', abort));
|
|
285
|
-
const timeout =
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
{ code: 'grant_timeout' },
|
|
294
|
-
)));
|
|
295
|
-
}, timeoutMs);
|
|
351
|
+
const timeout = input.timeoutMs !== undefined
|
|
352
|
+
? setTimeout(() => {
|
|
353
|
+
abandonAndReject(new AbloClaimedError(
|
|
354
|
+
`claim timed out after ${input.timeoutMs}ms (claimId=${input.claimId})`,
|
|
355
|
+
{ code: 'grant_timeout' },
|
|
356
|
+
));
|
|
357
|
+
}, input.timeoutMs)
|
|
358
|
+
: undefined;
|
|
296
359
|
if (input.signal?.aborted) {
|
|
297
360
|
abort();
|
|
298
361
|
return;
|
|
@@ -307,23 +370,42 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
307
370
|
});
|
|
308
371
|
}
|
|
309
372
|
|
|
310
|
-
release(input: {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
373
|
+
async release(input: {
|
|
374
|
+
claimId: string;
|
|
375
|
+
entityType?: string;
|
|
376
|
+
entityId?: string;
|
|
377
|
+
}): Promise<void> {
|
|
315
378
|
await this.ready();
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
379
|
+
const requestId = crypto.randomUUID();
|
|
380
|
+
await new Promise<void>((resolve, reject) => {
|
|
381
|
+
const finish = (outcome: () => void): void => {
|
|
382
|
+
clearTimeout(timeout);
|
|
383
|
+
offAck();
|
|
384
|
+
offDisconnect();
|
|
385
|
+
outcome();
|
|
386
|
+
};
|
|
387
|
+
const offAck = this.socket.subscribe('claim_abandon_ack', (event) => {
|
|
388
|
+
if (event.requestId !== requestId || event.claimId !== input.claimId) return;
|
|
389
|
+
finish(resolve);
|
|
390
|
+
});
|
|
391
|
+
const offDisconnect = this.socket.subscribe('disconnected', () => {
|
|
392
|
+
finish(() => reject(new AbloConnectionError(
|
|
393
|
+
`WebSocket closed before claim ${input.claimId} release was confirmed.`,
|
|
394
|
+
)));
|
|
395
|
+
});
|
|
396
|
+
const timeout = setTimeout(() => {
|
|
397
|
+
finish(() => reject(new AbloConnectionError(
|
|
398
|
+
`Timed out waiting for claim ${input.claimId} release confirmation.`,
|
|
399
|
+
{ code: 'claim_lease_unavailable' },
|
|
400
|
+
)));
|
|
401
|
+
}, 15_000);
|
|
402
|
+
this.socket.send({
|
|
403
|
+
type: 'claim_abandon',
|
|
404
|
+
payload: { ...input, requestId },
|
|
405
|
+
});
|
|
320
406
|
});
|
|
321
407
|
}
|
|
322
408
|
|
|
323
|
-
leave(claimId: string): void {
|
|
324
|
-
this.socket.sendRelease(claimId);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
409
|
subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
|
|
328
410
|
event: K,
|
|
329
411
|
listener: (...args: SyncWebSocketEventMap<TEvents>[K]) => void,
|
|
@@ -357,7 +439,7 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
357
439
|
wake?.();
|
|
358
440
|
wake = undefined;
|
|
359
441
|
});
|
|
360
|
-
const offError = this.
|
|
442
|
+
const offError = this.onTerminal((error) => {
|
|
361
443
|
failure = error;
|
|
362
444
|
wake?.();
|
|
363
445
|
wake = undefined;
|
|
@@ -408,19 +490,21 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
|
|
|
408
490
|
}
|
|
409
491
|
|
|
410
492
|
async acknowledge(lastSyncId: number): Promise<void> {
|
|
411
|
-
const attempt = this.acknowledgeLane.catch(
|
|
493
|
+
const attempt = this.acknowledgeLane.catch(ignoreObservedAcknowledgeFailure).then(async () => {
|
|
412
494
|
const position = this.socket.positionAfter(lastSyncId);
|
|
413
495
|
await this.options.cursorStore?.save(this.cursorKey, JSON.stringify(position));
|
|
414
496
|
this.socket.markDurable(position);
|
|
415
497
|
this.socket.acknowledge(position.lastSyncId);
|
|
416
498
|
});
|
|
417
|
-
this.acknowledgeLane = attempt.catch(
|
|
499
|
+
this.acknowledgeLane = attempt.catch(ignoreObservedAcknowledgeFailure);
|
|
418
500
|
await attempt;
|
|
419
501
|
}
|
|
420
502
|
|
|
421
503
|
async close(): Promise<void> {
|
|
422
504
|
if (this.closed) return;
|
|
423
505
|
this.closed = true;
|
|
506
|
+
this.credentialLifecycle?.stop();
|
|
507
|
+
this.terminalListeners.clear();
|
|
424
508
|
this.rejectReady?.(new AbloConnectionError('The WebSocket session was disposed.'));
|
|
425
509
|
await this.acknowledgeLane;
|
|
426
510
|
this.socket.disconnect();
|
|
@@ -439,11 +523,16 @@ export async function createWebSocketSession<
|
|
|
439
523
|
): Promise<AbloWebSocketSession<TEvents>> {
|
|
440
524
|
const cursorKey = options.cursorKey ?? 'websocket';
|
|
441
525
|
const stored = await options.cursorStore?.load(cursorKey) ?? null;
|
|
442
|
-
const
|
|
526
|
+
const credential = await options.access.credential();
|
|
527
|
+
const token = credentialToken(credential);
|
|
443
528
|
if (!token) {
|
|
444
529
|
throw new AbloSessionError('The WebSocket transport requires an authenticated credential.');
|
|
445
530
|
}
|
|
446
|
-
const session = new WebSocketSession<TEvents>(
|
|
531
|
+
const session = new WebSocketSession<TEvents>(
|
|
532
|
+
options,
|
|
533
|
+
parsePosition(stored),
|
|
534
|
+
credential,
|
|
535
|
+
);
|
|
447
536
|
let rejectOpening: ((error: Error) => void) | undefined;
|
|
448
537
|
const aborted = new Promise<never>((_resolve, reject) => {
|
|
449
538
|
rejectOpening = reject;
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from '../../coordination/schema.js';
|
|
9
9
|
import type { ClientSyncDelta } from '../../observation/contract.js';
|
|
10
10
|
import type { ObserveCursorStore } from '../../client/contract.js';
|
|
11
|
+
import type { SessionAccess } from '../../sessions/source.js';
|
|
11
12
|
import type { CommitFrameOperation } from '../websocket/commitFrames.js';
|
|
12
13
|
import type {
|
|
13
14
|
CoreSyncEventMap,
|
|
@@ -18,7 +19,8 @@ import type {
|
|
|
18
19
|
|
|
19
20
|
export interface WebSocketSessionOptions {
|
|
20
21
|
readonly baseUrl?: string;
|
|
21
|
-
|
|
22
|
+
/** Normalized once at the client boundary; transports do not infer policy. */
|
|
23
|
+
readonly access: SessionAccess;
|
|
22
24
|
readonly syncGroups?: readonly string[];
|
|
23
25
|
readonly collaborationEvents?: readonly string[];
|
|
24
26
|
readonly cursorKey?: string;
|
|
@@ -44,15 +46,6 @@ export interface WebSocketObserveOptions {
|
|
|
44
46
|
readonly signal?: AbortSignal;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
/** A multiplexed participant-scope registration on the shared socket. */
|
|
48
|
-
export interface WebSocketJoinInput {
|
|
49
|
-
readonly claimId: string;
|
|
50
|
-
readonly syncGroups: readonly string[];
|
|
51
|
-
readonly capabilityToken?: string;
|
|
52
|
-
readonly ttlSeconds?: number;
|
|
53
|
-
readonly timeoutMs?: number;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
49
|
/** A durable row/field claim carried over the same live protocol. */
|
|
57
50
|
export type WebSocketClaimInput = ClaimBeginPayload & {
|
|
58
51
|
readonly timeoutMs?: number;
|
|
@@ -94,9 +87,7 @@ export interface AbloWebSocketSession<
|
|
|
94
87
|
readonly claimId: string;
|
|
95
88
|
readonly entityType?: string;
|
|
96
89
|
readonly entityId?: string;
|
|
97
|
-
}): void
|
|
98
|
-
join(input: WebSocketJoinInput): Promise<{ syncGroups: string[]; ttlSeconds?: number }>;
|
|
99
|
-
leave(claimId: string): void;
|
|
90
|
+
}): Promise<void>;
|
|
100
91
|
subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
|
|
101
92
|
event: K,
|
|
102
93
|
listener: (...args: SyncWebSocketEventMap<TEvents>[K]) => void,
|
|
@@ -22,28 +22,23 @@ import { EventEmitter } from 'events';
|
|
|
22
22
|
import type { ParticipantKind } from '../../types/participant.js';
|
|
23
23
|
import {
|
|
24
24
|
AbloConnectionError,
|
|
25
|
-
AbloError,
|
|
26
25
|
AbloSessionError,
|
|
27
26
|
AbloValidationError,
|
|
28
27
|
toAbloError,
|
|
29
28
|
} from '../../errors.js';
|
|
30
|
-
import {
|
|
31
|
-
participantClaimPayloadSchema,
|
|
32
|
-
updateSubscriptionPayloadSchema,
|
|
33
|
-
} from '../../coordination/schema.js';
|
|
29
|
+
import { updateSubscriptionPayloadSchema } from '../../coordination/schema.js';
|
|
34
30
|
import type { BootstrapReason } from '../../wire/bootstrapReason.js';
|
|
35
31
|
import type { ClientSyncDelta } from '../../observation/contract.js';
|
|
36
32
|
import type {
|
|
37
33
|
ClaimAcquired,
|
|
34
|
+
ClaimAbandonAckPayload,
|
|
38
35
|
PresenceUpdate,
|
|
39
|
-
ClaimExpired,
|
|
40
36
|
ClaimGranted,
|
|
41
37
|
ClaimHeartbeatAckPayload,
|
|
42
38
|
ClaimLost,
|
|
43
39
|
ClaimQueue,
|
|
44
40
|
ClaimQueued,
|
|
45
41
|
ClaimRejection,
|
|
46
|
-
ParticipantClaimPayload,
|
|
47
42
|
ReadDependency,
|
|
48
43
|
WireClaim,
|
|
49
44
|
} from '../../coordination/schema.js';
|
|
@@ -60,7 +55,6 @@ import {
|
|
|
60
55
|
readWsInboundFrame,
|
|
61
56
|
type WsSession,
|
|
62
57
|
type PendingCommit,
|
|
63
|
-
type PendingClaim,
|
|
64
58
|
type PendingSubscription,
|
|
65
59
|
} from './frameHandlers.js';
|
|
66
60
|
import { HeartbeatController } from './heartbeat.js';
|
|
@@ -213,14 +207,6 @@ export interface CoreSyncEventMap {
|
|
|
213
207
|
*/
|
|
214
208
|
handshake_failed: [CloseEvent];
|
|
215
209
|
reconnect_failed: [{ attempts: number }];
|
|
216
|
-
/**
|
|
217
|
-
* Server-initiated notification that a previously-active claim's
|
|
218
|
-
* TTL has expired. Consumers (e.g., the participant SDK) re-mint
|
|
219
|
-
* a fresh capability and re-claim, OR accept the drop. The claim
|
|
220
|
-
* is already inactive on the server side by the time this fires —
|
|
221
|
-
* no client-side action needed unless re-claiming.
|
|
222
|
-
*/
|
|
223
|
-
claim_expired: [ClaimExpired];
|
|
224
210
|
/**
|
|
225
211
|
* Server rejected an `claim_begin` because another participant
|
|
226
212
|
* already holds an open claim on the same target (cooperative
|
|
@@ -243,6 +229,7 @@ export interface CoreSyncEventMap {
|
|
|
243
229
|
*/
|
|
244
230
|
claim_queue: [ClaimQueue];
|
|
245
231
|
claim_acquired: [ClaimAcquired];
|
|
232
|
+
claim_abandon_ack: [ClaimAbandonAckPayload];
|
|
246
233
|
claim_queued: [ClaimQueued];
|
|
247
234
|
claim_granted: [ClaimGranted];
|
|
248
235
|
claim_lost: [ClaimLost];
|
|
@@ -370,6 +357,10 @@ export class WsTransport<
|
|
|
370
357
|
* server rejected the upgrade" since browsers hide the HTTP status (e.g.
|
|
371
358
|
* 401) behind the opaque 1006 close code. */
|
|
372
359
|
private _everOpened = false;
|
|
360
|
+
/** True after any socket in this logical transport has opened. Unlike
|
|
361
|
+
* `_everOpened`, this survives socket replacement so a failed reconnect
|
|
362
|
+
* handshake is retried instead of being mistaken for a failed first login. */
|
|
363
|
+
private _hasConnected = false;
|
|
373
364
|
/**
|
|
374
365
|
* Diagnostic snapshot of the last connection lifecycle. Persisted across
|
|
375
366
|
* the lifetime of the transport so that any subsequent "not connected"
|
|
@@ -405,14 +396,6 @@ export class WsTransport<
|
|
|
405
396
|
*/
|
|
406
397
|
private pendingMutations = new Map<string, PendingCommit>();
|
|
407
398
|
|
|
408
|
-
/**
|
|
409
|
-
* In-flight `claim` requests keyed by claimId. Resolved when the matching
|
|
410
|
-
* `claim_ack` arrives, or rejected on timeout or disconnect — the same
|
|
411
|
-
* request/response pattern as `pendingMutations`, multiplexed over the one
|
|
412
|
-
* connection.
|
|
413
|
-
*/
|
|
414
|
-
private pendingClaims = new Map<string, PendingClaim>();
|
|
415
|
-
|
|
416
399
|
/**
|
|
417
400
|
* In-flight `update_subscription` frames awaiting `subscription_ack`.
|
|
418
401
|
* A FIFO queue rather than a keyed Map because the wire ack carries no
|
|
@@ -483,7 +466,6 @@ export class WsTransport<
|
|
|
483
466
|
logger: this.logger,
|
|
484
467
|
observability: this.observability,
|
|
485
468
|
pendingMutations: this.pendingMutations,
|
|
486
|
-
pendingClaims: this.pendingClaims,
|
|
487
469
|
shiftPendingSubscription: () => this.pendingSubscriptions.shift(),
|
|
488
470
|
options: this.options,
|
|
489
471
|
collaborationEventTypes: this.collaborationEventTypes,
|
|
@@ -731,6 +713,7 @@ export class WsTransport<
|
|
|
731
713
|
this.isConnecting = false;
|
|
732
714
|
this.reconnectAttempts = 0;
|
|
733
715
|
this._everOpened = true;
|
|
716
|
+
this._hasConnected = true;
|
|
734
717
|
this.lastOpenAt = Date.now();
|
|
735
718
|
this.emit('connected');
|
|
736
719
|
|
|
@@ -861,22 +844,6 @@ export class WsTransport<
|
|
|
861
844
|
this.pendingMutations.clear();
|
|
862
845
|
}
|
|
863
846
|
|
|
864
|
-
// Cancel in-flight claims — same rationale. Server-side
|
|
865
|
-
// claims are bound to the connection; a reconnect will need
|
|
866
|
-
// to re-claim. Higher-level retry belongs to whoever holds
|
|
867
|
-
// the participant handle (typically the SDK's claim manager).
|
|
868
|
-
if (this.pendingClaims.size > 0) {
|
|
869
|
-
for (const pending of this.pendingClaims.values()) {
|
|
870
|
-
clearTimeout(pending.timeout);
|
|
871
|
-
pending.reject(
|
|
872
|
-
new AbloConnectionError(
|
|
873
|
-
`WebSocket closed while claim was in flight (code=${event.code})`,
|
|
874
|
-
),
|
|
875
|
-
);
|
|
876
|
-
}
|
|
877
|
-
this.pendingClaims.clear();
|
|
878
|
-
}
|
|
879
|
-
|
|
880
847
|
// Cancel in-flight subscription updates — the reconnect handshake
|
|
881
848
|
// re-sends `options.syncGroups` (the last acked interest) in the
|
|
882
849
|
// upgrade URL, so a pending change that never acked is simply
|
|
@@ -946,7 +913,7 @@ export class WsTransport<
|
|
|
946
913
|
// from a transient network issue and transition the UI accordingly.
|
|
947
914
|
// Reconnecting blindly is what produced the infinite
|
|
948
915
|
// "offline → reconnecting → offline" loop on stale cookies.
|
|
949
|
-
if (!everOpened && !this.isManualClose) {
|
|
916
|
+
if (!everOpened && !this._hasConnected && !this.isManualClose) {
|
|
950
917
|
this.observability.captureWebSocketError({
|
|
951
918
|
context: 'handshake-failed-close',
|
|
952
919
|
code: event.code,
|
|
@@ -1172,100 +1139,6 @@ export class WsTransport<
|
|
|
1172
1139
|
this.ws.send(JSON.stringify(frame));
|
|
1173
1140
|
}
|
|
1174
1141
|
|
|
1175
|
-
/**
|
|
1176
|
-
* Activates a participant claim on this connection. One connection can hold
|
|
1177
|
-
* several concurrent claims at once, each scoped to a different set of sync
|
|
1178
|
-
* groups, so the SDK reuses the existing connection instead of opening a
|
|
1179
|
-
* separate socket per scope.
|
|
1180
|
-
*
|
|
1181
|
-
* Returns a promise that resolves with the server-canonicalized `syncGroups`
|
|
1182
|
-
* and effective `ttlSeconds` once `claim_ack` arrives, or rejects with a typed
|
|
1183
|
-
* error on a failed ack, a timeout, or a disconnect.
|
|
1184
|
-
*/
|
|
1185
|
-
sendClaim(
|
|
1186
|
-
claimId: string,
|
|
1187
|
-
syncGroups: readonly string[],
|
|
1188
|
-
options?: Pick<ParticipantClaimPayload, 'capabilityToken' | 'ttlSeconds'> & {
|
|
1189
|
-
timeoutMs?: number;
|
|
1190
|
-
},
|
|
1191
|
-
): Promise<{ syncGroups: string[]; ttlSeconds?: number }> {
|
|
1192
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
1193
|
-
return Promise.reject(this.notConnectedError('claim'));
|
|
1194
|
-
}
|
|
1195
|
-
// Checked against the schema the server ingests it with, for the same
|
|
1196
|
-
// reason `updateSubscription` below is: the two frames name their scopes
|
|
1197
|
-
// identically, so a group that would be refused there is refused here, at
|
|
1198
|
-
// the call that asked for it, rather than coming back as a failed ack a
|
|
1199
|
-
// round trip later with nothing to point at.
|
|
1200
|
-
const payload = participantClaimPayloadSchema.safeParse({
|
|
1201
|
-
claimId,
|
|
1202
|
-
syncGroups: [...syncGroups],
|
|
1203
|
-
capabilityToken: options?.capabilityToken,
|
|
1204
|
-
ttlSeconds: options?.ttlSeconds,
|
|
1205
|
-
});
|
|
1206
|
-
if (!payload.success) {
|
|
1207
|
-
return Promise.reject(
|
|
1208
|
-
new AbloValidationError(
|
|
1209
|
-
`join was given a sync group the protocol does not accept: ${payload.error.issues[0]?.message ?? 'unreadable'}. A group is 'default' or 'kind:id'.`,
|
|
1210
|
-
{ code: 'malformed_claim' },
|
|
1211
|
-
),
|
|
1212
|
-
);
|
|
1213
|
-
}
|
|
1214
|
-
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
1215
|
-
return new Promise((resolve, reject) => {
|
|
1216
|
-
const timeout = setTimeout(() => {
|
|
1217
|
-
this.pendingClaims.delete(claimId);
|
|
1218
|
-
reject(
|
|
1219
|
-
new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
|
|
1220
|
-
code: 'wait_for_timeout',
|
|
1221
|
-
}),
|
|
1222
|
-
);
|
|
1223
|
-
}, timeoutMs);
|
|
1224
|
-
this.pendingClaims.set(claimId, { resolve, reject, timeout });
|
|
1225
|
-
try {
|
|
1226
|
-
this.ws!.send(JSON.stringify({ type: 'claim', payload: payload.data }));
|
|
1227
|
-
} catch (error) {
|
|
1228
|
-
clearTimeout(timeout);
|
|
1229
|
-
this.pendingClaims.delete(claimId);
|
|
1230
|
-
reject(toAbloError(error));
|
|
1231
|
-
}
|
|
1232
|
-
});
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
/**
|
|
1236
|
-
* Drop a previously-active claim. Idempotent — `release` is
|
|
1237
|
-
* fire-and-forget per the wire contract; the server accepts
|
|
1238
|
-
* unknown claimIds silently so disconnect-time release storms
|
|
1239
|
-
* never error. No ack is expected.
|
|
1240
|
-
*
|
|
1241
|
-
* If a claim's send promise is still pending (no claim_ack yet),
|
|
1242
|
-
* we reject it locally — the user explicitly chose to release.
|
|
1243
|
-
*/
|
|
1244
|
-
sendRelease(claimId: string): void {
|
|
1245
|
-
// Cancel any in-flight claim that hadn't acked yet — the user
|
|
1246
|
-
// changed their mind. Without this the timer would eventually
|
|
1247
|
-
// reject; doing it now matches the user's claim immediately.
|
|
1248
|
-
const pending = this.pendingClaims.get(claimId);
|
|
1249
|
-
if (pending) {
|
|
1250
|
-
clearTimeout(pending.timeout);
|
|
1251
|
-
this.pendingClaims.delete(claimId);
|
|
1252
|
-
pending.reject(
|
|
1253
|
-
new AbloError(`claim ${claimId} released before ack`, {
|
|
1254
|
-
code: 'claim_wait_aborted',
|
|
1255
|
-
httpStatus: 409,
|
|
1256
|
-
}),
|
|
1257
|
-
);
|
|
1258
|
-
}
|
|
1259
|
-
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
1260
|
-
try {
|
|
1261
|
-
this.ws.send(
|
|
1262
|
-
JSON.stringify({ type: 'release', payload: { claimId } }),
|
|
1263
|
-
);
|
|
1264
|
-
} catch {
|
|
1265
|
-
// Idempotent contract — silent failure is acceptable here.
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
1142
|
/**
|
|
1270
1143
|
* Moves this connection's read interest — replaces the connection-level sync
|
|
1271
1144
|
* groups mid-session as the user opens and closes entities. This is the
|
|
@@ -1443,8 +1316,13 @@ export class WsTransport<
|
|
|
1443
1316
|
}
|
|
1444
1317
|
|
|
1445
1318
|
if (this.ws) {
|
|
1446
|
-
|
|
1319
|
+
// Detach before close: Node/undici may synchronously fire `onerror` from
|
|
1320
|
+
// close(). The handler's stale-socket guard then suppresses that expected
|
|
1321
|
+
// manual-close error instead of emitting EventEmitter's fatal unhandled
|
|
1322
|
+
// `error` event. `onclose` deliberately accepts a null owner below.
|
|
1323
|
+
const socket = this.ws;
|
|
1447
1324
|
this.ws = null;
|
|
1325
|
+
socket.close(1000, 'Manual disconnect');
|
|
1448
1326
|
}
|
|
1449
1327
|
}
|
|
1450
1328
|
|