@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/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']),
@@ -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, FASTLY_PROVIDER_DESCRIPTOR, {
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
- select(handles, count, timeout, done) {
257
- if (Number(count) !== 1 || Number(timeout) <= 0) return FASTLY_STATUS_ERROR;
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 wait = Math.max(0, Number(item.readyAt || 0) - monotonicMs);
262
- monotonicMs += Math.min(wait, Number(timeout));
263
- writeU32(done, wait >= Number(timeout) ? 0xffffffff : 0);
264
- trace.push({ module: 'fastly_async_io', name: 'select', timeout: Number(timeout), timedOut: wait >= Number(timeout) });
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 message = redactText(readUtf8(messagePointer, messageLength));
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
- return requestHeaderList(request.headers.filter(([key]) => key.toLowerCase() === name).map(([, value]) => value), buffer, size, cursor, end, written, 'values');
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 ? FASTLY_STATUS_NONE : writeUtf8(value, valuePointer, valueLength, writtenOut);
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 ? FASTLY_STATUS_NONE : writeUtf8(value, valuePointer, valueLength, writtenOut);
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) { trace.push({ module: 'fastly_http_body', name: 'close', handle: Number(handle) }); if (downstream?.streaming && downstream.bodyHandle === Number(handle)) { downstream.bodyBytes = bodyBytes(handle); downstream.finished = true; } return bodies.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
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 || bufferLength));
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
- if (options.signal || options.hostcallDelayMs) for (const [moduleName, namespace] of Object.entries(imports)) {
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
- checkActive(); return result;
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
- if (lastError !== FASTLY_STATUS_OK && !handledJwtError && !(options.allowAbortedResponse === true && downstream?.aborted)) {
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};
@@ -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.'}),
@@ -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
- bodyClass: execution.response.origin === true ? 'opaque' : 'structured'
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,