@astrale-os/kernel-server 0.5.0-beta.13 → 0.5.0-beta.15

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.
@@ -26,6 +26,8 @@ export declare class ActiveOperations {
26
26
  private changed;
27
27
  constructor(maximumConcurrentOperations: number);
28
28
  get isAccepting(): boolean;
29
+ /** Whether a newly arriving operation can be admitted without semantic dispatch. */
30
+ get isAvailable(): boolean;
29
31
  begin(timeoutMs: number, externalSignal?: AbortSignal): ActiveOperation | undefined;
30
32
  attachSocket(socket: ActiveSocket): boolean;
31
33
  detachSocket(socket: ActiveSocket): void;
@@ -13,10 +13,13 @@ export class ActiveOperations {
13
13
  get isAccepting() {
14
14
  return this.accepting;
15
15
  }
16
+ /** Whether a newly arriving operation can be admitted without semantic dispatch. */
17
+ get isAvailable() {
18
+ return this.accepting && this.operations.size < this.maximumConcurrentOperations;
19
+ }
16
20
  begin(timeoutMs, externalSignal) {
17
- if (!this.accepting || this.operations.size >= this.maximumConcurrentOperations) {
21
+ if (!this.isAvailable)
18
22
  return undefined;
19
- }
20
23
  const operation = new Operation(Date.now() + timeoutMs, () => {
21
24
  this.operations.delete(operation);
22
25
  this.changed?.();
@@ -138,8 +138,13 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
138
138
  });
139
139
  }
140
140
  if (result.kind === 'binary') {
141
+ if (isStreamingBinary(result.value)) {
142
+ handedOff = true;
143
+ return streamingBinaryResponse(result.value, operation, cors, request);
144
+ }
141
145
  const applicationHeaders = applicationBinaryHeaders(result.value.headers);
142
146
  return new Response(toBody(result.value.body), {
147
+ status: result.value.status ?? 200,
143
148
  headers: {
144
149
  ...cors.response(request, [
145
150
  protocol.http.BINARY_HEADERS_FIELD,
@@ -197,6 +202,66 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
197
202
  }
198
203
  });
199
204
  }
205
+ function streamingBinaryResponse(binary, operation, cors, request) {
206
+ const iterator = binary.body[Symbol.asyncIterator]();
207
+ const applicationHeaders = applicationBinaryHeaders(binary.headers);
208
+ let terminal = false;
209
+ let stopping;
210
+ const stop = () => {
211
+ stopping ??= Promise.resolve().then(async () => {
212
+ if (terminal)
213
+ return;
214
+ terminal = true;
215
+ try {
216
+ await iterator.return?.();
217
+ }
218
+ finally {
219
+ operation.settle();
220
+ }
221
+ });
222
+ return stopping;
223
+ };
224
+ const body = new ReadableStream({
225
+ async pull(controller) {
226
+ if (terminal)
227
+ return;
228
+ try {
229
+ const next = await iterator.next();
230
+ if (terminal)
231
+ return;
232
+ if (next.done) {
233
+ terminal = true;
234
+ controller.close();
235
+ operation.settle();
236
+ return;
237
+ }
238
+ controller.enqueue(next.value);
239
+ }
240
+ catch (cause) {
241
+ terminal = true;
242
+ controller.error(cause);
243
+ operation.settle();
244
+ }
245
+ },
246
+ async cancel(reason) {
247
+ const message = typeof reason === 'string' ? reason : 'native binary response cancelled';
248
+ operation.cancel(message);
249
+ await stop();
250
+ },
251
+ });
252
+ operation.onCancel(stop);
253
+ return new Response(body, {
254
+ status: binary.status ?? 200,
255
+ headers: {
256
+ ...cors.response(request, Object.keys(applicationHeaders)),
257
+ ...applicationHeaders,
258
+ 'content-type': binary.mediaType,
259
+ },
260
+ });
261
+ }
262
+ function isStreamingBinary(input) {
263
+ return !(input.body instanceof Uint8Array);
264
+ }
200
265
  function publicationPreflight(cors, request, snapshot, externalOrigin) {
201
266
  if (sameRoute(request.url, snapshot.publication.schema.bundle.href, externalOrigin)) {
202
267
  return cors.preflight(request, { methods: ['GET'], headers: ['if-none-match'] });
package/dist/server.js CHANGED
@@ -68,7 +68,7 @@ function createPreparedServer(input, config) {
68
68
  app.all(config.path, (context) => handleHttp(context.req.raw, hosts.get(context.req.raw)));
69
69
  if (config.health !== undefined) {
70
70
  app.get(config.health.path, (context) => {
71
- const ready = active.isAccepting && config.health.probe.check();
71
+ const ready = active.isAvailable && config.health.probe.check();
72
72
  return context.json({ status: ready ? 'ready' : 'not-ready' }, ready ? 200 : 503, {
73
73
  ...config.cors.response(context.req.raw),
74
74
  'cache-control': 'no-store',
@@ -1,3 +1,4 @@
1
+ import type { BufferedBinaryResult, StreamingBinaryResult } from '@astrale-os/kernel-core/callable';
1
2
  import type { Codec } from '@astrale-os/kernel-protocol/codecs';
2
3
  import * as errors from '@astrale-os/kernel-protocol/errors';
3
4
  import * as protocol from '@astrale-os/kernel-protocol/invocation';
@@ -19,13 +20,14 @@ export interface ErrorRenderOptions {
19
20
  }
20
21
  type HandlerResult = Awaited<ReturnType<Handler['dispatch']>>;
21
22
  export declare function renderHttpResult(result: HandlerResult, context: RenderContext): Response;
22
- export declare function renderBinary(requestId: protocol.RequestId, invocation: protocol.InvocationId, binary: Extract<HandlerResult, {
23
- readonly kind: 'binary';
24
- }>['value']): protocol.BinaryResponse;
23
+ export declare function renderBinary(requestId: protocol.RequestId, invocation: protocol.InvocationId, binary: BufferedBinaryResult): protocol.BinaryResponse;
25
24
  export declare function renderRedirect(requestId: protocol.RequestId, invocation: protocol.InvocationId, redirect: Extract<HandlerResult, {
26
25
  readonly kind: 'redirect';
27
26
  }>['redirect']): protocol.RedirectResponse;
28
27
  export declare function renderHttpError(requestId: protocol.RequestId, invocation: protocol.InvocationId, error: errors.ErrorPayload, codec: Codec, cors: CorsHeaders, options?: ErrorRenderOptions): Response;
29
28
  export declare function renderHttpStale(requestId: protocol.RequestId, invocation: protocol.InvocationId, current: protocol.Stale['current'], codec: Codec, cors: CorsHeaders, maximumResponseBytes?: number): Response;
30
29
  export declare function encodeEnvelope(codec: Codec, input: unknown): string | Uint8Array;
30
+ export declare function isStreamingBinary(input: Extract<HandlerResult, {
31
+ readonly kind: 'binary';
32
+ }>['value']): input is StreamingBinaryResult;
31
33
  export {};
@@ -19,6 +19,8 @@ export function renderHttpResult(result, context) {
19
19
  case 'miss':
20
20
  return settled(context.active, envelopeResponse(context.codec, protocol.miss(context.requestId, context.invocation), context.cors(), 200, true, context.maximumResponseBytes));
21
21
  case 'binary': {
22
+ if (isStreamingBinary(result.value))
23
+ return streamingBinaryHttpResponse(result.value, context);
22
24
  const binary = renderBinary(context.requestId, context.invocation, result.value);
23
25
  return settled(context.active, binaryHttpResponse(binary, context.cors, context.maximumResponseBytes));
24
26
  }
@@ -41,6 +43,7 @@ export function renderBinary(requestId, invocation, binary) {
41
43
  invocation,
42
44
  body: binary.body,
43
45
  mediaType: binary.mediaType,
46
+ status: binary.status ?? 200,
44
47
  ...(binary.headers === undefined ? {} : { headers: binary.headers }),
45
48
  });
46
49
  }
@@ -75,11 +78,74 @@ function envelopeResponse(codec, input, cors, status = 200, negotiated = true, m
75
78
  }
76
79
  function binaryHttpResponse(binary, cors, maximumResponseBytes) {
77
80
  requireResponseBound(binary.body, maximumResponseBytes);
81
+ const headers = binaryHttpHeaders(binary, cors, false);
82
+ return new Response(toBody(binary.body), { status: binary.status, headers });
83
+ }
84
+ function streamingBinaryHttpResponse(binary, context) {
85
+ const iterator = binary.body[Symbol.asyncIterator]();
86
+ const headers = binaryHttpHeaders(Object.freeze({
87
+ requestId: context.requestId,
88
+ invocation: context.invocation,
89
+ mediaType: binary.mediaType,
90
+ headers: binary.headers,
91
+ }), context.cors, true);
92
+ let terminal = false;
93
+ let stopping;
94
+ const stop = () => {
95
+ stopping ??= Promise.resolve().then(async () => {
96
+ if (terminal)
97
+ return;
98
+ terminal = true;
99
+ try {
100
+ await iterator.return?.();
101
+ }
102
+ finally {
103
+ context.active.settle();
104
+ }
105
+ });
106
+ return stopping;
107
+ };
108
+ const body = new ReadableStream({
109
+ async pull(controller) {
110
+ if (terminal)
111
+ return;
112
+ try {
113
+ const next = await iterator.next();
114
+ if (terminal)
115
+ return;
116
+ if (next.done) {
117
+ terminal = true;
118
+ controller.close();
119
+ context.active.settle();
120
+ return;
121
+ }
122
+ controller.enqueue(next.value);
123
+ }
124
+ catch (cause) {
125
+ terminal = true;
126
+ controller.error(cause);
127
+ context.active.settle();
128
+ }
129
+ },
130
+ async cancel(reason) {
131
+ const message = typeof reason === 'string' ? reason : 'binary response consumer cancelled';
132
+ context.active.cancel(message);
133
+ await stop();
134
+ },
135
+ });
136
+ context.active.onCancel(stop);
137
+ return new Response(body, { status: binary.status ?? 200, headers });
138
+ }
139
+ export function isStreamingBinary(input) {
140
+ return !(input.body instanceof Uint8Array);
141
+ }
142
+ function binaryHttpHeaders(binary, cors, streaming) {
78
143
  const applicationHeaders = Object.keys(binary.headers ?? {}).filter((name) => protocol.http.isApplicationBinaryHeader(name));
79
144
  const headers = new Headers(cors([
80
145
  protocol.http.REQUEST_ID_FIELD,
81
146
  protocol.http.INVOCATION_FIELD,
82
147
  protocol.http.BINARY_HEADERS_FIELD,
148
+ ...(streaming ? [protocol.http.BINARY_DELIVERY_FIELD] : []),
83
149
  ...applicationHeaders,
84
150
  ]));
85
151
  for (const [name, value] of Object.entries(binary.headers ?? {})) {
@@ -90,9 +156,12 @@ function binaryHttpResponse(binary, cors, maximumResponseBytes) {
90
156
  headers.set(protocol.http.BINARY_HEADERS_FIELD, protocol.http.encodeBinaryHeaderNames(binary.headers));
91
157
  headers.set(protocol.http.REQUEST_ID_FIELD, String(binary.requestId));
92
158
  headers.set(protocol.http.INVOCATION_FIELD, protocol.http.encodeInvocationId(binary.invocation));
159
+ if (streaming) {
160
+ headers.set(protocol.http.BINARY_DELIVERY_FIELD, protocol.http.STREAMING_BINARY_DELIVERY);
161
+ }
93
162
  headers.set('cache-control', 'no-store');
94
163
  appendVary(headers, 'accept');
95
- return new Response(toBody(binary.body), { status: 200, headers });
164
+ return headers;
96
165
  }
97
166
  function streamHttpResponse(stream, context) {
98
167
  const iterator = stream[Symbol.asyncIterator]();
@@ -5,7 +5,7 @@ import { ResponseBodyTooLargeError } from '../../errors/index.js';
5
5
  import { invocationContext, publicationRouteDecision, } from '../../invocation/index.js';
6
6
  import { byteLength } from '../body.js';
7
7
  import { resolveCredential } from '../credential.js';
8
- import { renderBinary, renderRedirect } from '../http/render.js';
8
+ import { isStreamingBinary, renderBinary, renderRedirect } from '../http/render.js';
9
9
  import { interruptedStreamError } from '../stream-error.js';
10
10
  const WS_OPEN = 1;
11
11
  /** Build one socket-pinned transport session. */
@@ -211,6 +211,17 @@ async function sendResult(socket, codec, requestId, assigned, result, operation,
211
211
  await send(socket, codec, protocol.miss(requestId, assigned), sendOutput);
212
212
  return;
213
213
  case 'binary': {
214
+ if (isStreamingBinary(result.value)) {
215
+ const streaming = result.value;
216
+ await Promise.allSettled([
217
+ sendError(socket, codec, requestId, assigned, {
218
+ code: errors.CODES.UNSUPPORTED,
219
+ message: 'Streaming binary output requires the HTTP transport.',
220
+ }, sendOutput),
221
+ Promise.resolve().then(() => streaming.body[Symbol.asyncIterator]().return?.()),
222
+ ]);
223
+ return;
224
+ }
214
225
  const binary = renderBinary(requestId, assigned, result.value);
215
226
  if (socket.readyState === WS_OPEN) {
216
227
  await sendOutput(socket, protocol.websocket.encodeBinary(binary));
package/dist/types.d.ts CHANGED
@@ -6,7 +6,7 @@ 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
- /** Maximum bytes in one encoded envelope, finite-binary body, or stream frame. */
9
+ /** Maximum bytes in one encoded envelope, buffered binary body, or stream frame. */
10
10
  readonly maximumResponseBytes?: number;
11
11
  /** Maximum Server-owned active operations across all mounted protocol surfaces. */
12
12
  readonly maximumConcurrentOperations?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/kernel-server",
3
- "version": "0.5.0-beta.13",
3
+ "version": "0.5.0-beta.15",
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,11 +33,11 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@astrale-os/kernel-core": ">=0.9.0-beta.10 <1.0.0",
37
- "@astrale-os/kernel-dsl": ">=0.2.0-beta.8 <1.0.0",
38
- "@astrale-os/kernel-protocol": ">=0.5.0-beta.10 <1.0.0",
39
36
  "hono": "^4.6.20",
40
- "msgpackr": "^2.0.5"
37
+ "msgpackr": "^2.0.5",
38
+ "@astrale-os/kernel-core": "0.9.0-beta.14",
39
+ "@astrale-os/kernel-dsl": "0.2.0-beta.11",
40
+ "@astrale-os/kernel-protocol": "0.5.0-beta.14"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@astrale/typescript-config": "npm:@jsr/astrale__typescript-config@~1.1.0",