@astrale-os/kernel-server 0.5.0-beta.61 → 0.5.0-beta.63

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,7 @@
1
+ import type { BinaryRequestBody } from '@astrale-os/kernel-core/callable';
1
2
  import type * as protocol from '@astrale-os/kernel-protocol/invocation';
2
3
  import type { ActiveOperation, ActiveOperations } from '../lifecycle/index.js';
3
4
  import type { ApplicationHost, InvocationContext } from './types.js';
4
- export declare function invocationContext(assigned: protocol.InvocationId, operation: ActiveOperation, active: ActiveOperations, host?: ApplicationHost, requestBody?: Uint8Array, idempotencyKey?: protocol.IdempotencyKey): InvocationContext;
5
+ export declare function invocationContext(assigned: protocol.InvocationId, operation: ActiveOperation, active: ActiveOperations, host?: ApplicationHost, requestBody?: Uint8Array, idempotencyKey?: protocol.IdempotencyKey, binary?: BinaryRequestBody): InvocationContext;
5
6
  /** Default non-durable timer used when the application host supplies no timer capability. */
6
7
  export declare function defaultSleep(durationMs: number): Promise<void>;
@@ -1,4 +1,4 @@
1
- export function invocationContext(assigned, operation, active, host, requestBody, idempotencyKey) {
1
+ export function invocationContext(assigned, operation, active, host, requestBody, idempotencyKey, binary) {
2
2
  const body = requestBody?.slice();
3
3
  return Object.freeze({
4
4
  invocation: assigned,
@@ -9,6 +9,7 @@ export function invocationContext(assigned, operation, active, host, requestBody
9
9
  },
10
10
  ...(idempotencyKey === undefined ? {} : { idempotencyKey }),
11
11
  ...(body === undefined ? {} : { request: Object.freeze({ body: () => body.slice() }) }),
12
+ ...(binary === undefined ? {} : { binary }),
12
13
  });
13
14
  }
14
15
  /** Default non-durable timer used when the application host supplies no timer capability. */
@@ -1,5 +1,5 @@
1
1
  import type { CredentialInput } from '@astrale-os/kernel-core/auth';
2
- import type { CallableResult } from '@astrale-os/kernel-core/callable';
2
+ import type { BinaryRequestBody, CallableResult } from '@astrale-os/kernel-core/callable';
3
3
  import type { Value } from '@astrale-os/kernel-dsl/value';
4
4
  import type * as protocol from '@astrale-os/kernel-protocol/invocation';
5
5
  import type { PublicationSnapshot } from '../publication/index.js';
@@ -27,6 +27,8 @@ export interface InvocationContext {
27
27
  readonly execution: ExecutionControl;
28
28
  readonly idempotencyKey?: protocol.IdempotencyKey;
29
29
  readonly request?: RequestEvidence;
30
+ /** Root-only binary HTTP ingress; ordinary envelopes and WebSocket carry none. */
31
+ readonly binary?: BinaryRequestBody;
30
32
  readonly background: RequestBackground;
31
33
  /** Rejects promptly when execution is cancelled or reaches its deadline. */
32
34
  readonly sleep: RequestSleep;
@@ -12,6 +12,8 @@ export interface ActiveOperation {
12
12
  /** Register cooperative cleanup and return an idempotent unregistration function. */
13
13
  onCancel(cancel: CancelWork): () => void;
14
14
  cancel(reason: string): void;
15
+ /** Replace the remaining budget after a trusted transport binding is admitted. */
16
+ extend(timeoutMs: number): void;
15
17
  settle(): void;
16
18
  readonly finished: Promise<void>;
17
19
  }
@@ -1,3 +1,6 @@
1
+ // Streaming iterators can need several microtasks to return after cancellation. Keep the
2
+ // cleanup join bounded even when a producer ignores its cancellation signal.
3
+ const CANCELLATION_CLEANUP_GRACE_MS = 250;
1
4
  export class ActiveOperations {
2
5
  maximumConcurrentOperations;
3
6
  accepting = true;
@@ -66,19 +69,32 @@ export class ActiveOperations {
66
69
  }
67
70
  close(deadline) {
68
71
  this.drain();
69
- this.closing ??= this.closeOnce(deadline);
72
+ if (this.closing === undefined) {
73
+ const flight = this.closeOnce(deadline);
74
+ this.closing = flight;
75
+ void flight.catch(() => {
76
+ // A failed close leaves admission terminal, but residual work may settle later.
77
+ // Let the same owner join that cleanup on its next close attempt.
78
+ if (this.closing === flight)
79
+ this.closing = undefined;
80
+ });
81
+ }
70
82
  return this.closing;
71
83
  }
72
84
  async closeOnce(deadline) {
73
85
  await this.waitUntilIdleOr(deadline);
86
+ const hasCancellationWork = [...this.operations].some((operation) => operation.hasCancellationWork);
74
87
  if (this.operations.size > 0) {
75
88
  for (const operation of this.operations)
76
89
  operation.cancel('server closing');
77
90
  }
78
91
  this.closeSockets();
79
- // Give cooperative cancellation and iterator returns one turn to settle. The absolute deadline
80
- // has already elapsed; an uncooperative operation must reject close rather than pin Host forever.
81
- await Promise.resolve();
92
+ if (hasCancellationWork) {
93
+ await this.waitUntilIdleOr(Date.now() + CANCELLATION_CLEANUP_GRACE_MS);
94
+ }
95
+ else {
96
+ await Promise.resolve();
97
+ }
82
98
  if (this.operations.size > 0 || this.backgroundTasks.size > 0) {
83
99
  this.closed = true;
84
100
  throw new Error(`Server close deadline elapsed with ${this.operations.size} active operation(s) and ${this.backgroundTasks.size} background task(s).`);
@@ -113,11 +129,11 @@ export class ActiveOperations {
113
129
  }
114
130
  }
115
131
  class Operation {
116
- deadline;
117
132
  onSettled;
118
133
  controller = new AbortController();
119
134
  cancelWork = new Set();
120
135
  timer;
136
+ deadlineValue;
121
137
  external;
122
138
  settled = false;
123
139
  cancelled = false;
@@ -125,15 +141,31 @@ class Operation {
125
141
  resolveFinished;
126
142
  finished;
127
143
  constructor(deadline, onSettled) {
128
- this.deadline = deadline;
129
144
  this.onSettled = onSettled;
145
+ this.deadlineValue = deadline;
130
146
  this.finished = new Promise((resolve) => {
131
147
  this.resolveFinished = resolve;
132
148
  });
133
- this.timer = setTimeout(() => {
149
+ this.timer = this.arm();
150
+ }
151
+ get deadline() {
152
+ return this.deadlineValue;
153
+ }
154
+ extend(timeoutMs) {
155
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
156
+ throw new TypeError('Active operation timeout is invalid.');
157
+ }
158
+ if (this.cancelled || this.settled)
159
+ return;
160
+ clearTimeout(this.timer);
161
+ this.deadlineValue = Date.now() + timeoutMs;
162
+ this.timer = this.arm();
163
+ }
164
+ arm() {
165
+ return setTimeout(() => {
134
166
  this.timeout = true;
135
167
  this.cancel('invocation deadline exceeded');
136
- }, Math.min(Math.max(0, deadline - Date.now()), 2_147_483_647));
168
+ }, Math.min(Math.max(0, this.deadlineValue - Date.now()), 2_147_483_647));
137
169
  }
138
170
  link(signal) {
139
171
  if (signal === undefined)
@@ -151,6 +183,9 @@ class Operation {
151
183
  get timedOut() {
152
184
  return this.timeout;
153
185
  }
186
+ get hasCancellationWork() {
187
+ return this.cancelWork.size > 0;
188
+ }
154
189
  onCancel(cancel) {
155
190
  if (this.cancelled) {
156
191
  void settleCancellation(cancel, abortReason(this.controller.signal));
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Path } from '@astrale-os/kernel-core/path';
1
2
  import { PATH as PUBLICATION_PATH, url as publicationUrl, } from '@astrale-os/kernel-protocol/publication/publication';
2
3
  import { captureMethod } from './capture.js';
3
4
  import { prepareCors } from './cors/index.js';
@@ -244,16 +245,42 @@ function admitLimits(limits) {
244
245
  if (limits.invocationTimeoutMs > 2_147_483_647) {
245
246
  throw new TypeError('invocationTimeoutMs exceeds the platform timer range.');
246
247
  }
248
+ if ((limits.binaryDownloadTarget === undefined) !==
249
+ (limits.binaryDownloadTimeoutMs === undefined)) {
250
+ throw new TypeError('binaryDownloadTarget and binaryDownloadTimeoutMs must be configured together.');
251
+ }
252
+ const binaryDownloadTarget = limits.binaryDownloadTarget === undefined
253
+ ? undefined
254
+ : Path.parse(limits.binaryDownloadTarget).raw;
255
+ const binaryDownloadTimeoutMs = limits.binaryDownloadTimeoutMs === undefined
256
+ ? undefined
257
+ : admitTimerLimit(limits.binaryDownloadTimeoutMs, 'binaryDownloadTimeoutMs');
247
258
  return Object.freeze({
248
259
  maximumRequestBytes: limits.maximumRequestBytes,
260
+ maximumBinaryRequestBytes: admitOptionalLimit(limits.maximumBinaryRequestBytes, limits.maximumRequestBytes, 'maximumBinaryRequestBytes'),
249
261
  maximumResponseBytes: admitOptionalLimit(limits.maximumResponseBytes, 16 * 1024 * 1024, 'maximumResponseBytes'),
250
262
  maximumConcurrentOperations: admitOptionalLimit(limits.maximumConcurrentOperations, 1_024, 'maximumConcurrentOperations'),
251
263
  maximumPublicationBytes: admitOptionalLimit(limits.maximumPublicationBytes, limits.maximumRequestBytes, 'maximumPublicationBytes'),
252
264
  maximumBundleBytes: admitOptionalLimit(limits.maximumBundleBytes, 16 * 1024 * 1024, 'maximumBundleBytes'),
253
265
  maximumDeliveryBytes: admitOptionalLimit(limits.maximumDeliveryBytes, limits.maximumRequestBytes, 'maximumDeliveryBytes'),
254
266
  invocationTimeoutMs: limits.invocationTimeoutMs,
267
+ binaryRequestTimeoutMs: admitTimerLimit(limits.binaryRequestTimeoutMs ?? limits.invocationTimeoutMs, 'binaryRequestTimeoutMs'),
268
+ ...(limits.binaryRequestIdleTimeoutMs === undefined
269
+ ? {}
270
+ : {
271
+ binaryRequestIdleTimeoutMs: admitTimerLimit(limits.binaryRequestIdleTimeoutMs, 'binaryRequestIdleTimeoutMs'),
272
+ }),
273
+ ...(binaryDownloadTarget === undefined
274
+ ? {}
275
+ : { binaryDownloadTarget, binaryDownloadTimeoutMs }),
255
276
  });
256
277
  }
278
+ function admitTimerLimit(input, label) {
279
+ if (!Number.isSafeInteger(input) || input <= 0 || input > 2_147_483_647) {
280
+ throw new TypeError(`${label} must be a positive platform timer duration.`);
281
+ }
282
+ return input;
283
+ }
257
284
  function admitOptionalLimit(input, fallback, label) {
258
285
  const value = input ?? fallback;
259
286
  if (!Number.isSafeInteger(value) || value <= 0) {
@@ -0,0 +1,11 @@
1
+ import { type BinaryRequestBody } from '@astrale-os/kernel-core/callable';
2
+ interface IdleBudget {
3
+ readonly timeoutMs: number;
4
+ readonly cancel: () => void;
5
+ }
6
+ /** Admit a declared length before dispatch and expose one counted, non-buffering body. */
7
+ export declare function binaryRequestBody(request: Request, maximumBytes: number, signal: AbortSignal, idle?: IdleBudget): BinaryRequestBody & {
8
+ readonly completed: () => boolean;
9
+ readonly cancel: (reason: unknown) => void;
10
+ };
11
+ export {};
@@ -0,0 +1,102 @@
1
+ import { BinaryBodyLengthError } from '@astrale-os/kernel-core/callable';
2
+ import * as protocol from '@astrale-os/kernel-protocol/invocation';
3
+ import { RequestBodyInvalidError, RequestBodyTooLargeError } from '../../errors/index.js';
4
+ /** Admit a declared length before dispatch and expose one counted, non-buffering body. */
5
+ export function binaryRequestBody(request, maximumBytes, signal, idle) {
6
+ const declared = request.headers.get(protocol.http.BINARY_LENGTH_FIELD);
7
+ if (declared === null || !/^(?:0|[1-9][0-9]*)$/u.test(declared)) {
8
+ throw new RequestBodyInvalidError('Binary request length is required and must be exact.');
9
+ }
10
+ const length = Number(declared);
11
+ if (!Number.isSafeInteger(length)) {
12
+ throw new RequestBodyInvalidError('Binary request length is invalid.');
13
+ }
14
+ const contentLength = request.headers.get('content-length');
15
+ if (contentLength !== null && contentLength !== declared) {
16
+ throw new RequestBodyInvalidError('Binary request length differs from Content-Length.');
17
+ }
18
+ if (length > maximumBytes)
19
+ throw new RequestBodyTooLargeError(maximumBytes);
20
+ if (request.body === null) {
21
+ if (length !== 0)
22
+ throw new RequestBodyInvalidError('Binary request body is missing.');
23
+ return Object.freeze({
24
+ body: new ReadableStream({ start: (controller) => controller.close() }),
25
+ length,
26
+ completed: () => true,
27
+ cancel: () => undefined,
28
+ });
29
+ }
30
+ const reader = request.body.getReader();
31
+ let size = 0;
32
+ let finished = false;
33
+ let completed = false;
34
+ let idleTimer;
35
+ const clearIdle = () => {
36
+ if (idleTimer !== undefined)
37
+ clearTimeout(idleTimer);
38
+ idleTimer = undefined;
39
+ };
40
+ const release = () => {
41
+ if (finished)
42
+ return;
43
+ finished = true;
44
+ clearIdle();
45
+ signal.removeEventListener('abort', abort);
46
+ reader.releaseLock();
47
+ };
48
+ const cancelSource = (reason) => {
49
+ if (finished)
50
+ return;
51
+ clearIdle();
52
+ void reader.cancel(reason).then(release, release);
53
+ };
54
+ const abort = () => cancelSource(signal.reason);
55
+ signal.addEventListener('abort', abort, { once: true });
56
+ if (signal.aborted)
57
+ abort();
58
+ const startIdle = () => {
59
+ if (idle !== undefined && idleTimer === undefined) {
60
+ idleTimer = setTimeout(idle.cancel, idle.timeoutMs);
61
+ }
62
+ };
63
+ const body = new ReadableStream({
64
+ async pull(controller) {
65
+ try {
66
+ if (signal.aborted)
67
+ throw signal.reason;
68
+ startIdle();
69
+ const next = await reader.read();
70
+ if (signal.aborted)
71
+ throw signal.reason;
72
+ if (next.done) {
73
+ if (size !== length)
74
+ throw new BinaryBodyLengthError();
75
+ completed = true;
76
+ release();
77
+ controller.close();
78
+ return;
79
+ }
80
+ if (!(next.value instanceof Uint8Array)) {
81
+ throw new RequestBodyInvalidError('Binary request yielded a non-byte chunk.');
82
+ }
83
+ size += next.value.byteLength;
84
+ if (size > length)
85
+ throw new BinaryBodyLengthError();
86
+ if (next.value.byteLength > 0)
87
+ clearIdle();
88
+ controller.enqueue(next.value);
89
+ }
90
+ catch (cause) {
91
+ cancelSource(cause);
92
+ release();
93
+ controller.error(cause);
94
+ }
95
+ },
96
+ cancel(reason) {
97
+ cancelSource(reason);
98
+ release();
99
+ },
100
+ }, { highWaterMark: 0 });
101
+ return Object.freeze({ body, length, completed: () => completed, cancel: cancelSource });
102
+ }
@@ -8,8 +8,13 @@ export interface HttpHandlerConfig {
8
8
  readonly active: ActiveOperations;
9
9
  readonly invocation: NextInvocationId;
10
10
  readonly maximumRequestBytes: number;
11
+ readonly maximumBinaryRequestBytes: number;
11
12
  readonly maximumResponseBytes: number;
12
13
  readonly invocationTimeoutMs: number;
14
+ readonly binaryRequestTimeoutMs: number;
15
+ readonly binaryRequestIdleTimeoutMs?: number;
16
+ readonly binaryDownloadTarget?: string;
17
+ readonly binaryDownloadTimeoutMs?: number;
13
18
  readonly cors: CorsPolicy;
14
19
  readonly websocket: boolean;
15
20
  readonly currentPublication?: (request: Request) => PublicationSnapshot | undefined;
@@ -6,6 +6,7 @@ import { invocationContext, publicationRouteDecision, } from '../../invocation/i
6
6
  import { readBoundedBody } from '../body.js';
7
7
  import { loadCodecs } from '../codecs.js';
8
8
  import { resolveCredential } from '../credential.js';
9
+ import { binaryRequestBody } from './binary-request.js';
9
10
  import { isStreamingBinary, renderHttpError, renderHttpResult, } from './render.js';
10
11
  export function createHttpHandler(config) {
11
12
  return async (request, host) => {
@@ -21,6 +22,8 @@ export function createHttpHandler(config) {
21
22
  headers: [
22
23
  'authorization',
23
24
  'content-type',
25
+ protocol.http.REQUEST_ENVELOPE_FIELD,
26
+ protocol.http.BINARY_LENGTH_FIELD,
24
27
  protocol.http.TRACEPARENT_FIELD,
25
28
  protocol.http.TRACESTATE_FIELD,
26
29
  ],
@@ -37,18 +40,24 @@ export function createHttpHandler(config) {
37
40
  ...options,
38
41
  maximumResponseBytes: config.maximumResponseBytes,
39
42
  });
40
- const active = config.active.begin(config.invocationTimeoutMs, request.signal);
43
+ const binaryRequest = request.headers.get('content-type')?.trim().toLowerCase() ===
44
+ protocol.http.BINARY_REQUEST_CONTENT_TYPE;
45
+ const active = config.active.begin(binaryRequest ? config.binaryRequestTimeoutMs : config.invocationTimeoutMs, request.signal);
41
46
  if (active === undefined) {
47
+ if (binaryRequest && request.body !== null && !request.body.locked) {
48
+ void request.body.cancel('binary request rejected').catch(() => undefined);
49
+ }
42
50
  return renderError(null, {
43
51
  code: errors.CODES.BACKEND_UNAVAILABLE,
44
52
  message: 'server is unavailable.',
45
53
  }, codecs.json, { status: 503, negotiated: false });
46
54
  }
47
55
  let handedOff = false;
56
+ let binary;
48
57
  try {
49
58
  const supported = await loadCodecs();
50
59
  const contentType = request.headers.get('content-type');
51
- const incoming = contentType === null || contentType.trim() === ''
60
+ const incoming = binaryRequest || contentType === null || contentType.trim() === ''
52
61
  ? codecs.json
53
62
  : protocol.http.codecForContentType(contentType, supported);
54
63
  if (incoming === undefined) {
@@ -66,7 +75,15 @@ export function createHttpHandler(config) {
66
75
  }
67
76
  let body;
68
77
  try {
69
- body = await readBoundedBody(request, config.maximumRequestBytes, active.signal);
78
+ if (binaryRequest)
79
+ binary = binaryRequestBody(request, config.maximumBinaryRequestBytes, active.signal, config.binaryRequestIdleTimeoutMs === undefined
80
+ ? undefined
81
+ : {
82
+ timeoutMs: config.binaryRequestIdleTimeoutMs,
83
+ cancel: () => active.cancel('binary request idle timeout exceeded'),
84
+ });
85
+ else
86
+ body = await readBoundedBody(request, config.maximumRequestBytes, active.signal);
70
87
  }
71
88
  catch (input) {
72
89
  const payload = input instanceof RequestBodyTooLargeError
@@ -87,22 +104,45 @@ export function createHttpHandler(config) {
87
104
  status: input instanceof RequestBodyTooLargeError ? 413 : errors.httpStatusOf(payload),
88
105
  });
89
106
  }
90
- let decoded;
91
- try {
92
- decoded = incoming.decode(body);
93
- }
94
- catch (input) {
95
- if (!(input instanceof codecs.DecodeError))
96
- throw input;
97
- return renderError(null, { code: errors.CODES.PARSE_ERROR, message: 'Request body is invalid.' }, outgoing);
98
- }
99
107
  let envelope;
100
108
  try {
101
- envelope = protocol.decodeRequest(decoded);
109
+ if (binaryRequest) {
110
+ envelope = protocol.http.decodeBinaryRequestEnvelope(request.headers.get(protocol.http.REQUEST_ENVELOPE_FIELD));
111
+ if (envelope.idempotencyKey !== undefined) {
112
+ return renderError(envelope.requestId, {
113
+ code: errors.CODES.INVALID_REQUEST,
114
+ message: 'Binary requests forbid idempotencyKey.',
115
+ }, outgoing);
116
+ }
117
+ }
118
+ else {
119
+ let decoded;
120
+ try {
121
+ decoded = incoming.decode(body);
122
+ }
123
+ catch (input) {
124
+ if (!(input instanceof codecs.DecodeError))
125
+ throw input;
126
+ return renderError(null, { code: errors.CODES.PARSE_ERROR, message: 'Request body is invalid.' }, outgoing);
127
+ }
128
+ envelope = protocol.decodeRequest(decoded);
129
+ }
102
130
  }
103
131
  catch (input) {
104
132
  return renderError(null, classifyError(input), outgoing);
105
133
  }
134
+ if (!binaryRequest &&
135
+ config.binaryDownloadTarget !== undefined &&
136
+ protocol.targetPath(envelope.call.target) === config.binaryDownloadTarget) {
137
+ active.extend(config.binaryDownloadTimeoutMs);
138
+ }
139
+ if (active.signal.aborted) {
140
+ const timedOut = active.timedOut;
141
+ return renderError(envelope.requestId, {
142
+ code: timedOut ? errors.CODES.DEADLINE_EXCEEDED : errors.CODES.CANCELLED,
143
+ message: timedOut ? 'Invocation deadline exceeded.' : 'Invocation was cancelled.',
144
+ }, outgoing, { status: timedOut ? 504 : 408 });
145
+ }
106
146
  const publication = config.currentPublication?.(request);
107
147
  if (config.currentPublication !== undefined && publication === undefined) {
108
148
  return renderError(envelope.requestId, {
@@ -136,8 +176,19 @@ export function createHttpHandler(config) {
136
176
  ...(envelope.routing?.kind === 'via' ? { via: envelope.routing } : {}),
137
177
  ...(envelope.schema === undefined ? {} : { schema: envelope.schema }),
138
178
  ...(publication === undefined ? {} : { publication }),
139
- context: invocationContext(assigned, active, config.active, host, undefined, envelope.idempotencyKey),
179
+ context: invocationContext(assigned, active, config.active, host, undefined, envelope.idempotencyKey, binary),
140
180
  }));
181
+ if (binary !== undefined && result.kind !== 'redirect' && result.kind !== 'miss') {
182
+ if (!binary.completed()) {
183
+ if (result.kind === 'stream') {
184
+ await result.stream.cancel('Binary request body was not fully consumed.');
185
+ }
186
+ else if (result.kind === 'binary' && isStreamingBinary(result.value)) {
187
+ await result.value.body[Symbol.asyncIterator]().return?.();
188
+ }
189
+ throw new Error('Binary Function returned before consuming its request body.');
190
+ }
191
+ }
141
192
  const response = renderHttpResult(result, {
142
193
  requestId: envelope.requestId,
143
194
  codec: outgoing,
@@ -169,6 +220,15 @@ export function createHttpHandler(config) {
169
220
  });
170
221
  }
171
222
  finally {
223
+ if (binary !== undefined) {
224
+ binary.cancel('binary invocation completed');
225
+ }
226
+ else if (binaryRequest &&
227
+ binary === undefined &&
228
+ request.body !== null &&
229
+ !request.body.locked) {
230
+ void request.body.cancel('binary request rejected').catch(() => undefined);
231
+ }
172
232
  if (!handedOff)
173
233
  active.settle();
174
234
  }
@@ -82,6 +82,21 @@ function binaryHttpResponse(binary, cors, maximumResponseBytes) {
82
82
  return new Response(toBody(binary.body), { status: binary.status, headers });
83
83
  }
84
84
  function streamingBinaryHttpResponse(binary, context) {
85
+ if (binary.length !== undefined && (!Number.isSafeInteger(binary.length) || binary.length < 0)) {
86
+ throw new TypeError('Streaming binary length must be a non-negative safe integer.');
87
+ }
88
+ const delivery = binary.delivery;
89
+ if (delivery !== undefined) {
90
+ if (!Number.isSafeInteger(delivery.maximumDurationMs) ||
91
+ delivery.maximumDurationMs <= 0 ||
92
+ delivery.maximumDurationMs > 2_147_483_647 ||
93
+ !Number.isSafeInteger(delivery.idleTimeoutMs) ||
94
+ delivery.idleTimeoutMs <= 0 ||
95
+ delivery.idleTimeoutMs > 2_147_483_647) {
96
+ throw new TypeError('Streaming binary delivery budget is invalid.');
97
+ }
98
+ context.active.extend(delivery.maximumDurationMs);
99
+ }
85
100
  const iterator = binary.body[Symbol.asyncIterator]();
86
101
  const headers = binaryHttpHeaders(Object.freeze({
87
102
  requestId: context.requestId,
@@ -89,13 +104,30 @@ function streamingBinaryHttpResponse(binary, context) {
89
104
  mediaType: binary.mediaType,
90
105
  headers: binary.headers,
91
106
  }), context.cors, true);
107
+ if (binary.length !== undefined)
108
+ headers.set('content-length', String(binary.length));
109
+ if (delivery !== undefined)
110
+ headers.set('cache-control', 'private, no-store');
92
111
  let terminal = false;
112
+ let delivered = 0;
93
113
  let controller;
94
114
  let stopping;
115
+ let idleTimer;
116
+ const clearIdle = () => {
117
+ if (idleTimer !== undefined)
118
+ clearTimeout(idleTimer);
119
+ };
120
+ const resetIdle = () => {
121
+ if (delivery === undefined || terminal)
122
+ return;
123
+ clearIdle();
124
+ idleTimer = setTimeout(() => context.active.cancel('binary response idle timeout exceeded'), delivery.idleTimeoutMs);
125
+ };
95
126
  const stop = (reason = 'Binary response was interrupted.') => {
96
127
  if (terminal)
97
128
  return stopping ?? Promise.resolve();
98
129
  terminal = true;
130
+ clearIdle();
99
131
  controller?.error(new Error(reason));
100
132
  stopping = Promise.resolve().then(async () => {
101
133
  try {
@@ -107,9 +139,21 @@ function streamingBinaryHttpResponse(binary, context) {
107
139
  });
108
140
  return stopping;
109
141
  };
142
+ const fail = (cause) => {
143
+ if (terminal)
144
+ return;
145
+ terminal = true;
146
+ clearIdle();
147
+ controller?.error(cause);
148
+ stopping = Promise.resolve()
149
+ .then(() => iterator.return?.())
150
+ .catch(() => undefined)
151
+ .then(() => context.active.settle());
152
+ };
110
153
  const body = new ReadableStream({
111
154
  start(output) {
112
155
  controller = output;
156
+ resetIdle();
113
157
  },
114
158
  async pull(controller) {
115
159
  if (terminal)
@@ -119,19 +163,29 @@ function streamingBinaryHttpResponse(binary, context) {
119
163
  if (terminal)
120
164
  return;
121
165
  if (next.done) {
166
+ if (binary.length !== undefined && delivered !== binary.length) {
167
+ fail(new Error('OUTCOME_UNKNOWN: streaming binary ended before its declared length.'));
168
+ return;
169
+ }
122
170
  terminal = true;
171
+ clearIdle();
123
172
  controller.close();
124
173
  context.active.settle();
125
174
  return;
126
175
  }
176
+ if (!(next.value instanceof Uint8Array)) {
177
+ throw new TypeError('Streaming binary yielded a non-byte chunk.');
178
+ }
179
+ delivered += next.value.byteLength;
180
+ if (binary.length !== undefined && delivered > binary.length) {
181
+ fail(new Error('OUTCOME_UNKNOWN: streaming binary exceeded its declared length.'));
182
+ return;
183
+ }
127
184
  controller.enqueue(next.value);
185
+ resetIdle();
128
186
  }
129
187
  catch (cause) {
130
- if (terminal)
131
- return;
132
- terminal = true;
133
- controller.error(cause);
134
- context.active.settle();
188
+ fail(cause);
135
189
  }
136
190
  },
137
191
  async cancel(reason) {
package/dist/types.d.ts CHANGED
@@ -6,6 +6,8 @@ import type { PublicationSource } from './publication/index.js';
6
6
  import type { Application, WebSocketAdapter } from './transport/index.js';
7
7
  export interface ServerLimits {
8
8
  readonly maximumRequestBytes: number;
9
+ /** Deployment cap for a streamed binary HTTP request; defaults to maximumRequestBytes. */
10
+ readonly maximumBinaryRequestBytes?: number;
9
11
  /** Maximum bytes in one encoded envelope, buffered binary body, or stream frame. */
10
12
  readonly maximumResponseBytes?: number;
11
13
  /** Maximum Server-owned active operations across all mounted protocol surfaces. */
@@ -15,6 +17,14 @@ export interface ServerLimits {
15
17
  readonly maximumDeliveryBytes?: number;
16
18
  /** Maximum lifetime of one invocation, including a returned stream. */
17
19
  readonly invocationTimeoutMs: number;
20
+ /** Binary request lifetime; defaults to invocationTimeoutMs. */
21
+ readonly binaryRequestTimeoutMs?: number;
22
+ /** Maximum wait for the next binary body chunk while a consumer is reading. */
23
+ readonly binaryRequestIdleTimeoutMs?: number;
24
+ /** Exact Host-selected callable Path whose ordinary JSON invocation may stream a large result. */
25
+ readonly binaryDownloadTarget?: string;
26
+ /** Required with binaryDownloadTarget; selected before semantic dispatch. */
27
+ readonly binaryDownloadTimeoutMs?: number;
18
28
  }
19
29
  export interface ServerCloseOptions {
20
30
  /** Absolute Unix time in milliseconds after which active work is cancelled. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/kernel-server",
3
- "version": "0.5.0-beta.61",
3
+ "version": "0.5.0-beta.63",
4
4
  "description": "Portable HTTP and WebSocket boundary for Astrale invocation, publication, installation, and issuer protocols",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -33,16 +33,16 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@astrale-os/kernel-core": "0.9.0-beta.53",
37
- "@astrale-os/kernel-dsl": "0.2.0-beta.38",
38
- "@astrale-os/kernel-protocol": "0.5.0-beta.59",
36
+ "@astrale-os/kernel-core": "0.9.0-beta.55",
37
+ "@astrale-os/kernel-dsl": "0.2.0-beta.40",
38
+ "@astrale-os/kernel-protocol": "0.5.0-beta.61",
39
39
  "hono": "^4.6.20",
40
40
  "msgpackr": "^2.0.5"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@astrale/typescript-config": "npm:@jsr/astrale__typescript-config@~1.1.0",
44
44
  "typescript": "7.0.2",
45
- "vitest": "^4.1.10"
45
+ "vitest": "^5.0.1"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsc",