@astrale-os/kernel-server 0.5.0-beta.62 → 0.5.0-beta.64
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/errors/admission.d.ts +7 -0
- package/dist/errors/admission.js +22 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +1 -0
- 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/issuer/exchange.js +11 -9
- package/dist/issuer/routes.js +20 -4
- package/dist/lifecycle/active.d.ts +2 -0
- package/dist/lifecycle/active.js +43 -8
- package/dist/publication/delivery/handler.d.ts +1 -1
- package/dist/publication/delivery/handler.js +8 -4
- package/dist/routes/routes.js +27 -21
- 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 +79 -19
- package/dist/transport/http/render.js +63 -8
- package/dist/transport/websocket/session.js +2 -5
- package/dist/transport/websocket/upgrade.js +5 -2
- package/dist/types.d.ts +10 -0
- package/package.json +4 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as errors from '@astrale-os/kernel-protocol/errors';
|
|
2
|
+
/** Advisory delay before a request the Server did not admit is sent again. */
|
|
3
|
+
export declare const ADMISSION_RETRY_AFTER_MS = 1000;
|
|
4
|
+
/** Public refusal of an operation the Server did not admit; nothing ran. */
|
|
5
|
+
export declare function admissionRefusal(accepting: boolean): errors.ErrorPayload;
|
|
6
|
+
/** HTTP presentation of a public error's retry delay, when it has one. */
|
|
7
|
+
export declare function retryAfterHeaders(error: errors.ErrorPayload): Readonly<Record<string, string>>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as errors from '@astrale-os/kernel-protocol/errors';
|
|
2
|
+
/** Advisory delay before a request the Server did not admit is sent again. */
|
|
3
|
+
export const ADMISSION_RETRY_AFTER_MS = 1_000;
|
|
4
|
+
/** Public refusal of an operation the Server did not admit; nothing ran. */
|
|
5
|
+
export function admissionRefusal(accepting) {
|
|
6
|
+
return accepting
|
|
7
|
+
? {
|
|
8
|
+
code: errors.CODES.BACKEND_UNAVAILABLE,
|
|
9
|
+
message: 'Server is at capacity; retry later.',
|
|
10
|
+
data: errors.retryReason(errors.RETRY_REASONS.CAPACITY_EXHAUSTED, ADMISSION_RETRY_AFTER_MS),
|
|
11
|
+
}
|
|
12
|
+
: {
|
|
13
|
+
code: errors.CODES.BACKEND_UNAVAILABLE,
|
|
14
|
+
message: 'Server is draining; retry later.',
|
|
15
|
+
data: errors.retryReason(errors.RETRY_REASONS.SERVER_DRAINING, ADMISSION_RETRY_AFTER_MS),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** HTTP presentation of a public error's retry delay, when it has one. */
|
|
19
|
+
export function retryAfterHeaders(error) {
|
|
20
|
+
const retryAfterMs = errors.retryAfterOf(error);
|
|
21
|
+
return retryAfterMs === undefined ? {} : { 'retry-after': errors.retryAfterField(retryAfterMs) };
|
|
22
|
+
}
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { ADMISSION_RETRY_AFTER_MS, admissionRefusal, retryAfterHeaders } from './admission.js';
|
|
1
2
|
export { DeliveryUnavailableError, IssuerKeysUnavailableError } from './availability.js';
|
|
2
3
|
export { classifyError } from './classify.js';
|
|
3
4
|
export { RequestBodyInvalidError, RequestBodyTooLargeError } from './request-body.js';
|
package/dist/errors/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { ADMISSION_RETRY_AFTER_MS, admissionRefusal, retryAfterHeaders } from './admission.js';
|
|
1
2
|
export { DeliveryUnavailableError, IssuerKeysUnavailableError } from './availability.js';
|
|
2
3
|
export { classifyError } from './classify.js';
|
|
3
4
|
export { RequestBodyInvalidError, RequestBodyTooLargeError } from './request-body.js';
|
|
@@ -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;
|
package/dist/issuer/exchange.js
CHANGED
|
@@ -2,6 +2,7 @@ import { inspect as inspectCredential } from '@astrale-os/kernel-core/auth/crede
|
|
|
2
2
|
import * as errors from '@astrale-os/kernel-protocol/errors';
|
|
3
3
|
import * as exchangeProtocol from '@astrale-os/kernel-protocol/issuer/exchange';
|
|
4
4
|
import { paths as issuerPaths } from '@astrale-os/kernel-protocol/issuer/paths';
|
|
5
|
+
import { admissionRefusal, retryAfterHeaders } from '../errors/index.js';
|
|
5
6
|
const PROVIDER_CODES = new Set([
|
|
6
7
|
errors.CODES.AUTH_INVALID,
|
|
7
8
|
errors.CODES.AUTH_EXPIRED,
|
|
@@ -24,7 +25,10 @@ export function mount(app, issuer, provider, cors, active, timeoutMs) {
|
|
|
24
25
|
}
|
|
25
26
|
app.all(path, async (context) => {
|
|
26
27
|
const request = context.req.raw;
|
|
27
|
-
const respond = (payload, extra) => json(payload, errors.httpStatusOf(payload.error), request, cors,
|
|
28
|
+
const respond = (payload, extra) => json(payload, errors.httpStatusOf(payload.error), request, cors, {
|
|
29
|
+
...retryAfterHeaders(payload.error),
|
|
30
|
+
...extra,
|
|
31
|
+
});
|
|
28
32
|
let admitted;
|
|
29
33
|
try {
|
|
30
34
|
admitted = exchangeProtocol.acceptHttpRequest({
|
|
@@ -40,12 +44,9 @@ export function mount(app, issuer, provider, cors, active, timeoutMs) {
|
|
|
40
44
|
}
|
|
41
45
|
throw cause;
|
|
42
46
|
}
|
|
43
|
-
if (!active.isAccepting) {
|
|
44
|
-
return respond(error('BACKEND_UNAVAILABLE', 'Token exchange is unavailable.'));
|
|
45
|
-
}
|
|
46
47
|
const operation = active.begin(timeoutMs, request.signal);
|
|
47
48
|
if (operation === undefined) {
|
|
48
|
-
return respond(
|
|
49
|
+
return respond(exchangeProtocol.acceptErrorResponse({ error: admissionRefusal(active.isAccepting) }));
|
|
49
50
|
}
|
|
50
51
|
try {
|
|
51
52
|
try {
|
|
@@ -134,12 +135,13 @@ function error(code, message) {
|
|
|
134
135
|
});
|
|
135
136
|
}
|
|
136
137
|
function json(value, status, request, cors, extra) {
|
|
138
|
+
const additional = new Headers(extra);
|
|
137
139
|
const headers = new Headers(exchangeProtocol.responseHeaders());
|
|
138
|
-
|
|
140
|
+
const exposed = additional.has('retry-after') ? ['retry-after'] : [];
|
|
141
|
+
for (const [name, value] of Object.entries(cors.response(request, exposed))) {
|
|
139
142
|
headers.set(name, value);
|
|
140
|
-
if (extra !== undefined) {
|
|
141
|
-
for (const [name, value] of new Headers(extra))
|
|
142
|
-
headers.set(name, value);
|
|
143
143
|
}
|
|
144
|
+
for (const [name, value] of additional)
|
|
145
|
+
headers.set(name, value);
|
|
144
146
|
return new Response(JSON.stringify(value), { status, headers });
|
|
145
147
|
}
|
package/dist/issuer/routes.js
CHANGED
|
@@ -2,7 +2,7 @@ import { configuration } from '@astrale-os/kernel-protocol/issuer/configuration'
|
|
|
2
2
|
import { hosted } from '@astrale-os/kernel-protocol/issuer/hosted';
|
|
3
3
|
import { keySet } from '@astrale-os/kernel-protocol/issuer/key-set';
|
|
4
4
|
import { paths as issuerPaths } from '@astrale-os/kernel-protocol/issuer/paths';
|
|
5
|
-
import { IssuerKeysUnavailableError } from '../errors/index.js';
|
|
5
|
+
import { admissionRefusal, IssuerKeysUnavailableError, retryAfterHeaders } from '../errors/index.js';
|
|
6
6
|
import { mount } from './exchange.js';
|
|
7
7
|
export function issuerRouteReservations(input, tokenExchange = false) {
|
|
8
8
|
const paths = issuerPaths(input.issuer);
|
|
@@ -32,6 +32,19 @@ export function mountIssuer(app, input, cors, active, timeoutMs, tokenExchange)
|
|
|
32
32
|
...cors.response(request),
|
|
33
33
|
'cache-control': 'no-cache',
|
|
34
34
|
});
|
|
35
|
+
// A lifecycle refusal names saturation or drain and presents its retry delay.
|
|
36
|
+
const refusal = (request) => {
|
|
37
|
+
const refused = admissionRefusal(active.isAccepting);
|
|
38
|
+
const retry = retryAfterHeaders(refused);
|
|
39
|
+
return {
|
|
40
|
+
body: { error: refused.message },
|
|
41
|
+
headers: {
|
|
42
|
+
...cors.response(request, Object.keys(retry)),
|
|
43
|
+
'cache-control': 'no-cache',
|
|
44
|
+
...retry,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
};
|
|
35
48
|
const algorithm = input.signingAlgorithm ?? 'ES256';
|
|
36
49
|
const discovery = configuration(issuer, [algorithm], {
|
|
37
50
|
tokenExchange: tokenExchange !== undefined,
|
|
@@ -44,7 +57,8 @@ export function mountIssuer(app, input, cors, active, timeoutMs, tokenExchange)
|
|
|
44
57
|
app.get(paths.keys, async (context) => {
|
|
45
58
|
const operation = active.begin(timeoutMs, context.req.raw.signal);
|
|
46
59
|
if (operation === undefined) {
|
|
47
|
-
|
|
60
|
+
const refused = refusal(context.req.raw);
|
|
61
|
+
return context.json(refused.body, 503, refused.headers);
|
|
48
62
|
}
|
|
49
63
|
try {
|
|
50
64
|
const keys = await input.loadPublicKeys(operation.signal);
|
|
@@ -76,7 +90,8 @@ export function mountIssuer(app, input, cors, active, timeoutMs, tokenExchange)
|
|
|
76
90
|
return context.json({ error: 'Issuer not found.' }, 404, headers(context.req.raw));
|
|
77
91
|
const operation = active.begin(timeoutMs, context.req.raw.signal);
|
|
78
92
|
if (operation === undefined) {
|
|
79
|
-
|
|
93
|
+
const refused = refusal(context.req.raw);
|
|
94
|
+
return context.json(refused.body, 503, refused.headers);
|
|
80
95
|
}
|
|
81
96
|
try {
|
|
82
97
|
const keys = await hostedKeys(loadIssuerPublicKeys, hostedIssuer, operation.signal, algorithm);
|
|
@@ -100,7 +115,8 @@ export function mountIssuer(app, input, cors, active, timeoutMs, tokenExchange)
|
|
|
100
115
|
return context.json({ error: 'Issuer not found.' }, 404, headers(context.req.raw));
|
|
101
116
|
const operation = active.begin(timeoutMs, context.req.raw.signal);
|
|
102
117
|
if (operation === undefined) {
|
|
103
|
-
|
|
118
|
+
const refused = refusal(context.req.raw);
|
|
119
|
+
return context.json(refused.body, 503, refused.headers);
|
|
104
120
|
}
|
|
105
121
|
try {
|
|
106
122
|
const keys = await hostedKeys(loadIssuerPublicKeys, hostedIssuer, operation.signal, algorithm);
|
|
@@ -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));
|
|
@@ -6,4 +6,4 @@ export interface Limits {
|
|
|
6
6
|
readonly invocationTimeoutMs: number;
|
|
7
7
|
}
|
|
8
8
|
/** Serve one delivery request against the exact Publication snapshot that selected its URL. */
|
|
9
|
-
export declare function handleDelivery(request: Request, snapshot: PreparedPublicationSnapshot, provider: Provider | undefined, active: ActiveOperations, limits: Limits,
|
|
9
|
+
export declare function handleDelivery(request: Request, snapshot: PreparedPublicationSnapshot, provider: Provider | undefined, active: ActiveOperations, limits: Limits, corsFor: (exposedHeaders?: readonly string[]) => Readonly<Record<string, string>>): Promise<Response>;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as codecs from '@astrale-os/kernel-protocol/codecs';
|
|
2
2
|
import * as protocolPublication from '@astrale-os/kernel-protocol/publication';
|
|
3
|
-
import { DeliveryUnavailableError } from '../../errors/index.js';
|
|
3
|
+
import { admissionRefusal, DeliveryUnavailableError, retryAfterHeaders, } from '../../errors/index.js';
|
|
4
4
|
import { RequestBodyInvalidError, RequestBodyTooLargeError } from '../../errors/index.js';
|
|
5
5
|
import { readBoundedBody } from '../../transport/index.js';
|
|
6
6
|
const delivery = protocolPublication.delivery;
|
|
7
7
|
/** Serve one delivery request against the exact Publication snapshot that selected its URL. */
|
|
8
|
-
export async function handleDelivery(request, snapshot, provider, active, limits,
|
|
8
|
+
export async function handleDelivery(request, snapshot, provider, active, limits, corsFor) {
|
|
9
|
+
const cors = corsFor();
|
|
9
10
|
if (request.method !== 'POST') {
|
|
10
11
|
return response({ error: 'Delivery requires POST.' }, 405, cors);
|
|
11
12
|
}
|
|
@@ -17,8 +18,11 @@ export async function handleDelivery(request, snapshot, provider, active, limits
|
|
|
17
18
|
return response({ error: 'Delivery is unavailable.' }, 404, cors);
|
|
18
19
|
}
|
|
19
20
|
const operation = active.begin(limits.invocationTimeoutMs, request.signal);
|
|
20
|
-
if (operation === undefined)
|
|
21
|
-
|
|
21
|
+
if (operation === undefined) {
|
|
22
|
+
const refusal = admissionRefusal(active.isAccepting);
|
|
23
|
+
const retry = retryAfterHeaders(refusal);
|
|
24
|
+
return response({ error: refusal.message }, 503, { ...corsFor(Object.keys(retry)), ...retry });
|
|
25
|
+
}
|
|
22
26
|
try {
|
|
23
27
|
const authorization = request.headers.get('authorization');
|
|
24
28
|
const token = bearer(authorization);
|
package/dist/routes/routes.js
CHANGED
|
@@ -5,7 +5,7 @@ import { bundleUrl } from '@astrale-os/kernel-protocol/publication';
|
|
|
5
5
|
import { mediaType as publicationMediaType, PATH as PUBLICATION_PATH, url as publicationUrl, } from '@astrale-os/kernel-protocol/publication/publication';
|
|
6
6
|
import { reference as publicationReference } from '@astrale-os/kernel-protocol/publication/reference';
|
|
7
7
|
import * as routes from '@astrale-os/kernel-protocol/routes';
|
|
8
|
-
import { classifyError, RequestBodyInvalidError, RequestBodyTooLargeError, } from '../errors/index.js';
|
|
8
|
+
import { admissionRefusal, classifyError, RequestBodyInvalidError, RequestBodyTooLargeError, retryAfterHeaders, } from '../errors/index.js';
|
|
9
9
|
import { invocationContext } from '../invocation/index.js';
|
|
10
10
|
import { handleDelivery } from '../publication/delivery/index.js';
|
|
11
11
|
import { readPreparedPublicationSnapshot, } from '../publication/index.js';
|
|
@@ -25,8 +25,9 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
|
|
|
25
25
|
}
|
|
26
26
|
app.get(publicationPath, (context) => {
|
|
27
27
|
const responseCors = cors.response(context.req.raw, ['etag']);
|
|
28
|
-
if (!active.isAccepting)
|
|
29
|
-
return
|
|
28
|
+
if (!active.isAccepting) {
|
|
29
|
+
return drained('Publication is unavailable while draining.', (exposed) => cors.response(context.req.raw, exposed));
|
|
30
|
+
}
|
|
30
31
|
const snapshot = servingSnapshot(source, limits, context.req.raw, reservations, externalIssuer, externalOrigin);
|
|
31
32
|
if (snapshot === undefined)
|
|
32
33
|
return response({ error: 'Publication is not ready.' }, 503, responseCors);
|
|
@@ -60,12 +61,13 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
|
|
|
60
61
|
return publicationPreflight(cors, request, snapshot, externalOrigin);
|
|
61
62
|
}
|
|
62
63
|
const responseCors = cors.response(request);
|
|
64
|
+
const corsFor = (exposed) => cors.response(request, exposed);
|
|
63
65
|
const descriptor = snapshot.publication.schema.bundle;
|
|
64
66
|
const addressed = bundleUrl(snapshot.publication.identity.issuer, descriptor.ref);
|
|
65
67
|
const immutable = descriptor.href === addressed;
|
|
66
68
|
if (sameRoute(request.url, snapshot.publication.schema.bundle.href, externalOrigin)) {
|
|
67
69
|
if (!active.isAccepting)
|
|
68
|
-
return
|
|
70
|
+
return drained('Publication artifact is unavailable while draining.', corsFor);
|
|
69
71
|
if (request.method !== 'GET')
|
|
70
72
|
return response({ error: 'Bundle retrieval requires GET.' }, 405, responseCors);
|
|
71
73
|
const body = snapshot.bundle;
|
|
@@ -100,7 +102,7 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
|
|
|
100
102
|
}
|
|
101
103
|
const deliveryUrl = snapshot.publication.delivery?.href;
|
|
102
104
|
if (deliveryUrl !== undefined && sameRoute(request.url, deliveryUrl, externalOrigin)) {
|
|
103
|
-
return handleDelivery(request, snapshot, deliveryProvider, active, limits,
|
|
105
|
+
return handleDelivery(request, snapshot, deliveryProvider, active, limits, corsFor);
|
|
104
106
|
}
|
|
105
107
|
let routing;
|
|
106
108
|
try {
|
|
@@ -120,7 +122,7 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
|
|
|
120
122
|
}
|
|
121
123
|
catch (cause) {
|
|
122
124
|
if (cause instanceof routes.RouteError) {
|
|
123
|
-
return nativeError({ code: errors.CODES.INVALID_REQUEST, message: 'Native route routing is invalid.' }, 400,
|
|
125
|
+
return nativeError({ code: errors.CODES.INVALID_REQUEST, message: 'Native route routing is invalid.' }, 400, corsFor);
|
|
124
126
|
}
|
|
125
127
|
throw cause;
|
|
126
128
|
}
|
|
@@ -130,10 +132,7 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
|
|
|
130
132
|
const assigned = nextInvocation();
|
|
131
133
|
const operation = active.begin(limits.invocationTimeoutMs, request.signal);
|
|
132
134
|
if (operation === undefined)
|
|
133
|
-
return nativeError(
|
|
134
|
-
code: errors.CODES.BACKEND_UNAVAILABLE,
|
|
135
|
-
message: 'Server is draining.',
|
|
136
|
-
}, 503, responseCors);
|
|
135
|
+
return nativeError(admissionRefusal(active.isAccepting), 503, corsFor);
|
|
137
136
|
let handedOff = false;
|
|
138
137
|
try {
|
|
139
138
|
const body = await readBoundedBody(request, limits.maximumRequestBytes, operation.signal);
|
|
@@ -178,7 +177,7 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
|
|
|
178
177
|
}
|
|
179
178
|
if (result.kind === 'stream') {
|
|
180
179
|
handedOff = true;
|
|
181
|
-
return await streamResponse(result.stream, operation,
|
|
180
|
+
return await streamResponse(result.stream, operation, corsFor);
|
|
182
181
|
}
|
|
183
182
|
if (result.kind === 'miss') {
|
|
184
183
|
return new Response(null, {
|
|
@@ -192,29 +191,29 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
|
|
|
192
191
|
return nativeError({
|
|
193
192
|
code: errors.CODES.UNSUPPORTED,
|
|
194
193
|
message: 'Native routes do not forward Runtime redirects.',
|
|
195
|
-
}, 502,
|
|
194
|
+
}, 502, corsFor);
|
|
196
195
|
}
|
|
197
196
|
catch (cause) {
|
|
198
197
|
if (cause instanceof RequestBodyTooLargeError) {
|
|
199
|
-
return nativeError({ code: errors.CODES.LIMIT_EXCEEDED, message: 'Request body is too large.' }, 413,
|
|
198
|
+
return nativeError({ code: errors.CODES.LIMIT_EXCEEDED, message: 'Request body is too large.' }, 413, corsFor);
|
|
200
199
|
}
|
|
201
200
|
if (cause instanceof RequestBodyInvalidError) {
|
|
202
|
-
return nativeError({ code: errors.CODES.INVALID_REQUEST, message: 'Request body is invalid.' }, 400,
|
|
201
|
+
return nativeError({ code: errors.CODES.INVALID_REQUEST, message: 'Request body is invalid.' }, 400, corsFor);
|
|
203
202
|
}
|
|
204
203
|
if (cause instanceof routes.RouteError) {
|
|
205
|
-
return nativeError({ code: errors.CODES.INVALID_REQUEST, message: 'Native route request is invalid.' }, 400,
|
|
204
|
+
return nativeError({ code: errors.CODES.INVALID_REQUEST, message: 'Native route request is invalid.' }, 400, corsFor);
|
|
206
205
|
}
|
|
207
206
|
if (operation.timedOut) {
|
|
208
207
|
return nativeError({
|
|
209
208
|
code: errors.CODES.DEADLINE_EXCEEDED,
|
|
210
209
|
message: 'Invocation deadline exceeded.',
|
|
211
|
-
}, 504,
|
|
210
|
+
}, 504, corsFor);
|
|
212
211
|
}
|
|
213
212
|
if (operation.signal.aborted) {
|
|
214
|
-
return nativeError({ code: errors.CODES.CANCELLED, message: 'Invocation was cancelled.' }, 408,
|
|
213
|
+
return nativeError({ code: errors.CODES.CANCELLED, message: 'Invocation was cancelled.' }, 408, corsFor);
|
|
215
214
|
}
|
|
216
215
|
const payload = classifyError(cause);
|
|
217
|
-
return nativeError(payload, errors.httpStatusOf(payload),
|
|
216
|
+
return nativeError(payload, errors.httpStatusOf(payload), corsFor);
|
|
218
217
|
}
|
|
219
218
|
finally {
|
|
220
219
|
if (!handedOff)
|
|
@@ -404,7 +403,7 @@ async function streamResponse(stream, operation, cors) {
|
|
|
404
403
|
if (operation.signal.aborted) {
|
|
405
404
|
void cancelSetup('native stream setup cancelled');
|
|
406
405
|
const payload = preStreamError(operation.signal.reason, operation);
|
|
407
|
-
return response({ error: payload }, errors.httpStatusOf(payload), cors);
|
|
406
|
+
return response({ error: payload }, errors.httpStatusOf(payload), cors());
|
|
408
407
|
}
|
|
409
408
|
const encoder = new TextEncoder();
|
|
410
409
|
let pending = first;
|
|
@@ -482,7 +481,7 @@ async function streamResponse(stream, operation, cors) {
|
|
|
482
481
|
operation.onCancel((reason) => stop(reason, interruptedStreamError(operation.timedOut)));
|
|
483
482
|
return new Response(body, {
|
|
484
483
|
headers: {
|
|
485
|
-
...cors,
|
|
484
|
+
...cors(),
|
|
486
485
|
'content-type': routes.STREAM_MEDIA_TYPE,
|
|
487
486
|
'cache-control': 'no-cache',
|
|
488
487
|
},
|
|
@@ -529,9 +528,16 @@ function response(body, status, cors) {
|
|
|
529
528
|
headers: { ...cors, 'content-type': 'application/json' },
|
|
530
529
|
});
|
|
531
530
|
}
|
|
531
|
+
/** A Publication artifact refused while draining presents the lifecycle retry delay. */
|
|
532
|
+
function drained(message, cors) {
|
|
533
|
+
const retry = retryAfterHeaders(admissionRefusal(false));
|
|
534
|
+
return response({ error: message }, 503, { ...cors(Object.keys(retry)), ...retry });
|
|
535
|
+
}
|
|
536
|
+
/** Every native error, including a capacity refusal raised by dispatch, presents its retry delay. */
|
|
532
537
|
function nativeError(error, status, cors) {
|
|
533
538
|
const admitted = routes.decodeErrorResponse({ error });
|
|
534
|
-
|
|
539
|
+
const retry = retryAfterHeaders(error);
|
|
540
|
+
return response(admitted, status, { ...cors(Object.keys(retry)), ...retry });
|
|
535
541
|
}
|
|
536
542
|
function encodedBody(input) {
|
|
537
543
|
return typeof input === 'string' ? input : toBody(input);
|
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;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as codecs from '@astrale-os/kernel-protocol/codecs';
|
|
2
2
|
import * as errors from '@astrale-os/kernel-protocol/errors';
|
|
3
3
|
import * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
4
|
-
import { classifyError, RequestBodyInvalidError, RequestBodyTooLargeError, } from '../../errors/index.js';
|
|
4
|
+
import { admissionRefusal, classifyError, RequestBodyInvalidError, RequestBodyTooLargeError, } from '../../errors/index.js';
|
|
5
5
|
import { invocationContext, publicationRouteDecision, } from '../../invocation/index.js';
|
|
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) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
if (binaryRequest && request.body !== null && !request.body.locked) {
|
|
48
|
+
void request.body.cancel('binary request rejected').catch(() => undefined);
|
|
49
|
+
}
|
|
50
|
+
return renderError(null, admissionRefusal(config.active.isAccepting), codecs.json, {
|
|
51
|
+
status: 503,
|
|
52
|
+
negotiated: false,
|
|
53
|
+
});
|
|
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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as errors from '@astrale-os/kernel-protocol/errors';
|
|
2
2
|
import * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
3
3
|
import { HTTP_ROUTE_MISS, HTTP_ROUTE_STALE } from '@astrale-os/kernel-protocol/routes/routing';
|
|
4
|
-
import { classifyError } from '../../errors/index.js';
|
|
4
|
+
import { classifyError, retryAfterHeaders } from '../../errors/index.js';
|
|
5
5
|
import { ResponseBodyTooLargeError } from '../../errors/index.js';
|
|
6
6
|
import { interruptedStreamError } from '../stream-error.js';
|
|
7
7
|
export function renderHttpResult(result, context) {
|
|
@@ -57,8 +57,9 @@ export function renderRedirect(requestId, invocation, redirect) {
|
|
|
57
57
|
}
|
|
58
58
|
export function renderHttpError(requestId, invocation, error, codec, cors, options = {}) {
|
|
59
59
|
const status = options.status ?? errors.httpStatusOf(error);
|
|
60
|
-
const
|
|
61
|
-
|
|
60
|
+
const retry = retryAfterHeaders(error);
|
|
61
|
+
const extra = status === 401 ? { 'www-authenticate': 'Bearer realm="astrale-invocation"' } : retry;
|
|
62
|
+
return errorEnvelopeResponse(codec, { error, requestId, invocation }, { ...cors(Object.keys(retry)), ...extra }, status, options.negotiated ?? true, options.maximumResponseBytes);
|
|
62
63
|
}
|
|
63
64
|
export function renderHttpStale(requestId, invocation, current, codec, cors, maximumResponseBytes) {
|
|
64
65
|
return envelopeResponse(codec, protocol.stale(requestId, invocation, current), cors(), 200, true, maximumResponseBytes);
|
|
@@ -82,6 +83,21 @@ function binaryHttpResponse(binary, cors, maximumResponseBytes) {
|
|
|
82
83
|
return new Response(toBody(binary.body), { status: binary.status, headers });
|
|
83
84
|
}
|
|
84
85
|
function streamingBinaryHttpResponse(binary, context) {
|
|
86
|
+
if (binary.length !== undefined && (!Number.isSafeInteger(binary.length) || binary.length < 0)) {
|
|
87
|
+
throw new TypeError('Streaming binary length must be a non-negative safe integer.');
|
|
88
|
+
}
|
|
89
|
+
const delivery = binary.delivery;
|
|
90
|
+
if (delivery !== undefined) {
|
|
91
|
+
if (!Number.isSafeInteger(delivery.maximumDurationMs) ||
|
|
92
|
+
delivery.maximumDurationMs <= 0 ||
|
|
93
|
+
delivery.maximumDurationMs > 2_147_483_647 ||
|
|
94
|
+
!Number.isSafeInteger(delivery.idleTimeoutMs) ||
|
|
95
|
+
delivery.idleTimeoutMs <= 0 ||
|
|
96
|
+
delivery.idleTimeoutMs > 2_147_483_647) {
|
|
97
|
+
throw new TypeError('Streaming binary delivery budget is invalid.');
|
|
98
|
+
}
|
|
99
|
+
context.active.extend(delivery.maximumDurationMs);
|
|
100
|
+
}
|
|
85
101
|
const iterator = binary.body[Symbol.asyncIterator]();
|
|
86
102
|
const headers = binaryHttpHeaders(Object.freeze({
|
|
87
103
|
requestId: context.requestId,
|
|
@@ -89,13 +105,30 @@ function streamingBinaryHttpResponse(binary, context) {
|
|
|
89
105
|
mediaType: binary.mediaType,
|
|
90
106
|
headers: binary.headers,
|
|
91
107
|
}), context.cors, true);
|
|
108
|
+
if (binary.length !== undefined)
|
|
109
|
+
headers.set('content-length', String(binary.length));
|
|
110
|
+
if (delivery !== undefined)
|
|
111
|
+
headers.set('cache-control', 'private, no-store');
|
|
92
112
|
let terminal = false;
|
|
113
|
+
let delivered = 0;
|
|
93
114
|
let controller;
|
|
94
115
|
let stopping;
|
|
116
|
+
let idleTimer;
|
|
117
|
+
const clearIdle = () => {
|
|
118
|
+
if (idleTimer !== undefined)
|
|
119
|
+
clearTimeout(idleTimer);
|
|
120
|
+
};
|
|
121
|
+
const resetIdle = () => {
|
|
122
|
+
if (delivery === undefined || terminal)
|
|
123
|
+
return;
|
|
124
|
+
clearIdle();
|
|
125
|
+
idleTimer = setTimeout(() => context.active.cancel('binary response idle timeout exceeded'), delivery.idleTimeoutMs);
|
|
126
|
+
};
|
|
95
127
|
const stop = (reason = 'Binary response was interrupted.') => {
|
|
96
128
|
if (terminal)
|
|
97
129
|
return stopping ?? Promise.resolve();
|
|
98
130
|
terminal = true;
|
|
131
|
+
clearIdle();
|
|
99
132
|
controller?.error(new Error(reason));
|
|
100
133
|
stopping = Promise.resolve().then(async () => {
|
|
101
134
|
try {
|
|
@@ -107,9 +140,21 @@ function streamingBinaryHttpResponse(binary, context) {
|
|
|
107
140
|
});
|
|
108
141
|
return stopping;
|
|
109
142
|
};
|
|
143
|
+
const fail = (cause) => {
|
|
144
|
+
if (terminal)
|
|
145
|
+
return;
|
|
146
|
+
terminal = true;
|
|
147
|
+
clearIdle();
|
|
148
|
+
controller?.error(cause);
|
|
149
|
+
stopping = Promise.resolve()
|
|
150
|
+
.then(() => iterator.return?.())
|
|
151
|
+
.catch(() => undefined)
|
|
152
|
+
.then(() => context.active.settle());
|
|
153
|
+
};
|
|
110
154
|
const body = new ReadableStream({
|
|
111
155
|
start(output) {
|
|
112
156
|
controller = output;
|
|
157
|
+
resetIdle();
|
|
113
158
|
},
|
|
114
159
|
async pull(controller) {
|
|
115
160
|
if (terminal)
|
|
@@ -119,19 +164,29 @@ function streamingBinaryHttpResponse(binary, context) {
|
|
|
119
164
|
if (terminal)
|
|
120
165
|
return;
|
|
121
166
|
if (next.done) {
|
|
167
|
+
if (binary.length !== undefined && delivered !== binary.length) {
|
|
168
|
+
fail(new Error('OUTCOME_UNKNOWN: streaming binary ended before its declared length.'));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
122
171
|
terminal = true;
|
|
172
|
+
clearIdle();
|
|
123
173
|
controller.close();
|
|
124
174
|
context.active.settle();
|
|
125
175
|
return;
|
|
126
176
|
}
|
|
177
|
+
if (!(next.value instanceof Uint8Array)) {
|
|
178
|
+
throw new TypeError('Streaming binary yielded a non-byte chunk.');
|
|
179
|
+
}
|
|
180
|
+
delivered += next.value.byteLength;
|
|
181
|
+
if (binary.length !== undefined && delivered > binary.length) {
|
|
182
|
+
fail(new Error('OUTCOME_UNKNOWN: streaming binary exceeded its declared length.'));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
127
185
|
controller.enqueue(next.value);
|
|
186
|
+
resetIdle();
|
|
128
187
|
}
|
|
129
188
|
catch (cause) {
|
|
130
|
-
|
|
131
|
-
return;
|
|
132
|
-
terminal = true;
|
|
133
|
-
controller.error(cause);
|
|
134
|
-
context.active.settle();
|
|
189
|
+
fail(cause);
|
|
135
190
|
}
|
|
136
191
|
},
|
|
137
192
|
async cancel(reason) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as errors from '@astrale-os/kernel-protocol/errors';
|
|
2
2
|
import * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
3
|
-
import { classifyError } from '../../errors/index.js';
|
|
3
|
+
import { admissionRefusal, classifyError } from '../../errors/index.js';
|
|
4
4
|
import { ResponseBodyTooLargeError } from '../../errors/index.js';
|
|
5
5
|
import { invocationContext, publicationRouteDecision, } from '../../invocation/index.js';
|
|
6
6
|
import { byteLength } from '../body.js';
|
|
@@ -129,10 +129,7 @@ export function createWebSocketSession(config) {
|
|
|
129
129
|
}
|
|
130
130
|
const operation = config.active.begin(config.invocationTimeoutMs);
|
|
131
131
|
if (operation === undefined) {
|
|
132
|
-
await sendError(socket, admittedCodec, envelope.requestId, assigned,
|
|
133
|
-
code: errors.CODES.BACKEND_UNAVAILABLE,
|
|
134
|
-
message: 'server is unavailable.',
|
|
135
|
-
}, sendOutput);
|
|
132
|
+
await sendError(socket, admittedCodec, envelope.requestId, assigned, admissionRefusal(config.active.isAccepting), sendOutput);
|
|
136
133
|
return;
|
|
137
134
|
}
|
|
138
135
|
operations.set(key, operation);
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
2
|
+
import { admissionRefusal, retryAfterHeaders } from '../../errors/index.js';
|
|
2
3
|
import { loadCodecs } from '../codecs.js';
|
|
3
4
|
import { createWebSocketSession } from './session.js';
|
|
4
5
|
export async function handleWebSocketUpgrade(config) {
|
|
5
6
|
if (!config.cors.allowsBrowserOrigin(config.request))
|
|
6
7
|
return failure(403);
|
|
7
8
|
const operation = config.active.begin(config.invocationTimeoutMs, config.request.signal);
|
|
8
|
-
if (operation === undefined)
|
|
9
|
-
|
|
9
|
+
if (operation === undefined) {
|
|
10
|
+
const retry = retryAfterHeaders(admissionRefusal(config.active.isAccepting));
|
|
11
|
+
return failure(503, { ...config.cors.response(config.request, Object.keys(retry)), ...retry });
|
|
12
|
+
}
|
|
10
13
|
try {
|
|
11
14
|
const supported = await loadCodecs();
|
|
12
15
|
const selection = protocol.websocket.selectSubprotocol(config.request.headers.get('sec-websocket-protocol'), supported);
|
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.64",
|
|
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,9 +33,9 @@
|
|
|
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.56",
|
|
37
|
+
"@astrale-os/kernel-dsl": "0.2.0-beta.41",
|
|
38
|
+
"@astrale-os/kernel-protocol": "0.5.0-beta.62",
|
|
39
39
|
"hono": "^4.6.20",
|
|
40
40
|
"msgpackr": "^2.0.5"
|
|
41
41
|
},
|