@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.
- package/dist/invocation/context.d.ts +2 -1
- package/dist/invocation/context.js +2 -1
- package/dist/invocation/types.d.ts +3 -1
- package/dist/lifecycle/active.d.ts +2 -0
- package/dist/lifecycle/active.js +43 -8
- package/dist/server.js +27 -0
- package/dist/transport/http/binary-request.d.ts +11 -0
- package/dist/transport/http/binary-request.js +102 -0
- package/dist/transport/http/handler.d.ts +5 -0
- package/dist/transport/http/handler.js +74 -14
- package/dist/transport/http/render.js +59 -5
- package/dist/types.d.ts +10 -0
- package/package.json +5 -5
|
@@ -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
|
}
|
package/dist/lifecycle/active.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
37
|
-
"@astrale-os/kernel-dsl": "0.2.0-beta.
|
|
38
|
-
"@astrale-os/kernel-protocol": "0.5.0-beta.
|
|
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": "^
|
|
45
|
+
"vitest": "^5.0.1"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "tsc",
|