@astrale-os/kernel-client 0.6.0-beta.70 → 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.
Files changed (40) hide show
  1. package/dist/auth/auth.d.ts +17 -5
  2. package/dist/auth/auth.js +3 -4
  3. package/dist/auth/credential.d.ts +5 -1
  4. package/dist/auth/index.d.ts +1 -1
  5. package/dist/auth/index.js +0 -1
  6. package/dist/client/client.d.ts +4 -2
  7. package/dist/client/client.js +13 -2
  8. package/dist/client/credential.d.ts +4 -2
  9. package/dist/client/credential.js +7 -2
  10. package/dist/client/index.d.ts +1 -1
  11. package/dist/client/index.js +1 -1
  12. package/dist/client/request.d.ts +7 -1
  13. package/dist/client/request.js +31 -6
  14. package/dist/index.d.ts +1 -1
  15. package/dist/session/admission.d.ts +18 -0
  16. package/dist/session/admission.js +117 -0
  17. package/dist/session/bound.d.ts +12 -9
  18. package/dist/session/bound.js +24 -5
  19. package/dist/session/content.d.ts +30 -0
  20. package/dist/session/content.js +160 -0
  21. package/dist/session/context/operation.d.ts +5 -1
  22. package/dist/session/discovery/index.d.ts +1 -0
  23. package/dist/session/discovery/index.js +1 -0
  24. package/dist/session/index.d.ts +3 -2
  25. package/dist/session/reference.d.ts +7 -3
  26. package/dist/session/reference.js +1 -1
  27. package/dist/session/routing/dispatch.d.ts +10 -0
  28. package/dist/session/routing/dispatch.js +68 -23
  29. package/dist/session/routing/index.d.ts +1 -1
  30. package/dist/session/routing/index.js +1 -1
  31. package/dist/session/routing/request.d.ts +9 -0
  32. package/dist/session/routing/request.js +17 -0
  33. package/dist/session/session.d.ts +12 -3
  34. package/dist/session/session.js +58 -13
  35. package/dist/session/session.typecheck.js +46 -1
  36. package/dist/transport/http/byte-stream.d.ts +2 -1
  37. package/dist/transport/http/byte-stream.js +9 -1
  38. package/dist/transport/http/transport.d.ts +3 -0
  39. package/dist/transport/http/transport.js +42 -10
  40. package/package.json +6 -6
@@ -1,6 +1,7 @@
1
1
  import { Path } from '@astrale-os/kernel-core/path';
2
2
  import { callableFingerprint } from '@astrale-os/kernel-dsl/v1/schema/fingerprint';
3
- import { Client } from '../client/index.js';
3
+ import { createAuth } from '../auth/auth.js';
4
+ import { Client, acceptCall } from '../client/index.js';
4
5
  import { SessionError } from '../errors/index.js';
5
6
  import { createDomainBinding } from '../graph/domain/binding.js';
6
7
  import { createGraph } from '../graph/index.js';
@@ -9,10 +10,12 @@ import { createSchema, DEFAULT_SCHEMA_LIFECYCLE_TIMEOUT_MS } from '../schema/ind
9
10
  import { AutoTransport } from '../transport/auto/index.js';
10
11
  import { HttpTransport } from '../transport/http/index.js';
11
12
  import { WebSocketTransport } from '../transport/websocket/index.js';
12
- import { admitControlOptions, admitPolicy, createSessionOperation, DEFAULT_MAXIMUM_BUNDLE_BYTES, DEFAULT_MAXIMUM_PUBLICATION_BYTES, DEFAULT_MAXIMUM_RESPONSE_BYTES, DEFAULT_TIMEOUT_MS, admitKernel, kernelInvocationEndpoint, platformDuration, positive, } from './context/index.js';
13
+ import { admitFunction } from './admission.js';
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';
13
16
  import { createSessionDiscovery } from './discovery/index.js';
14
17
  import { admitReference } from './reference.js';
15
- import { admitEnvelopeTransport, admitRequestOptions, admitSessionAuth, requireResultKind, RouteCache, SessionDispatcher, } from './routing/index.js';
18
+ import { admitEnvelopeTransport, admitInvocationOptions, admitSessionAuth, requireResultKind, RouteCache, SessionDispatcher, } from './routing/index.js';
16
19
  import { resolveViewsFor, targetViewCatalog } from './views/index.js';
17
20
  const DEFAULT_SESSION_POLICY = Object.freeze({ maximumRouteAgeMs: 60_000 });
18
21
  /** Caller-owned discovery and redirect-following conversation with one exact source Kernel. */
@@ -20,6 +23,8 @@ export class ClientSession {
20
23
  kernel;
21
24
  schema;
22
25
  graph;
26
+ auth;
27
+ content;
23
28
  ownedPool;
24
29
  discovery;
25
30
  dispatcher;
@@ -80,16 +85,39 @@ export class ClientSession {
80
85
  acquisitionOperation: () => this.operation(undefined),
81
86
  });
82
87
  this.routes = new RouteCache(policy.maximumRouteAgeMs, undefined, options.routeStore);
88
+ const auth = admitSessionAuth(options.auth);
89
+ const admission = {
90
+ kernel: this.kernel,
91
+ auth,
92
+ source: () => {
93
+ allowEndpoint(sourceEndpoint, policy);
94
+ return pool.clientFor({ transport: 'http', url: sourceEndpoint });
95
+ },
96
+ fetch: fetchImpl,
97
+ policy,
98
+ limits: { maximumPublicationBytes, maximumBundleBytes },
99
+ maximumResponseBytes,
100
+ };
83
101
  this.dispatcher = new SessionDispatcher({
84
102
  kernel: this.kernel,
85
103
  sourceEndpoint,
86
104
  pool,
87
105
  websocketAvailable,
88
- auth: admitSessionAuth(options.auth),
106
+ auth,
89
107
  cache: this.routes,
90
108
  policy,
91
109
  envelopeTransport: admitEnvelopeTransport(options.envelopeTransport ?? 'http'),
92
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),
93
121
  });
94
122
  this.schema = createSchema((request, options) => this.dispatch(request, options), {
95
123
  maximumPublicationBytes,
@@ -97,6 +125,10 @@ export class ClientSession {
97
125
  lifecycleTimeoutMs: schemaLifecycleTimeoutMs,
98
126
  });
99
127
  this.graph = createGraph((call, options) => this.call(call, options));
128
+ this.auth = createAuth((target, input, options) => this.call({ target, input }, options), (call, options) => {
129
+ const operation = this.operation(admitControlOptions(options));
130
+ return admitFunction(admission, acceptCall(call), options, operation);
131
+ });
100
132
  }
101
133
  /** Bind one admitted DSL Domain to authenticated graph projection and Method dispatch. */
102
134
  bind(domain) {
@@ -114,23 +146,32 @@ export class ClientSession {
114
146
  return resolveViewsFor(async (call, operation) => requireResultKind('value', await this.dispatcher.dispatch(call, {}, operation)).value, this.admitTargetViews, target, this.operation(admitControlOptions(options)));
115
147
  }
116
148
  dispatch(call, options) {
117
- const admitted = admitRequestOptions(options);
118
- return this.dispatcher.dispatch(call, admitted, this.operation(admitted));
149
+ return this.dispatchWith(call, options);
119
150
  }
120
151
  async call(call, options) {
121
- const result = await this.dispatch(call, options);
152
+ const result = await this.dispatchWith(call, options);
122
153
  return requireResultKind('value', result).value;
123
154
  }
124
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) {
125
166
  const admittedReference = admitReference(reference);
126
- const admittedOptions = admitRequestOptions(options);
167
+ const admitted = admitInvocationOptions(options);
127
168
  const { anchor, steps } = admittedReference.target.ast;
128
169
  const last = steps.at(-1);
129
170
  const instance = last?.kind === 'method' && last.dispatch === 'instance'
130
171
  ? Path.fromAST({ anchor, steps: steps.slice(0, -1) }).raw
131
172
  : undefined;
132
173
  // A generated reference pins the callable meaning; a hand-written one still pins the revision.
133
- const result = await this.dispatcher.dispatch({
174
+ const call = {
134
175
  target: Object.freeze({
135
176
  callable: admittedReference.callable,
136
177
  ...(admittedReference.fingerprint === undefined
@@ -139,20 +180,24 @@ export class ClientSession {
139
180
  ...(instance === undefined ? {} : { instance }),
140
181
  }),
141
182
  input,
142
- }, 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));
143
188
  const narrowed = requireResultKind(admittedReference.outputMode, result);
144
- return (narrowed.kind === 'value'
189
+ return narrowed.kind === 'value'
145
190
  ? narrowed.value
146
191
  : narrowed.kind === 'stream'
147
192
  ? narrowed.stream
148
- : narrowed.value);
193
+ : narrowed.value;
149
194
  }
150
195
  async stream(call, options) {
151
196
  const result = await this.dispatch(call, options);
152
197
  return requireResultKind('stream', result).stream;
153
198
  }
154
199
  async binary(call, options) {
155
- const result = await this.dispatch(call, options);
200
+ const result = await this.dispatchWith(call, options, true);
156
201
  return requireResultKind('binary', result).value;
157
202
  }
158
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;
@@ -12,6 +14,13 @@ void request;
12
14
  void snapshot;
13
15
  const observedPublication = session.publication(control);
14
16
  void observedPublication;
17
+ const admitted = session.auth.admit({
18
+ target: Path.parse('/:example.test:function.write'),
19
+ input: {},
20
+ }, { attenuation: { kind: 'identity', self: true }, ...control });
21
+ void admitted;
22
+ // @ts-expect-error Admission belongs to Auth, not the top-level Session.
23
+ void session.admit({ target: Path.parse('/:example.test:function.write'), input: {} });
15
24
  const credentials = createSessionCredentialProvider({
16
25
  ttlSeconds: 60,
17
26
  mint: async () => minted,
@@ -33,7 +42,10 @@ const renameInput = valueSchema()({
33
42
  required: ['name'],
34
43
  additionalProperties: false,
35
44
  });
36
- 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);
37
49
  const booleanOutput = valueSchema()({ type: 'boolean' });
38
50
  const typedDomain = schema.resolve(defineSchema('client.input-witness.typecheck', {
39
51
  dependencies: { kernel: KernelSchema },
@@ -61,9 +73,42 @@ const typedDomain = schema.resolve(defineSchema('client.input-witness.typecheck'
61
73
  const registerReference = reference(typedDomain, typedDomain.functions.registerIdentity);
62
74
  const staticRegisterReference = reference(typedDomain, typedDomain.classes.Person.static.methods.registerIdentity);
63
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;
64
84
  void session.invoke(registerReference, registration);
65
85
  void boundSession.invoke(staticRegisterReference, registration);
66
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() });
67
112
  const registerInputIsRequest = true;
68
113
  const renameInputIsInterface = true;
69
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.70",
3
+ "version": "0.6.0-beta.72",
4
4
  "description": "Universal Client — protocol-first, transport-agnostic",
5
5
  "keywords": [
6
6
  "astrale",
@@ -65,18 +65,18 @@
65
65
  "access": "public"
66
66
  },
67
67
  "dependencies": {
68
- "@astrale-os/kernel-core": "0.9.0-beta.53",
69
- "@astrale-os/kernel-dsl": "0.2.0-beta.38",
70
- "@astrale-os/kernel-protocol": "0.5.0-beta.59",
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.61",
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",
79
- "vitest": "^4.1.10"
79
+ "vitest": "^5.0.1"
80
80
  },
81
81
  "scripts": {
82
82
  "build": "tsc",