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

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 (60) hide show
  1. package/dist/auth/credential.d.ts +6 -1
  2. package/dist/auth/credential.js +2 -2
  3. package/dist/client/client.d.ts +4 -2
  4. package/dist/client/client.js +13 -2
  5. package/dist/client/credential.d.ts +4 -2
  6. package/dist/client/credential.js +7 -2
  7. package/dist/client/index.d.ts +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client/request.d.ts +7 -1
  10. package/dist/client/request.js +31 -6
  11. package/dist/index.d.ts +1 -1
  12. package/dist/schema/introspection.js +4 -0
  13. package/dist/session/admission.d.ts +3 -15
  14. package/dist/session/admission.js +16 -74
  15. package/dist/session/bound.d.ts +10 -8
  16. package/dist/session/bound.js +20 -3
  17. package/dist/session/content.d.ts +30 -0
  18. package/dist/session/content.js +160 -0
  19. package/dist/session/context/operation.d.ts +5 -1
  20. package/dist/session/credential/index.d.ts +1 -1
  21. package/dist/session/credential/index.js +1 -1
  22. package/dist/session/credential/provider.d.ts +5 -0
  23. package/dist/session/credential/provider.js +8 -1
  24. package/dist/session/exchange/domain.d.ts +27 -0
  25. package/dist/session/exchange/domain.js +63 -0
  26. package/dist/session/exchange/exchange.d.ts +54 -0
  27. package/dist/session/exchange/exchange.js +58 -0
  28. package/dist/session/exchange/index.d.ts +5 -0
  29. package/dist/session/exchange/index.js +5 -0
  30. package/dist/session/exchange/issuer.d.ts +18 -0
  31. package/dist/session/exchange/issuer.js +89 -0
  32. package/dist/session/exchange/limits.d.ts +10 -0
  33. package/dist/session/exchange/limits.js +10 -0
  34. package/dist/session/exchange/options.d.ts +29 -0
  35. package/dist/session/exchange/options.js +57 -0
  36. package/dist/session/exchange/presentation.d.ts +18 -0
  37. package/dist/session/exchange/presentation.js +73 -0
  38. package/dist/session/exchange/source.d.ts +34 -0
  39. package/dist/session/exchange/source.js +78 -0
  40. package/dist/session/execution.d.ts +65 -0
  41. package/dist/session/execution.js +35 -0
  42. package/dist/session/index.d.ts +5 -2
  43. package/dist/session/index.js +1 -0
  44. package/dist/session/reference.d.ts +13 -4
  45. package/dist/session/routing/auth.d.ts +10 -0
  46. package/dist/session/routing/auth.js +8 -1
  47. package/dist/session/routing/dispatch.d.ts +12 -1
  48. package/dist/session/routing/dispatch.js +73 -25
  49. package/dist/session/routing/index.d.ts +2 -2
  50. package/dist/session/routing/index.js +1 -1
  51. package/dist/session/routing/request.d.ts +9 -0
  52. package/dist/session/routing/request.js +17 -0
  53. package/dist/session/session.d.ts +26 -3
  54. package/dist/session/session.js +59 -14
  55. package/dist/session/session.typecheck.js +71 -2
  56. package/dist/transport/http/byte-stream.d.ts +2 -1
  57. package/dist/transport/http/byte-stream.js +9 -1
  58. package/dist/transport/http/transport.d.ts +3 -0
  59. package/dist/transport/http/transport.js +42 -11
  60. package/package.json +5 -5
@@ -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,13 @@ 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';
15
+ import type { SessionCredential } from './credential/index.js';
14
16
  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';
17
+ import type { SessionExchangeOptions, SessionExchangeRequest } from './exchange/index.js';
18
+ import type { CallableReference, InputModeOfReference, InputOfReference, OutputOfReference } from './reference.js';
19
+ import type { SessionRouteStore, SessionAuth, SessionEnvelopeTransport, SessionRequestOptions, SessionRequestOptionsWithBody } from './routing/index.js';
17
20
  import type { ViewCatalog } from './views/index.js';
18
21
  export interface ClientSessionOptions {
19
22
  /** Exact canonical source Kernel identity; its invocation and discovery endpoints are derived. */
@@ -23,6 +26,12 @@ export interface ClientSessionOptions {
23
26
  readonly pool?: Pick<ClientPoolType, 'clientFor'>;
24
27
  readonly websocket?: WebSocketConstructor;
25
28
  readonly auth?: SessionAuth;
29
+ /**
30
+ * Present each Domain callable with a credential its declaring Domain exchanges for the `auth`
31
+ * caller, so the Domain is the principal and the caller is `auth`. Kernel and local callables keep
32
+ * the `auth` credential.
33
+ */
34
+ readonly exchange?: SessionExchangeOptions;
26
35
  /** Optional caller-owned persistence for bounded confidential route artifacts. */
27
36
  readonly routeStore?: SessionRouteStore;
28
37
  readonly policy: SessionPolicy;
@@ -44,24 +53,38 @@ export declare class ClientSession {
44
53
  readonly schema: SchemaApi;
45
54
  readonly graph: GraphApi;
46
55
  readonly auth: AuthApi;
56
+ readonly content: ContentApi;
47
57
  private readonly ownedPool?;
48
58
  private readonly discovery;
49
59
  private readonly dispatcher;
50
60
  private readonly lifecycle;
51
61
  private readonly timeoutMs;
52
62
  private readonly routes;
63
+ private readonly exchangeDependencies;
64
+ private readonly callExchange?;
53
65
  private readonly admitTargetViews;
54
66
  private closed;
55
67
  constructor(options: ClientSessionOptions);
56
68
  /** Bind one admitted DSL Domain to authenticated graph projection and Method dispatch. */
57
69
  bind<const DomainValue extends Domain>(domain: DomainValue): DomainBinding<DomainValue>;
70
+ /**
71
+ * Delegate this Session's caller to one Domain issuer and exchange that delegation for the
72
+ * Domain's credential to this Kernel. Nothing is cached: hold the result, for example through
73
+ * `createSessionCredentialProvider`, for as long as it covers the calls it carries.
74
+ */
75
+ exchange(domain: string, options?: SessionExchangeRequest): Promise<SessionCredential>;
58
76
  /** Revalidate the intrinsic Publication without acquiring its bundle or Views. */
59
77
  publication(options?: SessionControlOptions): Promise<publication.Publication>;
60
78
  snapshot(options?: SessionControlOptions): Promise<SessionSnapshot>;
61
79
  viewsFor(target: PathLike, options?: SessionControlOptions): Promise<ViewCatalog>;
62
80
  dispatch(call: Call, options?: SessionRequestOptions): Promise<Result>;
81
+ dispatch(call: Call, options: SessionRequestOptionsWithBody): Promise<Result>;
63
82
  call(call: Call, options?: SessionRequestOptions): Promise<Input>;
64
- invoke<const Reference extends CallableReference<unknown>>(reference: Reference, input: InputOfReference<Reference>, options?: SessionRequestOptions): Promise<OutputOfReference<Reference>>;
83
+ call(call: Call, options: SessionRequestOptionsWithBody): Promise<Input>;
84
+ invoke<const Reference extends CallableReference<unknown>>(reference: InputModeOfReference<Reference> extends 'binary' ? never : Reference, input: InputOfReference<Reference>, options?: SessionRequestOptions): Promise<OutputOfReference<Reference>>;
85
+ invoke<const Reference extends CallableReference<unknown, unknown, 'binary'>>(reference: Reference, input: InputOfReference<Reference>, options: SessionRequestOptionsWithBody): Promise<OutputOfReference<Reference>>;
86
+ private dispatchWith;
87
+ private invokeWith;
65
88
  stream(call: Call, options?: SessionRequestOptions): Promise<import("../client/result.js").Stream>;
66
89
  binary(call: Call, options?: SessionRequestOptions): Promise<import("@astrale-os/kernel-core/callable").BinaryOutput>;
67
90
  close(): void;
@@ -11,10 +11,12 @@ 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';
17
+ import { acquireDomainCredential, admitExchangeOptions, admitExchangeRequest, createSessionExchange, } from './exchange/index.js';
16
18
  import { admitReference } from './reference.js';
17
- import { admitEnvelopeTransport, admitRequestOptions, admitSessionAuth, requireResultKind, RouteCache, SessionDispatcher, } from './routing/index.js';
19
+ import { admitEnvelopeTransport, admitInvocationOptions, admitSessionAuth, requireResultKind, RouteCache, SessionDispatcher, } from './routing/index.js';
18
20
  import { resolveViewsFor, targetViewCatalog } from './views/index.js';
19
21
  const DEFAULT_SESSION_POLICY = Object.freeze({ maximumRouteAgeMs: 60_000 });
20
22
  /** Caller-owned discovery and redirect-following conversation with one exact source Kernel. */
@@ -23,12 +25,15 @@ export class ClientSession {
23
25
  schema;
24
26
  graph;
25
27
  auth;
28
+ content;
26
29
  ownedPool;
27
30
  discovery;
28
31
  dispatcher;
29
32
  lifecycle = new AbortController();
30
33
  timeoutMs;
31
34
  routes;
35
+ exchangeDependencies;
36
+ callExchange;
32
37
  admitTargetViews;
33
38
  closed = false;
34
39
  constructor(options) {
@@ -84,28 +89,45 @@ export class ClientSession {
84
89
  });
85
90
  this.routes = new RouteCache(policy.maximumRouteAgeMs, undefined, options.routeStore);
86
91
  const auth = admitSessionAuth(options.auth);
87
- const admission = {
92
+ const exchange = admitExchangeOptions(options.exchange, auth);
93
+ this.exchangeDependencies = Object.freeze({
88
94
  kernel: this.kernel,
89
- auth,
90
95
  source: () => {
91
96
  allowEndpoint(sourceEndpoint, policy);
92
97
  return pool.clientFor({ transport: 'http', url: sourceEndpoint });
93
98
  },
99
+ ...(auth === undefined ? {} : { auth }),
94
100
  fetch: fetchImpl,
95
101
  policy,
96
102
  limits: { maximumPublicationBytes, maximumBundleBytes },
97
103
  maximumResponseBytes,
98
- };
104
+ operation: (signal) => this.operation(signal === undefined ? undefined : { signal }),
105
+ });
106
+ this.callExchange =
107
+ exchange === undefined || auth === undefined
108
+ ? undefined
109
+ : createSessionExchange({ ...this.exchangeDependencies, auth }, exchange);
99
110
  this.dispatcher = new SessionDispatcher({
100
111
  kernel: this.kernel,
101
112
  sourceEndpoint,
102
113
  pool,
103
114
  websocketAvailable,
104
115
  auth,
116
+ ...(this.callExchange === undefined ? {} : { exchange: this.callExchange }),
105
117
  cache: this.routes,
106
118
  policy,
107
119
  envelopeTransport: admitEnvelopeTransport(options.envelopeTransport ?? 'http'),
108
120
  requireOpen: () => this.requireOpen(),
121
+ streamingHttp: {
122
+ fetch: fetchImpl,
123
+ maximumResponseBytes: 100 * 1024 * 1024,
124
+ timeoutMs: 30 * 60 * 1_000,
125
+ },
126
+ });
127
+ this.content = createContentApi({
128
+ operation: (options) => this.operation(options),
129
+ invoke: (reference, input, options, operation) => this.invokeWith(reference, input, options, operation),
130
+ dispatch: (target, input, operation, body) => this.dispatcher.dispatchHttp({ target, input }, {}, operation, body),
109
131
  });
110
132
  this.schema = createSchema((request, options) => this.dispatch(request, options), {
111
133
  maximumPublicationBytes,
@@ -115,13 +137,22 @@ export class ClientSession {
115
137
  this.graph = createGraph((call, options) => this.call(call, options));
116
138
  this.auth = createAuth((target, input, options) => this.call({ target, input }, options), (call, options) => {
117
139
  const operation = this.operation(admitControlOptions(options));
118
- return admitFunction(admission, acceptCall(call), options, operation);
140
+ return admitFunction(this.exchangeDependencies, acceptCall(call), options, operation);
119
141
  });
120
142
  }
121
143
  /** Bind one admitted DSL Domain to authenticated graph projection and Method dispatch. */
122
144
  bind(domain) {
123
145
  return createDomainBinding(domain, this.graph, (node, method, input) => this.invokeDomainMethod(domain, node, method, input));
124
146
  }
147
+ /**
148
+ * Delegate this Session's caller to one Domain issuer and exchange that delegation for the
149
+ * Domain's credential to this Kernel. Nothing is cached: hold the result, for example through
150
+ * `createSessionCredentialProvider`, for as long as it covers the calls it carries.
151
+ */
152
+ async exchange(domain, options) {
153
+ const request = admitExchangeRequest(domain, options);
154
+ return acquireDomainCredential(this.exchangeDependencies, request, this.operation(request.control));
155
+ }
125
156
  /** Revalidate the intrinsic Publication without acquiring its bundle or Views. */
126
157
  publication(options) {
127
158
  return this.discovery.publication(this.operation(admitControlOptions(options)));
@@ -134,23 +165,32 @@ export class ClientSession {
134
165
  return resolveViewsFor(async (call, operation) => requireResultKind('value', await this.dispatcher.dispatch(call, {}, operation)).value, this.admitTargetViews, target, this.operation(admitControlOptions(options)));
135
166
  }
136
167
  dispatch(call, options) {
137
- const admitted = admitRequestOptions(options);
138
- return this.dispatcher.dispatch(call, admitted, this.operation(admitted));
168
+ return this.dispatchWith(call, options);
139
169
  }
140
170
  async call(call, options) {
141
- const result = await this.dispatch(call, options);
171
+ const result = await this.dispatchWith(call, options);
142
172
  return requireResultKind('value', result).value;
143
173
  }
144
174
  async invoke(reference, input, options) {
175
+ return this.invokeWith(reference, input, options);
176
+ }
177
+ dispatchWith(call, options, requireHttp = false) {
178
+ const admitted = admitInvocationOptions(options);
179
+ const operation = this.operation(admitted.options);
180
+ return admitted.body === undefined && !requireHttp
181
+ ? this.dispatcher.dispatch(call, admitted.options, operation)
182
+ : this.dispatcher.dispatchHttp(call, admitted.options, operation, admitted.body);
183
+ }
184
+ async invokeWith(reference, input, options, sharedOperation) {
145
185
  const admittedReference = admitReference(reference);
146
- const admittedOptions = admitRequestOptions(options);
186
+ const admitted = admitInvocationOptions(options);
147
187
  const { anchor, steps } = admittedReference.target.ast;
148
188
  const last = steps.at(-1);
149
189
  const instance = last?.kind === 'method' && last.dispatch === 'instance'
150
190
  ? Path.fromAST({ anchor, steps: steps.slice(0, -1) }).raw
151
191
  : undefined;
152
192
  // A generated reference pins the callable meaning; a hand-written one still pins the revision.
153
- const result = await this.dispatcher.dispatch({
193
+ const call = {
154
194
  target: Object.freeze({
155
195
  callable: admittedReference.callable,
156
196
  ...(admittedReference.fingerprint === undefined
@@ -159,20 +199,24 @@ export class ClientSession {
159
199
  ...(instance === undefined ? {} : { instance }),
160
200
  }),
161
201
  input,
162
- }, admittedOptions, this.operation(admittedOptions));
202
+ };
203
+ const operation = sharedOperation ?? this.operation(admitted.options);
204
+ const result = await (admitted.body === undefined && admittedReference.outputMode !== 'binary'
205
+ ? this.dispatcher.dispatch(call, admitted.options, operation)
206
+ : this.dispatcher.dispatchHttp(call, admitted.options, operation, admitted.body));
163
207
  const narrowed = requireResultKind(admittedReference.outputMode, result);
164
- return (narrowed.kind === 'value'
208
+ return narrowed.kind === 'value'
165
209
  ? narrowed.value
166
210
  : narrowed.kind === 'stream'
167
211
  ? narrowed.stream
168
- : narrowed.value);
212
+ : narrowed.value;
169
213
  }
170
214
  async stream(call, options) {
171
215
  const result = await this.dispatch(call, options);
172
216
  return requireResultKind('stream', result).stream;
173
217
  }
174
218
  async binary(call, options) {
175
- const result = await this.dispatch(call, options);
219
+ const result = await this.dispatchWith(call, options, true);
176
220
  return requireResultKind('binary', result).value;
177
221
  }
178
222
  close() {
@@ -183,6 +227,7 @@ export class ClientSession {
183
227
  this.ownedPool?.close();
184
228
  this.routes.clear();
185
229
  this.discovery.clear();
230
+ this.callExchange?.clear();
186
231
  }
187
232
  requireOpen() {
188
233
  if (this.closed)
@@ -2,7 +2,9 @@ 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 { ClientSession, createSessionCredentialProvider, reference } from './index.js';
5
+ import { isBinaryInput } from '@astrale-os/kernel-dsl/v1/language';
6
+ import { call } from '../client/index.js';
7
+ import { ClientSession, createSessionCredentialProvider, execution, reference } from './index.js';
6
8
  void new ClientSession(options);
7
9
  void auth;
8
10
  void control;
@@ -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;
@@ -98,6 +136,37 @@ const abstractDomain = schema.resolve(defineSchema('client.reference.typecheck',
98
136
  AbstractItem: nodeClass({ abstract: true, methods: { abstractMethod } }),
99
137
  },
100
138
  }));
139
+ const reminderOutput = valueSchema()({
140
+ type: 'object',
141
+ properties: { reminder: { type: 'string' } },
142
+ required: ['reminder'],
143
+ additionalProperties: false,
144
+ });
145
+ const durableDomain = schema.resolve(defineSchema('client.durable.typecheck', {
146
+ classes: {
147
+ Invoice: nodeClass({
148
+ methods: {
149
+ remind: method({
150
+ auth: 'authenticated',
151
+ input: renameInput,
152
+ output: reminderOutput,
153
+ execution: { durable: true },
154
+ }),
155
+ },
156
+ }),
157
+ },
158
+ }));
159
+ const remindReference = reference(durableDomain, durableDomain.classes.Invoice.methods.remind)(Path.id(NodeId('invoice-1')));
160
+ const durableReturnsExecution = true;
161
+ const outcomeIsTyped = true;
162
+ const statusIsTyped = true;
163
+ void session.invoke(execution.result(run), { waitMs: 1_000 });
164
+ void session.invoke(execution.signal(run), { name: 'approval', value: { by: 'billing' } });
165
+ // @ts-expect-error Force is the only cancellation option.
166
+ void session.invoke(execution.cancel(run), { waitMs: 1_000 });
167
+ void durableReturnsExecution;
168
+ void outcomeIsTyped;
169
+ void statusIsTyped;
101
170
  // @ts-expect-error Abstract Methods are declarations and cannot become Client references.
102
171
  reference(abstractDomain, abstractDomain.classes.AbstractItem.methods.abstractMethod);
103
172
  // @ts-expect-error Exact identity is derived internally, never supplied as a Session option.
@@ -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,26 @@ 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
- cache: 'no-store',
152
166
  referrerPolicy: 'no-referrer',
167
+ // Fetch owns Content-Length; the protocol carries Blob.size across Fetch runtimes.
153
168
  headers: {
154
169
  ...this.headers,
155
170
  ...protocol.http.encodeTraceHeaders(trace),
156
- 'content-type': codec.contentType,
171
+ 'content-type': binaryBody === undefined ? codec.contentType : protocol.http.BINARY_REQUEST_CONTENT_TYPE,
157
172
  accept: codec.contentType,
173
+ ...(binaryBody === undefined
174
+ ? {}
175
+ : {
176
+ [protocol.http.REQUEST_ENVELOPE_FIELD]: encoded,
177
+ [protocol.http.BINARY_LENGTH_FIELD]: String(binaryBody.size),
178
+ }),
158
179
  },
159
- body: toBody(encoded),
180
+ body: binaryBody ?? toBody(encoded),
160
181
  signal: control.signal,
161
182
  };
162
183
  return this.fetchImpl(this.url, init);
@@ -165,22 +186,32 @@ export class HttpTransport {
165
186
  if (binaryId !== String(requestId)) {
166
187
  throw new ProtocolError(`binary response requestId ${JSON.stringify(binaryId)} does not match request requestId ${requestId}.`);
167
188
  }
168
- const mediaType = normalizeMediaType(response.headers.get('content-type'));
169
- if (mediaType === undefined) {
189
+ const contentType = response.headers.get('content-type');
190
+ if (contentType === null) {
170
191
  throw new ProtocolError('binary response has no media type.');
171
192
  }
193
+ const mediaType = decodeProtocol('binary media type', () => canonicalMediaType(contentType));
172
194
  const headerNames = decodeProtocol('binary application-header manifest', () => protocol.http.decodeBinaryHeaderNames(response.headers.get(protocol.http.BINARY_HEADERS_FIELD)));
173
195
  const headers = binaryHeaders(response.headers, headerNames);
174
196
  const invocation = decodeProtocol('binary InvocationId field', () => protocol.http.decodeInvocationId(response.headers.get(protocol.http.INVOCATION_FIELD)));
175
197
  if (streaming) {
176
198
  if (response.body === null)
177
199
  throw new ProtocolError('streaming binary has no body.');
200
+ const length = response.headers.get('content-length');
201
+ const expectedBytes = length === null ? undefined : Number(length);
202
+ if (length !== null &&
203
+ (!/^(?:0|[1-9][0-9]*)$/u.test(length) || !Number.isSafeInteger(expectedBytes))) {
204
+ throw new ProtocolError('streaming binary has an invalid Content-Length.');
205
+ }
206
+ if (expectedBytes !== undefined && expectedBytes > this.maximumResponseBytes) {
207
+ throw new ProtocolError(`streaming binary exceeds maximumResponseBytes (${this.maximumResponseBytes}).`);
208
+ }
178
209
  requireLive(control);
179
210
  return {
180
211
  kind: 'binary',
181
212
  invocation,
182
213
  value: {
183
- body: new HttpByteStream(response.body, this.maximumResponseBytes, control),
214
+ body: new HttpByteStream(response.body, this.maximumResponseBytes, control, expectedBytes),
184
215
  mediaType,
185
216
  ...(response.status === 200 ? {} : { status: response.status }),
186
217
  ...(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.74",
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.56",
69
+ "@astrale-os/kernel-dsl": "0.2.0-beta.41",
70
+ "@astrale-os/kernel-protocol": "0.5.0-beta.62",
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.64",
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",