@pulse-compute/provider-fastly 1.0.0-beta.1 → 1.0.0-beta.10

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.
Files changed (68) hide show
  1. package/README.md +7 -6
  2. package/dist/config-schema.json +30 -0
  3. package/dist/index.d.ts +16 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +8 -0
  6. package/dist/index.js.map +1 -1
  7. package/package.json +11 -10
  8. package/src/build/canonical-target.js +35 -2
  9. package/src/build/digest-native.js +25 -0
  10. package/src/build/effect-invocations.js +49 -0
  11. package/src/build/embedded-assets.as.ts +32 -0
  12. package/src/build/entities-native.js +6 -11
  13. package/src/build/kv-native.as.ts +331 -0
  14. package/src/build/kv-native.js +27 -0
  15. package/src/build/native-application-errors.js +159 -0
  16. package/src/build/native-binary-resources.js +30 -0
  17. package/src/build/native-body-forwarding.as.ts +341 -0
  18. package/src/build/native-body-forwarding.js +69 -0
  19. package/src/build/native-dynamic-backends.as.ts +115 -0
  20. package/src/build/native-dynamic-backends.js +52 -0
  21. package/src/build/native-fetch-deadlines.as.ts +164 -0
  22. package/src/build/native-fetch-deadlines.js +27 -0
  23. package/src/build/native-fetch-errors.js +38 -0
  24. package/src/build/native-handle-ownership.js +127 -0
  25. package/src/build/native-header-lookup.js +50 -0
  26. package/src/build/native-http-effects.js +155 -32
  27. package/src/build/native-platform-capabilities.js +755 -210
  28. package/src/build/native-request-body.js +44 -0
  29. package/src/build/native-request-headers.js +93 -0
  30. package/src/build/native-send-error-detail.as.ts +12 -0
  31. package/src/build/native-stdio.js +45 -0
  32. package/src/build/native-string-values.js +143 -0
  33. package/src/build/native-transport-details.as.ts +84 -0
  34. package/src/build/native-value-budget.js +90 -0
  35. package/src/build/package-native-application.js +114 -0
  36. package/src/build/request-budget.as.ts +50 -0
  37. package/src/build/request-budget.js +47 -0
  38. package/src/build/s3-body.as.ts +97 -0
  39. package/src/build/s3-native.as.ts +228 -0
  40. package/src/build/s3-native.js +30 -0
  41. package/src/build/schema-admission.js +83 -0
  42. package/src/build/schema-nested-json.js +93 -0
  43. package/src/build/schema-scalar-record.js +41 -0
  44. package/src/build/time-native.js +49 -0
  45. package/src/build/wasm-budget.js +28 -0
  46. package/src/config-api.js +6 -0
  47. package/src/config-schema.json +30 -0
  48. package/src/index.ts +25 -0
  49. package/src/javascript/jwt-verifier.js +14 -4
  50. package/src/javascript/logging.js +11 -2
  51. package/src/javascript/package-effects.js +5 -2
  52. package/src/javascript/runtime-host.js +7 -3
  53. package/src/javascript/source-package.js +4 -2
  54. package/src/javascript/target-support-policy.js +20 -3
  55. package/src/javascript/target.js +17 -1
  56. package/src/javascript/test-runtime.js +1 -0
  57. package/src/provider-contract.js +22 -6
  58. package/src/runtime/canonical-api-runtime.js +6 -0
  59. package/src/testing/conditional-kv-host.js +155 -0
  60. package/src/testing/native-dynamic-backends-host.js +39 -0
  61. package/src/testing/native-http-effects-host.js +9 -1
  62. package/src/testing/native-platform-capabilities-host.js +349 -24
  63. package/src/toolchain/body-forwarding.js +25 -0
  64. package/src/toolchain/config.js +16 -0
  65. package/src/toolchain/index.js +48 -5
  66. package/src/toolchain/logging.js +18 -0
  67. package/src/toolchain/native-execution.js +78 -0
  68. package/src/toolchain/s3.js +26 -0
@@ -4,13 +4,14 @@ 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;
10
11
  const FASTLY_STATUS_NONE = 10;
11
12
  const FASTLY_KV_ERROR_OK = 1;
12
13
  const FASTLY_KV_ERROR_NOT_FOUND = 3;
13
- const SENSITIVE_HEADERS = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'x-auth-token', 'cookie', 'set-cookie']);
14
+ const SENSITIVE_HEADERS = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'x-auth-token', 'cookie', 'set-cookie', 'x-amz-security-token', 'x-amz-content-sha256']);
14
15
 
15
16
  class FastlyNativePlatformCapabilitiesMockError extends Error {
16
17
  constructor(message, code = 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_MOCK_FAILED', detail = {}) {
@@ -113,6 +114,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
113
114
  const url = String(requestInput.url || `https://pulse.test${pathValue.startsWith('/') ? pathValue : `/${pathValue}`}`);
114
115
  const requestHeaders = normalizeHeaders(requestInput.headers);
115
116
  const trace = [];
117
+ let monotonicMs = 0;
118
+ const privateUrls = new Set();
116
119
  let nextHandle = 10;
117
120
  const typedHandleStarts = options.handleStarts && typeof options.handleStarts === 'object' ? options.handleStarts : {};
118
121
  const typedHandleNext = new Map();
@@ -127,7 +130,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
127
130
  const secretStores = normalizeNamedStores(options.secretStores, options.secretStore || 'app_secrets', options.secrets);
128
131
  const secretValues = [...secretStores.values()].flatMap((values) => [...values.values()].map((value) => String(value))).filter(Boolean);
129
132
  const redactText = (value) => secretValues.reduce((output, secret) => output.split(secret).join('[REDACTED]'), String(value));
130
- const redactRuntimeHeaders = (headers) => headers.map(([name, value]) => [name, SENSITIVE_HEADERS.has(String(name).toLowerCase()) ? '[REDACTED]' : redactText(value)]);
133
+ const redactRuntimeHeaders = (headers, privateS3 = false) => headers.map(([name, value]) => [name, SENSITIVE_HEADERS.has(String(name).toLowerCase()) || privateS3 && ['etag', 'content-type'].includes(String(name).toLowerCase()) ? '[REDACTED]' : redactText(value)]);
131
134
  const kvStores = normalizeNamedStores(options.kvStores, options.kvStore || 'app_sessions', undefined);
132
135
  for (const values of kvStores.values()) for (const [key, value] of values) values.set(key, encodeKvValue(value));
133
136
  const configHandles = new Map();
@@ -141,7 +144,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
141
144
  const downstreamRequestHandle = 1;
142
145
  const downstreamBodyHandle = 2;
143
146
  requests.set(downstreamRequestHandle, { method, url, headers: requestHeaders.map((entry) => [...entry]) });
144
- bodies.set(downstreamBodyHandle, { bytes: normalizeBody(requestInput.body), readOffset: 0, writes: [] });
147
+ bodies.set(downstreamBodyHandle, { bytes: normalizeBody(requestInput.body), readOffset: 0, writes: [],
148
+ readyAt: monotonicMs + Number(requestInput.readyDelayMs || 0), fixture: requestInput });
145
149
  let downstream;
146
150
 
147
151
  function alloc(kind = 'default') {
@@ -170,6 +174,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
170
174
  function readBytes(pointer, length) { return Buffer.from(new Uint8Array(memory().buffer, Number(pointer), Number(length))); }
171
175
  function readUtf8(pointer, length) { return readBytes(pointer, length).toString('utf8'); }
172
176
 
177
+ const dynamicBackends = require('./native-dynamic-backends-host.js').createDynamicBackendHost({ view, readUtf8, trace, options, beforeRegister: () => { monotonicMs += Number(options.dynamicBackendRegistrationMs || 0); } });
178
+
173
179
  function writeUtf8(value, pointer, maxLength, writtenOut) {
174
180
  const bytes = Buffer.from(String(value), 'utf8');
175
181
  writeU32(writtenOut, bytes.length);
@@ -201,22 +207,152 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
201
207
  body = JSON.stringify(normalized.json);
202
208
  if (!headers.some(([name]) => name.toLowerCase() === 'content-type')) headers.push(['content-type', 'application/json; charset=utf-8']);
203
209
  }
210
+ // Ordinary Pulse harness fixtures use value/text; preserve the ABI fixture
211
+ // body's explicit precedence and its existing json shorthand.
212
+ if (body === undefined && Object.prototype.hasOwnProperty.call(normalized, 'value')) {
213
+ body = JSON.stringify(normalized.value);
214
+ if (!headers.some(([name]) => name.toLowerCase() === 'content-type')) headers.push(['content-type', 'application/json; charset=utf-8']);
215
+ } else if (body === undefined && Object.prototype.hasOwnProperty.call(normalized, 'text')) {
216
+ body = String(normalized.text);
217
+ if (!headers.some(([name]) => name.toLowerCase() === 'content-type')) headers.push(['content-type', 'text/plain; charset=utf-8']);
218
+ }
204
219
  responses.set(responseHandle, {
205
220
  status: Number(normalized.status || 200),
206
221
  headers,
207
222
  origin: true
208
223
  });
209
- bodies.set(bodyHandle, { bytes: normalizeBody(body), readOffset: 0, writes: [] });
224
+ bodies.set(bodyHandle, { bytes: normalizeBody(body), readOffset: 0, writes: [], fixture: normalized, readyAt: monotonicMs + Number(normalized.bodyDelayMs || 0) });
210
225
  return { responseHandle, bodyHandle };
211
226
  }
212
227
 
228
+ function headerList(values, buffer, size, end, written) {
229
+ const output = Buffer.from(values.map((value) => String(value) + '\0').join(''));
230
+ writeU32(written, output.length); view().setBigInt64(Number(end), -1n, true);
231
+ if (output.length > Number(size)) return FASTLY_STATUS_BUFLEN;
232
+ new Uint8Array(memory().buffer, Number(buffer), output.length).set(output);
233
+ return FASTLY_STATUS_OK;
234
+ }
235
+ function requestHeaderList(values, buffer, size, cursor, end, written, kind, lookupKind) {
236
+ const limit = Math.min(Number(size), options.requestHeaderPageBytes ?? Number(size));
237
+ let index = Number(cursor), count = 0;
238
+ if (index < 0 || index > values.length) return FASTLY_STATUS_ERROR;
239
+ while (index < values.length) {
240
+ const bytes = Buffer.from(String(values[index]) + '\0');
241
+ if (count + bytes.length > limit) {
242
+ if (count === 0) { writeU32(written, bytes.length); return FASTLY_STATUS_BUFLEN; }
243
+ break;
244
+ }
245
+ new Uint8Array(memory().buffer, Number(buffer) + count, bytes.length).set(bytes);
246
+ count += bytes.length; index++;
247
+ }
248
+ writeU32(written, count);
249
+ view().setBigInt64(Number(end), index === values.length ? -1n : BigInt(index), true);
250
+ // Fault injection is host-fixture-only; neither values nor faults enter traces.
251
+ const fault = (lookupKind && options.headerLookupFault?.({kind:lookupKind})) || options.requestHeaderFault?.({kind, cursor: Number(cursor)});
252
+ if (fault?.written !== undefined) writeU32(written, fault.written);
253
+ if (fault?.cursor !== undefined) view().setBigInt64(Number(end), BigInt(fault.cursor), true);
254
+ if (fault?.unterminated && count) new Uint8Array(memory().buffer)[Number(buffer) + count - 1] = 65;
255
+ return fault?.status ?? FASTLY_STATUS_OK;
256
+ }
257
+ function writeSendErrorDetail(item, handle, detail, outputs, name) {
258
+ // Independent wasm32 ABI oracle: SendErrorDetail is 20 bytes, aligned to
259
+ // four, even when no optional field is valid. Do not import guest constants.
260
+ const strict = options.sendErrorDetailLayout;
261
+ if (strict) {
262
+ const fail = message => { throw new FastlyNativePlatformCapabilitiesMockError(message, 'PULSE_FASTLY_SEND_ERROR_DETAIL_LAYOUT'); };
263
+ if (detail % 4) fail('SendErrorDetail must be four-byte aligned');
264
+ // AssemblyScript managed StaticArray rtSize is the word at data - 4.
265
+ if (strict === 'managed' && view().getUint32(detail - 4, true) < 20) fail('SendErrorDetail allocation is smaller than 20 bytes');
266
+ for (const output of outputs) if (output < detail + 20 && output + 4 > detail) fail('SendErrorDetail overlaps an output');
267
+ if (view().getUint32(detail, true) !== 0 || view().getUint32(detail + 4, true) !== 30) fail('SendErrorDetail tag/mask were not reset before host call');
268
+ for (let offset = 8; offset < 20; offset++) if (view().getUint8(detail + offset) !== 0) fail('SendErrorDetail contains stale optional fields');
269
+ }
270
+ const guards = [...new Set([detail - 4, detail + 20, ...outputs])];
271
+ const saved = guards.map(pointer => view().getUint32(pointer, true));
272
+ const canary = guards.map((_, i) => 0x13579bd0 + i);
273
+ if (strict) guards.forEach((pointer, i) => writeU32(pointer, canary[i]));
274
+ const wire = item.fixture.sendErrorDetail === null ? {tag:0,mask:0} : item.fixture.sendErrorDetail || { tag: item.fixture.transportStatus ? 22 : 1, mask: 0 };
275
+ // Write the entire C-layout record, including padding and the final word.
276
+ new Uint8Array(memory().buffer, detail, 20).fill(0);
277
+ writeU32(detail, wire.tag); writeU32(detail + 4, wire.mask);
278
+ view().setUint16(detail + 8, wire.dnsRcode || 0, true);
279
+ view().setUint16(detail + 10, wire.dnsInfo || 0, true);
280
+ view().setUint8(detail + 12, wire.tlsAlert || 0);
281
+ view().setUint8(detail + 13, wire.h2Frame || 0);
282
+ writeU32(detail + 16, wire.h2Code || 0);
283
+ if (strict) guards.forEach((pointer, i) => {
284
+ if (view().getUint32(pointer, true) !== canary[i]) throw new FastlyNativePlatformCapabilitiesMockError('SendErrorDetail overwrote an adjacent/output sentinel', 'PULSE_FASTLY_SEND_ERROR_DETAIL_LAYOUT');
285
+ writeU32(pointer, saved[i]);
286
+ });
287
+ trace.push({module:'fastly_http_req',name,pendingHandle:Number(handle),
288
+ detailBytes:20, detailTag:wire.tag, detailMask:wire.mask, detailLayoutChecked:Boolean(strict)});
289
+ }
213
290
  const imports = {
291
+ fastly_async_io: {
292
+ is_ready(handle, out) {
293
+ const item = pending.get(Number(handle)) || bodies.get(Number(handle));
294
+ if (!item) return FASTLY_STATUS_BADF;
295
+ const ready = monotonicMs >= (item.readyAt || 0) ? 1 : 0;
296
+ writeU32(out, ready);
297
+ trace.push({module:'fastly_async_io',name:'is_ready',handle:Number(handle),ready,now:monotonicMs});
298
+ return FASTLY_STATUS_OK;
299
+ },
300
+ select(handles, count, timeout, done) {
301
+ if (Number(count) < 1 || Number(timeout) <= 0) return FASTLY_STATUS_ERROR;
302
+ const selected = Array.from({length:Number(count)},(_,i) => view().getUint32(Number(handles)+4*i,true));
303
+ const items = selected.map(handle => pending.get(handle) || bodies.get(handle));
304
+ if (items.some(item => !item)) return FASTLY_STATUS_BADF;
305
+ const waits = items.map(item => Math.max(0, Number(item.readyAt || 0) - monotonicMs));
306
+ const wait = Math.min(...waits), timedOut = wait >= Number(timeout);
307
+ const fault = options.asyncSelectFault?.({handles:selected,timeout:Number(timeout),now:monotonicMs});
308
+ monotonicMs += fault?.advanceMs ?? Math.min(wait,Number(timeout));
309
+ writeU32(done, fault?.selected ?? (timedOut ? 0xffffffff : waits.indexOf(wait)));
310
+ trace.push({ module: 'fastly_async_io', name: 'select', handles:selected, timeout:Number(timeout), timedOut, now:monotonicMs });
311
+ if (fault?.status) return fault.status;
312
+ return FASTLY_STATUS_OK;
313
+ }
314
+ },
214
315
  wasi_snapshot_preview1: {
316
+ fd_write(fd, iovs, count, writtenOut) {
317
+ // WASI errno values, independent of the Fastly status enum.
318
+ if (Number(fd) !== 1) return 8; // BADF
319
+ if (options.logWriteFailure === true) {
320
+ trace.push({ module: 'wasi_snapshot_preview1', name: 'fd_write', fd: 1, accepted: false });
321
+ return 29; // IO
322
+ }
323
+ const chunks = [];
324
+ for (let index = 0; index < Number(count); index++) {
325
+ const pointer = Number(iovs) + index * 8;
326
+ chunks.push(readBytes(view().getUint32(pointer, true), view().getUint32(pointer + 4, true)));
327
+ }
328
+ const bytes = Buffer.concat(chunks);
329
+ const written = Math.min(bytes.length, options.logWriteMaxBytes ?? bytes.length);
330
+ const emitted = bytes.subarray(0, written).toString('utf8');
331
+ if (typeof options.onLogWrite === 'function') options.onLogWrite(emitted);
332
+ const message = redactText(emitted);
333
+ writeU32(writtenOut, written);
334
+ logs.push(Object.freeze({ endpoint: 'stdout', message }));
335
+ trace.push({ module: 'wasi_snapshot_preview1', name: 'fd_write', fd: 1, count: Number(count), bytes: bytes.length, written, message, accepted: true });
336
+ return 0;
337
+ },
215
338
  clock_time_get(clockId, precision, timeOut) {
339
+ if (Number(clockId) === 1 && typeof options.monotonicClock === 'function') {
340
+ const sample = options.monotonicClock(monotonicMs);
341
+ if (sample.status) return sample.status;
342
+ writeU64(timeOut, sample.nanoseconds);
343
+ return FASTLY_STATUS_OK;
344
+ }
345
+ if (Number(clockId) === 0 && typeof options.realtimeClock === 'function') {
346
+ const sample = options.realtimeClock();
347
+ trace.push({ module: 'wasi_snapshot_preview1', name: 'clock_time_get', clockId: 0, precision: String(precision), status: sample.status || 0 });
348
+ if (sample.status) return sample.status;
349
+ writeU64(timeOut, sample.nanoseconds);
350
+ return FASTLY_STATUS_OK;
351
+ }
216
352
  const seconds = Number.isFinite(Number(options.clockUnixSeconds))
217
353
  ? Number(options.clockUnixSeconds)
218
354
  : Math.floor(Date.now() / 1000);
219
- writeU64(timeOut, BigInt(Math.trunc(seconds * 1_000_000_000)));
355
+ writeU64(timeOut, BigInt(Math.trunc((Number(clockId) === 1 ? monotonicMs : seconds * 1000 + monotonicMs) * 1_000_000)));
220
356
  trace.push({
221
357
  module: 'wasi_snapshot_preview1',
222
358
  name: 'clock_time_get',
@@ -241,7 +377,12 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
241
377
  fastly_log: {
242
378
  endpoint_get(namePointer, nameLength, handleOut) {
243
379
  const endpoint = readUtf8(namePointer, nameLength);
244
- if (options.logEndpointFailure === true) {
380
+ // Match Viceroy/Compute: these names belong to stdio or the platform.
381
+ if (/^(?:stdout$|stderr$|fst_managed_)/i.test(endpoint)) {
382
+ trace.push({ module: 'fastly_log', name: 'endpoint_get', endpoint, opened: false });
383
+ return FASTLY_STATUS_INVALID_ARGUMENT;
384
+ }
385
+ if (options.logEndpointFailure === true || (options.logEndpoints && !options.logEndpoints.includes(endpoint))) {
245
386
  trace.push({ module: 'fastly_log', name: 'endpoint_get', endpoint, opened: false });
246
387
  return FASTLY_STATUS_ERROR;
247
388
  }
@@ -258,14 +399,20 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
258
399
  trace.push({ module: 'fastly_log', name: 'write', endpoint, accepted: false });
259
400
  return FASTLY_STATUS_ERROR;
260
401
  }
261
- const message = redactText(readUtf8(messagePointer, messageLength));
262
- writeU32(writtenOut, Buffer.byteLength(message));
402
+ const length = Math.min(Number(messageLength), options.logWriteMaxBytes ?? Number(messageLength));
403
+ const emitted = readUtf8(messagePointer, length);
404
+ // Test-only assertion seam before proof-trace redaction. Never retain raw
405
+ // guest bytes in the returned evidence, even when the guest regresses.
406
+ if (typeof options.onLogWrite === 'function') options.onLogWrite(emitted);
407
+ const message = redactText(emitted);
408
+ writeU32(writtenOut, length);
263
409
  logs.push(Object.freeze({ endpoint, message }));
264
410
  trace.push({ module: 'fastly_log', name: 'write', endpoint, message, accepted: true });
265
411
  return FASTLY_STATUS_OK;
266
412
  }
267
413
  },
268
414
  fastly_http_req: {
415
+ register_dynamic_backend: dynamicBackends.register,
269
416
  body_downstream_get(requestOut, bodyOut) {
270
417
  trace.push({ module: 'fastly_http_req', name: 'body_downstream_get' });
271
418
  writeU32(requestOut, downstreamRequestHandle);
@@ -282,13 +429,27 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
282
429
  trace.push({ module: 'fastly_http_req', name: 'uri_get', handle: Number(handle) });
283
430
  return request ? writeUtf8(request.url, buffer, bufferLength, writtenOut) : FASTLY_STATUS_BADF;
284
431
  },
432
+ header_names_get(handle, buffer, size, cursor, end, written) {
433
+ trace.push({module: 'fastly_http_req', name: 'header_names_get', cursor: Number(cursor)});
434
+ const request = requests.get(Number(handle));
435
+ if (!request) return FASTLY_STATUS_BADF;
436
+ return requestHeaderList([...new Set(request.headers.map(([name]) => name.toLowerCase()))], buffer, size, cursor, end, written, 'names');
437
+ },
438
+ header_values_get(handle, pointer, length, buffer, size, cursor, end, written) {
439
+ trace.push({module: 'fastly_http_req', name: 'header_values_get', cursor: Number(cursor)});
440
+ const request = requests.get(Number(handle));
441
+ if (!request) return FASTLY_STATUS_BADF;
442
+ const name = readUtf8(pointer, length).toLowerCase();
443
+ if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name)) return FASTLY_STATUS_INVALID_ARGUMENT;
444
+ return requestHeaderList(request.headers.filter(([key]) => key.toLowerCase() === name).map(([, value]) => value), buffer, size, cursor, end, written, 'values', 'request');
445
+ },
285
446
  header_value_get(handle, namePointer, nameLength, valuePointer, valueLength, writtenOut) {
286
447
  const request = requests.get(Number(handle));
287
448
  const name = readUtf8(namePointer, nameLength);
288
449
  trace.push({ module: 'fastly_http_req', name: 'header_value_get', handle: Number(handle), header: name });
289
450
  if (!request) return FASTLY_STATUS_BADF;
290
451
  const value = headerValue(request.headers, name);
291
- return value === undefined ? FASTLY_STATUS_NONE : writeUtf8(value, valuePointer, valueLength, writtenOut);
452
+ return value === undefined ? FASTLY_STATUS_INVALID_ARGUMENT : writeUtf8(value, valuePointer, valueLength, writtenOut);
292
453
  },
293
454
  new(handleOut) {
294
455
  const handle = alloc('request');
@@ -316,9 +477,46 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
316
477
  if (!request) return FASTLY_STATUS_BADF;
317
478
  const name = readUtf8(namePointer, nameLength);
318
479
  const value = readUtf8(valuePointer, valueLength);
480
+ if (name.toLowerCase() === 'authorization' && value.includes('/s3/aws4_request')) privateUrls.add(request.url);
319
481
  request.headers = request.headers.filter(([headerName]) => headerName.toLowerCase() !== name.toLowerCase());
320
482
  request.headers.push([name, value]);
321
- trace.push({ module: 'fastly_http_req', name: 'header_insert', handle: Number(handle), header: [name, SENSITIVE_HEADERS.has(name.toLowerCase()) ? '[REDACTED]' : redactText(value)] });
483
+ trace.push({ module: 'fastly_http_req', name: 'header_insert', handle: Number(handle), header: redactRuntimeHeaders([[name, value]], privateUrls.has(request.url))[0] });
484
+ return FASTLY_STATUS_OK;
485
+ },
486
+ cache_override_set(handle, tag, ttl, swr) {
487
+ const request = requests.get(Number(handle)); if (!request) return FASTLY_STATUS_BADF;
488
+ request.cacheOverride = Number(tag); return FASTLY_STATUS_OK;
489
+ },
490
+ auto_decompress_response_set(handle, encodings) {
491
+ const request = requests.get(Number(handle)); if (!request) return FASTLY_STATUS_BADF;
492
+ request.decompression = Number(encodings); return FASTLY_STATUS_OK;
493
+ },
494
+ close(handle) { return requests.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
495
+ pending_req_poll_v2(handle, detail, done, responseOut, bodyOut) {
496
+ const item = pending.get(Number(handle)); if (!item) return FASTLY_STATUS_BADF;
497
+ writeSendErrorDetail(item, handle, detail, [done, responseOut, bodyOut], 'pending_req_poll_v2');
498
+ // Exercise a readiness race: select/is_ready can precede a not-ready poll.
499
+ if (monotonicMs >= item.readyAt && item.fixture.pollNotReadyMs && !item.delayedPoll) {
500
+ item.delayedPoll = true; item.readyAt = monotonicMs + Number(item.fixture.pollNotReadyMs);
501
+ }
502
+ if (monotonicMs < item.readyAt) { writeU32(done, 0); return FASTLY_STATUS_OK; }
503
+ writeU32(done, 1); return imports.fastly_http_req.pending_req_wait(handle, responseOut, bodyOut);
504
+ },
505
+ send_async_streaming(handle, bodyHandle, backendPointer, backendLength, pendingOut) {
506
+ const request = requests.get(Number(handle)), body = bodies.get(Number(bodyHandle));
507
+ if (!request || !body) return FASTLY_STATUS_BADF;
508
+ const backend = readUtf8(backendPointer, backendLength);
509
+ const fixture = resolveFixture(options.fixtures, backend, request.method, request.url);
510
+ requests.delete(Number(handle));
511
+ if (!fixture || fixture.sendStatus) { bodies.delete(Number(bodyHandle)); return Number(fixture?.sendStatus || 1); }
512
+ const pendingHandle = alloc('pending');
513
+ const record = {backend, method:request.method, url:redactText(request.url), headers:redactRuntimeHeaders(request.headers), bodyBytes:Buffer.alloc(0)};
514
+ outboundRequests.push(record);
515
+ const item = {request, backend, fixture, readyAt:fixture.earlyResponse ? monotonicMs + Number(fixture.delayMs || 0) : Infinity};
516
+ pending.set(pendingHandle, item);
517
+ body.streamingUpload = {item, record}; body.readyAt = monotonicMs + Number(options.uploadReadyDelayMs || 0);
518
+ writeU32(pendingOut, pendingHandle);
519
+ trace.push({module:'fastly_http_req',name:'send_async_streaming',bodyHandle:Number(bodyHandle),pendingHandle});
322
520
  return FASTLY_STATUS_OK;
323
521
  },
324
522
  send_async(handle, bodyHandle, backendPointer, backendLength, pendingOut) {
@@ -326,25 +524,37 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
326
524
  const body = bodies.get(Number(bodyHandle));
327
525
  if (!request || !body) return FASTLY_STATUS_BADF;
328
526
  const backend = readUtf8(backendPointer, backendLength);
527
+ dynamicBackends.route(backend, request);
528
+ const signedS3 = request.headers.some(([name, value]) => name.toLowerCase() === 'authorization' && value.startsWith('AWS4-HMAC-SHA256 '));
529
+ if (signedS3) privateUrls.add(request.url);
329
530
  const fixture = resolveFixture(options.fixtures, backend, request.method, request.url);
330
531
  if (!fixture) {
331
- trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), backend, method: request.method, url: request.url, missingFixture: true });
532
+ trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), backend, method: request.method, url: signedS3 ? '[REDACTED]' : request.url, missingFixture: true });
332
533
  return FASTLY_STATUS_ERROR;
333
534
  }
334
535
  const pendingHandle = alloc('pending');
335
536
  const capturedBody = bodyBytes(bodyHandle);
336
- pending.set(pendingHandle, { request: { ...request, headers: request.headers.map((entry) => [...entry]), body: capturedBody }, backend, fixture });
537
+ pending.set(pendingHandle, { request: { ...request, headers: request.headers.map((entry) => [...entry]), body: capturedBody }, backend, fixture, readyAt: fixture.neverHeaders ? Infinity : monotonicMs + Number(fixture.delayMs || 0) });
538
+ if (typeof options.onOutboundRequest === 'function') options.onOutboundRequest({ ...request, backend, headers: request.headers.map((entry) => [...entry]), body: capturedBody });
539
+ if (fixture.sendStatus) { pending.delete(pendingHandle); return Number(fixture.sendStatus); }
540
+ if (request.headers.some(([name, value]) => name.toLowerCase() === 'authorization' && value.startsWith('AWS4-HMAC-SHA256 '))) privateUrls.add(request.url);
541
+ requests.delete(Number(handle)); bodies.delete(Number(bodyHandle));
337
542
  outboundRequests.push(Object.freeze({
338
543
  backend,
339
544
  method: request.method,
340
- url: redactText(request.url),
341
- headers: Object.freeze(redactRuntimeHeaders(request.headers).map((header) => Object.freeze(header))),
342
- body: redactText(capturedBody.toString('utf8'))
545
+ url: privateUrls.has(request.url) ? '[REDACTED]' : redactText(request.url),
546
+ headers: Object.freeze(redactRuntimeHeaders(request.headers, signedS3).map((header) => Object.freeze(header))),
547
+ body: signedS3 ? '[REDACTED]' : redactText(capturedBody.toString('utf8'))
343
548
  }));
344
549
  writeU32(pendingOut, pendingHandle);
345
- trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), bodyHandle: Number(bodyHandle), pendingHandle, backend, method: request.method, url: redactText(request.url), headers: redactRuntimeHeaders(request.headers), bodyBytes: capturedBody.length });
550
+ trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), bodyHandle: Number(bodyHandle), pendingHandle, backend, method: request.method, url: redactText(request.url), headers: redactRuntimeHeaders(request.headers, signedS3), bodyBytes: capturedBody.length });
346
551
  return FASTLY_STATUS_OK;
347
552
  },
553
+ pending_req_wait_v2(handle, detail, responseOut, bodyOut) {
554
+ const item = pending.get(Number(handle)); if (!item) return FASTLY_STATUS_BADF;
555
+ writeSendErrorDetail(item, handle, detail, [responseOut, bodyOut], 'pending_req_wait_v2');
556
+ return imports.fastly_http_req.pending_req_wait(handle, responseOut, bodyOut);
557
+ },
348
558
  pending_req_wait(handle, responseOut, bodyOut) {
349
559
  const item = pending.get(Number(handle));
350
560
  trace.push({ module: 'fastly_http_req', name: 'pending_req_wait', pendingHandle: Number(handle), backend: item && item.backend, url: item && item.request.url });
@@ -384,6 +594,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
384
594
  const handle = alloc('secretStore'); secretStoreHandles.set(handle, values); writeU32(handleOut, handle); return FASTLY_STATUS_OK;
385
595
  },
386
596
  get(handle, keyPointer, keyLength, secretOut) {
597
+ monotonicMs += Number(options.secretDelayMs || 0);
598
+ if (typeof options.onSecretLookup === 'function') options.onSecretLookup();
387
599
  const values = secretStoreHandles.get(Number(handle));
388
600
  const key = readUtf8(keyPointer, keyLength);
389
601
  const value = values && values.get(key);
@@ -447,6 +659,16 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
447
659
  }
448
660
  },
449
661
  fastly_http_resp: {
662
+ close(handle) { trace.push({ module: 'fastly_http_resp', name: 'close' }); return responses.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
663
+ header_names_get(handle, buffer, size, cursor, end, written) {
664
+ const response = responses.get(Number(handle)); if (!response || cursor !== 0) return FASTLY_STATUS_BADF;
665
+ return headerList([...new Set(response.headers.map(([name]) => name.toLowerCase()))], buffer, size, end, written);
666
+ },
667
+ header_values_get(handle, pointer, length, buffer, size, cursor, end, written) {
668
+ const response = responses.get(Number(handle)); if (!response || cursor !== 0) return FASTLY_STATUS_BADF;
669
+ const name = readUtf8(pointer, length).toLowerCase();
670
+ return headerList(response.headers.filter(([key]) => key.toLowerCase() === name).map(([, value]) => value), buffer, size, end, written);
671
+ },
450
672
  new(handleOut) {
451
673
  const handle = alloc('response');
452
674
  responses.set(handle, { status: 200, headers: [], origin: false });
@@ -463,13 +685,21 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
463
685
  trace.push({ module: 'fastly_http_resp', name: 'header_append', handle: Number(handle), header: [name, value] });
464
686
  return FASTLY_STATUS_OK;
465
687
  },
688
+ header_values_get(handle, pointer, length, buffer, size, cursor, end, written) {
689
+ trace.push({module:'fastly_http_resp', name:'header_values_get', cursor:Number(cursor)});
690
+ const response = responses.get(Number(handle));
691
+ if (!response) return FASTLY_STATUS_BADF;
692
+ const name = readUtf8(pointer, length).toLowerCase();
693
+ if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name)) return FASTLY_STATUS_INVALID_ARGUMENT;
694
+ return requestHeaderList(response.headers.filter(([key]) => key.toLowerCase() === name).map(([, value]) => value), buffer, size, cursor, end, written, 'response-values', 'response');
695
+ },
466
696
  header_value_get(handle, namePointer, nameLength, valuePointer, valueLength, writtenOut) {
467
697
  const response = responses.get(Number(handle));
468
698
  const name = readUtf8(namePointer, nameLength);
469
699
  trace.push({ module: 'fastly_http_resp', name: 'header_value_get', handle: Number(handle), header: name });
470
700
  if (!response) return FASTLY_STATUS_BADF;
471
701
  const value = headerValue(response.headers, name);
472
- return value === undefined ? FASTLY_STATUS_NONE : writeUtf8(value, valuePointer, valueLength, writtenOut);
702
+ return value === undefined ? FASTLY_STATUS_INVALID_ARGUMENT : writeUtf8(value, valuePointer, valueLength, writtenOut);
473
703
  },
474
704
  status_get(handle, statusOut) {
475
705
  const response = responses.get(Number(handle));
@@ -499,10 +729,30 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
499
729
  responseHandle: Number(handle),
500
730
  bodyHandle: Number(bodyHandle)
501
731
  };
732
+ if (Number(streaming) === 1) responses.delete(Number(handle));
502
733
  return FASTLY_STATUS_OK;
503
734
  }
504
735
  },
505
736
  fastly_http_body: {
737
+ close(handle) {
738
+ trace.push({module:'fastly_http_body',name:'close',handle:Number(handle)});
739
+ const body = bodies.get(Number(handle));
740
+ if (body?.streamingUpload) {
741
+ const {item,record} = body.streamingUpload;
742
+ record.bodyBytes = bodyBytes(handle);
743
+ if (item.fixture.echoBody) item.fixture = {...item.fixture,body:record.bodyBytes};
744
+ item.readyAt = item.fixture.neverHeaders ? Infinity : monotonicMs + Number(item.fixture.delayMs || 0);
745
+ }
746
+ if (downstream?.streaming && downstream.bodyHandle === Number(handle)) { downstream.bodyBytes = bodyBytes(handle); downstream.finished = true; }
747
+ return bodies.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF;
748
+ },
749
+ abandon(handle) {
750
+ trace.push({module:'fastly_http_body',name:'abandon',handle:Number(handle)});
751
+ const body = bodies.get(Number(handle));
752
+ if (body?.streamingUpload) { body.streamingUpload.record.bodyBytes = bodyBytes(handle); body.streamingUpload.record.aborted = true; }
753
+ if (downstream?.streaming && downstream.bodyHandle === Number(handle)) { downstream.bodyBytes = bodyBytes(handle); downstream.aborted = true; }
754
+ return bodies.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF;
755
+ },
506
756
  new(handleOut) {
507
757
  const handle = alloc('body');
508
758
  bodies.set(handle, { bytes: Buffer.alloc(0), readOffset: 0, writes: [] });
@@ -513,33 +763,81 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
513
763
  read(handle, buffer, bufferLength, readOut) {
514
764
  const body = bodies.get(Number(handle));
515
765
  if (!body) return FASTLY_STATUS_BADF;
766
+ if (body.fixture && body.fixture.bodyReadStatus) return Number(body.fixture.bodyReadStatus);
516
767
  const source = bodyBytes(handle);
517
768
  const offset = body.readOffset || 0;
518
- const count = Math.min(Number(bufferLength), Math.max(0, source.length - offset));
769
+ const count = Math.min(Number(bufferLength), Math.max(0, source.length - offset), Number(body.fixture && body.fixture.chunkBytes || 65536));
519
770
  if (count > 0) new Uint8Array(memory().buffer, Number(buffer), count).set(source.subarray(offset, offset + count));
520
771
  body.readOffset = offset + count;
772
+ body.readyAt = monotonicMs + Number(body.fixture && body.fixture.chunkDelayMs || 0);
521
773
  writeU32(readOut, count);
522
- trace.push({ module: 'fastly_http_body', name: 'read', handle: Number(handle), bytes: count });
774
+ trace.push({ module: 'fastly_http_body', name: 'read', handle: Number(handle), bytes: count, ...(options.traceBodyIo === true ? {pointer:Number(buffer), capacity:Number(bufferLength), now:monotonicMs} : {}) });
523
775
  return FASTLY_STATUS_OK;
524
776
  },
525
777
  write(handle, buffer, bufferLength, end, writtenOut) {
526
778
  const body = bodies.get(Number(handle));
527
779
  if (!body) return FASTLY_STATUS_BADF;
528
- const bytes = readBytes(buffer, bufferLength);
780
+ if (downstream?.streaming && Number(handle) === downstream.bodyHandle && options.downstreamBodyWriteStatus) return Number(options.downstreamBodyWriteStatus);
781
+ if ((requests.size > 1 || body.streamingUpload) && options.outboundBodyWriteStatus) return Number(options.outboundBodyWriteStatus);
782
+ const length = Math.min(Number(bufferLength), Number(options.bodyWriteChunkBytes ?? bufferLength));
783
+ const bytes = readBytes(buffer, length);
529
784
  body.writes.push(bytes);
785
+ if (body.streamingUpload) body.readyAt = monotonicMs + Number(options.uploadWriteDelayMs || 0);
786
+ if (downstream?.streaming && downstream.bodyHandle === Number(handle)) body.readyAt = monotonicMs + Number(options.downstreamWriteDelayMs || 0);
530
787
  writeU32(writtenOut, bytes.length);
531
- trace.push({ module: 'fastly_http_body', name: 'write', handle: Number(handle), bytes: bytes.length, end: Number(end) });
788
+ 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} : {}) });
532
789
  return FASTLY_STATUS_OK;
533
790
  }
534
791
  }
535
792
  };
536
793
 
794
+ const kvEvidence = options.conditionalKv ? require('./conditional-kv-host.js').attachConditionalKvHost(imports, {
795
+ view, writeU32, writeU64, readBytes, readUtf8, alloc, bodies, bodyBytes, trace,
796
+ now: () => monotonicMs, advance: ms => { monotonicMs += ms; }
797
+ }, options.conditionalKv) : undefined;
798
+
799
+ // Model invocation termination, including cancellation raised by a host call.
800
+ // Throwing stops the Wasm invocation; it never fabricates an effect result.
801
+ const checkActive = () => { if (options.signal && options.signal.aborted) throw options.signal.reason || new Error('Request cancelled.'); };
802
+ checkActive();
803
+ // A test-only post-call hook can corrupt ABI outputs or advance the clock.
804
+ // It observes the real generated guest; it is never used by provider execution.
805
+ if (options.signal || options.hostcallDelayMs || options.afterHostcall) for (const [moduleName, namespace] of Object.entries(imports)) {
806
+ for (const [name, hostcall] of Object.entries(namespace)) if (typeof hostcall === 'function') namespace[name] = (...args) => {
807
+ checkActive(); const result = hostcall(...args);
808
+ monotonicMs += Number(options.hostcallDelayMs?.[`${moduleName}.${name}`] || 0);
809
+ const replacement = options.afterHostcall?.({module:moduleName,name,args,result,trace,
810
+ writeU32,writeU64,readU32:pointer=>view().getUint32(Number(pointer),true),
811
+ advance:ms=>{monotonicMs+=ms;}});
812
+ checkActive(); return replacement ?? result;
813
+ };
814
+ }
537
815
  const module = new WebAssembly.Module(wasm);
538
816
  instance = new WebAssembly.Instance(module, imports);
539
817
  if (typeof instance.exports._start !== 'function') {
540
818
  throw new FastlyNativePlatformCapabilitiesMockError('Pass98 native module is missing _start.', 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_MOCK_START_MISSING');
541
819
  }
542
- instance.exports._start();
820
+ if (options.conditionalKv && options.conditionalKv.deadlineNs !== undefined) instance.exports.pulse_fastly_kv_request_deadline(BigInt(options.conditionalKv.deadlineNs));
821
+ const callableEvidence = () => input?.plan?.callables?.length ? {
822
+ guestMemoryBytes: instance.exports.memory.buffer.byteLength,
823
+ memory: { bytes: Number(instance.exports.pulse_fastly_memory_bytes()),
824
+ values: Number(instance.exports.pulse_fastly_memory_values()) },
825
+ responseStatus: downstream?.status
826
+ } : {};
827
+ try { instance.exports._start(); }
828
+ catch (error) {
829
+ if (Number(instance.exports.pulse_fastly_last_error?.()) !== 1010) {
830
+ Object.defineProperty(error, 'execution', { value: Object.freeze(callableEvidence()), configurable: true });
831
+ throw error;
832
+ }
833
+ throw new FastlyNativePlatformCapabilitiesMockError('Native retained-value budget exceeded.',
834
+ 'PULSE_RUNTIME_MEMORY_LIMIT_EXCEEDED', {
835
+ ...callableEvidence(), lastError: 1010, errorStage: Number(instance.exports.pulse_fastly_error_stage()), trace,
836
+ bytes: Number(instance.exports.pulse_fastly_memory_bytes()),
837
+ values: Number(instance.exports.pulse_fastly_memory_values())
838
+ });
839
+ }
840
+ for (const entry of trace) if (entry.url && privateUrls.has(entry.url)) entry.url = '[REDACTED]';
543
841
  const lastError = typeof instance.exports.pulse_fastly_last_error === 'function'
544
842
  ? Number(instance.exports.pulse_fastly_last_error())
545
843
  : undefined;
@@ -552,17 +850,32 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
552
850
  const pulseLastError = typeof instance.exports.pulse_last_error_code === 'function'
553
851
  ? Number(instance.exports.pulse_last_error_code())
554
852
  : undefined;
853
+ if (downstream?.streaming && !downstream.finished) { downstream.bodyBytes = bodyBytes(downstream.bodyHandle) || downstream.bodyBytes; downstream.aborted = true; }
555
854
  const handledJwtError = options.allowHandledJwtError === true
556
855
  && lastError === 1008
557
856
  && Boolean(downstream);
558
- if (lastError !== FASTLY_STATUS_OK && !handledJwtError) {
559
- 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 });
857
+ const handledHeaderError = options.allowHandledHeaderError === true
858
+ && lastError === 1003 && (errorStage === 22 || errorStage === 23) && downstream?.status === 500;
859
+ const handledTransportError = options.allowHandledTransportError === true
860
+ && (errorStage === 77 || errorStage === 81)
861
+ && ((lastError === 1006 && downstream?.status === 502) || (lastError === 1003 && downstream?.status === 500));
862
+ const handledDeadlineError = options.allowHandledFetchDeadlineError === true
863
+ && ((lastError === 1006 && errorStage === 271 && downstream?.status === 504)
864
+ || (lastError === 1001 && errorStage === 270 && downstream?.status === 400)
865
+ || (lastError === 1003 && errorStage === 272 && downstream?.status === 500));
866
+ const handledDynamicBackendError = options.allowHandledDynamicBackendError === true
867
+ && ((lastError === 1001 && errorStage === 273 && downstream?.status === 400)
868
+ || (lastError === 1001 && errorStage === 276 && downstream?.status === 500)
869
+ || (lastError === 1003 && (errorStage === 274 || errorStage === 275) && downstream?.status === 500));
870
+ if (lastError !== FASTLY_STATUS_OK && !handledDynamicBackendError && !handledDeadlineError && !handledTransportError && !handledHeaderError && !handledJwtError && !(options.allowAbortedResponse === true && downstream?.aborted)) {
871
+ throw new FastlyNativePlatformCapabilitiesMockError('Fastly native platform capabilities module reported an execution error.', 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_MOCK_EXECUTION_FAILED', { ...callableEvidence(), lastError, errorStage, errorEffect, pulseLastError, trace });
560
872
  }
561
873
  if (!downstream) {
562
874
  throw new FastlyNativePlatformCapabilitiesMockError('Fastly native platform capabilities module did not send a downstream response.', 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_MOCK_RESPONSE_NOT_SENT', { trace });
563
875
  }
564
876
 
565
877
  return Object.freeze({
878
+ ...callableEvidence(),
566
879
  version: 'pulse.fastly-native-platform-capabilities-mock.v1',
567
880
  request: Object.freeze({ method, url, path: pathValue, headers: Object.freeze(requestHeaders.map((entry) => Object.freeze(entry))) }),
568
881
  response: Object.freeze({
@@ -572,6 +885,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
572
885
  bodyBytes: downstream.bodyBytes,
573
886
  sent: true,
574
887
  streaming: downstream.streaming,
888
+ aborted: downstream.aborted === true,
575
889
  origin: downstream.origin,
576
890
  responseHandle: downstream.responseHandle,
577
891
  bodyHandle: downstream.bodyHandle
@@ -580,6 +894,17 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
580
894
  logs: Object.freeze(logs),
581
895
  outboundRequests: Object.freeze(outboundRequests),
582
896
  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) }),
897
+ kvEvidence,
898
+ monotonicMs,
899
+ transportResources: Object.freeze({pendingRequests: pending.size,
900
+ outboundRequests: requests.size - Number(requests.has(downstreamRequestHandle))}),
901
+ // Non-streaming send transfers these two handles even though the fixture
902
+ // keeps their bytes for assertions. Pending work is counted separately:
903
+ // Fastly has no cancellation hostcall for an unresolved pending request.
904
+ forwardingResources: Object.freeze({
905
+ bodies: bodies.size - Number(!downstream?.streaming && bodies.has(downstream?.bodyHandle)),
906
+ responses: responses.size - Number(!downstream?.streaming && responses.has(downstream?.responseHandle))
907
+ }),
583
908
  instance
584
909
  });
585
910
  }
@@ -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};