@pulse-compute/provider-fastly 1.0.0-beta.8 → 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.
@@ -254,6 +254,14 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
254
254
  }
255
255
  const imports = {
256
256
  fastly_async_io: {
257
+ is_ready(handle, out) {
258
+ const item = pending.get(Number(handle)) || bodies.get(Number(handle));
259
+ if (!item) return FASTLY_STATUS_BADF;
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
+ },
257
265
  select(handles, count, timeout, done) {
258
266
  if (Number(count) < 1 || Number(timeout) <= 0) return FASTLY_STATUS_ERROR;
259
267
  const selected = Array.from({length:Number(count)},(_,i) => view().getUint32(Number(handles)+4*i,true));
@@ -270,6 +278,28 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
270
278
  }
271
279
  },
272
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
+ },
273
303
  clock_time_get(clockId, precision, timeOut) {
274
304
  if (Number(clockId) === 1 && typeof options.monotonicClock === 'function') {
275
305
  const sample = options.monotonicClock(monotonicMs);
@@ -312,6 +342,11 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
312
342
  fastly_log: {
313
343
  endpoint_get(namePointer, nameLength, handleOut) {
314
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
+ }
315
350
  if (options.logEndpointFailure === true) {
316
351
  trace.push({ module: 'fastly_log', name: 'endpoint_get', endpoint, opened: false });
317
352
  return FASTLY_STATUS_ERROR;
@@ -426,6 +461,23 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
426
461
  if (monotonicMs < item.readyAt) { writeU32(done, 0); return FASTLY_STATUS_OK; }
427
462
  writeU32(done, 1); return imports.fastly_http_req.pending_req_wait(handle, responseOut, bodyOut);
428
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
+ },
429
481
  send_async(handle, bodyHandle, backendPointer, backendLength, pendingOut) {
430
482
  const request = requests.get(Number(handle));
431
483
  const body = bodies.get(Number(bodyHandle));
@@ -630,11 +682,30 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
630
682
  responseHandle: Number(handle),
631
683
  bodyHandle: Number(bodyHandle)
632
684
  };
685
+ if (Number(streaming) === 1) responses.delete(Number(handle));
633
686
  return FASTLY_STATUS_OK;
634
687
  }
635
688
  },
636
689
  fastly_http_body: {
637
- 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
+ },
638
709
  new(handleOut) {
639
710
  const handle = alloc('body');
640
711
  bodies.set(handle, { bytes: Buffer.alloc(0), readOffset: 0, writes: [] });
@@ -653,19 +724,21 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
653
724
  body.readOffset = offset + count;
654
725
  body.readyAt = monotonicMs + Number(body.fixture && body.fixture.chunkDelayMs || 0);
655
726
  writeU32(readOut, count);
656
- 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} : {}) });
657
728
  return FASTLY_STATUS_OK;
658
729
  },
659
730
  write(handle, buffer, bufferLength, end, writtenOut) {
660
731
  const body = bodies.get(Number(handle));
661
732
  if (!body) return FASTLY_STATUS_BADF;
662
733
  if (downstream?.streaming && Number(handle) === downstream.bodyHandle && options.downstreamBodyWriteStatus) return Number(options.downstreamBodyWriteStatus);
663
- if (requests.size > 1 && options.outboundBodyWriteStatus) return Number(options.outboundBodyWriteStatus);
664
- 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));
665
736
  const bytes = readBytes(buffer, length);
666
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);
667
740
  writeU32(writtenOut, bytes.length);
668
- 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} : {}) });
669
742
  return FASTLY_STATUS_OK;
670
743
  }
671
744
  }
@@ -680,11 +753,16 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
680
753
  // Throwing stops the Wasm invocation; it never fabricates an effect result.
681
754
  const checkActive = () => { if (options.signal && options.signal.aborted) throw options.signal.reason || new Error('Request cancelled.'); };
682
755
  checkActive();
683
- 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)) {
684
759
  for (const [name, hostcall] of Object.entries(namespace)) if (typeof hostcall === 'function') namespace[name] = (...args) => {
685
760
  checkActive(); const result = hostcall(...args);
686
761
  monotonicMs += Number(options.hostcallDelayMs?.[`${moduleName}.${name}`] || 0);
687
- 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;
688
766
  };
689
767
  }
690
768
  const module = new WebAssembly.Module(wasm);
@@ -716,7 +794,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
716
794
  const pulseLastError = typeof instance.exports.pulse_last_error_code === 'function'
717
795
  ? Number(instance.exports.pulse_last_error_code())
718
796
  : undefined;
719
- 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; }
720
798
  const handledJwtError = options.allowHandledJwtError === true
721
799
  && lastError === 1008
722
800
  && Boolean(downstream);
@@ -759,6 +837,13 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
759
837
  monotonicMs,
760
838
  transportResources: Object.freeze({pendingRequests: pending.size,
761
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
+ }),
762
847
  instance
763
848
  });
764
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({
@@ -59,7 +59,12 @@ function prepareFastlyNativeExecution(invocation) {
59
59
  response: Object.freeze({
60
60
  ...execution.response,
61
61
  kind: type.includes('application/json') || type.includes('+json') ? 'json' : type.startsWith('text/') ? 'text' : 'response',
62
- 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'
63
68
  }),
64
69
  // The fixture ABI has hostcall traces, not canonical continuation telemetry.
65
70
  effectCount: null,