@astrale-os/kernel-client 0.6.0-beta.71 → 0.6.0-beta.72

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.
@@ -1,6 +1,6 @@
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, dispatchTrace, isAdmittedMissError, isAdmittedStaleRouteError, } from '../../client/index.js';
3
+ import { acceptCall, Client, dispatchTrace, isAdmittedMissError, isAdmittedStaleRouteError, } from '../../client/index.js';
4
4
  import { ClientError, ResponseError } from '../../errors/index.js';
5
5
  import { runSessionOperation, sessionTransportOptions, allowEndpoint } from '../context/index.js';
6
6
  import { resolveAuth } from './auth.js';
@@ -11,6 +11,13 @@ export class SessionDispatcher {
11
11
  this.state = state;
12
12
  }
13
13
  async dispatch(input, options, operation) {
14
+ return this.dispatchWith(input, options, operation);
15
+ }
16
+ /** Reuse Session authority and route recovery while forcing HTTP for streaming calls. */
17
+ async dispatchHttp(input, options, operation, body) {
18
+ return this.dispatchWith(input, options, operation, { body });
19
+ }
20
+ async dispatchWith(input, options, operation, streaming) {
14
21
  this.state.requireOpen();
15
22
  const call = acceptCall(input);
16
23
  // Authority is resolved before warm lookup so changed credentials or delegation cannot reuse a
@@ -27,7 +34,7 @@ export class SessionDispatcher {
27
34
  let learned = await this.state.cache.get(cacheKey, auth.credential);
28
35
  if (learned !== undefined) {
29
36
  try {
30
- requireCachedRoute(learned, source, call.target, this.state.envelopeTransport, this.state.websocketAvailable, this.state.policy);
37
+ requireCachedRoute(learned, source, call.target, streaming === undefined ? this.state.envelopeTransport : 'http', this.state.websocketAvailable, this.state.policy);
31
38
  }
32
39
  catch {
33
40
  this.state.cache.delete(cacheKey);
@@ -35,22 +42,31 @@ export class SessionDispatcher {
35
42
  }
36
43
  }
37
44
  if (learned !== undefined) {
38
- return this.dispatchDestinationWithRecovery(learned, dispatch, options, operation);
45
+ return this.dispatchDestinationWithRecovery(learned, dispatch, options, operation, undefined, streaming);
39
46
  }
40
- const result = await this.dispatchSource(dispatch, undefined, options, operation);
41
- return this.followSource(result, dispatch, options, operation);
47
+ const result = await this.dispatchSource(dispatch, undefined, options, operation, streaming);
48
+ return this.followSource(result, dispatch, options, operation, streaming);
42
49
  }
43
- async dispatchSource(state, routing, options, operation) {
50
+ async dispatchSource(state, routing, options, operation, streaming) {
44
51
  this.state.requireOpen();
45
52
  allowEndpoint(this.state.sourceEndpoint, this.state.policy);
46
- const client = this.state.pool.clientFor(Object.freeze({ transport: 'http', url: this.state.sourceEndpoint }));
47
- return runSessionOperation(operation, () => authenticated(client, state.auth.credential).dispatch(state.call, {
48
- ...sessionTransportOptions(operation, options.idempotencyKey),
49
- ...(state.auth.delegate === undefined ? {} : { delegate: state.auth.delegate }),
50
- ...(routing === undefined ? {} : { routing }),
51
- }));
53
+ const client = streaming === undefined
54
+ ? this.state.pool.clientFor(Object.freeze({ transport: 'http', url: this.state.sourceEndpoint }))
55
+ : this.streamingClientFor(this.state.sourceEndpoint);
56
+ return runSessionOperation(operation, () => streaming?.body === undefined
57
+ ? authenticated(client, state.auth.credential).dispatch(state.call, {
58
+ ...sessionTransportOptions(operation, options.idempotencyKey),
59
+ ...(state.auth.delegate === undefined ? {} : { delegate: state.auth.delegate }),
60
+ ...(routing === undefined ? {} : { routing }),
61
+ })
62
+ : authenticated(client, state.auth.credential).dispatch(state.call, {
63
+ ...sessionTransportOptions(operation),
64
+ ...(state.auth.delegate === undefined ? {} : { delegate: state.auth.delegate }),
65
+ ...(routing === undefined ? {} : { routing }),
66
+ body: streaming.body,
67
+ }));
52
68
  }
53
- async followSource(result, state, options, operation) {
69
+ async followSource(result, state, options, operation, streaming) {
54
70
  if (result.kind !== 'redirect')
55
71
  return result;
56
72
  const redirect = result.redirect;
@@ -59,11 +75,11 @@ export class SessionDispatcher {
59
75
  }
60
76
  requireTargetPublication(redirect.route, state.call.target);
61
77
  const learned = await this.state.cache.learn(state.cacheKey, redirect.route, redirect.credential, state.auth.credential);
62
- return this.dispatchDestinationWithRecovery(learned, state, options, operation, redirect.trace);
78
+ return this.dispatchDestinationWithRecovery(learned, state, options, operation, redirect.trace, streaming);
63
79
  }
64
- async dispatchDestinationWithRecovery(learned, state, options, operation, trace) {
80
+ async dispatchDestinationWithRecovery(learned, state, options, operation, trace, streaming) {
65
81
  try {
66
- return await this.dispatchDestination(learned, state.call, options, operation, state.source, trace);
82
+ return await this.dispatchDestination(learned, state.call, options, operation, state.source, trace, streaming);
67
83
  }
68
84
  catch (cause) {
69
85
  const stale = isAdmittedStaleRouteError(cause);
@@ -83,20 +99,31 @@ export class SessionDispatcher {
83
99
  stale: learned.route.via.publication.etag,
84
100
  })
85
101
  : undefined;
86
- const refreshed = await this.dispatchSource(state, routing, options, operation);
87
- return this.followSource(refreshed, state, options, operation);
102
+ const refreshed = await this.dispatchSource(state, routing, options, operation, streaming);
103
+ return this.followSource(refreshed, state, options, operation, streaming);
88
104
  }
89
105
  }
90
- async dispatchDestination(learned, call, options, operation, source, trace) {
106
+ async dispatchDestination(learned, call, options, operation, source, trace, streaming) {
91
107
  if (learned.route.via.issuer !== source) {
92
108
  throw new ClientError('Learned route issuer differs from the session-pinned source Kernel.');
93
109
  }
94
110
  requireTargetPublication(learned.route, call.target);
95
- const envelope = selectEnvelope(learned.route.endpoint, this.state.envelopeTransport, this.state.websocketAvailable, this.state.pool, this.state.policy);
111
+ const destination = streaming === undefined
112
+ ? undefined
113
+ : selectEnvelopeTarget(learned.route.endpoint, 'http', this.state.websocketAvailable, this.state.policy);
114
+ const client = streaming === undefined
115
+ ? selectEnvelope(learned.route.endpoint, this.state.envelopeTransport, this.state.websocketAvailable, this.state.pool, this.state.policy).client
116
+ : destination?.transport === 'http'
117
+ ? this.streamingClientFor(destination.url)
118
+ : invalidStreamingDestination();
96
119
  const requestOptions = {
97
120
  ...sessionTransportOptions(operation, options.idempotencyKey),
98
121
  routing: learned.route.via,
99
122
  };
123
+ const binaryRequestOptions = {
124
+ ...sessionTransportOptions(operation),
125
+ routing: learned.route.via,
126
+ };
100
127
  // The source has admitted this fingerprint against the exact Publication carried by Via.
101
128
  // Pin that revision at the destination so existing Workers can execute the same admitted
102
129
  // callable without understanding the newer fingerprint Target representation. The cache
@@ -111,14 +138,32 @@ export class SessionDispatcher {
111
138
  }),
112
139
  }
113
140
  : call;
114
- const result = await runSessionOperation(operation, () => trace === undefined
115
- ? authenticated(envelope.client, learned.credential).dispatch(destinationCall, requestOptions)
116
- : dispatchTrace(envelope.client, learned.credential, destinationCall, requestOptions, trace));
141
+ const result = await runSessionOperation(operation, () => streaming?.body !== undefined
142
+ ? trace === undefined
143
+ ? authenticated(client, learned.credential).dispatch(destinationCall, {
144
+ ...binaryRequestOptions,
145
+ body: streaming.body,
146
+ })
147
+ : dispatchTrace(client, learned.credential, destinationCall, { ...binaryRequestOptions, body: streaming.body }, trace)
148
+ : trace === undefined
149
+ ? authenticated(client, learned.credential).dispatch(destinationCall, requestOptions)
150
+ : dispatchTrace(client, learned.credential, destinationCall, requestOptions, trace));
117
151
  if (result.kind === 'redirect') {
118
152
  throw new ClientError('A destination redirect is invalid; only the source Kernel may redirect.');
119
153
  }
120
154
  return result;
121
155
  }
156
+ streamingClientFor(url) {
157
+ return new Client({
158
+ url,
159
+ fetch: this.state.streamingHttp.fetch,
160
+ timeoutMs: this.state.streamingHttp.timeoutMs,
161
+ maximumResponseBytes: this.state.streamingHttp.maximumResponseBytes,
162
+ });
163
+ }
164
+ }
165
+ function invalidStreamingDestination() {
166
+ throw new ClientError('Streaming route has no HTTP endpoint.');
122
167
  }
123
168
  function requireCachedRoute(learned, source, target, envelopeTransport, websocketAvailable, policy) {
124
169
  if (learned.route.via.issuer !== source) {
@@ -2,4 +2,4 @@ export { admitSessionAuth, resolveAuth, type Auth, type EffectiveAuth, type Sess
2
2
  export { RouteCache, type LearnedRoute, type PersistedRoute, type SessionRouteArtifact, type SessionRouteStore, } from './cache.js';
3
3
  export { requireResultKind, SessionDispatcher, type SessionDispatcherOptions } from './dispatch.js';
4
4
  export { type ClientPoolCapability, type EnvelopeSelection } from './envelope.js';
5
- export { admitEnvelopeTransport, admitRequestOptions, type SessionEnvelopeTransport, type SessionRequestOptions, } from './request.js';
5
+ export { admitEnvelopeTransport, admitInvocationOptions, admitRequestOptions, type SessionEnvelopeTransport, type SessionRequestOptions, type SessionRequestOptionsWithBody, } from './request.js';
@@ -2,4 +2,4 @@ export { admitSessionAuth, resolveAuth, } from './auth.js';
2
2
  export { RouteCache, } from './cache.js';
3
3
  export { requireResultKind, SessionDispatcher } from './dispatch.js';
4
4
  export {} from './envelope.js';
5
- export { admitEnvelopeTransport, admitRequestOptions, } from './request.js';
5
+ export { admitEnvelopeTransport, admitInvocationOptions, admitRequestOptions, } from './request.js';
@@ -3,6 +3,15 @@ import type { SessionControlOptions } from '../context/index.js';
3
3
  export type SessionEnvelopeTransport = 'auto' | 'http' | 'websocket';
4
4
  export interface SessionRequestOptions extends SessionControlOptions {
5
5
  readonly idempotencyKey?: invocation.IdempotencyKey;
6
+ readonly body?: never;
6
7
  }
8
+ export type SessionRequestOptionsWithBody = Omit<SessionRequestOptions, 'body' | 'idempotencyKey'> & {
9
+ readonly body: Blob;
10
+ readonly idempotencyKey?: never;
11
+ };
12
+ export declare function admitInvocationOptions(input: SessionRequestOptions | SessionRequestOptionsWithBody | undefined): {
13
+ readonly options: Readonly<SessionRequestOptions>;
14
+ readonly body?: Blob;
15
+ };
7
16
  export declare function admitRequestOptions(input?: SessionRequestOptions): Readonly<SessionRequestOptions>;
8
17
  export declare function admitEnvelopeTransport(input: SessionEnvelopeTransport): SessionEnvelopeTransport;
@@ -1,5 +1,22 @@
1
1
  import * as invocationProtocol from '@astrale-os/kernel-protocol/invocation';
2
2
  import { admitControlOptions } from '../context/index.js';
3
+ export function admitInvocationOptions(input) {
4
+ if (input === undefined)
5
+ return Object.freeze({ options: admitRequestOptions() });
6
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
7
+ throw new TypeError('Session request options are invalid.');
8
+ }
9
+ if (!Object.hasOwn(input, 'body')) {
10
+ return Object.freeze({ options: admitRequestOptions(input) });
11
+ }
12
+ const { body, ...options } = input;
13
+ if (!(body instanceof Blob))
14
+ throw new TypeError('Binary input must be a Blob or File.');
15
+ if (options.idempotencyKey !== undefined) {
16
+ throw new TypeError('Binary requests cannot carry an idempotency key.');
17
+ }
18
+ return Object.freeze({ options: admitRequestOptions(options), body });
19
+ }
3
20
  export function admitRequestOptions(input = {}) {
4
21
  if (input === null || typeof input !== 'object' || Array.isArray(input)) {
5
22
  throw new TypeError('Session request options are invalid.');
@@ -10,10 +10,11 @@ import type { SchemaApi } from '../schema/index.js';
10
10
  import type { ClientWarning } from '../transport/auto/index.js';
11
11
  import type { Fetch } from '../transport/http/index.js';
12
12
  import type { WebSocketConstructor } from '../transport/websocket/index.js';
13
+ import type { ContentApi } from './content.js';
13
14
  import type { SessionControlOptions, SessionPolicy } from './context/index.js';
14
15
  import type { SessionSnapshot } from './discovery/index.js';
15
- import type { CallableReference, InputOfReference, OutputOfReference } from './reference.js';
16
- import type { SessionRouteStore, SessionAuth, SessionEnvelopeTransport, SessionRequestOptions } from './routing/index.js';
16
+ import type { CallableReference, InputModeOfReference, InputOfReference, OutputOfReference } from './reference.js';
17
+ import type { SessionRouteStore, SessionAuth, SessionEnvelopeTransport, SessionRequestOptions, SessionRequestOptionsWithBody } from './routing/index.js';
17
18
  import type { ViewCatalog } from './views/index.js';
18
19
  export interface ClientSessionOptions {
19
20
  /** Exact canonical source Kernel identity; its invocation and discovery endpoints are derived. */
@@ -44,6 +45,7 @@ export declare class ClientSession {
44
45
  readonly schema: SchemaApi;
45
46
  readonly graph: GraphApi;
46
47
  readonly auth: AuthApi;
48
+ readonly content: ContentApi;
47
49
  private readonly ownedPool?;
48
50
  private readonly discovery;
49
51
  private readonly dispatcher;
@@ -60,8 +62,13 @@ export declare class ClientSession {
60
62
  snapshot(options?: SessionControlOptions): Promise<SessionSnapshot>;
61
63
  viewsFor(target: PathLike, options?: SessionControlOptions): Promise<ViewCatalog>;
62
64
  dispatch(call: Call, options?: SessionRequestOptions): Promise<Result>;
65
+ dispatch(call: Call, options: SessionRequestOptionsWithBody): Promise<Result>;
63
66
  call(call: Call, options?: SessionRequestOptions): Promise<Input>;
64
- invoke<const Reference extends CallableReference<unknown>>(reference: Reference, input: InputOfReference<Reference>, options?: SessionRequestOptions): Promise<OutputOfReference<Reference>>;
67
+ call(call: Call, options: SessionRequestOptionsWithBody): Promise<Input>;
68
+ invoke<const Reference extends CallableReference<unknown>>(reference: InputModeOfReference<Reference> extends 'binary' ? never : Reference, input: InputOfReference<Reference>, options?: SessionRequestOptions): Promise<OutputOfReference<Reference>>;
69
+ invoke<const Reference extends CallableReference<unknown, unknown, 'binary'>>(reference: Reference, input: InputOfReference<Reference>, options: SessionRequestOptionsWithBody): Promise<OutputOfReference<Reference>>;
70
+ private dispatchWith;
71
+ private invokeWith;
65
72
  stream(call: Call, options?: SessionRequestOptions): Promise<import("../client/result.js").Stream>;
66
73
  binary(call: Call, options?: SessionRequestOptions): Promise<import("@astrale-os/kernel-core/callable").BinaryOutput>;
67
74
  close(): void;
@@ -11,10 +11,11 @@ import { AutoTransport } from '../transport/auto/index.js';
11
11
  import { HttpTransport } from '../transport/http/index.js';
12
12
  import { WebSocketTransport } from '../transport/websocket/index.js';
13
13
  import { admitFunction } from './admission.js';
14
+ import { createContentApi } from './content.js';
14
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
16
  import { createSessionDiscovery } from './discovery/index.js';
16
17
  import { admitReference } from './reference.js';
17
- import { admitEnvelopeTransport, admitRequestOptions, admitSessionAuth, requireResultKind, RouteCache, SessionDispatcher, } from './routing/index.js';
18
+ import { admitEnvelopeTransport, admitInvocationOptions, admitSessionAuth, requireResultKind, RouteCache, SessionDispatcher, } from './routing/index.js';
18
19
  import { resolveViewsFor, targetViewCatalog } from './views/index.js';
19
20
  const DEFAULT_SESSION_POLICY = Object.freeze({ maximumRouteAgeMs: 60_000 });
20
21
  /** Caller-owned discovery and redirect-following conversation with one exact source Kernel. */
@@ -23,6 +24,7 @@ export class ClientSession {
23
24
  schema;
24
25
  graph;
25
26
  auth;
27
+ content;
26
28
  ownedPool;
27
29
  discovery;
28
30
  dispatcher;
@@ -106,6 +108,16 @@ export class ClientSession {
106
108
  policy,
107
109
  envelopeTransport: admitEnvelopeTransport(options.envelopeTransport ?? 'http'),
108
110
  requireOpen: () => this.requireOpen(),
111
+ streamingHttp: {
112
+ fetch: fetchImpl,
113
+ maximumResponseBytes: 100 * 1024 * 1024,
114
+ timeoutMs: 30 * 60 * 1_000,
115
+ },
116
+ });
117
+ this.content = createContentApi({
118
+ operation: (options) => this.operation(options),
119
+ invoke: (reference, input, options, operation) => this.invokeWith(reference, input, options, operation),
120
+ dispatch: (target, input, operation, body) => this.dispatcher.dispatchHttp({ target, input }, {}, operation, body),
109
121
  });
110
122
  this.schema = createSchema((request, options) => this.dispatch(request, options), {
111
123
  maximumPublicationBytes,
@@ -134,23 +146,32 @@ export class ClientSession {
134
146
  return resolveViewsFor(async (call, operation) => requireResultKind('value', await this.dispatcher.dispatch(call, {}, operation)).value, this.admitTargetViews, target, this.operation(admitControlOptions(options)));
135
147
  }
136
148
  dispatch(call, options) {
137
- const admitted = admitRequestOptions(options);
138
- return this.dispatcher.dispatch(call, admitted, this.operation(admitted));
149
+ return this.dispatchWith(call, options);
139
150
  }
140
151
  async call(call, options) {
141
- const result = await this.dispatch(call, options);
152
+ const result = await this.dispatchWith(call, options);
142
153
  return requireResultKind('value', result).value;
143
154
  }
144
155
  async invoke(reference, input, options) {
156
+ return this.invokeWith(reference, input, options);
157
+ }
158
+ dispatchWith(call, options, requireHttp = false) {
159
+ const admitted = admitInvocationOptions(options);
160
+ const operation = this.operation(admitted.options);
161
+ return admitted.body === undefined && !requireHttp
162
+ ? this.dispatcher.dispatch(call, admitted.options, operation)
163
+ : this.dispatcher.dispatchHttp(call, admitted.options, operation, admitted.body);
164
+ }
165
+ async invokeWith(reference, input, options, sharedOperation) {
145
166
  const admittedReference = admitReference(reference);
146
- const admittedOptions = admitRequestOptions(options);
167
+ const admitted = admitInvocationOptions(options);
147
168
  const { anchor, steps } = admittedReference.target.ast;
148
169
  const last = steps.at(-1);
149
170
  const instance = last?.kind === 'method' && last.dispatch === 'instance'
150
171
  ? Path.fromAST({ anchor, steps: steps.slice(0, -1) }).raw
151
172
  : undefined;
152
173
  // A generated reference pins the callable meaning; a hand-written one still pins the revision.
153
- const result = await this.dispatcher.dispatch({
174
+ const call = {
154
175
  target: Object.freeze({
155
176
  callable: admittedReference.callable,
156
177
  ...(admittedReference.fingerprint === undefined
@@ -159,20 +180,24 @@ export class ClientSession {
159
180
  ...(instance === undefined ? {} : { instance }),
160
181
  }),
161
182
  input,
162
- }, admittedOptions, this.operation(admittedOptions));
183
+ };
184
+ const operation = sharedOperation ?? this.operation(admitted.options);
185
+ const result = await (admitted.body === undefined && admittedReference.outputMode !== 'binary'
186
+ ? this.dispatcher.dispatch(call, admitted.options, operation)
187
+ : this.dispatcher.dispatchHttp(call, admitted.options, operation, admitted.body));
163
188
  const narrowed = requireResultKind(admittedReference.outputMode, result);
164
- return (narrowed.kind === 'value'
189
+ return narrowed.kind === 'value'
165
190
  ? narrowed.value
166
191
  : narrowed.kind === 'stream'
167
192
  ? narrowed.stream
168
- : narrowed.value);
193
+ : narrowed.value;
169
194
  }
170
195
  async stream(call, options) {
171
196
  const result = await this.dispatch(call, options);
172
197
  return requireResultKind('stream', result).stream;
173
198
  }
174
199
  async binary(call, options) {
175
- const result = await this.dispatch(call, options);
200
+ const result = await this.dispatchWith(call, options, true);
176
201
  return requireResultKind('binary', result).value;
177
202
  }
178
203
  close() {
@@ -2,6 +2,8 @@ import { NodeId } from '@astrale-os/kernel-core/graph/node';
2
2
  import { Path } from '@astrale-os/kernel-core/path';
3
3
  import { K, KernelSchema } from '@astrale-os/kernel-core/schema';
4
4
  import { defineSchema, func, method, nodeClass, schema, valueSchema, } from '@astrale-os/kernel-dsl/v1';
5
+ import { isBinaryInput } from '@astrale-os/kernel-dsl/v1/language';
6
+ import { call } from '../client/index.js';
5
7
  import { ClientSession, createSessionCredentialProvider, reference } from './index.js';
6
8
  void new ClientSession(options);
7
9
  void auth;
@@ -40,7 +42,10 @@ const renameInput = valueSchema()({
40
42
  required: ['name'],
41
43
  additionalProperties: false,
42
44
  });
43
- const registrationInput = valueSchema()(K.functions.register.input);
45
+ const registerInput = K.functions.register.input;
46
+ if (isBinaryInput(registerInput))
47
+ throw new TypeError('Register input must be a Value.');
48
+ const registrationInput = valueSchema()(registerInput);
44
49
  const booleanOutput = valueSchema()({ type: 'boolean' });
45
50
  const typedDomain = schema.resolve(defineSchema('client.input-witness.typecheck', {
46
51
  dependencies: { kernel: KernelSchema },
@@ -68,9 +73,42 @@ const typedDomain = schema.resolve(defineSchema('client.input-witness.typecheck'
68
73
  const registerReference = reference(typedDomain, typedDomain.functions.registerIdentity);
69
74
  const staticRegisterReference = reference(typedDomain, typedDomain.classes.Person.static.methods.registerIdentity);
70
75
  const renameReference = reference(typedDomain, typedDomain.classes.Person.methods.rename)(Path.id(NodeId('person')));
76
+ const blobReceipt = session.content
77
+ .upload(blobBytes, { mediaType: 'image/png' })
78
+ .then(({ receipt }) => receipt);
79
+ const boundBlobReceipt = boundSession.content
80
+ .upload(blobBytes, { mediaType: 'image/png' })
81
+ .then(({ receipt }) => receipt);
82
+ void blobReceipt;
83
+ void boundBlobReceipt;
71
84
  void session.invoke(registerReference, registration);
72
85
  void boundSession.invoke(staticRegisterReference, registration);
73
86
  void session.invoke(renameReference, renameRequest);
87
+ const binaryUploadReference = reference(K, K.functions.upload);
88
+ void session.invoke(binaryUploadReference, { mediaType: 'image/png' }, { body: new Blob() });
89
+ void boundSession.invoke(binaryUploadReference, { mediaType: 'image/png' }, { body: new Blob() });
90
+ const rawBinaryCall = call(Path.parse('/:example.test:function.write'), { mediaType: 'image/png' });
91
+ void client.dispatch(rawBinaryCall, { body: new Blob() });
92
+ void client.as('credential').call(rawBinaryCall, { body: new Blob() });
93
+ // @ts-expect-error Binary Functions require a byte source.
94
+ void session.invoke(binaryUploadReference, { mediaType: 'image/png' });
95
+ // @ts-expect-error Value Functions reject binary bodies.
96
+ void session.invoke(registerReference, registration, { body: new Blob() });
97
+ // @ts-expect-error Bound binary Functions require a byte source.
98
+ void boundSession.invoke(binaryUploadReference, { mediaType: 'image/png' });
99
+ // @ts-expect-error Bound value Functions reject binary bodies.
100
+ void boundSession.invoke(registerReference, registration, { body: new Blob() });
101
+ void session.invoke(binaryUploadReference, { mediaType: 'image/png' },
102
+ // @ts-expect-error Binary invocations cannot carry an idempotency key.
103
+ { body: new Blob(), idempotencyKey: 'retry' });
104
+ // @ts-expect-error Raw Session binary calls cannot carry an idempotency key.
105
+ void session.dispatch(rawBinaryCall, { body: new Blob(), idempotencyKey: 'retry' });
106
+ // @ts-expect-error Raw Client binary calls cannot carry an idempotency key.
107
+ void client.dispatch(rawBinaryCall, { body: new Blob(), idempotencyKey: 'retry' });
108
+ // @ts-expect-error Authenticated Client binary calls cannot carry an idempotency key.
109
+ void client.as('credential').call(rawBinaryCall, { body: new Blob(), idempotencyKey: 'retry' });
110
+ void session.dispatch({ target: Path.parse('/:example.test:function.write'), input: {} }, { body: new Blob() });
111
+ void session.call({ target: Path.parse('/:example.test:function.write'), input: {} }, { body: new Blob() });
74
112
  const registerInputIsRequest = true;
75
113
  const renameInputIsInterface = true;
76
114
  const renameOutputIsBoolean = true;
@@ -3,12 +3,13 @@ import type { ControlledSignal } from '../index.js';
3
3
  export declare class HttpByteStream implements AsyncIterable<Uint8Array> {
4
4
  private readonly maximumBytes;
5
5
  private readonly control;
6
+ private readonly expectedBytes?;
6
7
  private readonly reader;
7
8
  private claimed;
8
9
  private terminal;
9
10
  private bytes;
10
11
  private interruption;
11
- constructor(body: ReadableStream<Uint8Array>, maximumBytes: number, control: ControlledSignal);
12
+ constructor(body: ReadableStream<Uint8Array>, maximumBytes: number, control: ControlledSignal, expectedBytes?: number | undefined);
12
13
  [Symbol.asyncIterator](): AsyncIterator<Uint8Array>;
13
14
  cancel(reason?: unknown): Promise<void>;
14
15
  private next;
@@ -4,14 +4,16 @@ import { asError } from '../index.js';
4
4
  export class HttpByteStream {
5
5
  maximumBytes;
6
6
  control;
7
+ expectedBytes;
7
8
  reader;
8
9
  claimed = false;
9
10
  terminal = false;
10
11
  bytes = 0;
11
12
  interruption;
12
- constructor(body, maximumBytes, control) {
13
+ constructor(body, maximumBytes, control, expectedBytes) {
13
14
  this.maximumBytes = maximumBytes;
14
15
  this.control = control;
16
+ this.expectedBytes = expectedBytes;
15
17
  this.reader = body.getReader();
16
18
  const abort = () => void this.interrupt(control.signal.reason);
17
19
  if (control.signal.aborted)
@@ -53,11 +55,17 @@ export class HttpByteStream {
53
55
  if (this.interruption !== undefined)
54
56
  throw this.interruption;
55
57
  if (next.done) {
58
+ if (this.expectedBytes !== undefined && this.bytes !== this.expectedBytes) {
59
+ throw new ProtocolError(`streaming binary ended after ${this.bytes} bytes; expected ${this.expectedBytes}.`);
60
+ }
56
61
  this.terminal = true;
57
62
  this.control.close();
58
63
  return { done: true, value: undefined };
59
64
  }
60
65
  this.bytes += next.value.byteLength;
66
+ if (this.expectedBytes !== undefined && this.bytes > this.expectedBytes) {
67
+ throw new ProtocolError(`streaming binary exceeded declared Content-Length (${this.expectedBytes}).`);
68
+ }
61
69
  if (this.bytes > this.maximumBytes) {
62
70
  throw new ProtocolError(`streaming binary exceeds maximumResponseBytes (${this.maximumBytes}).`);
63
71
  }
@@ -25,6 +25,9 @@ export declare class HttpTransport implements Transport {
25
25
  private nextId;
26
26
  constructor(options: HttpTransportOptions);
27
27
  dispatch(input: TransportRequest, options?: RequestOptions): Promise<TransportResult>;
28
+ /** HTTP-only binary invocation; each call may replay a re-readable Blob. */
29
+ dispatchBinary(input: TransportRequest, body: Blob, options?: RequestOptions): Promise<TransportResult>;
30
+ private dispatchWith;
28
31
  private fetchResponse;
29
32
  private decodeBinary;
30
33
  private nextRequestId;
@@ -1,10 +1,11 @@
1
+ import { canonicalMediaType } from '@astrale-os/kernel-protocol/artifact';
1
2
  import * as codecs from '@astrale-os/kernel-protocol/codecs';
2
3
  import * as protocol from '@astrale-os/kernel-protocol/invocation';
3
4
  import { ProtocolError, ResponseError, TransportError } from '../../errors/index.js';
4
5
  import { invocationFailure } from '../failure.js';
5
6
  import { asError, controlledSignal, positiveInteger, requireLive, } from '../index.js';
6
7
  import { HttpByteStream } from './byte-stream.js';
7
- import { binaryHeaders, codecForResponse, decodeProtocol, normalizeMediaType, readBoundedBody, requireId, } from './response.js';
8
+ import { binaryHeaders, codecForResponse, decodeProtocol, readBoundedBody, requireId, } from './response.js';
8
9
  import { HttpStream } from './stream.js';
9
10
  const DEFAULT_TIMEOUT_MS = 30_000;
10
11
  const DEFAULT_MAXIMUM_REQUEST_BYTES = 16 * 1024 * 1024;
@@ -32,6 +33,18 @@ export class HttpTransport {
32
33
  this.maximumResponseBytes = positiveInteger(options.maximumResponseBytes ?? DEFAULT_MAXIMUM_RESPONSE_BYTES, 'maximumResponseBytes');
33
34
  }
34
35
  async dispatch(input, options = {}) {
36
+ return this.dispatchWith(input, options);
37
+ }
38
+ /** HTTP-only binary invocation; each call may replay a re-readable Blob. */
39
+ async dispatchBinary(input, body, options = {}) {
40
+ if (!(body instanceof Blob))
41
+ throw new TypeError('Binary request body must be a Blob.');
42
+ if (options.idempotencyKey !== undefined) {
43
+ throw new TypeError('Binary requests cannot carry an idempotency key.');
44
+ }
45
+ return this.dispatchWith(input, options, body);
46
+ }
47
+ async dispatchWith(input, options, binaryBody) {
35
48
  const requestId = this.nextRequestId();
36
49
  const timeoutMs = positiveInteger(options.timeoutMs ?? this.timeoutMs, 'timeoutMs');
37
50
  const codec = await this.requestCodec();
@@ -43,19 +56,21 @@ export class HttpTransport {
43
56
  ...invocationInput,
44
57
  ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }),
45
58
  });
46
- const encoded = codec.encode(request);
59
+ const encoded = binaryBody === undefined
60
+ ? codec.encode(request)
61
+ : protocol.http.encodeBinaryRequestEnvelope(request);
47
62
  const control = controlledSignal(options.signal, timeoutMs);
48
63
  let handedOff = false;
49
64
  let phase = 'connect';
50
65
  let delivery = 'not-sent';
51
66
  try {
52
67
  requireLive(control);
53
- if (encodedByteLength(encoded) > this.maximumRequestBytes) {
68
+ if (binaryBody === undefined && encodedByteLength(encoded) > this.maximumRequestBytes) {
54
69
  throw invocationFailure(`request exceeds maximumRequestBytes (${this.maximumRequestBytes}).`, 'send');
55
70
  }
56
71
  phase = 'send';
57
72
  delivery = 'unknown';
58
- const response = await this.fetchResponse(codec, encoded, trace, control);
73
+ const response = await this.fetchResponse(codec, encoded, trace, control, binaryBody);
59
74
  phase = 'receive';
60
75
  requireLive(control);
61
76
  if (response.status >= 300 && response.status < 400) {
@@ -143,20 +158,27 @@ export class HttpTransport {
143
158
  control.close();
144
159
  }
145
160
  }
146
- async fetchResponse(codec, encoded, trace, control) {
161
+ async fetchResponse(codec, encoded, trace, control, binaryBody) {
147
162
  const init = {
148
163
  method: 'POST',
149
164
  redirect: 'manual',
150
165
  credentials: 'omit',
151
166
  cache: 'no-store',
152
167
  referrerPolicy: 'no-referrer',
168
+ // Fetch owns Content-Length; the protocol carries Blob.size across Fetch runtimes.
153
169
  headers: {
154
170
  ...this.headers,
155
171
  ...protocol.http.encodeTraceHeaders(trace),
156
- 'content-type': codec.contentType,
172
+ 'content-type': binaryBody === undefined ? codec.contentType : protocol.http.BINARY_REQUEST_CONTENT_TYPE,
157
173
  accept: codec.contentType,
174
+ ...(binaryBody === undefined
175
+ ? {}
176
+ : {
177
+ [protocol.http.REQUEST_ENVELOPE_FIELD]: encoded,
178
+ [protocol.http.BINARY_LENGTH_FIELD]: String(binaryBody.size),
179
+ }),
158
180
  },
159
- body: toBody(encoded),
181
+ body: binaryBody ?? toBody(encoded),
160
182
  signal: control.signal,
161
183
  };
162
184
  return this.fetchImpl(this.url, init);
@@ -165,22 +187,32 @@ export class HttpTransport {
165
187
  if (binaryId !== String(requestId)) {
166
188
  throw new ProtocolError(`binary response requestId ${JSON.stringify(binaryId)} does not match request requestId ${requestId}.`);
167
189
  }
168
- const mediaType = normalizeMediaType(response.headers.get('content-type'));
169
- if (mediaType === undefined) {
190
+ const contentType = response.headers.get('content-type');
191
+ if (contentType === null) {
170
192
  throw new ProtocolError('binary response has no media type.');
171
193
  }
194
+ const mediaType = decodeProtocol('binary media type', () => canonicalMediaType(contentType));
172
195
  const headerNames = decodeProtocol('binary application-header manifest', () => protocol.http.decodeBinaryHeaderNames(response.headers.get(protocol.http.BINARY_HEADERS_FIELD)));
173
196
  const headers = binaryHeaders(response.headers, headerNames);
174
197
  const invocation = decodeProtocol('binary InvocationId field', () => protocol.http.decodeInvocationId(response.headers.get(protocol.http.INVOCATION_FIELD)));
175
198
  if (streaming) {
176
199
  if (response.body === null)
177
200
  throw new ProtocolError('streaming binary has no body.');
201
+ const length = response.headers.get('content-length');
202
+ const expectedBytes = length === null ? undefined : Number(length);
203
+ if (length !== null &&
204
+ (!/^(?:0|[1-9][0-9]*)$/u.test(length) || !Number.isSafeInteger(expectedBytes))) {
205
+ throw new ProtocolError('streaming binary has an invalid Content-Length.');
206
+ }
207
+ if (expectedBytes !== undefined && expectedBytes > this.maximumResponseBytes) {
208
+ throw new ProtocolError(`streaming binary exceeds maximumResponseBytes (${this.maximumResponseBytes}).`);
209
+ }
178
210
  requireLive(control);
179
211
  return {
180
212
  kind: 'binary',
181
213
  invocation,
182
214
  value: {
183
- body: new HttpByteStream(response.body, this.maximumResponseBytes, control),
215
+ body: new HttpByteStream(response.body, this.maximumResponseBytes, control, expectedBytes),
184
216
  mediaType,
185
217
  ...(response.status === 200 ? {} : { status: response.status }),
186
218
  ...(Object.keys(headers).length === 0 ? {} : { headers }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/kernel-client",
3
- "version": "0.6.0-beta.71",
3
+ "version": "0.6.0-beta.72",
4
4
  "description": "Universal Client — protocol-first, transport-agnostic",
5
5
  "keywords": [
6
6
  "astrale",
@@ -65,14 +65,14 @@
65
65
  "access": "public"
66
66
  },
67
67
  "dependencies": {
68
- "@astrale-os/kernel-core": "0.9.0-beta.54",
69
- "@astrale-os/kernel-dsl": "0.2.0-beta.39",
70
- "@astrale-os/kernel-protocol": "0.5.0-beta.60",
68
+ "@astrale-os/kernel-core": "0.9.0-beta.55",
69
+ "@astrale-os/kernel-dsl": "0.2.0-beta.40",
70
+ "@astrale-os/kernel-protocol": "0.5.0-beta.61",
71
71
  "@tanstack/pacer": "0.20.1",
72
72
  "msgpackr": "^2.0.5"
73
73
  },
74
74
  "devDependencies": {
75
- "@astrale-os/kernel-server": "0.5.0-beta.62",
75
+ "@astrale-os/kernel-server": "0.5.0-beta.63",
76
76
  "@astrale/typescript-config": "npm:@jsr/astrale__typescript-config@~1.1.0",
77
77
  "@types/node": "^22.0.0",
78
78
  "typescript": "7.0.2",