@pulse-compute/provider-fastly 1.0.0-beta.7 → 1.0.0-beta.9
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/README.md +6 -6
- package/dist/config-schema.json +13 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/build/canonical-target.js +7 -0
- package/src/build/entities-native.js +6 -13
- package/src/build/kv-native.as.ts +10 -6
- package/src/build/native-application-errors.js +14 -8
- package/src/build/native-body-forwarding.as.ts +334 -0
- package/src/build/native-body-forwarding.js +69 -0
- package/src/build/native-fetch-deadlines.as.ts +161 -0
- package/src/build/native-fetch-deadlines.js +27 -0
- package/src/build/native-fetch-errors.js +40 -0
- package/src/build/native-handle-ownership.js +127 -0
- package/src/build/native-header-lookup.js +54 -0
- package/src/build/native-http-effects.js +6 -1
- package/src/build/native-platform-capabilities.js +99 -33
- package/src/build/native-stdio.js +20 -0
- package/src/build/native-string-values.js +2 -2
- package/src/build/native-value-budget.js +33 -1
- package/src/build/request-budget.js +3 -1
- package/src/build/schema-admission.js +3 -3
- package/src/config-api.js +1 -0
- package/src/config-schema.json +13 -0
- package/src/index.ts +11 -0
- package/src/provider-contract.js +6 -3
- package/src/testing/native-platform-capabilities-host.js +139 -24
- package/src/toolchain/body-forwarding.js +25 -0
- package/src/toolchain/config.js +4 -0
- package/src/toolchain/index.js +4 -2
- package/src/toolchain/native-execution.js +7 -1
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const { normalizeBodyForwarding } = require('../src/toolchain/body-forwarding.js') as { normalizeBodyForwarding(value: PulseFastlyBodyForwardingOptions | undefined, maxDurationMs?: number): PulseFastlyBodyForwardingOptions | undefined };
|
|
1
2
|
const { normalizeFastlyMaxWasmBytes } = require('../src/build/wasm-budget.js') as { normalizeFastlyMaxWasmBytes(value?: number): number };
|
|
2
3
|
const { normalizeRequestDuration } = require('@pulse-compute/runtime/host') as { normalizeRequestDuration(value?: number): number | undefined };
|
|
3
4
|
import FASTLY_CONFIG_SCHEMA from './config-schema.json';
|
|
@@ -24,10 +25,17 @@ export interface PulseFastlyLocalOptions {
|
|
|
24
25
|
readonly networkFetch?: boolean;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export interface PulseFastlyBodyForwardingOptions {
|
|
29
|
+
readonly requestMaxBytes: number;
|
|
30
|
+
readonly responseMaxBytes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
export interface PulseFastlyProviderOptions {
|
|
28
34
|
/** Inclusive final Native Wasm byte budget; default 4 MiB. */
|
|
29
35
|
readonly maxWasmBytes?: number;
|
|
30
36
|
readonly maxDurationMs?: number;
|
|
37
|
+
/** Experimental Native metered pump; final close hands delivery to Fastly. */
|
|
38
|
+
readonly bodyForwarding?: PulseFastlyBodyForwardingOptions;
|
|
31
39
|
readonly configStore?: string;
|
|
32
40
|
readonly secretStore?: string;
|
|
33
41
|
readonly kv?: Readonly<Record<string, string>>;
|
|
@@ -42,6 +50,8 @@ export interface PulseFastlyProviderOptions {
|
|
|
42
50
|
|
|
43
51
|
export interface PulseFastlyProviderConfig {
|
|
44
52
|
readonly maxDurationMs?: number;
|
|
53
|
+
/** Experimental Native metered pump; final close hands delivery to Fastly. */
|
|
54
|
+
readonly bodyForwarding?: PulseFastlyBodyForwardingOptions;
|
|
45
55
|
readonly kind: 'fastly';
|
|
46
56
|
readonly version: typeof FASTLY_PROVIDER_API_VERSION;
|
|
47
57
|
readonly bindings: {
|
|
@@ -105,6 +115,7 @@ export function fastly(options: PulseFastlyProviderOptions = {}): PulseFastlyPro
|
|
|
105
115
|
return Object.freeze({
|
|
106
116
|
kind: 'fastly',
|
|
107
117
|
maxDurationMs: normalizeRequestDuration(options.maxDurationMs),
|
|
118
|
+
bodyForwarding: normalizeBodyForwarding(options.bodyForwarding, options.maxDurationMs),
|
|
108
119
|
version: FASTLY_PROVIDER_API_VERSION,
|
|
109
120
|
bindings: Object.freeze({
|
|
110
121
|
configStore: String(options.configStore ?? fastlyDefaults['fastly.configStore']),
|
package/src/provider-contract.js
CHANGED
|
@@ -12,13 +12,14 @@ const FASTLY_PROVIDER_DESCRIPTOR = canonical.normalizeDescriptor({
|
|
|
12
12
|
deployable: true,
|
|
13
13
|
localExecution: true,
|
|
14
14
|
capabilities: [
|
|
15
|
-
'request', 'response.json', 'response.text', 'response.custom', 'fetch',
|
|
15
|
+
'request', 'request.body.forward', 'response.json', 'response.text', 'response.custom', 'fetch',
|
|
16
16
|
'time.wall-clock', 'crypto.digestText', 'config.get', 'secret.get', 'kv.get', 'kv.put', 'kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap', 'assets.lookup',
|
|
17
17
|
'grip.channel', 'grip.hold', 'grip.publish', 'grip.broadcast', 'jwt.verify', 'jwt.sign',
|
|
18
18
|
'opaque.pass-through', 's3.head', 's3.getText', 's3.putText', 's3.getBody'
|
|
19
19
|
],
|
|
20
20
|
lowering: {
|
|
21
21
|
request: 'fastly.compute.request',
|
|
22
|
+
'request.body.forward': 'fastly.native.body-forwarding',
|
|
22
23
|
'response.json': 'fastly.compute.response.json',
|
|
23
24
|
'response.text': 'fastly.compute.response.text',
|
|
24
25
|
'response.custom': 'fastly.compute.response.custom',
|
|
@@ -62,10 +63,12 @@ function validateStaticBackendBindings(metadata = {}, bindings = {}) {
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
function createFastlyLoweringPlan(metadata, bindings = {}) {
|
|
66
|
+
function createFastlyLoweringPlan(metadata, bindings = {}, options = {}) {
|
|
67
|
+
const forwarding = require('./toolchain/body-forwarding.js').normalizeBodyForwarding(options.bodyForwarding, options.maxDurationMs);
|
|
68
|
+
const descriptor = forwarding ? FASTLY_PROVIDER_DESCRIPTOR : {...FASTLY_PROVIDER_DESCRIPTOR, capabilities: FASTLY_PROVIDER_DESCRIPTOR.capabilities.filter(id => id !== 'request.body.forward')};
|
|
66
69
|
require('./toolchain/s3.js').resolveFastlyS3(metadata.providerOperations || [], bindings.s3);
|
|
67
70
|
validateStaticBackendBindings(metadata, bindings);
|
|
68
|
-
return canonical.createProviderLoweringPlan(metadata,
|
|
71
|
+
return canonical.createProviderLoweringPlan(metadata, descriptor, {
|
|
69
72
|
...bindings,
|
|
70
73
|
grip: bindings.grip || 'fastly-fanout'
|
|
71
74
|
});
|
|
@@ -4,6 +4,7 @@ const fs = require('node:fs');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
6
|
const FASTLY_STATUS_OK = 0;
|
|
7
|
+
const FASTLY_STATUS_INVALID_ARGUMENT = 2;
|
|
7
8
|
const FASTLY_STATUS_ERROR = 1;
|
|
8
9
|
const FASTLY_STATUS_BADF = 3;
|
|
9
10
|
const FASTLY_STATUS_BUFLEN = 4;
|
|
@@ -229,7 +230,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
229
230
|
new Uint8Array(memory().buffer, Number(buffer), output.length).set(output);
|
|
230
231
|
return FASTLY_STATUS_OK;
|
|
231
232
|
}
|
|
232
|
-
function requestHeaderList(values, buffer, size, cursor, end, written, kind) {
|
|
233
|
+
function requestHeaderList(values, buffer, size, cursor, end, written, kind, lookupKind) {
|
|
233
234
|
const limit = Math.min(Number(size), options.requestHeaderPageBytes ?? Number(size));
|
|
234
235
|
let index = Number(cursor), count = 0;
|
|
235
236
|
if (index < 0 || index > values.length) return FASTLY_STATUS_ERROR;
|
|
@@ -245,7 +246,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
245
246
|
writeU32(written, count);
|
|
246
247
|
view().setBigInt64(Number(end), index === values.length ? -1n : BigInt(index), true);
|
|
247
248
|
// Fault injection is host-fixture-only; neither values nor faults enter traces.
|
|
248
|
-
const fault = options.requestHeaderFault?.({kind, cursor: Number(cursor)});
|
|
249
|
+
const fault = (lookupKind && options.headerLookupFault?.({kind:lookupKind})) || options.requestHeaderFault?.({kind, cursor: Number(cursor)});
|
|
249
250
|
if (fault?.written !== undefined) writeU32(written, fault.written);
|
|
250
251
|
if (fault?.cursor !== undefined) view().setBigInt64(Number(end), BigInt(fault.cursor), true);
|
|
251
252
|
if (fault?.unterminated && count) new Uint8Array(memory().buffer)[Number(buffer) + count - 1] = 65;
|
|
@@ -253,19 +254,52 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
253
254
|
}
|
|
254
255
|
const imports = {
|
|
255
256
|
fastly_async_io: {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const handle = view().getUint32(Number(handles), true);
|
|
259
|
-
const item = pending.get(handle) || bodies.get(handle);
|
|
257
|
+
is_ready(handle, out) {
|
|
258
|
+
const item = pending.get(Number(handle)) || bodies.get(Number(handle));
|
|
260
259
|
if (!item) return FASTLY_STATUS_BADF;
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
260
|
+
const ready = monotonicMs >= (item.readyAt || 0) ? 1 : 0;
|
|
261
|
+
writeU32(out, ready);
|
|
262
|
+
trace.push({module:'fastly_async_io',name:'is_ready',handle:Number(handle),ready,now:monotonicMs});
|
|
263
|
+
return FASTLY_STATUS_OK;
|
|
264
|
+
},
|
|
265
|
+
select(handles, count, timeout, done) {
|
|
266
|
+
if (Number(count) < 1 || Number(timeout) <= 0) return FASTLY_STATUS_ERROR;
|
|
267
|
+
const selected = Array.from({length:Number(count)},(_,i) => view().getUint32(Number(handles)+4*i,true));
|
|
268
|
+
const items = selected.map(handle => pending.get(handle) || bodies.get(handle));
|
|
269
|
+
if (items.some(item => !item)) return FASTLY_STATUS_BADF;
|
|
270
|
+
const waits = items.map(item => Math.max(0, Number(item.readyAt || 0) - monotonicMs));
|
|
271
|
+
const wait = Math.min(...waits), timedOut = wait >= Number(timeout);
|
|
272
|
+
const fault = options.asyncSelectFault?.({handles:selected,timeout:Number(timeout),now:monotonicMs});
|
|
273
|
+
monotonicMs += fault?.advanceMs ?? Math.min(wait,Number(timeout));
|
|
274
|
+
writeU32(done, fault?.selected ?? (timedOut ? 0xffffffff : waits.indexOf(wait)));
|
|
275
|
+
trace.push({ module: 'fastly_async_io', name: 'select', handles:selected, timeout:Number(timeout), timedOut, now:monotonicMs });
|
|
276
|
+
if (fault?.status) return fault.status;
|
|
265
277
|
return FASTLY_STATUS_OK;
|
|
266
278
|
}
|
|
267
279
|
},
|
|
268
280
|
wasi_snapshot_preview1: {
|
|
281
|
+
fd_write(fd, iovs, count, writtenOut) {
|
|
282
|
+
// WASI errno values, independent of the Fastly status enum.
|
|
283
|
+
if (Number(fd) !== 1) return 8; // BADF
|
|
284
|
+
if (options.logWriteFailure === true) {
|
|
285
|
+
trace.push({ module: 'wasi_snapshot_preview1', name: 'fd_write', fd: 1, accepted: false });
|
|
286
|
+
return 29; // IO
|
|
287
|
+
}
|
|
288
|
+
const chunks = [];
|
|
289
|
+
for (let index = 0; index < Number(count); index++) {
|
|
290
|
+
const pointer = Number(iovs) + index * 8;
|
|
291
|
+
chunks.push(readBytes(view().getUint32(pointer, true), view().getUint32(pointer + 4, true)));
|
|
292
|
+
}
|
|
293
|
+
const bytes = Buffer.concat(chunks);
|
|
294
|
+
const written = Math.min(bytes.length, options.logWriteMaxBytes ?? bytes.length);
|
|
295
|
+
const emitted = bytes.subarray(0, written).toString('utf8');
|
|
296
|
+
if (typeof options.onLogWrite === 'function') options.onLogWrite(emitted);
|
|
297
|
+
const message = redactText(emitted);
|
|
298
|
+
writeU32(writtenOut, written);
|
|
299
|
+
logs.push(Object.freeze({ endpoint: 'stdout', message }));
|
|
300
|
+
trace.push({ module: 'wasi_snapshot_preview1', name: 'fd_write', fd: 1, count: Number(count), bytes: bytes.length, written, message, accepted: true });
|
|
301
|
+
return 0;
|
|
302
|
+
},
|
|
269
303
|
clock_time_get(clockId, precision, timeOut) {
|
|
270
304
|
if (Number(clockId) === 1 && typeof options.monotonicClock === 'function') {
|
|
271
305
|
const sample = options.monotonicClock(monotonicMs);
|
|
@@ -308,6 +342,11 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
308
342
|
fastly_log: {
|
|
309
343
|
endpoint_get(namePointer, nameLength, handleOut) {
|
|
310
344
|
const endpoint = readUtf8(namePointer, nameLength);
|
|
345
|
+
// Match Viceroy/Compute: these names belong to stdio or the platform.
|
|
346
|
+
if (/^(?:stdout$|stderr$|fst_managed_)/i.test(endpoint)) {
|
|
347
|
+
trace.push({ module: 'fastly_log', name: 'endpoint_get', endpoint, opened: false });
|
|
348
|
+
return FASTLY_STATUS_INVALID_ARGUMENT;
|
|
349
|
+
}
|
|
311
350
|
if (options.logEndpointFailure === true) {
|
|
312
351
|
trace.push({ module: 'fastly_log', name: 'endpoint_get', endpoint, opened: false });
|
|
313
352
|
return FASTLY_STATUS_ERROR;
|
|
@@ -325,7 +364,11 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
325
364
|
trace.push({ module: 'fastly_log', name: 'write', endpoint, accepted: false });
|
|
326
365
|
return FASTLY_STATUS_ERROR;
|
|
327
366
|
}
|
|
328
|
-
const
|
|
367
|
+
const emitted = readUtf8(messagePointer, messageLength);
|
|
368
|
+
// Test-only assertion seam before proof-trace redaction. Never retain raw
|
|
369
|
+
// guest bytes in the returned evidence, even when the guest regresses.
|
|
370
|
+
if (typeof options.onLogWrite === 'function') options.onLogWrite(emitted);
|
|
371
|
+
const message = redactText(emitted);
|
|
329
372
|
writeU32(writtenOut, Buffer.byteLength(message));
|
|
330
373
|
logs.push(Object.freeze({ endpoint, message }));
|
|
331
374
|
trace.push({ module: 'fastly_log', name: 'write', endpoint, message, accepted: true });
|
|
@@ -360,7 +403,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
360
403
|
const request = requests.get(Number(handle));
|
|
361
404
|
if (!request) return FASTLY_STATUS_BADF;
|
|
362
405
|
const name = readUtf8(pointer, length).toLowerCase();
|
|
363
|
-
|
|
406
|
+
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name)) return FASTLY_STATUS_INVALID_ARGUMENT;
|
|
407
|
+
return requestHeaderList(request.headers.filter(([key]) => key.toLowerCase() === name).map(([, value]) => value), buffer, size, cursor, end, written, 'values', 'request');
|
|
364
408
|
},
|
|
365
409
|
header_value_get(handle, namePointer, nameLength, valuePointer, valueLength, writtenOut) {
|
|
366
410
|
const request = requests.get(Number(handle));
|
|
@@ -368,7 +412,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
368
412
|
trace.push({ module: 'fastly_http_req', name: 'header_value_get', handle: Number(handle), header: name });
|
|
369
413
|
if (!request) return FASTLY_STATUS_BADF;
|
|
370
414
|
const value = headerValue(request.headers, name);
|
|
371
|
-
return value === undefined ?
|
|
415
|
+
return value === undefined ? FASTLY_STATUS_INVALID_ARGUMENT : writeUtf8(value, valuePointer, valueLength, writtenOut);
|
|
372
416
|
},
|
|
373
417
|
new(handleOut) {
|
|
374
418
|
const handle = alloc('request');
|
|
@@ -413,9 +457,27 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
413
457
|
close(handle) { return requests.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
|
|
414
458
|
pending_req_poll_v2(handle, detail, done, responseOut, bodyOut) {
|
|
415
459
|
const item = pending.get(Number(handle)); if (!item) return FASTLY_STATUS_BADF;
|
|
460
|
+
trace.push({module:'fastly_http_req',name:'pending_req_poll_v2',pendingHandle:Number(handle)});
|
|
416
461
|
if (monotonicMs < item.readyAt) { writeU32(done, 0); return FASTLY_STATUS_OK; }
|
|
417
462
|
writeU32(done, 1); return imports.fastly_http_req.pending_req_wait(handle, responseOut, bodyOut);
|
|
418
463
|
},
|
|
464
|
+
send_async_streaming(handle, bodyHandle, backendPointer, backendLength, pendingOut) {
|
|
465
|
+
const request = requests.get(Number(handle)), body = bodies.get(Number(bodyHandle));
|
|
466
|
+
if (!request || !body) return FASTLY_STATUS_BADF;
|
|
467
|
+
const backend = readUtf8(backendPointer, backendLength);
|
|
468
|
+
const fixture = resolveFixture(options.fixtures, backend, request.method, request.url);
|
|
469
|
+
requests.delete(Number(handle));
|
|
470
|
+
if (!fixture || fixture.sendStatus) { bodies.delete(Number(bodyHandle)); return Number(fixture?.sendStatus || 1); }
|
|
471
|
+
const pendingHandle = alloc('pending');
|
|
472
|
+
const record = {backend, method:request.method, url:redactText(request.url), headers:redactRuntimeHeaders(request.headers), bodyBytes:Buffer.alloc(0)};
|
|
473
|
+
outboundRequests.push(record);
|
|
474
|
+
const item = {request, backend, fixture, readyAt:fixture.earlyResponse ? monotonicMs + Number(fixture.delayMs || 0) : Infinity};
|
|
475
|
+
pending.set(pendingHandle, item);
|
|
476
|
+
body.streamingUpload = {item, record}; body.readyAt = monotonicMs + Number(options.uploadReadyDelayMs || 0);
|
|
477
|
+
writeU32(pendingOut, pendingHandle);
|
|
478
|
+
trace.push({module:'fastly_http_req',name:'send_async_streaming',bodyHandle:Number(bodyHandle),pendingHandle});
|
|
479
|
+
return FASTLY_STATUS_OK;
|
|
480
|
+
},
|
|
419
481
|
send_async(handle, bodyHandle, backendPointer, backendLength, pendingOut) {
|
|
420
482
|
const request = requests.get(Number(handle));
|
|
421
483
|
const body = bodies.get(Number(bodyHandle));
|
|
@@ -430,7 +492,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
430
492
|
}
|
|
431
493
|
const pendingHandle = alloc('pending');
|
|
432
494
|
const capturedBody = bodyBytes(bodyHandle);
|
|
433
|
-
pending.set(pendingHandle, { request: { ...request, headers: request.headers.map((entry) => [...entry]), body: capturedBody }, backend, fixture, readyAt: monotonicMs + Number(fixture.delayMs || 0) });
|
|
495
|
+
pending.set(pendingHandle, { request: { ...request, headers: request.headers.map((entry) => [...entry]), body: capturedBody }, backend, fixture, readyAt: fixture.neverHeaders ? Infinity : monotonicMs + Number(fixture.delayMs || 0) });
|
|
434
496
|
if (typeof options.onOutboundRequest === 'function') options.onOutboundRequest({ ...request, backend, headers: request.headers.map((entry) => [...entry]), body: capturedBody });
|
|
435
497
|
if (fixture.sendStatus) { pending.delete(pendingHandle); return Number(fixture.sendStatus); }
|
|
436
498
|
if (request.headers.some(([name, value]) => name.toLowerCase() === 'authorization' && value.startsWith('AWS4-HMAC-SHA256 '))) privateUrls.add(request.url);
|
|
@@ -576,13 +638,21 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
576
638
|
trace.push({ module: 'fastly_http_resp', name: 'header_append', handle: Number(handle), header: [name, value] });
|
|
577
639
|
return FASTLY_STATUS_OK;
|
|
578
640
|
},
|
|
641
|
+
header_values_get(handle, pointer, length, buffer, size, cursor, end, written) {
|
|
642
|
+
trace.push({module:'fastly_http_resp', name:'header_values_get', cursor:Number(cursor)});
|
|
643
|
+
const response = responses.get(Number(handle));
|
|
644
|
+
if (!response) return FASTLY_STATUS_BADF;
|
|
645
|
+
const name = readUtf8(pointer, length).toLowerCase();
|
|
646
|
+
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name)) return FASTLY_STATUS_INVALID_ARGUMENT;
|
|
647
|
+
return requestHeaderList(response.headers.filter(([key]) => key.toLowerCase() === name).map(([, value]) => value), buffer, size, cursor, end, written, 'response-values', 'response');
|
|
648
|
+
},
|
|
579
649
|
header_value_get(handle, namePointer, nameLength, valuePointer, valueLength, writtenOut) {
|
|
580
650
|
const response = responses.get(Number(handle));
|
|
581
651
|
const name = readUtf8(namePointer, nameLength);
|
|
582
652
|
trace.push({ module: 'fastly_http_resp', name: 'header_value_get', handle: Number(handle), header: name });
|
|
583
653
|
if (!response) return FASTLY_STATUS_BADF;
|
|
584
654
|
const value = headerValue(response.headers, name);
|
|
585
|
-
return value === undefined ?
|
|
655
|
+
return value === undefined ? FASTLY_STATUS_INVALID_ARGUMENT : writeUtf8(value, valuePointer, valueLength, writtenOut);
|
|
586
656
|
},
|
|
587
657
|
status_get(handle, statusOut) {
|
|
588
658
|
const response = responses.get(Number(handle));
|
|
@@ -612,11 +682,30 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
612
682
|
responseHandle: Number(handle),
|
|
613
683
|
bodyHandle: Number(bodyHandle)
|
|
614
684
|
};
|
|
685
|
+
if (Number(streaming) === 1) responses.delete(Number(handle));
|
|
615
686
|
return FASTLY_STATUS_OK;
|
|
616
687
|
}
|
|
617
688
|
},
|
|
618
689
|
fastly_http_body: {
|
|
619
|
-
close(handle) {
|
|
690
|
+
close(handle) {
|
|
691
|
+
trace.push({module:'fastly_http_body',name:'close',handle:Number(handle)});
|
|
692
|
+
const body = bodies.get(Number(handle));
|
|
693
|
+
if (body?.streamingUpload) {
|
|
694
|
+
const {item,record} = body.streamingUpload;
|
|
695
|
+
record.bodyBytes = bodyBytes(handle);
|
|
696
|
+
if (item.fixture.echoBody) item.fixture = {...item.fixture,body:record.bodyBytes};
|
|
697
|
+
item.readyAt = item.fixture.neverHeaders ? Infinity : monotonicMs + Number(item.fixture.delayMs || 0);
|
|
698
|
+
}
|
|
699
|
+
if (downstream?.streaming && downstream.bodyHandle === Number(handle)) { downstream.bodyBytes = bodyBytes(handle); downstream.finished = true; }
|
|
700
|
+
return bodies.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF;
|
|
701
|
+
},
|
|
702
|
+
abandon(handle) {
|
|
703
|
+
trace.push({module:'fastly_http_body',name:'abandon',handle:Number(handle)});
|
|
704
|
+
const body = bodies.get(Number(handle));
|
|
705
|
+
if (body?.streamingUpload) { body.streamingUpload.record.bodyBytes = bodyBytes(handle); body.streamingUpload.record.aborted = true; }
|
|
706
|
+
if (downstream?.streaming && downstream.bodyHandle === Number(handle)) { downstream.bodyBytes = bodyBytes(handle); downstream.aborted = true; }
|
|
707
|
+
return bodies.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF;
|
|
708
|
+
},
|
|
620
709
|
new(handleOut) {
|
|
621
710
|
const handle = alloc('body');
|
|
622
711
|
bodies.set(handle, { bytes: Buffer.alloc(0), readOffset: 0, writes: [] });
|
|
@@ -635,19 +724,21 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
635
724
|
body.readOffset = offset + count;
|
|
636
725
|
body.readyAt = monotonicMs + Number(body.fixture && body.fixture.chunkDelayMs || 0);
|
|
637
726
|
writeU32(readOut, count);
|
|
638
|
-
trace.push({ module: 'fastly_http_body', name: 'read', handle: Number(handle), bytes: count });
|
|
727
|
+
trace.push({ module: 'fastly_http_body', name: 'read', handle: Number(handle), bytes: count, ...(options.traceBodyIo === true ? {pointer:Number(buffer), capacity:Number(bufferLength), now:monotonicMs} : {}) });
|
|
639
728
|
return FASTLY_STATUS_OK;
|
|
640
729
|
},
|
|
641
730
|
write(handle, buffer, bufferLength, end, writtenOut) {
|
|
642
731
|
const body = bodies.get(Number(handle));
|
|
643
732
|
if (!body) return FASTLY_STATUS_BADF;
|
|
644
733
|
if (downstream?.streaming && Number(handle) === downstream.bodyHandle && options.downstreamBodyWriteStatus) return Number(options.downstreamBodyWriteStatus);
|
|
645
|
-
if (requests.size > 1 && options.outboundBodyWriteStatus) return Number(options.outboundBodyWriteStatus);
|
|
646
|
-
const length = Math.min(Number(bufferLength), Number(options.bodyWriteChunkBytes
|
|
734
|
+
if ((requests.size > 1 || body.streamingUpload) && options.outboundBodyWriteStatus) return Number(options.outboundBodyWriteStatus);
|
|
735
|
+
const length = Math.min(Number(bufferLength), Number(options.bodyWriteChunkBytes ?? bufferLength));
|
|
647
736
|
const bytes = readBytes(buffer, length);
|
|
648
737
|
body.writes.push(bytes);
|
|
738
|
+
if (body.streamingUpload) body.readyAt = monotonicMs + Number(options.uploadWriteDelayMs || 0);
|
|
739
|
+
if (downstream?.streaming && downstream.bodyHandle === Number(handle)) body.readyAt = monotonicMs + Number(options.downstreamWriteDelayMs || 0);
|
|
649
740
|
writeU32(writtenOut, bytes.length);
|
|
650
|
-
trace.push({ module: 'fastly_http_body', name: 'write', handle: Number(handle), bytes: bytes.length, end: Number(end) });
|
|
741
|
+
trace.push({ module: 'fastly_http_body', name: 'write', handle: Number(handle), bytes: bytes.length, end: Number(end), ...(options.traceBodyIo === true ? {pointer:Number(buffer), capacity:Number(bufferLength), now:monotonicMs} : {}) });
|
|
651
742
|
return FASTLY_STATUS_OK;
|
|
652
743
|
}
|
|
653
744
|
}
|
|
@@ -662,11 +753,16 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
662
753
|
// Throwing stops the Wasm invocation; it never fabricates an effect result.
|
|
663
754
|
const checkActive = () => { if (options.signal && options.signal.aborted) throw options.signal.reason || new Error('Request cancelled.'); };
|
|
664
755
|
checkActive();
|
|
665
|
-
|
|
756
|
+
// A test-only post-call hook can corrupt ABI outputs or advance the clock.
|
|
757
|
+
// It observes the real generated guest; it is never used by provider execution.
|
|
758
|
+
if (options.signal || options.hostcallDelayMs || options.afterHostcall) for (const [moduleName, namespace] of Object.entries(imports)) {
|
|
666
759
|
for (const [name, hostcall] of Object.entries(namespace)) if (typeof hostcall === 'function') namespace[name] = (...args) => {
|
|
667
760
|
checkActive(); const result = hostcall(...args);
|
|
668
761
|
monotonicMs += Number(options.hostcallDelayMs?.[`${moduleName}.${name}`] || 0);
|
|
669
|
-
|
|
762
|
+
const replacement = options.afterHostcall?.({module:moduleName,name,args,result,trace,
|
|
763
|
+
writeU32,writeU64,readU32:pointer=>view().getUint32(Number(pointer),true),
|
|
764
|
+
advance:ms=>{monotonicMs+=ms;}});
|
|
765
|
+
checkActive(); return replacement ?? result;
|
|
670
766
|
};
|
|
671
767
|
}
|
|
672
768
|
const module = new WebAssembly.Module(wasm);
|
|
@@ -698,11 +794,20 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
698
794
|
const pulseLastError = typeof instance.exports.pulse_last_error_code === 'function'
|
|
699
795
|
? Number(instance.exports.pulse_last_error_code())
|
|
700
796
|
: undefined;
|
|
701
|
-
if (downstream?.streaming && !downstream.finished) { downstream.bodyBytes = bodyBytes(downstream.bodyHandle); downstream.aborted = true; }
|
|
797
|
+
if (downstream?.streaming && !downstream.finished) { downstream.bodyBytes = bodyBytes(downstream.bodyHandle) || downstream.bodyBytes; downstream.aborted = true; }
|
|
702
798
|
const handledJwtError = options.allowHandledJwtError === true
|
|
703
799
|
&& lastError === 1008
|
|
704
800
|
&& Boolean(downstream);
|
|
705
|
-
|
|
801
|
+
const handledHeaderError = options.allowHandledHeaderError === true
|
|
802
|
+
&& lastError === 1003 && (errorStage === 22 || errorStage === 23) && downstream?.status === 500;
|
|
803
|
+
const handledTransportError = options.allowHandledTransportError === true
|
|
804
|
+
&& (errorStage === 77 || errorStage === 81)
|
|
805
|
+
&& ((lastError === 1006 && downstream?.status === 502) || (lastError === 1003 && downstream?.status === 500));
|
|
806
|
+
const handledDeadlineError = options.allowHandledFetchDeadlineError === true
|
|
807
|
+
&& ((lastError === 1006 && errorStage === 271 && downstream?.status === 504)
|
|
808
|
+
|| (lastError === 1001 && errorStage === 270 && downstream?.status === 400)
|
|
809
|
+
|| (lastError === 1003 && errorStage === 272 && downstream?.status === 500));
|
|
810
|
+
if (lastError !== FASTLY_STATUS_OK && !handledDeadlineError && !handledTransportError && !handledHeaderError && !handledJwtError && !(options.allowAbortedResponse === true && downstream?.aborted)) {
|
|
706
811
|
throw new FastlyNativePlatformCapabilitiesMockError('Fastly native platform capabilities module reported an execution error.', 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_MOCK_EXECUTION_FAILED', { lastError, errorStage, errorEffect, pulseLastError, trace });
|
|
707
812
|
}
|
|
708
813
|
if (!downstream) {
|
|
@@ -729,6 +834,16 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
|
|
|
729
834
|
outboundRequests: Object.freeze(outboundRequests),
|
|
730
835
|
stores: Object.freeze({ config: snapshotStores(configStores), secrets: Object.fromEntries([...secretStores].map(([name, values]) => [name, Object.fromEntries([...values].map(([key]) => [key, '[REDACTED]']))])), kv: snapshotStores(kvStores, true) }),
|
|
731
836
|
kvEvidence,
|
|
837
|
+
monotonicMs,
|
|
838
|
+
transportResources: Object.freeze({pendingRequests: pending.size,
|
|
839
|
+
outboundRequests: requests.size - Number(requests.has(downstreamRequestHandle))}),
|
|
840
|
+
// Non-streaming send transfers these two handles even though the fixture
|
|
841
|
+
// keeps their bytes for assertions. Pending work is counted separately:
|
|
842
|
+
// Fastly has no cancellation hostcall for an unresolved pending request.
|
|
843
|
+
forwardingResources: Object.freeze({
|
|
844
|
+
bodies: bodies.size - Number(!downstream?.streaming && bodies.has(downstream?.bodyHandle)),
|
|
845
|
+
responses: responses.size - Number(!downstream?.streaming && responses.has(downstream?.responseHandle))
|
|
846
|
+
}),
|
|
732
847
|
instance
|
|
733
848
|
});
|
|
734
849
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MAX_BODY_BYTES = 1048576;
|
|
4
|
+
function forwardingError(message) {
|
|
5
|
+
const error = new TypeError(message);
|
|
6
|
+
error.code = 'PULSE_FASTLY_BODY_FORWARDING_INVALID';
|
|
7
|
+
return error;
|
|
8
|
+
}
|
|
9
|
+
function normalizeBodyForwarding(value, maxDurationMs) {
|
|
10
|
+
if (value === undefined) return undefined;
|
|
11
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
12
|
+
|| Object.keys(value).some(key => !['requestMaxBytes', 'responseMaxBytes'].includes(key))) {
|
|
13
|
+
throw forwardingError('fastly.bodyForwarding requires requestMaxBytes and responseMaxBytes.');
|
|
14
|
+
}
|
|
15
|
+
for (const key of ['requestMaxBytes', 'responseMaxBytes']) {
|
|
16
|
+
if (!Number.isInteger(value[key]) || value[key] < 1 || value[key] > MAX_BODY_BYTES) {
|
|
17
|
+
throw forwardingError(`fastly.bodyForwarding.${key} must be an integer from 1 to ${MAX_BODY_BYTES}.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (!Number.isInteger(maxDurationMs) || maxDurationMs < 1 || maxDurationMs > 30000) {
|
|
21
|
+
throw forwardingError('fastly.bodyForwarding requires maxDurationMs from 1 to 30000.');
|
|
22
|
+
}
|
|
23
|
+
return Object.freeze({requestMaxBytes: value.requestMaxBytes, responseMaxBytes: value.responseMaxBytes});
|
|
24
|
+
}
|
|
25
|
+
module.exports = {normalizeBodyForwarding, forwardingError, MAX_BODY_BYTES};
|
package/src/toolchain/config.js
CHANGED
|
@@ -34,6 +34,7 @@ function normalizeFastlyProviderConfig(value) {
|
|
|
34
34
|
: {};
|
|
35
35
|
return Object.freeze({
|
|
36
36
|
kind: 'fastly',
|
|
37
|
+
bodyForwarding: require('./body-forwarding.js').normalizeBodyForwarding(input.bodyForwarding, input.maxDurationMs),
|
|
37
38
|
maxDurationMs: require('@pulse-compute/runtime/host').normalizeRequestDuration(input.maxDurationMs),
|
|
38
39
|
version: input.version ? String(input.version) : undefined,
|
|
39
40
|
bindings: Object.freeze({
|
|
@@ -71,6 +72,7 @@ function fastlyProjectConfigDocument(config) {
|
|
|
71
72
|
kind: 'fastly',
|
|
72
73
|
bindings: config.bindings,
|
|
73
74
|
maxDurationMs: config.maxDurationMs,
|
|
75
|
+
bodyForwarding: config.bodyForwarding,
|
|
74
76
|
build: config.build,
|
|
75
77
|
local: config.local,
|
|
76
78
|
providerSpecificUserland: false
|
|
@@ -104,6 +106,8 @@ const FASTLY_PROJECT_CONFIG_REFERENCE = Object.freeze({
|
|
|
104
106
|
})
|
|
105
107
|
]),
|
|
106
108
|
fields: Object.freeze([
|
|
109
|
+
Object.freeze({section:'fastly',path:'fastly.bodyForwarding',type:'object',default:'omitted',scope:'experimental Fastly Native forwarding',description:'Opt in to one terminal opaque POST with inline req.body() and a static backend. Requires maxDurationMs and both byte limits. Final close hands queued delivery to Fastly; it is not a client receipt.',diagnostics:Object.freeze(['PULSE_FASTLY_BODY_FORWARDING_INVALID'])}),
|
|
110
|
+
...['requestMaxBytes','responseMaxBytes'].map(name => Object.freeze({section:'fastly',path:`fastly.bodyForwarding.${name}`,type:'integer',allowed:'1–1048576',default:'required with bodyForwarding',scope:'experimental Fastly Native forwarding',description:'Independent actual-byte bound, including EOF verification; no unbounded default. Fixed 16 KiB raw scratch is reused across the sequential upload and response pumps.'})),
|
|
107
111
|
Object.freeze({section:'fastly',path:'fastly.maxWasmBytes',type:'integer',allowed:'positive safe integer',default:'4194304 (4 MiB)',scope:'fastly() facade',description:'Facade option projected to build.maxWasmBytes. Conventional profiles configure fastly.build.maxWasmBytes.'}),
|
|
108
112
|
Object.freeze({section:'fastly',path:'fastly.build.maxWasmBytes',type:'integer',allowed:'positive safe integer',default:'4194304 (4 MiB)',scope:'Native final Wasm artifact',description:'Inclusive byte budget checked for newly compiled and reused artifacts. Raising it does not increase runtime memory or change Fastly service limits. The fastly() facade accepts maxWasmBytes.'}),
|
|
109
113
|
Object.freeze({section:'fastly',path:'fastly.maxDurationMs',type:'integer',allowed:'1–30000',default:'omitted',scope:'HTTP request execution',description:'One provider-owned monotonic budget through buffered response handoff; expiry attempts 504 before headers commit and never asserts rollback of dispatched writes. No CPU preemption or post-handoff delivery guarantee.'}),
|
package/src/toolchain/index.js
CHANGED
|
@@ -271,7 +271,7 @@ function createDriver() {
|
|
|
271
271
|
execute: fastlyRuntime.executeCanonicalProgram,
|
|
272
272
|
prepareNativeExecution: require('./native-execution.js').prepareFastlyNativeExecution,
|
|
273
273
|
createLoweringPlan(metadata, providerConfig = {}) {
|
|
274
|
-
return createFastlyLoweringPlan(metadata, providerConfig.bindings || {});
|
|
274
|
+
return createFastlyLoweringPlan(metadata, providerConfig.bindings || {}, providerConfig);
|
|
275
275
|
},
|
|
276
276
|
inspectRealization(invocation) {
|
|
277
277
|
const realization = inspectFastlyCanonicalTarget({
|
|
@@ -285,6 +285,7 @@ function createDriver() {
|
|
|
285
285
|
realizationArtifacts: invocation.nativeArtifact.realizationArtifacts,
|
|
286
286
|
guestUnits: invocation.nativeArtifact.guestUnits,
|
|
287
287
|
nativeOptimization: invocation.optimization,
|
|
288
|
+
elideJsonCacheArena: invocation.nativeArtifact.manifest.elideJsonCacheArena === true,
|
|
288
289
|
emitWat: invocation.nativeArtifact.manifest.wat?.emitted === true,
|
|
289
290
|
compileTimeoutMs: invocation.timeoutMs
|
|
290
291
|
});
|
|
@@ -314,6 +315,7 @@ function createDriver() {
|
|
|
314
315
|
realizationArtifacts: invocation.nativeArtifact.realizationArtifacts,
|
|
315
316
|
guestUnits: invocation.nativeArtifact.guestUnits,
|
|
316
317
|
nativeOptimization: invocation.optimization,
|
|
318
|
+
elideJsonCacheArena: invocation.nativeArtifact.manifest.elideJsonCacheArena === true,
|
|
317
319
|
emitWat: invocation.nativeArtifact.manifest.wat?.emitted === true,
|
|
318
320
|
compileTimeoutMs: invocation.timeoutMs
|
|
319
321
|
});
|
|
@@ -350,7 +352,7 @@ function createDriver() {
|
|
|
350
352
|
providerPackage: providerBuild.packageFile,
|
|
351
353
|
providerSourceEntry: providerBuild.sourceEntryFile
|
|
352
354
|
}),
|
|
353
|
-
build: { ...providerBuild.build, reportAttribution: native.reportAttribution },
|
|
355
|
+
build: { ...providerBuild.build, reportAttribution: native.reportAttribution, buildObservation: native.buildObservation },
|
|
354
356
|
realization: publicRealization,
|
|
355
357
|
packaging: audit ? Object.freeze({
|
|
356
358
|
version: PROVIDER_PACKAGING_AUDIT_VERSION,
|
|
@@ -18,6 +18,7 @@ function prepareFastlyNativeExecution(invocation) {
|
|
|
18
18
|
realizationArtifacts: invocation.nativeArtifact.realizationArtifacts,
|
|
19
19
|
guestUnits: invocation.nativeArtifact.guestUnits,
|
|
20
20
|
nativeOptimization: invocation.optimization,
|
|
21
|
+
elideJsonCacheArena: invocation.nativeArtifact.manifest.elideJsonCacheArena === true,
|
|
21
22
|
emitWat: invocation.nativeArtifact.manifest.wat?.emitted === true,
|
|
22
23
|
compileTimeoutMs: invocation.timeoutMs || undefined
|
|
23
24
|
});
|
|
@@ -58,7 +59,12 @@ function prepareFastlyNativeExecution(invocation) {
|
|
|
58
59
|
response: Object.freeze({
|
|
59
60
|
...execution.response,
|
|
60
61
|
kind: type.includes('application/json') || type.includes('+json') ? 'json' : type.startsWith('text/') ? 'text' : 'response',
|
|
61
|
-
|
|
62
|
+
// Forwarding filters origin metadata into a new response handle, so
|
|
63
|
+
// its streamed payload remains opaque even though origin is false.
|
|
64
|
+
// Pre-commit errors use ordinary non-streaming structured responses.
|
|
65
|
+
bodyClass: execution.response.origin === true
|
|
66
|
+
|| (native.plan.capabilities.includes('request.body.forward') && execution.response.streaming === 1)
|
|
67
|
+
? 'opaque' : 'structured'
|
|
62
68
|
}),
|
|
63
69
|
// The fixture ABI has hostcall traces, not canonical continuation telemetry.
|
|
64
70
|
effectCount: null,
|