@astrale-os/kernel-client 0.6.0-beta.81 → 0.6.0-beta.83
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/client/admission.js +1 -9
- package/dist/client/client.js +3 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/routing-control.d.ts +11 -3
- package/dist/client/routing-control.js +18 -3
- package/dist/errors/stale-route-error.d.ts +14 -3
- package/dist/errors/stale-route-error.js +15 -3
- package/dist/errors/transport-error.d.ts +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/schema/introspection.js +10 -6
- package/dist/schema/schema.js +10 -2
- package/dist/session/content.js +4 -27
- package/dist/session/context/index.d.ts +2 -2
- package/dist/session/context/index.js +2 -2
- package/dist/session/context/limits.d.ts +1 -0
- package/dist/session/context/limits.js +1 -0
- package/dist/session/discovery/bundle.js +1 -1
- package/dist/session/discovery/discovery.d.ts +12 -4
- package/dist/session/discovery/discovery.js +28 -19
- package/dist/session/discovery/index.d.ts +8 -1
- package/dist/session/discovery/index.js +5 -8
- package/dist/session/discovery/legacy/index.d.ts +38 -0
- package/dist/session/discovery/legacy/index.js +38 -0
- package/dist/session/discovery/legacy/intrinsic.d.ts +33 -0
- package/dist/session/discovery/legacy/intrinsic.js +67 -0
- package/dist/session/discovery/legacy/projection.d.ts +8 -0
- package/dist/session/discovery/legacy/projection.js +17 -0
- package/dist/session/discovery/{publication.d.ts → legacy/publication.d.ts} +8 -5
- package/dist/session/discovery/{publication.js → legacy/publication.js} +13 -60
- package/dist/session/discovery/release.d.ts +29 -0
- package/dist/session/discovery/release.js +81 -0
- package/dist/session/discovery/response.d.ts +3 -0
- package/dist/session/discovery/response.js +59 -0
- package/dist/session/discovery/snapshot.d.ts +9 -2
- package/dist/session/discovery/snapshot.js +2 -2
- package/dist/session/discovery/transport.d.ts +1 -3
- package/dist/session/exchange/options.d.ts +12 -4
- package/dist/session/exchange/options.js +11 -3
- package/dist/session/exchange/presentation.d.ts +3 -2
- package/dist/session/index.d.ts +1 -1
- package/dist/session/reference.d.ts +3 -6
- package/dist/session/reference.js +0 -4
- package/dist/session/routing/cache.d.ts +1 -1
- package/dist/session/routing/cache.js +18 -2
- package/dist/session/routing/dispatch.js +42 -41
- package/dist/session/session.d.ts +21 -4
- package/dist/session/session.js +25 -13
- package/dist/session/session.typecheck.js +20 -0
- package/dist/transport/auto/transport.d.ts +0 -1
- package/dist/transport/auto/transport.js +0 -2
- package/dist/transport/http/response.d.ts +1 -1
- package/dist/transport/http/response.js +2 -6
- package/dist/transport/http/transport.d.ts +5 -3
- package/dist/transport/http/transport.js +40 -19
- package/dist/transport/index.d.ts +1 -1
- package/dist/transport/transport.d.ts +10 -2
- package/dist/transport/websocket/transport.d.ts +0 -3
- package/dist/transport/websocket/transport.js +1 -16
- package/package.json +6 -7
|
@@ -19,12 +19,12 @@ export class RouteCache {
|
|
|
19
19
|
const delegate = auth.delegate === undefined ? 'anonymous' : await digestText(canonicalDelegate(auth.delegate));
|
|
20
20
|
return JSON.stringify([source, call.target, credential, delegate]);
|
|
21
21
|
}
|
|
22
|
-
async get(key, sourceCredential, now
|
|
22
|
+
async get(key, sourceCredential, now, operationDeadline) {
|
|
23
23
|
const cached = this.entries.get(key);
|
|
24
24
|
if (cached === undefined)
|
|
25
25
|
return undefined;
|
|
26
26
|
const token = this.keyTokens.get(key);
|
|
27
|
-
if (cached.learned.expiresAt <= now) {
|
|
27
|
+
if (cached.learned.expiresAt <= (now ?? Date.now())) {
|
|
28
28
|
this.invalidate(key);
|
|
29
29
|
return undefined;
|
|
30
30
|
}
|
|
@@ -38,6 +38,17 @@ export class RouteCache {
|
|
|
38
38
|
this.invalidate(key);
|
|
39
39
|
return undefined;
|
|
40
40
|
}
|
|
41
|
+
// HMAC verification can outlast route age. Check the exact-entry fence before live facts.
|
|
42
|
+
if (cached.learned.expiresAt <= (now ?? Date.now())) {
|
|
43
|
+
this.invalidate(key);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (operationDeadline !== undefined &&
|
|
48
|
+
cached.credentialRefreshBoundary !== undefined &&
|
|
49
|
+
cached.credentialRefreshBoundary <= operationDeadline) {
|
|
50
|
+
// This operation needs a fresh carrier; a shorter concurrent operation may still reuse it.
|
|
51
|
+
return undefined;
|
|
41
52
|
}
|
|
42
53
|
this.entries.delete(key);
|
|
43
54
|
this.entries.set(key, cached);
|
|
@@ -45,6 +56,7 @@ export class RouteCache {
|
|
|
45
56
|
}
|
|
46
57
|
async learn(key, route, credential, sourceCredential, now = Date.now()) {
|
|
47
58
|
const credentialExpiry = credential === undefined ? undefined : jwtExpiry(credential);
|
|
59
|
+
const credentialRefreshBoundary = credentialExpiry === undefined ? undefined : refreshBoundary(credentialExpiry);
|
|
48
60
|
const expiresAt = Math.min(now + this.maximumAgeMs, refreshBoundary(credentialExpiry));
|
|
49
61
|
const entry = Object.freeze({
|
|
50
62
|
route,
|
|
@@ -79,6 +91,7 @@ export class RouteCache {
|
|
|
79
91
|
}
|
|
80
92
|
this.entries.set(key, Object.freeze({
|
|
81
93
|
learned: entry,
|
|
94
|
+
...(credentialRefreshBoundary === undefined ? {} : { credentialRefreshBoundary }),
|
|
82
95
|
...(integrity === undefined ? {} : { sealed: entry, integrity }),
|
|
83
96
|
}));
|
|
84
97
|
this.persist();
|
|
@@ -210,6 +223,9 @@ function admitStoredEntry(input, now, maximumAgeMs) {
|
|
|
210
223
|
}),
|
|
211
224
|
sealed,
|
|
212
225
|
integrity: value.integrity,
|
|
226
|
+
...(credentialExpiry === undefined
|
|
227
|
+
? {}
|
|
228
|
+
: { credentialRefreshBoundary: refreshBoundary(credentialExpiry) }),
|
|
213
229
|
});
|
|
214
230
|
}
|
|
215
231
|
catch {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Key } from '@astrale-os/kernel-dsl/v1/addressing';
|
|
2
2
|
import * as invocationProtocol from '@astrale-os/kernel-protocol/invocation';
|
|
3
|
-
import { acceptCall, Client, dispatchTrace, isAdmittedMissError, isAdmittedStaleRouteError, } from '../../client/index.js';
|
|
3
|
+
import { acceptCall, Client, dispatchTrace, isAdmittedDeploymentRetiredError, isAdmittedMissError, isAdmittedStaleRouteError, } from '../../client/index.js';
|
|
4
4
|
import { ClientError, ResponseError } from '../../errors/index.js';
|
|
5
|
-
import { runSessionOperation, sessionTransportOptions, allowEndpoint } from '../context/index.js';
|
|
5
|
+
import { runSessionOperation, remainingSessionTime, sessionTransportOptions, allowEndpoint, } from '../context/index.js';
|
|
6
6
|
import { resolveAuth } from './auth.js';
|
|
7
7
|
import { selectEnvelope, selectEnvelopeTarget } from './envelope.js';
|
|
8
8
|
export class SessionDispatcher {
|
|
@@ -27,14 +27,14 @@ export class SessionDispatcher {
|
|
|
27
27
|
? resolved
|
|
28
28
|
: await this.state.exchange.resolve(call, resolved, operation);
|
|
29
29
|
const source = this.state.kernel;
|
|
30
|
-
const cacheKey = await this.state.cache.key(source, call, auth);
|
|
30
|
+
const cacheKey = await runSessionOperation(operation, () => this.state.cache.key(source, call, auth));
|
|
31
31
|
const dispatch = Object.freeze({
|
|
32
32
|
call,
|
|
33
33
|
auth,
|
|
34
34
|
source,
|
|
35
35
|
cacheKey,
|
|
36
36
|
});
|
|
37
|
-
let learned = await this.state.cache.get(cacheKey, auth.credential);
|
|
37
|
+
let learned = await runSessionOperation(operation, () => this.state.cache.get(cacheKey, auth.credential, undefined, operation.deadline));
|
|
38
38
|
if (learned !== undefined) {
|
|
39
39
|
try {
|
|
40
40
|
requireCachedRoute(learned, source, call.target, streaming === undefined ? this.state.envelopeTransport : 'http', this.state.websocketAvailable, this.state.policy);
|
|
@@ -52,6 +52,7 @@ export class SessionDispatcher {
|
|
|
52
52
|
}
|
|
53
53
|
async dispatchSource(state, routing, options, operation, streaming) {
|
|
54
54
|
this.state.requireOpen();
|
|
55
|
+
remainingSessionTime(operation);
|
|
55
56
|
allowEndpoint(this.state.sourceEndpoint, this.state.policy);
|
|
56
57
|
const client = streaming === undefined
|
|
57
58
|
? this.state.pool.clientFor(Object.freeze({ transport: 'http', url: this.state.sourceEndpoint }))
|
|
@@ -77,7 +78,7 @@ export class SessionDispatcher {
|
|
|
77
78
|
throw new ClientError('Redirect issuer differs from the session-pinned source Kernel identity.');
|
|
78
79
|
}
|
|
79
80
|
requireTargetPublication(redirect.route, state.call.target);
|
|
80
|
-
const learned = await this.state.cache.learn(state.cacheKey, redirect.route, redirect.credential, state.auth.credential);
|
|
81
|
+
const learned = await runSessionOperation(operation, () => this.state.cache.learn(state.cacheKey, redirect.route, redirect.credential, state.auth.credential));
|
|
81
82
|
return this.dispatchDestinationWithRecovery(learned, state, options, operation, redirect.trace, streaming);
|
|
82
83
|
}
|
|
83
84
|
async dispatchDestinationWithRecovery(learned, state, options, operation, trace, streaming) {
|
|
@@ -85,28 +86,29 @@ export class SessionDispatcher {
|
|
|
85
86
|
return await this.dispatchDestination(learned, state.call, options, operation, state.source, trace, streaming);
|
|
86
87
|
}
|
|
87
88
|
catch (cause) {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
const control = routingControl(cause);
|
|
90
|
+
if (control === undefined) {
|
|
91
|
+
if (isRouteCompatibilityConflict(cause))
|
|
92
|
+
this.state.cache.delete(state.cacheKey);
|
|
92
93
|
throw cause;
|
|
93
94
|
}
|
|
94
|
-
if (!stale && !miss)
|
|
95
|
-
throw cause;
|
|
96
95
|
this.state.cache.delete(state.cacheKey);
|
|
97
96
|
if (!operation.claimRecovery())
|
|
98
97
|
throw cause;
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
// A stale or retired route names the release the destination no longer serves; a miss
|
|
99
|
+
// names a Path, so the source receives no digest for it.
|
|
100
|
+
const routing = control === 'miss'
|
|
101
|
+
? undefined
|
|
102
|
+
: Object.freeze({
|
|
101
103
|
kind: 'refresh',
|
|
102
|
-
stale: learned.route.via
|
|
103
|
-
})
|
|
104
|
-
: undefined;
|
|
104
|
+
stale: invocationProtocol.routedDigest(learned.route.via),
|
|
105
|
+
});
|
|
105
106
|
const refreshed = await this.dispatchSource(state, routing, options, operation, streaming);
|
|
106
107
|
return this.followSource(refreshed, state, options, operation, streaming);
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
async dispatchDestination(learned, call, options, operation, source, trace, streaming) {
|
|
111
|
+
remainingSessionTime(operation);
|
|
110
112
|
if (learned.route.via.issuer !== source) {
|
|
111
113
|
throw new ClientError('Learned route issuer differs from the session-pinned source Kernel.');
|
|
112
114
|
}
|
|
@@ -127,30 +129,16 @@ export class SessionDispatcher {
|
|
|
127
129
|
...sessionTransportOptions(operation),
|
|
128
130
|
routing: learned.route.via,
|
|
129
131
|
};
|
|
130
|
-
// The source has admitted this fingerprint against the exact Publication carried by Via.
|
|
131
|
-
// Pin that revision at the destination so existing Workers can execute the same admitted
|
|
132
|
-
// callable without understanding the newer fingerprint Target representation. The cache
|
|
133
|
-
// remains partitioned by the original fingerprint, and stale recovery re-admits it at source.
|
|
134
|
-
const destinationCall = typeof call.target !== 'string' && call.target.fingerprint !== undefined
|
|
135
|
-
? {
|
|
136
|
-
...call,
|
|
137
|
-
target: invocationProtocol.Target({
|
|
138
|
-
callable: call.target.callable,
|
|
139
|
-
revision: learned.route.via.publication.revision,
|
|
140
|
-
...(call.target.instance === undefined ? {} : { instance: call.target.instance }),
|
|
141
|
-
}),
|
|
142
|
-
}
|
|
143
|
-
: call;
|
|
144
132
|
const result = await runSessionOperation(operation, () => streaming?.body !== undefined
|
|
145
133
|
? trace === undefined
|
|
146
|
-
? authenticated(client, learned.credential).dispatch(
|
|
134
|
+
? authenticated(client, learned.credential).dispatch(call, {
|
|
147
135
|
...binaryRequestOptions,
|
|
148
136
|
body: streaming.body,
|
|
149
137
|
})
|
|
150
|
-
: dispatchTrace(client, learned.credential,
|
|
138
|
+
: dispatchTrace(client, learned.credential, call, { ...binaryRequestOptions, body: streaming.body }, trace)
|
|
151
139
|
: trace === undefined
|
|
152
|
-
? authenticated(client, learned.credential).dispatch(
|
|
153
|
-
: dispatchTrace(client, learned.credential,
|
|
140
|
+
? authenticated(client, learned.credential).dispatch(call, requestOptions)
|
|
141
|
+
: dispatchTrace(client, learned.credential, call, requestOptions, trace));
|
|
154
142
|
if (result.kind === 'redirect') {
|
|
155
143
|
throw new ClientError('A destination redirect is invalid; only the source Kernel may redirect.');
|
|
156
144
|
}
|
|
@@ -175,6 +163,20 @@ function requireCachedRoute(learned, source, target, envelopeTransport, websocke
|
|
|
175
163
|
requireTargetPublication(learned.route, target);
|
|
176
164
|
selectEnvelopeTarget(learned.route.endpoint, envelopeTransport, websocketAvailable, policy);
|
|
177
165
|
}
|
|
166
|
+
/** The privately witnessed pre-handler control a destination failure carries, if any. */
|
|
167
|
+
function routingControl(input) {
|
|
168
|
+
if (isAdmittedStaleRouteError(input))
|
|
169
|
+
return 'stale';
|
|
170
|
+
if (isAdmittedMissError(input))
|
|
171
|
+
return 'miss';
|
|
172
|
+
if (isAdmittedDeploymentRetiredError(input))
|
|
173
|
+
return 'retired';
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A failure that proves the warm route can no longer serve this call without witnessing a
|
|
178
|
+
* pre-handler control: the route is evicted for later calls, but the invocation is never replayed.
|
|
179
|
+
*/
|
|
178
180
|
function isRouteCompatibilityConflict(input) {
|
|
179
181
|
if (!(input instanceof ResponseError))
|
|
180
182
|
return false;
|
|
@@ -183,17 +185,16 @@ function isRouteCompatibilityConflict(input) {
|
|
|
183
185
|
message: input.message,
|
|
184
186
|
...(input.data === undefined ? {} : { data: input.data }),
|
|
185
187
|
});
|
|
186
|
-
return (failure?.data.code === invocationProtocol.
|
|
187
|
-
failure?.data.code === invocationProtocol.
|
|
188
|
-
failure?.data.code === invocationProtocol.
|
|
188
|
+
return (failure?.data.code === invocationProtocol.CALLABLE_FINGERPRINT_MISMATCH ||
|
|
189
|
+
failure?.data.code === invocationProtocol.SERVICE_TRUST_REQUIRED ||
|
|
190
|
+
failure?.data.code === invocationProtocol.DEPLOYMENT_RETIRED);
|
|
189
191
|
}
|
|
190
192
|
function requireTargetPublication(route, target) {
|
|
191
193
|
if (typeof target === 'string')
|
|
192
194
|
return;
|
|
193
|
-
const actual = route.via.publication;
|
|
194
|
-
if (actual.origin !== Key.origin(target.callable)
|
|
195
|
-
(
|
|
196
|
-
throw new ClientError('Resolved route does not match the expected Schema revision.');
|
|
195
|
+
const actual = 'release' in route.via ? route.via.release : route.via.publication;
|
|
196
|
+
if (actual.origin !== Key.origin(target.callable)) {
|
|
197
|
+
throw new ClientError('Resolved route does not match the Target origin.');
|
|
197
198
|
}
|
|
198
199
|
}
|
|
199
200
|
export function requireResultKind(expected, result) {
|
|
@@ -13,7 +13,7 @@ import type { WebSocketConstructor } from '../transport/websocket/index.js';
|
|
|
13
13
|
import type { ContentApi } from './content.js';
|
|
14
14
|
import type { SessionControlOptions, SessionPolicy } from './context/index.js';
|
|
15
15
|
import type { SessionCredential } from './credential/index.js';
|
|
16
|
-
import type { SessionSnapshot } from './discovery/index.js';
|
|
16
|
+
import type { IntrinsicRelease, SessionSnapshot } from './discovery/index.js';
|
|
17
17
|
import type { SessionExchangeOptions, SessionExchangeRequest } from './exchange/index.js';
|
|
18
18
|
import type { CallableReference, InputModeOfReference, InputOfReference, OutputOfReference } from './reference.js';
|
|
19
19
|
import type { SessionRouteStore, SessionAuth, SessionEnvelopeTransport, SessionRequestOptions, SessionRequestOptionsWithBody } from './routing/index.js';
|
|
@@ -29,7 +29,8 @@ export interface ClientSessionOptions {
|
|
|
29
29
|
/**
|
|
30
30
|
* Present each Domain callable with a credential its declaring Domain exchanges for the `auth`
|
|
31
31
|
* caller, so the Domain is the principal and the caller is `auth`. Kernel and local callables keep
|
|
32
|
-
* the `auth` credential.
|
|
32
|
+
* the `auth` credential. `connect` defaults to `auto` when `auth` is present; `new ClientSession`
|
|
33
|
+
* keeps source authority when this option is omitted. `{ mode: 'source' }` keeps it for every Call.
|
|
33
34
|
*/
|
|
34
35
|
readonly exchange?: SessionExchangeOptions;
|
|
35
36
|
/** Optional caller-owned persistence for bounded confidential route artifacts. */
|
|
@@ -37,6 +38,12 @@ export interface ClientSessionOptions {
|
|
|
37
38
|
readonly policy: SessionPolicy;
|
|
38
39
|
readonly envelopeTransport?: SessionEnvelopeTransport;
|
|
39
40
|
readonly onWarning?: (warning: ClientWarning) => void;
|
|
41
|
+
/** Bounds the source Kernel's own `release.json`. */
|
|
42
|
+
readonly maximumReleaseBytes?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Bounds every Publication the Session reads: the version 3 `domain.json` a Host that predates
|
|
45
|
+
* `release.json` serves, and the Publications Schema results carry.
|
|
46
|
+
*/
|
|
40
47
|
readonly maximumPublicationBytes?: number;
|
|
41
48
|
readonly maximumBundleBytes?: number;
|
|
42
49
|
readonly maximumResponseBytes?: number;
|
|
@@ -73,7 +80,17 @@ export declare class ClientSession {
|
|
|
73
80
|
* `createSessionCredentialProvider`, for as long as it covers the calls it carries.
|
|
74
81
|
*/
|
|
75
82
|
exchange(domain: string, options?: SessionExchangeRequest): Promise<SessionCredential>;
|
|
76
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Revalidate the source Kernel's own release (`release.json`) without acquiring its bundle or
|
|
85
|
+
* Views; a Host that predates intrinsic `release.json` yields its version 3 Publication instead.
|
|
86
|
+
*/
|
|
87
|
+
release(options?: SessionControlOptions): Promise<IntrinsicRelease>;
|
|
88
|
+
/**
|
|
89
|
+
* Revalidate the intrinsic release and return its v3 document: the Host's own version 3
|
|
90
|
+
* Publication, or the frozen v3 projection of its `DomainRelease`.
|
|
91
|
+
*
|
|
92
|
+
* @deprecated Use `release()`. Deleted when no supported Host predates intrinsic `release.json`.
|
|
93
|
+
*/
|
|
77
94
|
publication(options?: SessionControlOptions): Promise<publication.Publication>;
|
|
78
95
|
snapshot(options?: SessionControlOptions): Promise<SessionSnapshot>;
|
|
79
96
|
viewsFor(target: PathLike, options?: SessionControlOptions): Promise<ViewCatalog>;
|
|
@@ -91,5 +108,5 @@ export declare class ClientSession {
|
|
|
91
108
|
private invokeDomainMethod;
|
|
92
109
|
private operation;
|
|
93
110
|
}
|
|
94
|
-
/** Open
|
|
111
|
+
/** Open a Kernel conversation with secure defaults and automatic Domain exchange when authenticated. */
|
|
95
112
|
export declare function connect(options: ConnectOptions): ClientSession;
|
package/dist/session/session.js
CHANGED
|
@@ -12,7 +12,7 @@ import { HttpTransport } from '../transport/http/index.js';
|
|
|
12
12
|
import { WebSocketTransport } from '../transport/websocket/index.js';
|
|
13
13
|
import { admitFunction } from './admission.js';
|
|
14
14
|
import { createContentApi } from './content.js';
|
|
15
|
-
import { admitControlOptions, admitPolicy, createSessionOperation, DEFAULT_MAXIMUM_BUNDLE_BYTES, DEFAULT_MAXIMUM_PUBLICATION_BYTES, DEFAULT_MAXIMUM_RESPONSE_BYTES, DEFAULT_TIMEOUT_MS, admitKernel, allowEndpoint, kernelInvocationEndpoint, platformDuration, positive, } from './context/index.js';
|
|
15
|
+
import { admitControlOptions, admitPolicy, createSessionOperation, DEFAULT_MAXIMUM_BUNDLE_BYTES, DEFAULT_MAXIMUM_PUBLICATION_BYTES, DEFAULT_MAXIMUM_RELEASE_BYTES, DEFAULT_MAXIMUM_RESPONSE_BYTES, DEFAULT_TIMEOUT_MS, admitKernel, allowEndpoint, kernelInvocationEndpoint, platformDuration, positive, } from './context/index.js';
|
|
16
16
|
import { createSessionDiscovery } from './discovery/index.js';
|
|
17
17
|
import { acquireDomainCredential, admitExchangeOptions, admitExchangeRequest, createSessionExchange, } from './exchange/index.js';
|
|
18
18
|
import { admitReference } from './reference.js';
|
|
@@ -51,6 +51,7 @@ export class ClientSession {
|
|
|
51
51
|
if (options.client !== undefined && options.pool !== undefined) {
|
|
52
52
|
throw new TypeError('ClientSession accepts either client or pool, not both.');
|
|
53
53
|
}
|
|
54
|
+
const maximumReleaseBytes = positive(options.maximumReleaseBytes ?? DEFAULT_MAXIMUM_RELEASE_BYTES, 'maximumReleaseBytes');
|
|
54
55
|
const maximumPublicationBytes = positive(options.maximumPublicationBytes ?? DEFAULT_MAXIMUM_PUBLICATION_BYTES, 'maximumPublicationBytes');
|
|
55
56
|
const maximumBundleBytes = positive(options.maximumBundleBytes ?? DEFAULT_MAXIMUM_BUNDLE_BYTES, 'maximumBundleBytes');
|
|
56
57
|
const maximumResponseBytes = positive(options.maximumResponseBytes ?? DEFAULT_MAXIMUM_RESPONSE_BYTES, 'maximumResponseBytes');
|
|
@@ -82,6 +83,7 @@ export class ClientSession {
|
|
|
82
83
|
kernel: this.kernel,
|
|
83
84
|
fetch: fetchImpl,
|
|
84
85
|
policy,
|
|
86
|
+
maximumReleaseBytes,
|
|
85
87
|
maximumPublicationBytes,
|
|
86
88
|
maximumBundleBytes,
|
|
87
89
|
requireOpen: () => this.requireOpen(),
|
|
@@ -152,7 +154,19 @@ export class ClientSession {
|
|
|
152
154
|
const request = admitExchangeRequest(domain, options);
|
|
153
155
|
return acquireDomainCredential(this.exchangeDependencies, request, this.operation(request.control));
|
|
154
156
|
}
|
|
155
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Revalidate the source Kernel's own release (`release.json`) without acquiring its bundle or
|
|
159
|
+
* Views; a Host that predates intrinsic `release.json` yields its version 3 Publication instead.
|
|
160
|
+
*/
|
|
161
|
+
release(options) {
|
|
162
|
+
return this.discovery.release(this.operation(admitControlOptions(options)));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Revalidate the intrinsic release and return its v3 document: the Host's own version 3
|
|
166
|
+
* Publication, or the frozen v3 projection of its `DomainRelease`.
|
|
167
|
+
*
|
|
168
|
+
* @deprecated Use `release()`. Deleted when no supported Host predates intrinsic `release.json`.
|
|
169
|
+
*/
|
|
156
170
|
publication(options) {
|
|
157
171
|
return this.discovery.publication(this.operation(admitControlOptions(options)));
|
|
158
172
|
}
|
|
@@ -178,16 +192,14 @@ export class ClientSession {
|
|
|
178
192
|
const instance = last?.kind === 'method' && last.dispatch === 'instance'
|
|
179
193
|
? Path.fromAST({ anchor, steps: steps.slice(0, -1) }).raw
|
|
180
194
|
: undefined;
|
|
181
|
-
// A generated reference pins the callable meaning
|
|
182
|
-
//
|
|
195
|
+
// A generated reference pins the callable meaning; a Kernel reference pins nothing, like every
|
|
196
|
+
// other syscall the Session addresses.
|
|
183
197
|
const call = {
|
|
184
198
|
target: Object.freeze({
|
|
185
199
|
callable: admittedReference.callable,
|
|
186
|
-
...(admittedReference.fingerprint
|
|
187
|
-
? {
|
|
188
|
-
: admittedReference.
|
|
189
|
-
? { revision: admittedReference.revision }
|
|
190
|
-
: {}),
|
|
200
|
+
...(admittedReference.fingerprint === undefined
|
|
201
|
+
? {}
|
|
202
|
+
: { fingerprint: admittedReference.fingerprint }),
|
|
191
203
|
...(instance === undefined ? {} : { instance }),
|
|
192
204
|
}),
|
|
193
205
|
input,
|
|
@@ -241,7 +253,6 @@ export class ClientSession {
|
|
|
241
253
|
throw new TypeError(`Method owner ${method.owner.ref.origin} is absent.`);
|
|
242
254
|
return this.invoke({
|
|
243
255
|
origin: owner.origin,
|
|
244
|
-
revision: owner.revision,
|
|
245
256
|
fingerprint: callableFingerprint(method),
|
|
246
257
|
callable: method.key,
|
|
247
258
|
target: Path.instanceMethod(Path.id(node.id), method.key),
|
|
@@ -253,16 +264,17 @@ export class ClientSession {
|
|
|
253
264
|
return createSessionOperation(this.lifecycle.signal, options, this.timeoutMs);
|
|
254
265
|
}
|
|
255
266
|
}
|
|
256
|
-
/** Open
|
|
267
|
+
/** Open a Kernel conversation with secure defaults and automatic Domain exchange when authenticated. */
|
|
257
268
|
export function connect(options) {
|
|
258
269
|
if (options === null || typeof options !== 'object') {
|
|
259
270
|
throw new TypeError('Connect options are invalid.');
|
|
260
271
|
}
|
|
261
|
-
const { url, ...rest } = options;
|
|
272
|
+
const { url, exchange, ...rest } = options;
|
|
262
273
|
return new ClientSession({
|
|
263
274
|
...rest,
|
|
264
275
|
kernel: url,
|
|
265
|
-
|
|
276
|
+
exchange: exchange === undefined && rest.auth !== undefined ? { mode: 'auto' } : exchange,
|
|
277
|
+
policy: rest.policy ?? DEFAULT_SESSION_POLICY,
|
|
266
278
|
});
|
|
267
279
|
}
|
|
268
280
|
function admitClientPool(input) {
|
|
@@ -12,6 +12,9 @@ void envelope;
|
|
|
12
12
|
void policy;
|
|
13
13
|
void request;
|
|
14
14
|
void snapshot;
|
|
15
|
+
const observedRelease = session.release(control);
|
|
16
|
+
void observedRelease;
|
|
17
|
+
void observedRelease.then((current) => current.version === 4 ? current.build.digest : current.etag);
|
|
15
18
|
const observedPublication = session.publication(control);
|
|
16
19
|
void observedPublication;
|
|
17
20
|
const admitted = session.auth.admit({
|
|
@@ -28,6 +31,23 @@ const credentials = createSessionCredentialProvider({
|
|
|
28
31
|
// A credential provider is a Session authentication resolver; no adapter stands between them.
|
|
29
32
|
const credentialAuth = credentials;
|
|
30
33
|
void new ClientSession({ ...options, auth: credentialAuth });
|
|
34
|
+
void new ClientSession({ ...options, auth: credentialAuth, exchange: { mode: 'auto' } });
|
|
35
|
+
void new ClientSession({
|
|
36
|
+
...options,
|
|
37
|
+
auth: credentialAuth,
|
|
38
|
+
exchange: { mode: 'auto', ttlSeconds: 300 },
|
|
39
|
+
});
|
|
40
|
+
void new ClientSession({ ...options, auth: credentialAuth, exchange: { mode: 'source' } });
|
|
41
|
+
// @ts-expect-error Exchange options always name their mode.
|
|
42
|
+
void new ClientSession({ ...options, auth: credentialAuth, exchange: {} });
|
|
43
|
+
// @ts-expect-error Exchange options always name their mode.
|
|
44
|
+
void new ClientSession({ ...options, auth: credentialAuth, exchange: { ttlSeconds: 300 } });
|
|
45
|
+
// @ts-expect-error Source mode requests no Domain credential lifetime.
|
|
46
|
+
void new ClientSession({ ...options, exchange: { mode: 'source', ttlSeconds: 300 } });
|
|
47
|
+
// @ts-expect-error Exchange accepts named modes, not a boolean switch.
|
|
48
|
+
void new ClientSession({ ...options, exchange: true });
|
|
49
|
+
// @ts-expect-error Unknown exchange modes are not admitted.
|
|
50
|
+
void new ClientSession({ ...options, exchange: { mode: 'invalid' } });
|
|
31
51
|
// @ts-expect-error A minted credential must carry its own absolute expiry.
|
|
32
52
|
void createSessionCredentialProvider({ ttlSeconds: 60, mint: async () => minted.credential });
|
|
33
53
|
const dynamicReference = reference(dynamicDomain, dynamicMethod)(Path.id(NodeId('dynamic-node')));
|
|
@@ -7,7 +7,6 @@ export interface AutoTransportOptions {
|
|
|
7
7
|
readonly http: string;
|
|
8
8
|
readonly websocket: string;
|
|
9
9
|
};
|
|
10
|
-
readonly codec?: 'json' | 'msgpack';
|
|
11
10
|
readonly fetch?: Fetch;
|
|
12
11
|
readonly websocket?: WebSocketConstructor;
|
|
13
12
|
readonly headers?: Readonly<Record<string, string>>;
|
|
@@ -26,7 +26,6 @@ export class AutoTransport {
|
|
|
26
26
|
this.onWarning = options.onWarning;
|
|
27
27
|
this.http = new HttpTransport({
|
|
28
28
|
url: options.endpoints.http,
|
|
29
|
-
...(options.codec === undefined ? {} : { codec: options.codec }),
|
|
30
29
|
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
31
30
|
...(options.headers === undefined ? {} : { headers: options.headers }),
|
|
32
31
|
timeoutMs: this.timeoutMs,
|
|
@@ -39,7 +38,6 @@ export class AutoTransport {
|
|
|
39
38
|
});
|
|
40
39
|
this.websocket = new WebSocketTransport({
|
|
41
40
|
url: options.endpoints.websocket,
|
|
42
|
-
...(options.codec === undefined ? {} : { codec: options.codec }),
|
|
43
41
|
...(options.websocket === undefined ? {} : { websocket: options.websocket }),
|
|
44
42
|
timeoutMs: this.timeoutMs,
|
|
45
43
|
...(options.maximumRequestBytes === undefined
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as codecs from '@astrale-os/kernel-protocol/codecs';
|
|
2
2
|
import * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
3
|
-
export declare function codecForResponse(contentType: string | null):
|
|
3
|
+
export declare function codecForResponse(contentType: string | null): codecs.Codec | undefined;
|
|
4
4
|
export declare function readBoundedBody(response: Response, maximumBytes: number, signal: AbortSignal): Promise<Uint8Array>;
|
|
5
5
|
export declare function requireId(expected: protocol.RequestId, received: protocol.RequestId): void;
|
|
6
6
|
export declare function decodeProtocol<T>(label: string, decode: () => T): T;
|
|
@@ -2,12 +2,8 @@ import * as codecs from '@astrale-os/kernel-protocol/codecs';
|
|
|
2
2
|
import * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
3
3
|
import { ProtocolError, ResponseError } from '../../errors/index.js';
|
|
4
4
|
import { asError } from '../index.js';
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
const supported = mediaType === codecs.CONTENT_TYPE_MSGPACK
|
|
8
|
-
? [codecs.json, await codecs.loadMsgpack()]
|
|
9
|
-
: [codecs.json];
|
|
10
|
-
return protocol.http.codecForContentType(contentType, supported);
|
|
5
|
+
export function codecForResponse(contentType) {
|
|
6
|
+
return protocol.http.codecForContentType(contentType, [codecs.json]);
|
|
11
7
|
}
|
|
12
8
|
export async function readBoundedBody(response, maximumBytes, signal) {
|
|
13
9
|
const declared = response.headers.get('content-length');
|
|
@@ -3,7 +3,6 @@ import type { RequestOptions, Transport, TransportRequest, TransportResult } fro
|
|
|
3
3
|
export type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
4
4
|
export interface HttpTransportOptions {
|
|
5
5
|
readonly url: string;
|
|
6
|
-
readonly codec?: 'json' | 'msgpack';
|
|
7
6
|
/** Inject this capability when the host does not provide global Fetch. */
|
|
8
7
|
readonly fetch?: Fetch;
|
|
9
8
|
readonly headers?: Readonly<Record<string, string>>;
|
|
@@ -16,7 +15,6 @@ export interface HttpTransportOptions {
|
|
|
16
15
|
/** HTTP implementation of the universal invocation protocol. */
|
|
17
16
|
export declare class HttpTransport implements Transport {
|
|
18
17
|
readonly url: string;
|
|
19
|
-
private readonly codecName;
|
|
20
18
|
private readonly fetchImpl;
|
|
21
19
|
private readonly headers;
|
|
22
20
|
private readonly timeoutMs;
|
|
@@ -29,7 +27,11 @@ export declare class HttpTransport implements Transport {
|
|
|
29
27
|
dispatchBinary(input: TransportRequest, body: Blob, options?: RequestOptions): Promise<TransportResult>;
|
|
30
28
|
private dispatchWith;
|
|
31
29
|
private fetchResponse;
|
|
30
|
+
/**
|
|
31
|
+
* Admit the reserved retirement control only as the exact 410, header and envelope triple; the
|
|
32
|
+
* header beside any other status, value or body is an invalid response, never an ordinary error.
|
|
33
|
+
*/
|
|
34
|
+
private decodeDeploymentRetired;
|
|
32
35
|
private decodeBinary;
|
|
33
36
|
private nextRequestId;
|
|
34
|
-
private requestCodec;
|
|
35
37
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { canonicalMediaType } from '@astrale-os/kernel-protocol/artifact';
|
|
2
2
|
import * as codecs from '@astrale-os/kernel-protocol/codecs';
|
|
3
3
|
import * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
4
|
+
import * as retirement from '@astrale-os/kernel-protocol/routes/deployment-retired';
|
|
4
5
|
import { ProtocolError, ResponseError, TransportError } from '../../errors/index.js';
|
|
5
6
|
import { invocationFailure } from '../failure.js';
|
|
6
7
|
import { asError, controlledSignal, positiveInteger, requireLive, } from '../index.js';
|
|
@@ -13,7 +14,6 @@ const DEFAULT_MAXIMUM_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
|
13
14
|
/** HTTP implementation of the universal invocation protocol. */
|
|
14
15
|
export class HttpTransport {
|
|
15
16
|
url;
|
|
16
|
-
codecName;
|
|
17
17
|
fetchImpl;
|
|
18
18
|
headers;
|
|
19
19
|
timeoutMs;
|
|
@@ -25,7 +25,6 @@ export class HttpTransport {
|
|
|
25
25
|
throw new TypeError('HttpTransport options must be an object.');
|
|
26
26
|
}
|
|
27
27
|
this.url = normalizeUrl(options.url);
|
|
28
|
-
this.codecName = admitCodecName(options.codec);
|
|
29
28
|
this.fetchImpl = admitFetch(options.fetch);
|
|
30
29
|
this.headers = admitHeaders(options.headers ?? {});
|
|
31
30
|
this.timeoutMs = positiveInteger(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, 'timeoutMs');
|
|
@@ -47,7 +46,7 @@ export class HttpTransport {
|
|
|
47
46
|
async dispatchWith(input, options, binaryBody) {
|
|
48
47
|
const requestId = this.nextRequestId();
|
|
49
48
|
const timeoutMs = positiveInteger(options.timeoutMs ?? this.timeoutMs, 'timeoutMs');
|
|
50
|
-
const codec =
|
|
49
|
+
const codec = codecs.json;
|
|
51
50
|
const { trace: traceInput, ...invocationInput } = input;
|
|
52
51
|
const trace = traceInput === undefined ? undefined : protocol.admitTrace(traceInput);
|
|
53
52
|
const request = protocol.decodeRequest({
|
|
@@ -82,7 +81,15 @@ export class HttpTransport {
|
|
|
82
81
|
const binaryDelivery = response.headers.get(protocol.http.BINARY_DELIVERY_FIELD);
|
|
83
82
|
const hasStreamSignal = streamFraming !== null;
|
|
84
83
|
const hasBinarySignal = binaryId !== null || binaryManifest !== null || binaryDelivery !== null;
|
|
85
|
-
const responseCodec =
|
|
84
|
+
const responseCodec = codecForResponse(response.headers.get('content-type'));
|
|
85
|
+
if (response.headers.has(retirement.HTTP_DEPLOYMENT_RETIRED)) {
|
|
86
|
+
if (hasStreamSignal || hasBinarySignal) {
|
|
87
|
+
throw new ProtocolError('server returned a deployment-retired control with a stream or binary signal.');
|
|
88
|
+
}
|
|
89
|
+
const retired = await this.decodeDeploymentRetired(response, responseCodec, control);
|
|
90
|
+
requireLive(control);
|
|
91
|
+
return retired;
|
|
92
|
+
}
|
|
86
93
|
if (!response.ok && responseCodec === undefined && !hasStreamSignal && !hasBinarySignal) {
|
|
87
94
|
throw invocationFailure(`server returned HTTP ${response.status} without an invocation response.`, 'receive', { delivery: 'unknown' });
|
|
88
95
|
}
|
|
@@ -182,6 +189,29 @@ export class HttpTransport {
|
|
|
182
189
|
};
|
|
183
190
|
return this.fetchImpl(this.url, init);
|
|
184
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Admit the reserved retirement control only as the exact 410, header and envelope triple; the
|
|
194
|
+
* header beside any other status, value or body is an invalid response, never an ordinary error.
|
|
195
|
+
*/
|
|
196
|
+
async decodeDeploymentRetired(response, codec, control) {
|
|
197
|
+
if (codec === undefined) {
|
|
198
|
+
throw new ProtocolError('server returned a deployment-retired control without an envelope.');
|
|
199
|
+
}
|
|
200
|
+
requireNoStore(response);
|
|
201
|
+
const body = await readBoundedBody(response, this.maximumResponseBytes, control.signal);
|
|
202
|
+
const decoded = decodeProtocol('deployment-retired body', () => codec.decode(body));
|
|
203
|
+
const retired = decodeProtocol('deployment-retired control', () => retirement.decodeDeploymentRetired(response.status, response.headers, decoded));
|
|
204
|
+
const envelope = decodeProtocol('deployment-retired envelope', () => protocol.decodeResponse(decoded));
|
|
205
|
+
if (retired === undefined || !('error' in envelope)) {
|
|
206
|
+
throw new ProtocolError('server returned an invalid deployment-retired control.');
|
|
207
|
+
}
|
|
208
|
+
return Object.freeze({
|
|
209
|
+
kind: 'deployment-retired',
|
|
210
|
+
reason: retired.reason,
|
|
211
|
+
message: envelope.error.message,
|
|
212
|
+
invocation: envelope.invocation,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
185
215
|
async decodeBinary(response, requestId, binaryId, streaming, control) {
|
|
186
216
|
if (binaryId !== String(requestId)) {
|
|
187
217
|
throw new ProtocolError(`binary response requestId ${JSON.stringify(binaryId)} does not match request requestId ${requestId}.`);
|
|
@@ -197,14 +227,13 @@ export class HttpTransport {
|
|
|
197
227
|
if (streaming) {
|
|
198
228
|
if (response.body === null)
|
|
199
229
|
throw new ProtocolError('streaming binary has no body.');
|
|
200
|
-
// An intermediary may rewrite Content-Length (for example when it compresses the body), so
|
|
201
|
-
// protocol length
|
|
202
|
-
const
|
|
203
|
-
const length = declared ?? response.headers.get('content-length');
|
|
230
|
+
// An intermediary may rewrite Content-Length (for example when it compresses the body), so
|
|
231
|
+
// only the protocol length counts.
|
|
232
|
+
const length = response.headers.get(protocol.http.BINARY_LENGTH_FIELD);
|
|
204
233
|
const expectedBytes = length === null ? undefined : Number(length);
|
|
205
234
|
if (length !== null &&
|
|
206
235
|
(!/^(?:0|[1-9][0-9]*)$/u.test(length) || !Number.isSafeInteger(expectedBytes))) {
|
|
207
|
-
throw new ProtocolError(`streaming binary has an invalid ${
|
|
236
|
+
throw new ProtocolError(`streaming binary has an invalid ${protocol.http.BINARY_LENGTH_FIELD}.`);
|
|
208
237
|
}
|
|
209
238
|
if (expectedBytes !== undefined && expectedBytes > this.maximumResponseBytes) {
|
|
210
239
|
throw new ProtocolError(`streaming binary exceeds maximumResponseBytes (${this.maximumResponseBytes}).`);
|
|
@@ -215,6 +244,8 @@ export class HttpTransport {
|
|
|
215
244
|
invocation,
|
|
216
245
|
value: {
|
|
217
246
|
body: new HttpByteStream(response.body, this.maximumResponseBytes, control, expectedBytes),
|
|
247
|
+
// The byte stream enforces this exact count through its final read.
|
|
248
|
+
...(expectedBytes === undefined ? {} : { length: expectedBytes }),
|
|
218
249
|
mediaType,
|
|
219
250
|
...(response.status === 200 ? {} : { status: response.status }),
|
|
220
251
|
...(Object.keys(headers).length === 0 ? {} : { headers }),
|
|
@@ -249,9 +280,6 @@ export class HttpTransport {
|
|
|
249
280
|
this.nextId = requestId === Number.MAX_SAFE_INTEGER ? 1 : requestId + 1;
|
|
250
281
|
return requestId;
|
|
251
282
|
}
|
|
252
|
-
requestCodec() {
|
|
253
|
-
return this.codecName === 'msgpack' ? codecs.loadMsgpack() : Promise.resolve(codecs.json);
|
|
254
|
-
}
|
|
255
283
|
}
|
|
256
284
|
function redirectResult(response) {
|
|
257
285
|
return Object.freeze({
|
|
@@ -340,13 +368,6 @@ function transportFailure(input, control, parent, timeoutMs, url, phase, deliver
|
|
|
340
368
|
delivery,
|
|
341
369
|
});
|
|
342
370
|
}
|
|
343
|
-
function admitCodecName(input) {
|
|
344
|
-
if (input === undefined || input === 'json')
|
|
345
|
-
return 'json';
|
|
346
|
-
if (input === 'msgpack')
|
|
347
|
-
return input;
|
|
348
|
-
throw new TypeError('codec must be "json" or "msgpack".');
|
|
349
|
-
}
|
|
350
371
|
function admitFetch(input) {
|
|
351
372
|
const implementation = input ?? globalThis.fetch;
|
|
352
373
|
if (typeof implementation !== 'function') {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { RequestOptions, MissControl, StaleControl, Transport, TransportRequest, TransportResult, } from './transport.js';
|
|
1
|
+
export type { RequestOptions, DeploymentRetired, MissControl, StaleControl, Transport, TransportRequest, TransportResult, } from './transport.js';
|
|
2
2
|
export { asError, controlledSignal, positiveInteger, requireLive, type ControlledSignal, } from './deadline.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { invocation as protocol } from '@astrale-os/kernel-protocol';
|
|
1
|
+
import type { invocation as protocol, routes } from '@astrale-os/kernel-protocol';
|
|
2
2
|
import type { Result } from '../client/result.js';
|
|
3
3
|
export interface RequestOptions {
|
|
4
4
|
readonly signal?: AbortSignal;
|
|
@@ -25,7 +25,15 @@ export interface MissControl {
|
|
|
25
25
|
readonly kind: 'miss';
|
|
26
26
|
readonly invocation: protocol.InvocationId;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* A retired deployment's reserved 410 control: its typed `DEPLOYMENT_RETIRED` reason with the
|
|
30
|
+
* message and invocation of the envelope it carried, which Client raises unchanged.
|
|
31
|
+
*/
|
|
32
|
+
export interface DeploymentRetired extends routes.DeploymentRetired {
|
|
33
|
+
readonly message: string;
|
|
34
|
+
readonly invocation: protocol.InvocationId;
|
|
35
|
+
}
|
|
36
|
+
export type TransportResult = Result | StaleControl | MissControl | DeploymentRetired;
|
|
29
37
|
export interface Transport {
|
|
30
38
|
/** Primary embedding-environment adapter for a complete invocation boundary. */
|
|
31
39
|
dispatch(input: TransportRequest, options?: RequestOptions): Promise<TransportResult>;
|