@astrale-os/kernel-server 0.5.0-beta.73 → 0.5.0-beta.75
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/index.d.ts +1 -6
- package/dist/index.js +0 -5
- package/dist/invocation/index.d.ts +1 -1
- package/dist/invocation/index.js +1 -1
- package/dist/invocation/release-route.d.ts +3 -7
- package/dist/invocation/release-route.js +4 -27
- package/dist/invocation/types.d.ts +2 -14
- package/dist/issuer/routes.js +28 -47
- package/dist/release/delivery/handler.d.ts +1 -5
- package/dist/release/delivery/handler.js +8 -29
- package/dist/release/delivery/index.d.ts +0 -1
- package/dist/release/delivery/index.js +1 -1
- package/dist/release/delivery/types.d.ts +3 -13
- package/dist/release/served.d.ts +6 -15
- package/dist/release/served.js +3 -7
- package/dist/release/snapshot.d.ts +2 -6
- package/dist/release/snapshot.js +2 -7
- package/dist/release/types.d.ts +1 -11
- package/dist/routes/address.d.ts +2 -0
- package/dist/routes/address.js +6 -0
- package/dist/routes/binary.d.ts +9 -0
- package/dist/routes/binary.js +110 -0
- package/dist/routes/bundle.d.ts +5 -0
- package/dist/routes/bundle.js +50 -0
- package/dist/routes/csrf.d.ts +16 -0
- package/dist/routes/csrf.js +35 -0
- package/dist/routes/document.d.ts +6 -0
- package/dist/routes/document.js +57 -0
- package/dist/routes/index.d.ts +3 -1
- package/dist/routes/index.js +2 -1
- package/dist/routes/model.d.ts +15 -0
- package/dist/routes/native.d.ts +17 -0
- package/dist/routes/native.js +126 -0
- package/dist/routes/preflight.d.ts +4 -0
- package/dist/routes/preflight.js +55 -0
- package/dist/routes/response.d.ts +14 -0
- package/dist/routes/response.js +33 -0
- package/dist/routes/routes.d.ts +5 -24
- package/dist/routes/routes.js +19 -625
- package/dist/routes/snapshot.d.ts +13 -0
- package/dist/routes/snapshot.js +39 -0
- package/dist/routes/stream.d.ts +6 -0
- package/dist/routes/stream.js +122 -0
- package/dist/server.js +12 -30
- package/dist/transport/http/binary.d.ts +6 -0
- package/dist/transport/http/binary.js +35 -0
- package/dist/transport/http/byte-stream.d.ts +4 -0
- package/dist/transport/http/byte-stream.js +123 -0
- package/dist/transport/http/control.d.ts +12 -0
- package/dist/transport/http/control.js +24 -0
- package/dist/transport/http/handler.d.ts +1 -1
- package/dist/transport/http/handler.js +4 -2
- package/dist/transport/http/index.d.ts +3 -1
- package/dist/transport/http/index.js +2 -1
- package/dist/transport/http/model.d.ts +20 -0
- package/dist/transport/http/model.js +1 -0
- package/dist/transport/http/render.d.ts +3 -36
- package/dist/transport/http/render.js +16 -412
- package/dist/transport/http/response.d.ts +14 -0
- package/dist/transport/http/response.js +77 -0
- package/dist/transport/http/stream.d.ts +5 -0
- package/dist/transport/http/stream.js +134 -0
- package/dist/transport/index.d.ts +2 -1
- package/dist/transport/index.js +2 -1
- package/dist/transport/result.d.ts +13 -0
- package/dist/transport/result.js +35 -0
- package/dist/transport/websocket/session.d.ts +1 -1
- package/dist/transport/websocket/session.js +3 -3
- package/dist/types.d.ts +13 -16
- package/package.json +5 -13
- package/dist/invocation/legacy/index.d.ts +0 -20
- package/dist/invocation/legacy/index.js +0 -20
- package/dist/invocation/legacy/publication-route.d.ts +0 -16
- package/dist/invocation/legacy/publication-route.js +0 -39
- package/dist/release/legacy/delivery.d.ts +0 -18
- package/dist/release/legacy/delivery.js +0 -2
- package/dist/release/legacy/index.d.ts +0 -39
- package/dist/release/legacy/index.js +0 -38
- package/dist/release/legacy/snapshot.d.ts +0 -26
- package/dist/release/legacy/snapshot.js +0 -55
- package/dist/release/legacy/table.d.ts +0 -5
- package/dist/release/legacy/table.js +0 -16
- package/dist/release/legacy/types.d.ts +0 -31
- /package/dist/{release/legacy/types.js → routes/model.js} +0 -0
|
@@ -1,25 +1,26 @@
|
|
|
1
|
-
import { json } from '@astrale-os/kernel-protocol/codecs';
|
|
2
|
-
import * as errors from '@astrale-os/kernel-protocol/errors';
|
|
3
1
|
import * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
2
|
+
import { isStreamingBinary, renderBinary, renderRedirect } from '../result.js';
|
|
3
|
+
import { binaryHttpResponse } from './binary.js';
|
|
4
|
+
import { streamingBinaryHttpResponse } from './byte-stream.js';
|
|
5
|
+
import { envelopeResponse } from './response.js';
|
|
6
|
+
import { streamHttpResponse } from './stream.js';
|
|
7
|
+
/** Render one admitted Handler result and retain only live producers in the active operation. */
|
|
9
8
|
export function renderHttpResult(result, context) {
|
|
9
|
+
let envelope;
|
|
10
10
|
switch (result.kind) {
|
|
11
11
|
case 'value':
|
|
12
|
-
|
|
12
|
+
envelope = {
|
|
13
13
|
result: result.value,
|
|
14
14
|
requestId: context.requestId,
|
|
15
15
|
invocation: context.invocation,
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
};
|
|
17
|
+
break;
|
|
18
|
+
case 'redirect':
|
|
19
|
+
envelope = renderRedirect(context.requestId, context.invocation, result.redirect);
|
|
20
|
+
break;
|
|
21
21
|
case 'miss':
|
|
22
|
-
|
|
22
|
+
envelope = protocol.miss(context.requestId, context.invocation);
|
|
23
|
+
break;
|
|
23
24
|
case 'binary': {
|
|
24
25
|
if (isStreamingBinary(result.value))
|
|
25
26
|
return streamingBinaryHttpResponse(result.value, context);
|
|
@@ -29,406 +30,9 @@ export function renderHttpResult(result, context) {
|
|
|
29
30
|
case 'stream':
|
|
30
31
|
return streamHttpResponse(result.stream, context);
|
|
31
32
|
}
|
|
32
|
-
|
|
33
|
-
export function renderBinary(requestId, invocation, binary) {
|
|
34
|
-
const reserved = Object.keys(binary.headers ?? {}).find((name) => {
|
|
35
|
-
const normalized = name.toLowerCase();
|
|
36
|
-
return (normalized === protocol.http.INVOCATION_FIELD ||
|
|
37
|
-
normalized === HTTP_ROUTE_STALE ||
|
|
38
|
-
normalized === HTTP_ROUTE_MISS);
|
|
39
|
-
});
|
|
40
|
-
if (reserved !== undefined)
|
|
41
|
-
throw new TypeError(`${reserved} is reserved by Protocol.`);
|
|
42
|
-
return protocol.decodeBinaryResponse({
|
|
43
|
-
kind: 'binary',
|
|
44
|
-
requestId,
|
|
45
|
-
invocation,
|
|
46
|
-
body: binary.body,
|
|
47
|
-
mediaType: binary.mediaType,
|
|
48
|
-
status: binary.status ?? 200,
|
|
49
|
-
...(binary.headers === undefined ? {} : { headers: binary.headers }),
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
export function renderRedirect(requestId, invocation, redirect) {
|
|
53
|
-
return protocol.decodeRedirectResponse({
|
|
54
|
-
kind: 'redirect',
|
|
55
|
-
requestId,
|
|
56
|
-
invocation,
|
|
57
|
-
redirect,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
export function renderHttpError(requestId, invocation, error, codec, cors, options = {}) {
|
|
61
|
-
const status = options.status ?? errors.httpStatusOf(error);
|
|
62
|
-
const retry = retryAfterHeaders(error);
|
|
63
|
-
const extra = status === 401 ? { 'www-authenticate': 'Bearer realm="astrale-invocation"' } : retry;
|
|
64
|
-
return errorEnvelopeResponse(codec, { error, requestId, invocation }, { ...cors(Object.keys(retry)), ...extra }, status, options.negotiated ?? true, options.maximumResponseBytes);
|
|
65
|
-
}
|
|
66
|
-
export function renderHttpStale(requestId, invocation, current, codec, cors, maximumResponseBytes) {
|
|
67
|
-
return envelopeResponse(codec, protocol.stale(requestId, invocation, current), cors(), 200, true, maximumResponseBytes);
|
|
68
|
-
}
|
|
69
|
-
/** Retirement controls always use JSON, regardless of the negotiated request codec. */
|
|
70
|
-
export function renderDeploymentRetired(deployment, release, cors) {
|
|
71
|
-
return new Response(toBody(json.encode(deploymentRetiredBody(deployment, release))), {
|
|
72
|
-
status: HTTP_DEPLOYMENT_RETIRED_STATUS,
|
|
73
|
-
headers: { ...cors([HTTP_DEPLOYMENT_RETIRED]), ...deploymentRetiredHeaders() },
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
export function encodeEnvelope(codec, input) {
|
|
77
|
-
return codec.encode(input);
|
|
78
|
-
}
|
|
79
|
-
function envelopeResponse(codec, input, cors, status = 200, negotiated = true, maximumResponseBytes) {
|
|
80
|
-
const encoded = codec.encode(input);
|
|
81
|
-
requireResponseBound(encoded, maximumResponseBytes);
|
|
82
|
-
const headers = bindingHeaders(cors, negotiated);
|
|
83
|
-
headers.set('content-type', codec.contentType);
|
|
84
|
-
return new Response(toBody(encoded), {
|
|
85
|
-
status,
|
|
86
|
-
headers,
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
function binaryHttpResponse(binary, cors, maximumResponseBytes) {
|
|
90
|
-
requireResponseBound(binary.body, maximumResponseBytes);
|
|
91
|
-
const headers = binaryHttpHeaders(binary, cors, false);
|
|
92
|
-
return new Response(toBody(binary.body), { status: binary.status, headers });
|
|
93
|
-
}
|
|
94
|
-
function streamingBinaryHttpResponse(binary, context) {
|
|
95
|
-
if (binary.length !== undefined && (!Number.isSafeInteger(binary.length) || binary.length < 0)) {
|
|
96
|
-
throw new TypeError('Streaming binary length must be a non-negative safe integer.');
|
|
97
|
-
}
|
|
98
|
-
const delivery = binary.delivery;
|
|
99
|
-
if (delivery !== undefined) {
|
|
100
|
-
if (!Number.isSafeInteger(delivery.maximumDurationMs) ||
|
|
101
|
-
delivery.maximumDurationMs <= 0 ||
|
|
102
|
-
delivery.maximumDurationMs > 2_147_483_647 ||
|
|
103
|
-
!Number.isSafeInteger(delivery.idleTimeoutMs) ||
|
|
104
|
-
delivery.idleTimeoutMs <= 0 ||
|
|
105
|
-
delivery.idleTimeoutMs > 2_147_483_647) {
|
|
106
|
-
throw new TypeError('Streaming binary delivery budget is invalid.');
|
|
107
|
-
}
|
|
108
|
-
context.active.extend(delivery.maximumDurationMs);
|
|
109
|
-
}
|
|
110
|
-
const iterator = binary.body[Symbol.asyncIterator]();
|
|
111
|
-
const headers = binaryHttpHeaders(Object.freeze({
|
|
112
|
-
requestId: context.requestId,
|
|
113
|
-
invocation: context.invocation,
|
|
114
|
-
mediaType: binary.mediaType,
|
|
115
|
-
headers: binary.headers,
|
|
116
|
-
}), context.cors, true);
|
|
117
|
-
if (binary.length !== undefined) {
|
|
118
|
-
headers.set('content-length', String(binary.length));
|
|
119
|
-
// Content-Length belongs to HTTP and an intermediary may rewrite it; the Client counts the
|
|
120
|
-
// delivered bytes against this protocol field instead.
|
|
121
|
-
headers.set(protocol.http.BINARY_LENGTH_FIELD, String(binary.length));
|
|
122
|
-
}
|
|
123
|
-
if (delivery !== undefined)
|
|
124
|
-
headers.set('cache-control', 'private, no-store');
|
|
125
|
-
let terminal = false;
|
|
126
|
-
let delivered = 0;
|
|
127
|
-
let controller;
|
|
128
|
-
let stopping;
|
|
129
|
-
let idleTimer;
|
|
130
|
-
const clearIdle = () => {
|
|
131
|
-
if (idleTimer !== undefined)
|
|
132
|
-
clearTimeout(idleTimer);
|
|
133
|
-
};
|
|
134
|
-
const resetIdle = () => {
|
|
135
|
-
if (delivery === undefined || terminal)
|
|
136
|
-
return;
|
|
137
|
-
clearIdle();
|
|
138
|
-
idleTimer = setTimeout(() => context.active.cancel('binary response idle timeout exceeded'), delivery.idleTimeoutMs);
|
|
139
|
-
};
|
|
140
|
-
const stop = (reason = 'Binary response was interrupted.') => {
|
|
141
|
-
if (terminal)
|
|
142
|
-
return stopping ?? Promise.resolve();
|
|
143
|
-
terminal = true;
|
|
144
|
-
clearIdle();
|
|
145
|
-
controller?.error(new Error(reason));
|
|
146
|
-
stopping = Promise.resolve().then(async () => {
|
|
147
|
-
try {
|
|
148
|
-
await iterator.return?.();
|
|
149
|
-
}
|
|
150
|
-
finally {
|
|
151
|
-
context.active.settle();
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
return stopping;
|
|
155
|
-
};
|
|
156
|
-
const fail = (cause) => {
|
|
157
|
-
if (terminal)
|
|
158
|
-
return;
|
|
159
|
-
terminal = true;
|
|
160
|
-
clearIdle();
|
|
161
|
-
controller?.error(cause);
|
|
162
|
-
stopping = Promise.resolve()
|
|
163
|
-
.then(() => iterator.return?.())
|
|
164
|
-
.catch(() => undefined)
|
|
165
|
-
.then(() => context.active.settle());
|
|
166
|
-
};
|
|
167
|
-
const body = new ReadableStream({
|
|
168
|
-
start(output) {
|
|
169
|
-
controller = output;
|
|
170
|
-
resetIdle();
|
|
171
|
-
},
|
|
172
|
-
async pull(controller) {
|
|
173
|
-
if (terminal)
|
|
174
|
-
return;
|
|
175
|
-
try {
|
|
176
|
-
const next = await iterator.next();
|
|
177
|
-
if (terminal)
|
|
178
|
-
return;
|
|
179
|
-
if (next.done) {
|
|
180
|
-
if (binary.length !== undefined && delivered !== binary.length) {
|
|
181
|
-
fail(new Error('OUTCOME_UNKNOWN: streaming binary ended before its declared length.'));
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
terminal = true;
|
|
185
|
-
clearIdle();
|
|
186
|
-
controller.close();
|
|
187
|
-
context.active.settle();
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (!(next.value instanceof Uint8Array)) {
|
|
191
|
-
throw new TypeError('Streaming binary yielded a non-byte chunk.');
|
|
192
|
-
}
|
|
193
|
-
delivered += next.value.byteLength;
|
|
194
|
-
if (binary.length !== undefined && delivered > binary.length) {
|
|
195
|
-
fail(new Error('OUTCOME_UNKNOWN: streaming binary exceeded its declared length.'));
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
controller.enqueue(next.value);
|
|
199
|
-
resetIdle();
|
|
200
|
-
}
|
|
201
|
-
catch (cause) {
|
|
202
|
-
fail(cause);
|
|
203
|
-
}
|
|
204
|
-
},
|
|
205
|
-
async cancel(reason) {
|
|
206
|
-
const message = typeof reason === 'string' ? reason : 'binary response consumer cancelled';
|
|
207
|
-
context.active.cancel(message);
|
|
208
|
-
await stop();
|
|
209
|
-
},
|
|
210
|
-
});
|
|
211
|
-
context.active.onCancel(stop);
|
|
212
|
-
return new Response(body, { status: binary.status ?? 200, headers });
|
|
213
|
-
}
|
|
214
|
-
export function isStreamingBinary(input) {
|
|
215
|
-
return !(input.body instanceof Uint8Array);
|
|
216
|
-
}
|
|
217
|
-
function binaryHttpHeaders(binary, cors, streaming) {
|
|
218
|
-
const applicationHeaders = Object.keys(binary.headers ?? {}).filter((name) => protocol.http.isApplicationBinaryHeader(name));
|
|
219
|
-
const headers = new Headers(cors([
|
|
220
|
-
protocol.http.REQUEST_ID_FIELD,
|
|
221
|
-
protocol.http.INVOCATION_FIELD,
|
|
222
|
-
protocol.http.BINARY_HEADERS_FIELD,
|
|
223
|
-
...(streaming
|
|
224
|
-
? [protocol.http.BINARY_DELIVERY_FIELD, protocol.http.BINARY_LENGTH_FIELD]
|
|
225
|
-
: []),
|
|
226
|
-
...applicationHeaders,
|
|
227
|
-
]));
|
|
228
|
-
for (const [name, value] of Object.entries(binary.headers ?? {})) {
|
|
229
|
-
if (protocol.http.isApplicationBinaryHeader(name))
|
|
230
|
-
headers.set(name, value);
|
|
231
|
-
}
|
|
232
|
-
headers.set('content-type', binary.mediaType);
|
|
233
|
-
headers.set(protocol.http.BINARY_HEADERS_FIELD, protocol.http.encodeBinaryHeaderNames(binary.headers));
|
|
234
|
-
headers.set(protocol.http.REQUEST_ID_FIELD, String(binary.requestId));
|
|
235
|
-
headers.set(protocol.http.INVOCATION_FIELD, protocol.http.encodeInvocationId(binary.invocation));
|
|
236
|
-
if (streaming) {
|
|
237
|
-
headers.set(protocol.http.BINARY_DELIVERY_FIELD, protocol.http.STREAMING_BINARY_DELIVERY);
|
|
238
|
-
}
|
|
239
|
-
headers.set('cache-control', 'no-store');
|
|
240
|
-
appendVary(headers, 'accept');
|
|
241
|
-
return headers;
|
|
242
|
-
}
|
|
243
|
-
function streamHttpResponse(stream, context) {
|
|
244
|
-
const iterator = stream[Symbol.asyncIterator]();
|
|
245
|
-
const startFrame = encodeBoundedStreamFrame(context.codec, { kind: 'stream.start', requestId: context.requestId, invocation: context.invocation }, context.maximumResponseBytes);
|
|
246
|
-
let count = 0;
|
|
247
|
-
let terminal = false;
|
|
248
|
-
let controller;
|
|
249
|
-
let stopping;
|
|
250
|
-
const stop = (reason) => {
|
|
251
|
-
stopping ??= stopStream(reason);
|
|
252
|
-
return stopping;
|
|
253
|
-
};
|
|
254
|
-
async function stopStream(reason) {
|
|
255
|
-
if (terminal)
|
|
256
|
-
return;
|
|
257
|
-
terminal = true;
|
|
258
|
-
// End the wire response immediately. Producer cleanup remains part of the active operation,
|
|
259
|
-
// but an uncooperative producer must not leave the client waiting for a terminal frame.
|
|
260
|
-
if (controller !== undefined) {
|
|
261
|
-
try {
|
|
262
|
-
controller.enqueue(encodeBoundedStreamFrame(context.codec, cancellationFrame(context), context.maximumResponseBytes));
|
|
263
|
-
}
|
|
264
|
-
catch {
|
|
265
|
-
// The transport consumer may already have disconnected.
|
|
266
|
-
}
|
|
267
|
-
try {
|
|
268
|
-
controller.close();
|
|
269
|
-
}
|
|
270
|
-
catch {
|
|
271
|
-
// The stream may already be closed or cancelled by the consumer.
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
try {
|
|
275
|
-
await Promise.allSettled([
|
|
276
|
-
Promise.resolve().then(() => stream.cancel(reason)),
|
|
277
|
-
Promise.resolve().then(() => iterator.return?.()),
|
|
278
|
-
]);
|
|
279
|
-
}
|
|
280
|
-
finally {
|
|
281
|
-
context.active.settle();
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
const body = new ReadableStream({
|
|
285
|
-
start(output) {
|
|
286
|
-
controller = output;
|
|
287
|
-
output.enqueue(startFrame);
|
|
288
|
-
},
|
|
289
|
-
async pull(output) {
|
|
290
|
-
if (terminal)
|
|
291
|
-
return;
|
|
292
|
-
try {
|
|
293
|
-
const next = await iterator.next();
|
|
294
|
-
if (terminal)
|
|
295
|
-
return;
|
|
296
|
-
if (next.done) {
|
|
297
|
-
terminal = true;
|
|
298
|
-
output.enqueue(encodeBoundedStreamFrame(context.codec, {
|
|
299
|
-
kind: 'stream.end',
|
|
300
|
-
requestId: context.requestId,
|
|
301
|
-
count,
|
|
302
|
-
}, context.maximumResponseBytes));
|
|
303
|
-
output.close();
|
|
304
|
-
context.active.settle();
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
const frame = protocol.decodeStreamFrame({
|
|
308
|
-
kind: 'stream.item',
|
|
309
|
-
requestId: context.requestId,
|
|
310
|
-
index: count,
|
|
311
|
-
value: next.value,
|
|
312
|
-
});
|
|
313
|
-
output.enqueue(encodeBoundedStreamFrame(context.codec, frame, context.maximumResponseBytes));
|
|
314
|
-
count += 1;
|
|
315
|
-
}
|
|
316
|
-
catch (input) {
|
|
317
|
-
if (terminal)
|
|
318
|
-
return;
|
|
319
|
-
terminal = true;
|
|
320
|
-
if (!context.active.signal.aborted) {
|
|
321
|
-
try {
|
|
322
|
-
output.enqueue(encodeBoundedStreamFrame(context.codec, {
|
|
323
|
-
kind: 'stream.error',
|
|
324
|
-
requestId: context.requestId,
|
|
325
|
-
error: classifyError(input),
|
|
326
|
-
}, context.maximumResponseBytes));
|
|
327
|
-
}
|
|
328
|
-
catch {
|
|
329
|
-
// No valid terminal frame can be formed; closing is the only remaining signal.
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
output.close();
|
|
333
|
-
await Promise.allSettled([
|
|
334
|
-
Promise.resolve().then(() => stream.cancel('stream failed')),
|
|
335
|
-
Promise.resolve().then(() => iterator.return?.()),
|
|
336
|
-
]);
|
|
337
|
-
context.active.settle();
|
|
338
|
-
}
|
|
339
|
-
},
|
|
340
|
-
async cancel(reason) {
|
|
341
|
-
const message = typeof reason === 'string' ? reason : 'response consumer cancelled';
|
|
342
|
-
context.active.cancel(message);
|
|
343
|
-
await stop(message);
|
|
344
|
-
},
|
|
345
|
-
});
|
|
346
|
-
context.active.onCancel(stop);
|
|
347
|
-
return new Response(body, {
|
|
348
|
-
status: 200,
|
|
349
|
-
headers: {
|
|
350
|
-
...context.cors([protocol.http.STREAM_FRAMING_FIELD]),
|
|
351
|
-
'cache-control': 'no-store',
|
|
352
|
-
'content-type': context.codec.contentType,
|
|
353
|
-
vary: mergeVary(context.cors(), 'accept'),
|
|
354
|
-
[protocol.http.STREAM_FRAMING_FIELD]: protocol.http.LENGTH_PREFIX_V1,
|
|
355
|
-
},
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
function bindingHeaders(input, negotiated) {
|
|
359
|
-
const headers = new Headers(input);
|
|
360
|
-
headers.set('cache-control', 'no-store');
|
|
361
|
-
if (negotiated)
|
|
362
|
-
appendVary(headers, 'accept');
|
|
363
|
-
return headers;
|
|
364
|
-
}
|
|
365
|
-
function errorEnvelopeResponse(codec, response, cors, status, negotiated, maximumResponseBytes) {
|
|
366
|
-
try {
|
|
367
|
-
return envelopeResponse(codec, response, cors, status, negotiated, maximumResponseBytes);
|
|
368
|
-
}
|
|
369
|
-
catch (error) {
|
|
370
|
-
if (!(error instanceof ResponseBodyTooLargeError))
|
|
371
|
-
throw error;
|
|
372
|
-
const fallback = {
|
|
373
|
-
requestId: response.requestId,
|
|
374
|
-
invocation: response.invocation,
|
|
375
|
-
error: {
|
|
376
|
-
code: errors.CODES.LIMIT_EXCEEDED,
|
|
377
|
-
message: 'Response body exceeds the configured limit.',
|
|
378
|
-
},
|
|
379
|
-
};
|
|
380
|
-
try {
|
|
381
|
-
return envelopeResponse(codec, fallback, cors, errors.httpStatusOf(fallback.error), negotiated, maximumResponseBytes);
|
|
382
|
-
}
|
|
383
|
-
catch (fallbackError) {
|
|
384
|
-
if (!(fallbackError instanceof ResponseBodyTooLargeError))
|
|
385
|
-
throw fallbackError;
|
|
386
|
-
return new Response(null, {
|
|
387
|
-
status: 500,
|
|
388
|
-
headers: bindingHeaders(cors, negotiated),
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
function encodeBoundedStreamFrame(codec, frame, maximumResponseBytes) {
|
|
394
|
-
const encoded = protocol.http.encodeStreamFrame(codec, frame);
|
|
395
|
-
if (encoded.byteLength - 4 > maximumResponseBytes) {
|
|
396
|
-
throw new ResponseBodyTooLargeError(maximumResponseBytes);
|
|
397
|
-
}
|
|
398
|
-
return encoded;
|
|
399
|
-
}
|
|
400
|
-
function requireResponseBound(input, maximumResponseBytes) {
|
|
401
|
-
if (maximumResponseBytes === undefined)
|
|
402
|
-
return;
|
|
403
|
-
const bytes = typeof input === 'string' ? new TextEncoder().encode(input).byteLength : input.byteLength;
|
|
404
|
-
if (bytes > maximumResponseBytes)
|
|
405
|
-
throw new ResponseBodyTooLargeError(maximumResponseBytes);
|
|
33
|
+
return settled(context.active, envelopeResponse(context.codec, envelope, context.cors(), 200, true, context.maximumResponseBytes));
|
|
406
34
|
}
|
|
407
35
|
function settled(operation, response) {
|
|
408
36
|
operation.settle();
|
|
409
37
|
return response;
|
|
410
38
|
}
|
|
411
|
-
function appendVary(headers, value) {
|
|
412
|
-
headers.set('vary', mergeVary(Object.fromEntries(headers), value));
|
|
413
|
-
}
|
|
414
|
-
function mergeVary(headers, value) {
|
|
415
|
-
const names = new Set((headers.vary ?? '')
|
|
416
|
-
.split(',')
|
|
417
|
-
.map((name) => name.trim().toLowerCase())
|
|
418
|
-
.filter(Boolean));
|
|
419
|
-
names.add(value.toLowerCase());
|
|
420
|
-
return [...names].join(', ');
|
|
421
|
-
}
|
|
422
|
-
function cancellationFrame(context) {
|
|
423
|
-
return {
|
|
424
|
-
kind: 'stream.error',
|
|
425
|
-
requestId: context.requestId,
|
|
426
|
-
error: interruptedStreamError(context.active.timedOut),
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
function toBody(input) {
|
|
430
|
-
if (typeof input === 'string')
|
|
431
|
-
return input;
|
|
432
|
-
const copy = new Uint8Array(input);
|
|
433
|
-
return copy.buffer;
|
|
434
|
-
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Codec } from '@astrale-os/kernel-protocol/codecs';
|
|
2
|
+
import type * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
3
|
+
/** Encode a bounded envelope with its binding content type and cache policy. */
|
|
4
|
+
export declare function envelopeResponse(codec: Codec, input: unknown, cors: Readonly<Record<string, string>>, status?: number, negotiated?: boolean, maximumResponseBytes?: number): Response;
|
|
5
|
+
/** Bound an error, falling back to a correlated limit refusal or an empty response. */
|
|
6
|
+
export declare function errorEnvelopeResponse(codec: Codec, response: protocol.ErrorResponse, cors: Readonly<Record<string, string>>, status: number, negotiated: boolean, maximumResponseBytes: number | undefined): Response;
|
|
7
|
+
/** Enforce the configured byte budget before a buffered response is handed off. */
|
|
8
|
+
export declare function requireResponseBound(input: string | Uint8Array, maximumResponseBytes: number | undefined): void;
|
|
9
|
+
/** Retain existing Vary fields while declaring another response selector. */
|
|
10
|
+
export declare function appendVary(headers: Headers, value: string): void;
|
|
11
|
+
/** Merge one case-insensitive selector into the response Vary field. */
|
|
12
|
+
export declare function mergeVary(headers: Readonly<Record<string, string>>, value: string): string;
|
|
13
|
+
/** Detach encoded bytes for Fetch while retaining encoded text as text. */
|
|
14
|
+
export declare function toBody(input: string | Uint8Array): BodyInit;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as errors from '@astrale-os/kernel-protocol/errors';
|
|
2
|
+
import { ResponseBodyTooLargeError } from '../../errors/index.js';
|
|
3
|
+
/** Encode a bounded envelope with its binding content type and cache policy. */
|
|
4
|
+
export function envelopeResponse(codec, input, cors, status = 200, negotiated = true, maximumResponseBytes) {
|
|
5
|
+
const encoded = codec.encode(input);
|
|
6
|
+
requireResponseBound(encoded, maximumResponseBytes);
|
|
7
|
+
const headers = bindingHeaders(cors, negotiated);
|
|
8
|
+
headers.set('content-type', codec.contentType);
|
|
9
|
+
return new Response(toBody(encoded), {
|
|
10
|
+
status,
|
|
11
|
+
headers,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function bindingHeaders(input, negotiated) {
|
|
15
|
+
const headers = new Headers(input);
|
|
16
|
+
headers.set('cache-control', 'no-store');
|
|
17
|
+
if (negotiated)
|
|
18
|
+
appendVary(headers, 'accept');
|
|
19
|
+
return headers;
|
|
20
|
+
}
|
|
21
|
+
/** Bound an error, falling back to a correlated limit refusal or an empty response. */
|
|
22
|
+
export function errorEnvelopeResponse(codec, response, cors, status, negotiated, maximumResponseBytes) {
|
|
23
|
+
try {
|
|
24
|
+
return envelopeResponse(codec, response, cors, status, negotiated, maximumResponseBytes);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!(error instanceof ResponseBodyTooLargeError))
|
|
28
|
+
throw error;
|
|
29
|
+
const fallback = {
|
|
30
|
+
requestId: response.requestId,
|
|
31
|
+
invocation: response.invocation,
|
|
32
|
+
error: {
|
|
33
|
+
code: errors.CODES.LIMIT_EXCEEDED,
|
|
34
|
+
message: 'Response body exceeds the configured limit.',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
return envelopeResponse(codec, fallback, cors, errors.httpStatusOf(fallback.error), negotiated, maximumResponseBytes);
|
|
39
|
+
}
|
|
40
|
+
catch (fallbackError) {
|
|
41
|
+
if (!(fallbackError instanceof ResponseBodyTooLargeError))
|
|
42
|
+
throw fallbackError;
|
|
43
|
+
return new Response(null, {
|
|
44
|
+
status: 500,
|
|
45
|
+
headers: bindingHeaders(cors, negotiated),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Enforce the configured byte budget before a buffered response is handed off. */
|
|
51
|
+
export function requireResponseBound(input, maximumResponseBytes) {
|
|
52
|
+
if (maximumResponseBytes === undefined)
|
|
53
|
+
return;
|
|
54
|
+
const bytes = typeof input === 'string' ? new TextEncoder().encode(input).byteLength : input.byteLength;
|
|
55
|
+
if (bytes > maximumResponseBytes)
|
|
56
|
+
throw new ResponseBodyTooLargeError(maximumResponseBytes);
|
|
57
|
+
}
|
|
58
|
+
/** Retain existing Vary fields while declaring another response selector. */
|
|
59
|
+
export function appendVary(headers, value) {
|
|
60
|
+
headers.set('vary', mergeVary(Object.fromEntries(headers), value));
|
|
61
|
+
}
|
|
62
|
+
/** Merge one case-insensitive selector into the response Vary field. */
|
|
63
|
+
export function mergeVary(headers, value) {
|
|
64
|
+
const names = new Set((headers.vary ?? '')
|
|
65
|
+
.split(',')
|
|
66
|
+
.map((name) => name.trim().toLowerCase())
|
|
67
|
+
.filter(Boolean));
|
|
68
|
+
names.add(value.toLowerCase());
|
|
69
|
+
return [...names].join(', ');
|
|
70
|
+
}
|
|
71
|
+
/** Detach encoded bytes for Fetch while retaining encoded text as text. */
|
|
72
|
+
export function toBody(input) {
|
|
73
|
+
if (typeof input === 'string')
|
|
74
|
+
return input;
|
|
75
|
+
const copy = new Uint8Array(input);
|
|
76
|
+
return copy.buffer;
|
|
77
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RenderContext } from './model.js';
|
|
2
|
+
/** Frame ordered Invocation items and retain cleanup until the producer settles. */
|
|
3
|
+
export declare function streamHttpResponse(stream: AsyncIterable<unknown> & {
|
|
4
|
+
cancel(reason?: string): Promise<void>;
|
|
5
|
+
}, context: RenderContext): Response;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as protocol from '@astrale-os/kernel-protocol/invocation';
|
|
2
|
+
import { classifyError, ResponseBodyTooLargeError } from '../../errors/index.js';
|
|
3
|
+
import { interruptedStreamError } from '../stream-error.js';
|
|
4
|
+
import { mergeVary } from './response.js';
|
|
5
|
+
/** Frame ordered Invocation items and retain cleanup until the producer settles. */
|
|
6
|
+
export function streamHttpResponse(stream, context) {
|
|
7
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
8
|
+
const startFrame = encodeBoundedStreamFrame(context.codec, { kind: 'stream.start', requestId: context.requestId, invocation: context.invocation }, context.maximumResponseBytes);
|
|
9
|
+
let count = 0;
|
|
10
|
+
let terminal = false;
|
|
11
|
+
let controller;
|
|
12
|
+
let stopping;
|
|
13
|
+
const stop = (reason) => {
|
|
14
|
+
stopping ??= stopStream(reason);
|
|
15
|
+
return stopping;
|
|
16
|
+
};
|
|
17
|
+
async function stopStream(reason) {
|
|
18
|
+
if (terminal)
|
|
19
|
+
return;
|
|
20
|
+
terminal = true;
|
|
21
|
+
// End the wire response immediately. Producer cleanup remains part of the active operation,
|
|
22
|
+
// but an uncooperative producer must not leave the client waiting for a terminal frame.
|
|
23
|
+
if (controller !== undefined) {
|
|
24
|
+
try {
|
|
25
|
+
controller.enqueue(encodeBoundedStreamFrame(context.codec, cancellationFrame(context), context.maximumResponseBytes));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// The transport consumer may already have disconnected.
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
controller.close();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// The stream may already be closed or cancelled by the consumer.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await Promise.allSettled([
|
|
39
|
+
Promise.resolve().then(() => stream.cancel(reason)),
|
|
40
|
+
Promise.resolve().then(() => iterator.return?.()),
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
context.active.settle();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const body = new ReadableStream({
|
|
48
|
+
start(output) {
|
|
49
|
+
controller = output;
|
|
50
|
+
output.enqueue(startFrame);
|
|
51
|
+
},
|
|
52
|
+
async pull(output) {
|
|
53
|
+
if (terminal)
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
const next = await iterator.next();
|
|
57
|
+
if (terminal)
|
|
58
|
+
return;
|
|
59
|
+
if (next.done) {
|
|
60
|
+
terminal = true;
|
|
61
|
+
output.enqueue(encodeBoundedStreamFrame(context.codec, {
|
|
62
|
+
kind: 'stream.end',
|
|
63
|
+
requestId: context.requestId,
|
|
64
|
+
count,
|
|
65
|
+
}, context.maximumResponseBytes));
|
|
66
|
+
output.close();
|
|
67
|
+
context.active.settle();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const frame = protocol.decodeStreamFrame({
|
|
71
|
+
kind: 'stream.item',
|
|
72
|
+
requestId: context.requestId,
|
|
73
|
+
index: count,
|
|
74
|
+
value: next.value,
|
|
75
|
+
});
|
|
76
|
+
output.enqueue(encodeBoundedStreamFrame(context.codec, frame, context.maximumResponseBytes));
|
|
77
|
+
count += 1;
|
|
78
|
+
}
|
|
79
|
+
catch (input) {
|
|
80
|
+
if (terminal)
|
|
81
|
+
return;
|
|
82
|
+
terminal = true;
|
|
83
|
+
if (!context.active.signal.aborted) {
|
|
84
|
+
try {
|
|
85
|
+
output.enqueue(encodeBoundedStreamFrame(context.codec, {
|
|
86
|
+
kind: 'stream.error',
|
|
87
|
+
requestId: context.requestId,
|
|
88
|
+
error: classifyError(input),
|
|
89
|
+
}, context.maximumResponseBytes));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// No valid terminal frame can be formed; closing is the only remaining signal.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
output.close();
|
|
96
|
+
await Promise.allSettled([
|
|
97
|
+
Promise.resolve().then(() => stream.cancel('stream failed')),
|
|
98
|
+
Promise.resolve().then(() => iterator.return?.()),
|
|
99
|
+
]);
|
|
100
|
+
context.active.settle();
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async cancel(reason) {
|
|
104
|
+
const message = typeof reason === 'string' ? reason : 'response consumer cancelled';
|
|
105
|
+
context.active.cancel(message);
|
|
106
|
+
await stop(message);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
context.active.onCancel(stop);
|
|
110
|
+
return new Response(body, {
|
|
111
|
+
status: 200,
|
|
112
|
+
headers: {
|
|
113
|
+
...context.cors([protocol.http.STREAM_FRAMING_FIELD]),
|
|
114
|
+
'cache-control': 'no-store',
|
|
115
|
+
'content-type': context.codec.contentType,
|
|
116
|
+
vary: mergeVary(context.cors(), 'accept'),
|
|
117
|
+
[protocol.http.STREAM_FRAMING_FIELD]: protocol.http.LENGTH_PREFIX_V1,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function encodeBoundedStreamFrame(codec, frame, maximumResponseBytes) {
|
|
122
|
+
const encoded = protocol.http.encodeStreamFrame(codec, frame);
|
|
123
|
+
if (encoded.byteLength - 4 > maximumResponseBytes) {
|
|
124
|
+
throw new ResponseBodyTooLargeError(maximumResponseBytes);
|
|
125
|
+
}
|
|
126
|
+
return encoded;
|
|
127
|
+
}
|
|
128
|
+
function cancellationFrame(context) {
|
|
129
|
+
return {
|
|
130
|
+
kind: 'stream.error',
|
|
131
|
+
requestId: context.requestId,
|
|
132
|
+
error: interruptedStreamError(context.active.timedOut),
|
|
133
|
+
};
|
|
134
|
+
}
|