@cortexkit/claustrum-client 0.11.0 → 0.13.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/README.md +85 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/watch.d.ts +38 -0
- package/dist/watch.js +104 -0
- package/dist/wire.d.ts +3 -0
- package/dist/wire.js +68 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,21 @@ credential caching, refresh, account scheduling, and retry policy.
|
|
|
23
23
|
as a version older than every real one.
|
|
24
24
|
• Unknown server error classes are bounded to `transient`; callers must retry them
|
|
25
25
|
rather than treating a forward-compatible class as permanent.
|
|
26
|
+
• **Changed in 0.13.0:** a transport `Error` frame whose answer no retry or reconnect
|
|
27
|
+
can change is reported as `permanent` with action `gone`, and the client no longer
|
|
28
|
+
reconnects for it: `unknown_method`, `invalid_params`, `invalid_request`,
|
|
29
|
+
`flow_scopes_unsupported`, `invalid_control_body`, `unknown_module`, and the SDK's
|
|
30
|
+
pre-send `missing_identity`. These are not verdicts about a credential: do not mark
|
|
31
|
+
anything gone or needing re-authentication on them; stop retrying and surface the
|
|
32
|
+
code. `unknown_method` from a vault means the vault predates that operation (for
|
|
33
|
+
example an enrollment op against a vault older than the enrollment ceremony):
|
|
34
|
+
update the vault. `watchScoped` is the exception: there `unknown_method` resolves
|
|
35
|
+
`closed` as `unsupported`, the signal to fall back to polling `listScoped`. Before 0.13.0 these were `transient`/`retry`. Every other
|
|
36
|
+
transport failure is still `transient`/`retry`.
|
|
37
|
+
• Since 0.13.0, when a terminal transport failure makes the client reconnect and the
|
|
38
|
+
reconnect or the retried call then fails, the error carries the ORIGINAL call's code
|
|
39
|
+
and class. Earlier versions threw the reconnect's error instead, usually
|
|
40
|
+
`transport_error`, hiding the original call's code.
|
|
26
41
|
• `listScoped` rows always carry `providerIds` (a required field since 0.6.0, so code
|
|
27
42
|
that builds rows by hand, such as test fixtures, must set it). An empty array means
|
|
28
43
|
no provider ids are set. Since 0.6.2 a daemon too old to send the field decodes the
|
|
@@ -47,6 +62,76 @@ copy. One behaviour reaches callers: while the vault is restarting or refusing r
|
|
|
47
62
|
opens as retryable, a call now keeps retrying for up to 90 s (paced, with jitter) instead
|
|
48
63
|
of 30 s. Give every vault call on a latency-sensitive path its own timeout.
|
|
49
64
|
|
|
65
|
+
## Watching for changes
|
|
66
|
+
|
|
67
|
+
Since 0.12.0, `client.watchScoped(onNotice, { enrollmentToken? })` holds an
|
|
68
|
+
`auth.watch_scoped` request on a dedicated route. It returns `{ stop(), closed }`.
|
|
69
|
+
`stop()` is idempotent and resolves `closed` as `{ kind: 'stopped' }`. Notices have
|
|
70
|
+
exactly `{ kind: 'changed', epoch, seq, view, keys }`; malformed notices reject
|
|
71
|
+
`closed` with `ScopedWatchError` (`code: 'invalid_notice'`). Callback exceptions
|
|
72
|
+
also reject it (`callback_failed`); async callbacks must handle their own rejection.
|
|
73
|
+
Sequence gaps and new epochs are delivered, not silently filtered.
|
|
74
|
+
|
|
75
|
+
**The caller owns reconnecting and re-subscribing.** Read `listScoped` on connect.
|
|
76
|
+
On every notice, re-read if `view` differs from the last listing actually read,
|
|
77
|
+
`epoch` is new, or `seq` skipped. Notices are sheddable hints, not an event journal.
|
|
78
|
+
`keys` matters only to consumers that announce vault-held public keys: re-read
|
|
79
|
+
those keys when that digest changes, independently of the inventory view.
|
|
80
|
+
|
|
81
|
+
`@cortexkit/subc-client` 0.22 reports Response (including refusal) and STREAM_END
|
|
82
|
+
frames as the same successful subscription close, without the Response body.
|
|
83
|
+
Every accepted vault watch immediately sends notice seq 1, so the client uses
|
|
84
|
+
whether any notices arrived to interpret that ambiguous close:
|
|
85
|
+
|
|
86
|
+
| `closed` outcome | Caller action |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `ended` (closed after notices) | Re-read and re-subscribe once for this close. A mid-watch revocation may take this extra round trip to discover. |
|
|
89
|
+
| `refused`, with the existing typed credential `error` | Stop. Never reconnect for that refusal. |
|
|
90
|
+
| `not_accepting` | Zero notices, then one successful `listScoped` confirmation. The vault may be draining: wait for reconnect or a caller-chosen delay, not a tight re-subscribe loop. |
|
|
91
|
+
| `transport_lost`, with the original SDK `error` (including `route_closed`) | Reconnect, re-read, then re-watch. |
|
|
92
|
+
| `unsupported` (`unknown_method`) | Fall back to polling `listScoped`. |
|
|
93
|
+
|
|
94
|
+
A zero-notice close performs exactly one `listScoped` using the same enrollment
|
|
95
|
+
token: its refusal becomes `refused`, its success becomes `not_accepting`. There is
|
|
96
|
+
no automatic retry or reconnection in that confirmation read. Malformed replies
|
|
97
|
+
reject `closed` with `ScopedWatchError('invalid_response')`.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
let client = await ClaustrumClient.connect(connectionOptions)
|
|
101
|
+
for (;;) {
|
|
102
|
+
let lastView = (await client.listScoped(token)).view // read on every connect/reopen
|
|
103
|
+
let epoch: string | undefined
|
|
104
|
+
let seq = 0
|
|
105
|
+
let reads = Promise.resolve()
|
|
106
|
+
let readError: unknown
|
|
107
|
+
const watch = client.watchScoped((notice) => {
|
|
108
|
+
const discontinuity = notice.epoch !== epoch || notice.seq !== seq + 1
|
|
109
|
+
epoch = notice.epoch
|
|
110
|
+
seq = notice.seq
|
|
111
|
+
reads = reads.then(async () => {
|
|
112
|
+
if (discontinuity || notice.view !== lastView) {
|
|
113
|
+
const listing = await client.listScoped(token)
|
|
114
|
+
lastView = listing.view // acknowledge the digest actually read, not the hint
|
|
115
|
+
useInventory(listing)
|
|
116
|
+
}
|
|
117
|
+
}).catch((error) => { readError = error; watch.stop() })
|
|
118
|
+
// Key-announcing callers separately compare notice.keys and re-read held keys.
|
|
119
|
+
}, { enrollmentToken: token })
|
|
120
|
+
let outcome
|
|
121
|
+
try { outcome = await watch.closed; await reads; if (readError) throw readError }
|
|
122
|
+
catch (error) { watch.stop(); throw error } // malformed notice/read/callback failure
|
|
123
|
+
if (outcome.kind === 'ended') continue // one new watch per close, never hidden retries
|
|
124
|
+
if (outcome.kind === 'transport_lost') {
|
|
125
|
+
client.close()
|
|
126
|
+
client = await ClaustrumClient.connect(connectionOptions)
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (outcome.kind === 'not_accepting') { await callerBackoff(); continue }
|
|
130
|
+
if (outcome.kind === 'unsupported') await pollScopedInventory(client, token)
|
|
131
|
+
break // refused or explicitly stopped
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
50
135
|
## OpenCode handle-file exports
|
|
51
136
|
|
|
52
137
|
`defaultHandleFilePath`, `readHandleFile`, `handleFileRevision`, and `parseHandleFile`
|
package/dist/index.d.ts
CHANGED
|
@@ -4,4 +4,5 @@ export { writeEnrollmentTokenFile, type EnrollmentTokenFile, } from './enrollmen
|
|
|
4
4
|
export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, type ManifestHandleAccount, type ManifestHandleFile, type ManifestHandleProvider, type ManifestLockError, type ManifestLockErrorCode, } from './manifest-lock.js';
|
|
5
5
|
export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, type ClaustrumCredentialErrorAction, type ClaustrumCredentialErrorClass, } from './errors.js';
|
|
6
6
|
export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, type ScopedGrantTuple, type ScopedInventory, type ScopedInventoryRow, type ServedCredential, } from './wire.js';
|
|
7
|
+
export { ScopedWatchError, type ScopedWatch, type ScopedWatchNotice, type ScopedWatchOptions, type ScopedWatchOutcome, } from './watch.js';
|
|
7
8
|
export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, type HandleAccount, type HandleFileIo, type HandleProvider, type OpenCodeHandleFileV1, } from './handles.js';
|
package/dist/index.js
CHANGED
|
@@ -4,4 +4,5 @@ export { writeEnrollmentTokenFile, } from './enrollment.js';
|
|
|
4
4
|
export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, } from './manifest-lock.js';
|
|
5
5
|
export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, } from './errors.js';
|
|
6
6
|
export { ClaustrumClient, } from './wire.js';
|
|
7
|
+
export { ScopedWatchError, } from './watch.js';
|
|
7
8
|
export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, } from './handles.js';
|
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BindIdentity, SubcClient } from '@cortexkit/subc-client';
|
|
2
|
+
import { ClaustrumCredentialError } from './errors.js';
|
|
3
|
+
export type ScopedWatchNotice = {
|
|
4
|
+
kind: 'changed';
|
|
5
|
+
epoch: string;
|
|
6
|
+
seq: number;
|
|
7
|
+
view: string;
|
|
8
|
+
keys: string;
|
|
9
|
+
};
|
|
10
|
+
export type ScopedWatchOutcome = {
|
|
11
|
+
kind: 'refused';
|
|
12
|
+
error: ClaustrumCredentialError;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'ended';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'not_accepting';
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'transport_lost';
|
|
19
|
+
error: unknown;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'unsupported';
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'stopped';
|
|
24
|
+
};
|
|
25
|
+
export declare class ScopedWatchError extends Error {
|
|
26
|
+
readonly code: 'invalid_notice' | 'callback_failed' | 'invalid_response';
|
|
27
|
+
constructor(code: 'invalid_notice' | 'callback_failed' | 'invalid_response');
|
|
28
|
+
}
|
|
29
|
+
export type ScopedWatch = {
|
|
30
|
+
stop(): void;
|
|
31
|
+
closed: Promise<ScopedWatchOutcome>;
|
|
32
|
+
};
|
|
33
|
+
export type ScopedWatchOptions = {
|
|
34
|
+
enrollmentToken?: string;
|
|
35
|
+
};
|
|
36
|
+
/** Decode only the notice schema; sequence gaps and new epochs belong to the consumer. */
|
|
37
|
+
export declare function decodeScopedWatchNotice(bytes: Uint8Array): ScopedWatchNotice;
|
|
38
|
+
export declare function openScopedWatch(client: Pick<SubcClient, 'routeOpen' | 'subscribe' | 'closeRoute'>, identity: BindIdentity, onNotice: (notice: ScopedWatchNotice) => void, options: ScopedWatchOptions, readScoped: () => Promise<unknown>): ScopedWatch;
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ClaustrumCredentialError } from './errors.js';
|
|
2
|
+
export class ScopedWatchError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(`Scoped watch failed: ${code}`);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = 'ScopedWatchError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Decode only the notice schema; sequence gaps and new epochs belong to the consumer. */
|
|
11
|
+
export function decodeScopedWatchNotice(bytes) {
|
|
12
|
+
try {
|
|
13
|
+
const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
14
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) ||
|
|
15
|
+
Object.keys(value).sort().join(',') !== 'epoch,keys,kind,seq,view' ||
|
|
16
|
+
value.kind !== 'changed' || typeof value.epoch !== 'string' || value.epoch.length === 0 ||
|
|
17
|
+
!Number.isSafeInteger(value.seq) || value.seq <= 0 ||
|
|
18
|
+
typeof value.view !== 'string' || typeof value.keys !== 'string')
|
|
19
|
+
throw new ScopedWatchError('invalid_notice');
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new ScopedWatchError('invalid_notice');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function openScopedWatch(client, identity, onNotice, options, readScoped) {
|
|
27
|
+
let settled = false;
|
|
28
|
+
let notices = 0;
|
|
29
|
+
let subscription;
|
|
30
|
+
let route;
|
|
31
|
+
let resolve;
|
|
32
|
+
let reject;
|
|
33
|
+
const closed = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
34
|
+
const finish = (outcome) => {
|
|
35
|
+
if (settled)
|
|
36
|
+
return;
|
|
37
|
+
settled = true;
|
|
38
|
+
subscription?.unsubscribe();
|
|
39
|
+
if (route)
|
|
40
|
+
void client.closeRoute(route).catch(() => { });
|
|
41
|
+
if (outcome instanceof ScopedWatchError)
|
|
42
|
+
reject(outcome);
|
|
43
|
+
else
|
|
44
|
+
resolve(outcome);
|
|
45
|
+
};
|
|
46
|
+
void (async () => {
|
|
47
|
+
try {
|
|
48
|
+
route = await client.routeOpen({ kind: 'management_surface', module_id: 'claustrum' }, identity, { consumerIdentity: null });
|
|
49
|
+
if (settled) {
|
|
50
|
+
await client.closeRoute(route);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
subscription = client.subscribe(route, {
|
|
54
|
+
method: 'auth.watch_scoped',
|
|
55
|
+
params: options.enrollmentToken === undefined ? {} : { enrollment_token: options.enrollmentToken },
|
|
56
|
+
}, (bytes) => {
|
|
57
|
+
if (settled)
|
|
58
|
+
return;
|
|
59
|
+
let notice;
|
|
60
|
+
try {
|
|
61
|
+
notice = decodeScopedWatchNotice(bytes);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
finish(new ScopedWatchError('invalid_notice'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
notices++;
|
|
68
|
+
try {
|
|
69
|
+
onNotice(notice);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
finish(new ScopedWatchError('callback_failed'));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
await subscription.closed;
|
|
76
|
+
if (settled)
|
|
77
|
+
return;
|
|
78
|
+
// The SDK erases Response bodies. Only an immediate close needs a confirming
|
|
79
|
+
// list read; after notices, the caller reopens once to discover a revocation.
|
|
80
|
+
if (notices > 0)
|
|
81
|
+
finish({ kind: 'ended' });
|
|
82
|
+
else {
|
|
83
|
+
try {
|
|
84
|
+
await readScoped();
|
|
85
|
+
finish({ kind: 'not_accepting' });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof ClaustrumCredentialError && error.code !== 'invalid_response') {
|
|
89
|
+
finish({ kind: 'refused', error });
|
|
90
|
+
}
|
|
91
|
+
else if (error instanceof ClaustrumCredentialError)
|
|
92
|
+
finish(new ScopedWatchError('invalid_response'));
|
|
93
|
+
else
|
|
94
|
+
finish({ kind: 'transport_lost', error });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const code = error !== null && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
100
|
+
finish(code === 'unknown_method' ? { kind: 'unsupported' } : { kind: 'transport_lost', error });
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
return { stop: () => finish({ kind: 'stopped' }), closed };
|
|
104
|
+
}
|
package/dist/wire.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SubcClient, type BindIdentity } from '@cortexkit/subc-client';
|
|
2
|
+
import { type ScopedWatch, type ScopedWatchNotice, type ScopedWatchOptions } from './watch.js';
|
|
2
3
|
export type ClaustrumConnector = (options: {
|
|
3
4
|
connectionFile: string;
|
|
4
5
|
handshakeTimeoutMs?: number;
|
|
@@ -107,6 +108,8 @@ export declare class ClaustrumClient {
|
|
|
107
108
|
* object, so a caller cannot ask a different question by omitting it.
|
|
108
109
|
*/
|
|
109
110
|
listScoped(enrollmentToken?: string): Promise<ScopedInventory>;
|
|
111
|
+
/** One watch on a dedicated route. Reopen/reconnect policy belongs to the caller. */
|
|
112
|
+
watchScoped(onNotice: (notice: ScopedWatchNotice) => void, options?: ScopedWatchOptions): ScopedWatch;
|
|
110
113
|
/**
|
|
111
114
|
* Fetch by credential id, authorized by a grant rather than a bearer handle.
|
|
112
115
|
*
|
package/dist/wire.js
CHANGED
|
@@ -2,8 +2,40 @@ import { SubcCallError, SubcClient, } from '@cortexkit/subc-client';
|
|
|
2
2
|
import { resolveClaustrumConnectionPath } from './detect.js';
|
|
3
3
|
import { asCredentialError, ClaustrumCredentialError, hasCredentialError, } from './errors.js';
|
|
4
4
|
import { storeIdentity } from './identity.js';
|
|
5
|
+
import { openScopedWatch } from './watch.js';
|
|
5
6
|
const CLAUSTRUM_MODULE_ID = 'claustrum';
|
|
6
7
|
const RECONNECT_BACKOFF_MS = 60_000;
|
|
8
|
+
/**
|
|
9
|
+
* Transport `Error` frame codes whose answer neither a retry nor a fresh connection can
|
|
10
|
+
* change. A call failing with one of these is reported as `permanent`/`gone` and is never
|
|
11
|
+
* reconnected; every other transport failure stays `transient`/`retry`.
|
|
12
|
+
*
|
|
13
|
+
* This is the ONLY list of such codes: the error classification and the reconnect
|
|
14
|
+
* decision both read it, so the two cannot disagree about a code.
|
|
15
|
+
*
|
|
16
|
+
* - `unknown_method`: the vault does not implement the operation. From a released vault
|
|
17
|
+
* this means the vault predates the operation, and the remedy is to update the vault.
|
|
18
|
+
* - `invalid_params`, `invalid_request`: the vault could not decode the request body or
|
|
19
|
+
* its params. The same bytes are refused the same way on any connection.
|
|
20
|
+
* - `flow_scopes_unsupported`: the vault refuses routes opened under a flow scope.
|
|
21
|
+
* - `invalid_control_body`: the vault build cannot decode the daemon's route bind.
|
|
22
|
+
* - `unknown_module`: the daemon has no module with this id registered or supervised at
|
|
23
|
+
* all. A vault that is restarting or warming up is reported as `module_warming` or
|
|
24
|
+
* `target_unavailable` instead, which the SDK already retries in place. A fresh
|
|
25
|
+
* connection to the same daemon gets the same answer, and the wasted reconnect would
|
|
26
|
+
* also start the reconnect backoff, leaving a genuinely wedged connection unable to
|
|
27
|
+
* recover for a minute.
|
|
28
|
+
* - `missing_identity`: the SDK refused before sending because no bind identity was set.
|
|
29
|
+
*/
|
|
30
|
+
const DEFINITIVE_TRANSPORT_CODES = new Set([
|
|
31
|
+
'unknown_method',
|
|
32
|
+
'invalid_params',
|
|
33
|
+
'invalid_request',
|
|
34
|
+
'flow_scopes_unsupported',
|
|
35
|
+
'invalid_control_body',
|
|
36
|
+
'unknown_module',
|
|
37
|
+
'missing_identity',
|
|
38
|
+
]);
|
|
7
39
|
function isRecord(value) {
|
|
8
40
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
41
|
}
|
|
@@ -163,6 +195,14 @@ export class ClaustrumClient {
|
|
|
163
195
|
const response = await this.#call('credential.list_scoped', enrollmentToken === undefined ? {} : { enrollment_token: enrollmentToken });
|
|
164
196
|
return decodeScopedInventory(response, this.#logger);
|
|
165
197
|
}
|
|
198
|
+
/** One watch on a dedicated route. Reopen/reconnect policy belongs to the caller. */
|
|
199
|
+
watchScoped(onNotice, options = {}) {
|
|
200
|
+
const client = this.#client;
|
|
201
|
+
return openScopedWatch(client, this.#identity, onNotice, options, async () => {
|
|
202
|
+
const response = await client.call('claustrum', 'credential.list_scoped', options.enrollmentToken === undefined ? {} : { enrollment_token: options.enrollmentToken }, { identity: this.#identity, consumerIdentity: null });
|
|
203
|
+
return decodeScopedInventory(response, this.#logger);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
166
206
|
/**
|
|
167
207
|
* Fetch by credential id, authorized by a grant rather than a bearer handle.
|
|
168
208
|
*
|
|
@@ -275,46 +315,45 @@ export class ClaustrumClient {
|
|
|
275
315
|
});
|
|
276
316
|
}
|
|
277
317
|
catch (error) {
|
|
278
|
-
if (this.#shouldReconnect(error))
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
318
|
+
if (!this.#shouldReconnect(error))
|
|
319
|
+
throw this.#asTransportError(error);
|
|
320
|
+
try {
|
|
321
|
+
if (this.#client === sentClient)
|
|
322
|
+
await this.#reconnect();
|
|
323
|
+
return await this.#client.call(CLAUSTRUM_MODULE_ID, method, params, {
|
|
324
|
+
identity: this.#identity,
|
|
325
|
+
consumerIdentity: null,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
catch (recoveryError) {
|
|
329
|
+
// Report the call's own failure, not the recovery's. A failed reconnect would
|
|
330
|
+
// otherwise replace the original call's code with `transport_error`, so
|
|
331
|
+
// the one actionable code would appear once and every later diagnostic would
|
|
332
|
+
// name a transport failure that never happened. Only a failure that carried no
|
|
333
|
+
// code at all has nothing to preserve.
|
|
334
|
+
throw this.#transportErrorCode(error) === undefined
|
|
335
|
+
? this.#asTransportError(recoveryError)
|
|
336
|
+
: this.#asTransportError(error);
|
|
295
337
|
}
|
|
296
|
-
throw this.#asTransportError(error);
|
|
297
338
|
}
|
|
298
339
|
}
|
|
340
|
+
#transportErrorCode(error) {
|
|
341
|
+
return error instanceof SubcCallError && error.code ? error.code : undefined;
|
|
342
|
+
}
|
|
299
343
|
#asTransportError(error) {
|
|
300
344
|
if (error instanceof ClaustrumCredentialError)
|
|
301
345
|
return error;
|
|
302
|
-
const code = error
|
|
303
|
-
|
|
346
|
+
const code = this.#transportErrorCode(error);
|
|
347
|
+
if (code !== undefined && DEFINITIVE_TRANSPORT_CODES.has(code)) {
|
|
348
|
+
return new ClaustrumCredentialError(code, 'permanent', 'gone');
|
|
349
|
+
}
|
|
350
|
+
return new ClaustrumCredentialError(code ?? 'transport_error', 'transient', 'retry');
|
|
304
351
|
}
|
|
305
352
|
#shouldReconnect(error) {
|
|
306
353
|
return (!this.#closed &&
|
|
307
354
|
error instanceof SubcCallError &&
|
|
308
355
|
error.kind === 'terminal' &&
|
|
309
|
-
error.code
|
|
310
|
-
error.code !== 'invalid_control_body' &&
|
|
311
|
-
// The daemon answers unknown_module only when no module with this id is
|
|
312
|
-
// registered or supervised at all; a vault that is restarting or warming up
|
|
313
|
-
// is reported as module_warming or target_unavailable, which the SDK already
|
|
314
|
-
// retries in place. A fresh connection to the same daemon gets the same
|
|
315
|
-
// answer, and the wasted reconnect would also start the reconnect backoff,
|
|
316
|
-
// leaving a genuinely wedged connection unable to recover for a minute.
|
|
317
|
-
error.code !== 'unknown_module');
|
|
356
|
+
!DEFINITIVE_TRANSPORT_CODES.has(error.code ?? ''));
|
|
318
357
|
}
|
|
319
358
|
async #reconnect() {
|
|
320
359
|
if (this.#closed)
|