@pulse-compute/provider-fastly 1.0.0-beta.4 → 1.0.0-beta.5

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.
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ // Host value handles live for the invocation. Store concatenation as shared,
4
+ // immutable prefixes instead of retaining a flat copy for every intermediate.
5
+ // This changes representation only: coercion happens at the original operation,
6
+ // and aliases keep their original string even after a later append.
7
+ const nativeStringFields = `
8
+ textLeaf: string = ""
9
+ textLeft: __PulseFastlyValue | null = null
10
+ textRight: __PulseFastlyValue | null = null
11
+ textLength: i32 = 0
12
+ get text(): string {
13
+ if (this.textLeft === null) return this.textLeaf
14
+ const pending = new Array<__PulseFastlyValue>()
15
+ const parts = new Array<string>()
16
+ pending.push(this)
17
+ while (pending.length > 0) {
18
+ const node = pending.pop()
19
+ if (node.textLeft === null) parts.push(node.textLeaf)
20
+ else { pending.push(node.textRight!); pending.push(node.textLeft!) }
21
+ }
22
+ this.textLeaf = parts.join("")
23
+ this.textLeft = null
24
+ this.textRight = null
25
+ return this.textLeaf
26
+ }
27
+ set text(value: string) {
28
+ this.textLeaf = value
29
+ this.textLength = value.length
30
+ this.textLeft = null
31
+ this.textRight = null
32
+ }
33
+ `;
34
+ const nativeStringConcat = `
35
+ function __pulse_fastly_concat(leftHandle: i32, rightHandle: i32): i32 {
36
+ let left = __pulse_fastly_value(leftHandle)
37
+ let right = __pulse_fastly_value(rightHandle)
38
+ // Snapshot non-string coercions now; a referenced object can change later.
39
+ if (left.kind != PULSE_VALUE_STRING) {
40
+ leftHandle = __pulse_fastly_string_value(__pulse_fastly_string(leftHandle))
41
+ left = __pulse_fastly_value(leftHandle)
42
+ }
43
+ if (right.kind != PULSE_VALUE_STRING) {
44
+ rightHandle = __pulse_fastly_string_value(__pulse_fastly_string(rightHandle))
45
+ right = __pulse_fastly_value(rightHandle)
46
+ }
47
+ if (left.textLength == 0) return rightHandle
48
+ if (right.textLength == 0) return leftHandle
49
+ const length = i64(left.textLength) + i64(right.textLength)
50
+ if (length > String.MAX_LENGTH) { __pulse_fastly_fail(PULSE_ERROR_VALUE, 57, -1); return 0 }
51
+ const value = new __PulseFastlyValue()
52
+ value.kind = PULSE_VALUE_STRING
53
+ value.textLength = i32(length)
54
+ value.textLeft = left
55
+ value.textRight = right
56
+ return __pulse_fastly_put(value)
57
+ }
58
+ `;
59
+
60
+ // ECMAScript WhiteSpace + LineTerminator code units. Keep this explicit rather
61
+ // than inheriting the AssemblyScript library's Unicode whitespace policy.
62
+ const nativeStringTrim = `
63
+ function __pulse_fastly_trim_space(code: i32): bool {
64
+ return (code >= 9 && code <= 13) || code == 32 || code == 160 || code == 5760
65
+ || (code >= 8192 && code <= 8202) || code == 8232 || code == 8233
66
+ || code == 8239 || code == 8287 || code == 12288 || code == 65279
67
+ }
68
+ function host_value_string_trim(handle: i32): i32 {
69
+ const value = __pulse_fastly_value(handle)
70
+ if (value.kind != PULSE_VALUE_STRING) { __pulse_fastly_fail(PULSE_ERROR_VALUE, 56, -1); return 0 }
71
+ let start = 0
72
+ let end = value.text.length
73
+ while (start < end && __pulse_fastly_trim_space(value.text.charCodeAt(start))) start += 1
74
+ while (end > start && __pulse_fastly_trim_space(value.text.charCodeAt(end - 1))) end -= 1
75
+ return __pulse_fastly_string_value(value.text.substring(start, end))
76
+ }
77
+ `;
78
+
79
+ // Indexed reads expose one UTF-16 code unit, including an isolated surrogate.
80
+ // Validate property spelling before converting to i32: "01", fractions,
81
+ // negative indices and nonnumeric keys are ordinary missing properties.
82
+ const nativeStringIndex = `
83
+ function __pulse_fastly_string_index(value: __PulseFastlyValue, keyHandle: i32): i32 {
84
+ const key = __pulse_fastly_value(keyHandle)
85
+ if (key.kind == PULSE_VALUE_STRING && key.text == "length") return host_value_number(value.textLength)
86
+ if (key.kind != PULSE_VALUE_NUMBER && key.kind != PULSE_VALUE_STRING) return host_value_undefined()
87
+ const index = __pulse_fastly_number(keyHandle)
88
+ if (!(index >= 0 && index < value.textLength)) return host_value_undefined()
89
+ const position = i32(index)
90
+ if (index != f64(position)) return host_value_undefined()
91
+ if (key.kind == PULSE_VALUE_STRING && key.text != position.toString()) return host_value_undefined()
92
+ let node = value
93
+ let offset = position
94
+ while (node.textLeft !== null) {
95
+ const left = node.textLeft!
96
+ if (offset < left.textLength) node = left
97
+ else { offset -= left.textLength; node = node.textRight! }
98
+ }
99
+ return __pulse_fastly_string_value(node.textLeaf.charAt(offset))
100
+ }
101
+ `;
102
+
103
+ function needsNativeValueFailureGuard(plan) {
104
+ if ((plan.schemas?.references || []).some(entry => ['value-encode', 'text-decode'].includes(entry.usage))) return true;
105
+ function containsValueOperation(node) {
106
+ if (!node || typeof node !== 'object') return false;
107
+ if (node.kind === 'pure-loop' || (node.kind === 'method-call' && node.method === 'string.trim')) return true;
108
+ return Object.values(node).some(containsValueOperation);
109
+ }
110
+ return containsValueOperation(plan.entry?.body) || containsValueOperation(plan.effects);
111
+ }
112
+
113
+ module.exports = { nativeStringFields, nativeStringConcat, nativeStringTrim, nativeStringIndex, needsNativeValueFailureGuard };
@@ -0,0 +1,49 @@
1
+ // Provider-owned request lifecycle. No wall time or caller header grants time.
2
+ @external('fastly_async_io', 'select') declare function __request_select(handles: usize, count: i32, timeout: i32, done: usize): i32
3
+ let __request_deadline: i64 = -1;
4
+ let __request_last_clock: i64 = -1;
5
+ let __request_expired: bool = false;
6
+ let __request_error_response: bool = false;
7
+ export function pulse_fastly_request_expired(): i32 { return __request_expired ? 1 : 0; }
8
+ function __request_clock(): i64 {
9
+ const out = new StaticArray<i64>(1);
10
+ if (wasi_snapshot_preview1_clock_time_get(1, 1, changetype<usize>(out)) != 0 || out[0] < __request_last_clock) {
11
+ __request_expired = true;
12
+ return -1;
13
+ }
14
+ __request_last_clock = out[0];
15
+ return out[0];
16
+ }
17
+ function __request_remaining(): i32 {
18
+ if (__request_expired) return 0;
19
+ const now = __request_clock();
20
+ if (now < 0 || now >= __request_deadline) { __request_expired = true; return 0; }
21
+ return i32((__request_deadline - now + 999999) / 1000000);
22
+ }
23
+ function __request_check(index: i32 = -1): bool {
24
+ if (__request_error_response) return true;
25
+ if (__request_remaining() > 0) return true;
26
+ __pulse_fastly_fail(PULSE_ERROR_HOSTCALL, 260, index);
27
+ return false;
28
+ }
29
+ function __request_wait(handle: i32, index: i32): bool {
30
+ const remaining = __request_remaining();
31
+ if (remaining <= 0) return __request_check(index);
32
+ const handles = new StaticArray<i32>(1), ready = new StaticArray<i32>(1);
33
+ handles[0] = handle;
34
+ const status = __request_select(changetype<usize>(handles), 1, remaining, changetype<usize>(ready));
35
+ if (status != 0) { __pulse_fastly_fail(PULSE_ERROR_HOSTCALL, 261, index); return false; }
36
+ if (ready[0] == -1) __request_expired = true;
37
+ return __request_check(index);
38
+ }
39
+ function __request_send_timeout(): void {
40
+ // A timeout says nothing about remote acceptance of dispatched writes.
41
+ __request_error_response = true;
42
+ __pulse_fastly_last_error = 0;
43
+ const options = host_value_object();
44
+ host_value_object_set(options, __pulse_fastly_string_value('status'), host_value_number(504.0));
45
+ const response = host_response_text(__pulse_fastly_string_value('Gateway Timeout'), options);
46
+ __pulse_fastly_last_error = 0;
47
+ const status = __pulse_fastly_send_result(response);
48
+ if (status != 0) __pulse_fastly_fail(PULSE_ERROR_HOSTCALL, 262, -1);
49
+ }
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const {normalizeRequestDuration} = require('@pulse-compute/runtime/host');
5
+
6
+ function instrumentRequestBudget(input, duration, plan) {
7
+ const ms = normalizeRequestDuration(duration);
8
+ if (ms === undefined) return input;
9
+ let source = input;
10
+ const replace = (before, after) => {
11
+ if (!source.includes(before)) throw new Error('Fastly request budget boundary drift: ' + before.slice(0, 100));
12
+ source = source.replace(before, after);
13
+ };
14
+ replace(' __pulse_fastly_last_error = fastly_abi_init', ` __request_expired = false; __request_error_response = false; __request_last_clock = -1;
15
+ __request_deadline = __request_clock() + i64(${ms}) * 1000000;
16
+ __pulse_fastly_last_error = fastly_abi_init`);
17
+ replace(' const handles = new StaticArray<i32>(2)\n const downstreamStatus', ` if (!__request_check()) { __request_send_timeout(); return; }
18
+ const handles = new StaticArray<i32>(2)
19
+ const downstreamStatus`);
20
+ replace('function host_effect_begin(effectIndex: i32, payload: i32): void {', 'function host_effect_begin(effectIndex: i32, payload: i32): void {\n if (!__request_check(effectIndex)) return;');
21
+ replace(' let runStatus = pulse_start()', ' if (!__request_check()) { __request_send_timeout(); return; }\n let runStatus = pulse_start()');
22
+ replace('function __pulse_fastly_resolve_effect(effectIndex: i32): i32 {', `function __pulse_fastly_resolve_effect(effectIndex: i32): i32 {
23
+ if (!__request_check(effectIndex)) return 0;
24
+ const kind = __pulse_fastly_effect_kind(effectIndex);
25
+ if (unchecked(__pulse_fastly_pending_mode[effectIndex]) == PULSE_FASTLY_PENDING_ASYNC && kind != 12 && kind != 13 && kind != 14 && ${plan.effects.filter(e=>e.kind.startsWith('kv.')&&['kv.getVersioned','kv.insertIfAbsent','kv.compareAndSwap'].includes(e.kind)).map(e=>'effectIndex != '+plan.effects.indexOf(e)).join(' && ')||'true'}) {
26
+ if (!__request_wait(unchecked(__pulse_fastly_pending[effectIndex]), effectIndex)) return 0;
27
+ }`);
28
+ replace(' const buffer = new Uint8Array(PULSE_FASTLY_BUFFER_BYTES), read = __pulse_fastly_out_i32();', ' if (!__request_wait(handle, effectIndex)) return "";\n const buffer = new Uint8Array(PULSE_FASTLY_BUFFER_BYTES), read = __pulse_fastly_out_i32();');
29
+ replace('while (offset < bytes.byteLength) { const written', 'while (offset < bytes.byteLength) { if (!__request_check()) return 1; const written');
30
+ replace('function __pulse_fastly_send_result(handle: i32): i32 {', 'function __pulse_fastly_send_result(handle: i32): i32 { if (!__request_check()) return 1;');
31
+ replace('return fastly_http_resp_send_downstream(responseHandle, bodyHandle, 0)', 'if (!__request_check()) return 1; return fastly_http_resp_send_downstream(responseHandle, bodyHandle, 0)');
32
+ replace('if (sendStatus != FASTLY_STATUS_OK) __pulse_fastly_fail(PULSE_ERROR_HOSTCALL, 104, -1)', 'if (sendStatus != FASTLY_STATUS_OK) { if (__request_expired) { __request_send_timeout(); return; } __pulse_fastly_fail(PULSE_ERROR_HOSTCALL, 104, -1); }');
33
+ replace('status = fastly_http_req_send_async(requestHandle', 'if (!__request_check(effectIndex)) return; status = fastly_http_req_send_async(requestHandle');
34
+ replace('function __pulse_fastly_ready_effect(effectIndex: i32, handle: i32): void {', 'function __pulse_fastly_ready_effect(effectIndex: i32, handle: i32): void { if (!__request_check(effectIndex)) return;');
35
+ source = source.replace('if (fatal) return', 'if (fatal) { if (__request_expired || !__request_check()) __request_send_timeout(); return; }');
36
+ replace('function __pulse_fastly_jwt_send_error(): void {', 'function __pulse_fastly_jwt_send_error(): void { if (__request_expired || !__request_check()) { __request_send_timeout(); return; }');
37
+ source = source.replaceAll('runStatus = pulse_resume()', 'if (!__request_check()) { __request_send_timeout(); return; }\n runStatus = pulse_resume()');
38
+ replace(' const result = pulse_result_handle()', ' if (!__request_check()) { __request_send_timeout(); return; }\n const result = pulse_result_handle()');
39
+ if (source.includes('__kv_deadline[index] = start +')) replace('__kv_deadline[index] = start + i64(__KV_timeoutMs) * 1000000;', '__kv_deadline[index] = min<i64>(__request_deadline, start + i64(__KV_timeoutMs) * 1000000);');
40
+ if (source.includes('__s3_deadlines[index] = start +')) replace('__s3_deadlines[index] = start + i64(binding.timeout) * 1000000;', '__s3_deadlines[index] = min<i64>(__request_deadline, start + i64(binding.timeout) * 1000000);');
41
+ return source + '\n' + fs.readFileSync(path.join(__dirname,'request-budget.as.ts'),'utf8');
42
+ }
43
+ module.exports = {instrumentRequestBudget};
@@ -71,7 +71,9 @@ function __pulse_fastly_s3_begin(index: i32, outer: __PulseFastlyValue): void {
71
71
  let data = new Uint8Array(0);
72
72
  if (put) {
73
73
  const text = __pulse_fastly_value(__pulse_fastly_payload_field(payload, 'text'));
74
- if (text.kind != PULSE_VALUE_STRING || !__pulse_s3_scalar(text.text)) { __s3_fail(index, 'invalid-text'); return; }
74
+ if (text.kind != PULSE_VALUE_STRING) { __s3_fail(index, 'invalid-text'); return; }
75
+ if (text.text.length > binding.max) { __s3_fail(index, 'too-large'); return; }
76
+ if (!__pulse_s3_scalar(text.text)) { __s3_fail(index, 'invalid-text'); return; }
75
77
  data = __pulse_s3_bytes(text.text);
76
78
  if (data.length > binding.max) { __s3_fail(index, 'too-large'); return; }
77
79
  __pulse_fastly_remember_secret(text.text);
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ // Provider-owned realtime sampling. The portable guest still suspends through
4
+ // effect_begin; no clock import is exposed to application-authored code.
5
+ function timeNativeSource(plan) {
6
+ if (!(plan.effects || []).some(effect => effect.kind === 'time.now')) return '';
7
+ return `
8
+ function __pulse_time_pad(value: i32, width: i32): string {
9
+ let text = value.toString()
10
+ while (text.length < width) text = "0" + text
11
+ return text
12
+ }
13
+ function __pulse_time_iso(ms: u64): string {
14
+ // Gregorian civil date from whole days since the Unix epoch.
15
+ const z = <i32>(ms / 86400000) + 719468
16
+ const era = z / 146097
17
+ const doe = z - era * 146097
18
+ const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
19
+ let year = yoe + era * 400
20
+ const doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
21
+ const mp = (5 * doy + 2) / 153
22
+ const day = doy - (153 * mp + 2) / 5 + 1
23
+ const month = mp + (mp < 10 ? 3 : -9)
24
+ if (month <= 2) year += 1
25
+ const rest = <i32>(ms % 86400000)
26
+ return __pulse_time_pad(year, 4) + "-" + __pulse_time_pad(month, 2) + "-" + __pulse_time_pad(day, 2)
27
+ + "T" + __pulse_time_pad(rest / 3600000, 2) + ":" + __pulse_time_pad((rest / 60000) % 60, 2)
28
+ + ":" + __pulse_time_pad((rest / 1000) % 60, 2) + "." + __pulse_time_pad(rest % 1000, 3) + "Z"
29
+ }
30
+ function __pulse_fastly_time_begin(effectIndex: i32, payload: __PulseFastlyValue): void {
31
+ const out = new StaticArray<u64>(1)
32
+ const status = wasi_snapshot_preview1_clock_time_get(0, 1000000, changetype<usize>(out))
33
+ const result = host_value_object()
34
+ if (status != 0) {
35
+ host_value_object_set(result, __pulse_fastly_string_value("status"), __pulse_fastly_string_value("failed"))
36
+ host_value_object_set(result, __pulse_fastly_string_value("reason"), __pulse_fastly_string_value("unavailable"))
37
+ } else {
38
+ // Divide integer nanoseconds before conversion to f64 to retain millisecond precision.
39
+ const ms = unchecked(out[0]) / 1000000
40
+ host_value_object_set(result, __pulse_fastly_string_value("status"), __pulse_fastly_string_value("ok"))
41
+ host_value_object_set(result, __pulse_fastly_string_value("unixEpochMs"), host_value_number(<f64>ms))
42
+ host_value_object_set(result, __pulse_fastly_string_value("iso8601"), __pulse_fastly_string_value(__pulse_time_iso(ms)))
43
+ }
44
+ __pulse_fastly_ready_effect(effectIndex, result)
45
+ }
46
+ `;
47
+ }
48
+
49
+ module.exports = Object.freeze({ timeNativeSource });
package/src/config-api.js CHANGED
@@ -38,6 +38,7 @@ function fastly(options = {}) {
38
38
  const normalizedGrip = options.grip === undefined ? undefined : gripBindings(options.grip);
39
39
  return Object.freeze({
40
40
  kind: 'fastly',
41
+ ...(options.maxDurationMs === undefined ? {} : { maxDurationMs: require('@pulse-compute/runtime/host').normalizeRequestDuration(options.maxDurationMs) }),
41
42
  version: FASTLY_PROVIDER_API_VERSION,
42
43
  bindings: Object.freeze({
43
44
  configStore: String(options.configStore ?? fastlyConfigDefault('fastly.configStore')),
@@ -72,6 +72,9 @@
72
72
  "fastly.local.networkFetch": {
73
73
  "kind": "boolean",
74
74
  "default": false
75
+ },
76
+ "fastly.maxDurationMs": {
77
+ "kind": "optional-number"
75
78
  }
76
79
  }
77
80
  }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ const { normalizeRequestDuration } = require('@pulse-compute/runtime/host') as { normalizeRequestDuration(value?: number): number | undefined };
1
2
  import FASTLY_CONFIG_SCHEMA from './config-schema.json';
2
3
 
3
4
  type FastlySchemaRule = Readonly<{ kind: string; default?: unknown }>;
@@ -23,6 +24,7 @@ export interface PulseFastlyLocalOptions {
23
24
  }
24
25
 
25
26
  export interface PulseFastlyProviderOptions {
27
+ readonly maxDurationMs?: number;
26
28
  readonly configStore?: string;
27
29
  readonly secretStore?: string;
28
30
  readonly kv?: Readonly<Record<string, string>>;
@@ -36,6 +38,7 @@ export interface PulseFastlyProviderOptions {
36
38
  }
37
39
 
38
40
  export interface PulseFastlyProviderConfig {
41
+ readonly maxDurationMs?: number;
39
42
  readonly kind: 'fastly';
40
43
  readonly version: typeof FASTLY_PROVIDER_API_VERSION;
41
44
  readonly bindings: {
@@ -97,6 +100,7 @@ export function fastly(options: PulseFastlyProviderOptions = {}): PulseFastlyPro
97
100
  const normalizedGrip = options.grip === undefined ? undefined : gripBindings(options.grip);
98
101
  return Object.freeze({
99
102
  kind: 'fastly',
103
+ maxDurationMs: normalizeRequestDuration(options.maxDurationMs),
100
104
  version: FASTLY_PROVIDER_API_VERSION,
101
105
  bindings: Object.freeze({
102
106
  configStore: String(options.configStore ?? fastlyDefaults['fastly.configStore']),
@@ -15,10 +15,12 @@ function unavailable(effect = {}) {
15
15
  function createFastlyJavascriptPackageEffectCapabilities(options = {}) {
16
16
  const assetsLookup = options.assetsLookup;
17
17
  const gripBroadcast = options.gripBroadcast;
18
+ const digestText = require('@pulse-compute/crypto/provider').createJavascriptTextDigest(options);
18
19
  const jwtVerify = options.jwtVerify;
19
20
  return Object.freeze({
20
21
  version: FASTLY_JAVASCRIPT_PACKAGE_EFFECTS_VERSION,
21
22
  effect(effect, execution) {
23
+ if (effect && effect.contractId === 'pulse.crypto' && effect.operation === 'digestText') return digestText(effect, execution);
22
24
  if (effect && effect.contractId === 'pulse.assets' && effect.operation === 'lookup') {
23
25
  if (typeof assetsLookup !== 'function') return unavailable(effect);
24
26
  return assetsLookup(effect.payload, execution);
@@ -49,7 +51,8 @@ function withFastlyPackageEffectCapabilities(capabilities, options = {}) {
49
51
  ...base,
50
52
  effect(effect, execution) {
51
53
  if (effect && (
52
- effect.contractId === 'pulse.assets'
54
+ effect.contractId === 'pulse.crypto'
55
+ || effect.contractId === 'pulse.assets'
53
56
  || effect.contractId === 'pulse.grip'
54
57
  || effect.contractId === 'pulse.jwt'
55
58
  )) {
@@ -45,10 +45,10 @@ function createFastlyJavascriptCapabilities(options = {}) {
45
45
  : undefined;
46
46
  return withFastlyPackageEffectCapabilities(
47
47
  withFastlyFetchCapability(
48
- withFastlyBindingCapabilities(options.capabilities, { ...options, bindingCapabilities }),
48
+ withFastlyBindingCapabilities({ time: () => runtimeHost.readWallTime(options.wallClock === undefined ? () => Date.now() : options.wallClock), ...options.capabilities }, { ...options, bindingCapabilities }),
49
49
  { ...options, fetchImplementation: options.fetchImplementation }
50
50
  ),
51
- { assetsLookup, gripBroadcast, jwtVerify }
51
+ { assetsLookup, gripBroadcast, jwtVerify, digestSubtle: options.digestSubtle }
52
52
  );
53
53
  }
54
54
 
@@ -57,6 +57,9 @@ function requestHeaderPairs(request) {
57
57
  }
58
58
 
59
59
  async function executeFastlyJavascriptApplication(application, request, options = {}) {
60
+ if (options.maxDurationMs !== undefined || options.requestBudget !== undefined) {
61
+ throw new runtimeHost.PulseRuntimeContractError('PULSE_REQUEST_DURATION_UNSUPPORTED', 'Fastly JavaScript does not implement the request deadline; select Native.');
62
+ }
60
63
  const capabilities = options.effectAdapter === undefined
61
64
  ? createFastlyJavascriptCapabilities(options)
62
65
  : undefined;
@@ -16,6 +16,7 @@ function blockedRestriction(id, restriction) {
16
16
 
17
17
  function classifyFastlyJavascriptCapability(id, restrictions = {}) {
18
18
  const capability = String(id);
19
+ if (capability === 'crypto.digestText') return decision(capability, 'eligible', 'fastly-javascript-crypto-sha256-runtime-builtin', 'provider-fastly');
19
20
  if (['kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap'].includes(capability)) return decision(capability, 'blocked', 'fastly-conditional-kv-incomplete', 'provider-fastly');
20
21
  if (['s3.head', 's3.getText', 's3.putText'].includes(capability)) return decision(capability, 'blocked', 'fastly-javascript-s3-raw-headers-unavailable', 'provider-fastly');
21
22
  if (isJavascriptCoreCapability(capability)) {
@@ -102,6 +103,7 @@ function classifyFastlyJavascriptCapability(id, restrictions = {}) {
102
103
 
103
104
  function classifyFastlyJavascriptProviderRequirement(id, restrictions = {}) {
104
105
  const requirement = String(id);
106
+ if (requirement === 'crypto.digestText') return decision(requirement, 'eligible', 'fastly-javascript-crypto-sha256-runtime-builtin', 'provider-fastly');
105
107
  if (['kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap'].includes(requirement)) return decision(requirement, 'blocked', 'fastly-conditional-kv-incomplete', 'provider-fastly');
106
108
  if (['s3.head', 's3.getText', 's3.putText'].includes(requirement)) return decision(requirement, 'blocked', 'fastly-javascript-s3-raw-headers-unavailable', 'provider-fastly');
107
109
  if (requirement === 'request' || ['response.json', 'response.text', 'response.custom'].includes(requirement)) {
@@ -169,6 +171,11 @@ function restriction(reasonId, configuration) {
169
171
  }
170
172
 
171
173
  function fastlyJavascriptProjectRestrictions(compiled, project) {
174
+ if (project.providerConfig?.maxDurationMs !== undefined) {
175
+ const error = new TypeError('fastly.maxDurationMs requires the Native target; Fastly JavaScript does not implement the request deadline.');
176
+ error.code = 'PULSE_REQUEST_DURATION_UNSUPPORTED';
177
+ throw error;
178
+ }
172
179
  const bindings = project.providerConfig && project.providerConfig.bindings || {};
173
180
  const backends = bindings.backends && typeof bindings.backends === 'object' ? bindings.backends : {};
174
181
  const kv = bindings.kv && typeof bindings.kv === 'object' ? bindings.kv : {};
@@ -14,6 +14,11 @@ const FASTLY_JAVASCRIPT_TARGET_DESCRIPTOR = normalizeJavascriptTargetDescriptor(
14
14
  crypto: defineCryptoTargetCapabilities({
15
15
  target: 'javascript',
16
16
  algorithms: [{
17
+ algorithm: 'SHA-256',
18
+ realization: 'runtime-builtin',
19
+ implemented: true,
20
+ status: 'implemented-d1'
21
+ }, {
17
22
  algorithm: 'HS256',
18
23
  realization: 'runtime-builtin',
19
24
  implemented: true,
@@ -45,7 +50,7 @@ const FASTLY_JAVASCRIPT_TARGET_DESCRIPTOR = normalizeJavascriptTargetDescriptor(
45
50
  kv: true,
46
51
  parallel: true
47
52
  },
48
- packages: ['pulse.assets', 'pulse.grip', 'pulse.jwt'],
53
+ packages: ['pulse.assets', 'pulse.crypto', 'pulse.grip', 'pulse.jwt'],
49
54
  restrictions: {
50
55
  fetch: 'origin-backend-map-or-dynamic-opt-in',
51
56
  kv: 'logical-store-map-required',
@@ -13,7 +13,7 @@ const FASTLY_PROVIDER_DESCRIPTOR = canonical.normalizeDescriptor({
13
13
  localExecution: true,
14
14
  capabilities: [
15
15
  'request', 'response.json', 'response.text', 'response.custom', 'fetch',
16
- 'config.get', 'secret.get', 'kv.get', 'kv.put', 'kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap', 'assets.lookup',
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',
18
18
  'opaque.pass-through', 's3.head', 's3.getText', 's3.putText'
19
19
  ],
@@ -23,6 +23,8 @@ const FASTLY_PROVIDER_DESCRIPTOR = canonical.normalizeDescriptor({
23
23
  'response.text': 'fastly.compute.response.text',
24
24
  'response.custom': 'fastly.compute.response.custom',
25
25
  fetch: 'fastly.backend.fetch',
26
+ 'time.wall-clock': 'wasi.clock_time_get.realtime',
27
+ 'crypto.digestText': 'pulse.crypto.guest-source.sha256',
26
28
  'config.get': 'fastly.config-store.get',
27
29
  'secret.get': 'fastly.secret-store.get',
28
30
  'kv.get': 'fastly.kv-store.get',
@@ -344,6 +344,8 @@ function createFastlyProviderAdapter(baseOptions = {}) {
344
344
  async dispatchEffect(effect, executionOptions = {}) {
345
345
  const state = stateFor(executionOptions);
346
346
  if (effect.kind === 'fetch') return dispatchFetch(effect, executionOptions);
347
+ if (effect.kind === 'crypto.digestText') return require('@pulse-compute/crypto/provider').createJavascriptTextDigest(baseOptions)(effect, executionOptions);
348
+ if (effect.kind === 'time.now') return require('@pulse-compute/runtime/host').readWallTime(baseOptions.wallClock === undefined ? () => Date.now() : baseOptions.wallClock);
347
349
  if (effect.kind === 'config.get') return state.surface.config.adapter.get(effect.name);
348
350
  if (effect.kind === 'secret.get') {
349
351
  const secret = state.surface.secret.adapter.get(effect.name);
@@ -143,7 +143,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
143
143
  const downstreamRequestHandle = 1;
144
144
  const downstreamBodyHandle = 2;
145
145
  requests.set(downstreamRequestHandle, { method, url, headers: requestHeaders.map((entry) => [...entry]) });
146
- bodies.set(downstreamBodyHandle, { bytes: normalizeBody(requestInput.body), readOffset: 0, writes: [] });
146
+ bodies.set(downstreamBodyHandle, { bytes: normalizeBody(requestInput.body), readOffset: 0, writes: [],
147
+ readyAt: monotonicMs + Number(requestInput.readyDelayMs || 0), fixture: requestInput });
147
148
  let downstream;
148
149
 
149
150
  function alloc(kind = 'default') {
@@ -219,6 +220,28 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
219
220
  new Uint8Array(memory().buffer, Number(buffer), output.length).set(output);
220
221
  return FASTLY_STATUS_OK;
221
222
  }
223
+ function requestHeaderList(values, buffer, size, cursor, end, written, kind) {
224
+ const limit = Math.min(Number(size), options.requestHeaderPageBytes ?? Number(size));
225
+ let index = Number(cursor), count = 0;
226
+ if (index < 0 || index > values.length) return FASTLY_STATUS_ERROR;
227
+ while (index < values.length) {
228
+ const bytes = Buffer.from(String(values[index]) + '\0');
229
+ if (count + bytes.length > limit) {
230
+ if (count === 0) { writeU32(written, bytes.length); return FASTLY_STATUS_BUFLEN; }
231
+ break;
232
+ }
233
+ new Uint8Array(memory().buffer, Number(buffer) + count, bytes.length).set(bytes);
234
+ count += bytes.length; index++;
235
+ }
236
+ writeU32(written, count);
237
+ view().setBigInt64(Number(end), index === values.length ? -1n : BigInt(index), true);
238
+ // Fault injection is host-fixture-only; neither values nor faults enter traces.
239
+ const fault = options.requestHeaderFault?.({kind, cursor: Number(cursor)});
240
+ if (fault?.written !== undefined) writeU32(written, fault.written);
241
+ if (fault?.cursor !== undefined) view().setBigInt64(Number(end), BigInt(fault.cursor), true);
242
+ if (fault?.unterminated && count) new Uint8Array(memory().buffer)[Number(buffer) + count - 1] = 65;
243
+ return fault?.status ?? FASTLY_STATUS_OK;
244
+ }
222
245
  const imports = {
223
246
  fastly_async_io: {
224
247
  select(handles, count, timeout, done) {
@@ -235,6 +258,19 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
235
258
  },
236
259
  wasi_snapshot_preview1: {
237
260
  clock_time_get(clockId, precision, timeOut) {
261
+ if (Number(clockId) === 1 && typeof options.monotonicClock === 'function') {
262
+ const sample = options.monotonicClock(monotonicMs);
263
+ if (sample.status) return sample.status;
264
+ writeU64(timeOut, sample.nanoseconds);
265
+ return FASTLY_STATUS_OK;
266
+ }
267
+ if (Number(clockId) === 0 && typeof options.realtimeClock === 'function') {
268
+ const sample = options.realtimeClock();
269
+ trace.push({ module: 'wasi_snapshot_preview1', name: 'clock_time_get', clockId: 0, precision: String(precision), status: sample.status || 0 });
270
+ if (sample.status) return sample.status;
271
+ writeU64(timeOut, sample.nanoseconds);
272
+ return FASTLY_STATUS_OK;
273
+ }
238
274
  const seconds = Number.isFinite(Number(options.clockUnixSeconds))
239
275
  ? Number(options.clockUnixSeconds)
240
276
  : Math.floor(Date.now() / 1000);
@@ -304,6 +340,19 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
304
340
  trace.push({ module: 'fastly_http_req', name: 'uri_get', handle: Number(handle) });
305
341
  return request ? writeUtf8(request.url, buffer, bufferLength, writtenOut) : FASTLY_STATUS_BADF;
306
342
  },
343
+ header_names_get(handle, buffer, size, cursor, end, written) {
344
+ trace.push({module: 'fastly_http_req', name: 'header_names_get', cursor: Number(cursor)});
345
+ const request = requests.get(Number(handle));
346
+ if (!request) return FASTLY_STATUS_BADF;
347
+ return requestHeaderList([...new Set(request.headers.map(([name]) => name.toLowerCase()))], buffer, size, cursor, end, written, 'names');
348
+ },
349
+ header_values_get(handle, pointer, length, buffer, size, cursor, end, written) {
350
+ trace.push({module: 'fastly_http_req', name: 'header_values_get', cursor: Number(cursor)});
351
+ const request = requests.get(Number(handle));
352
+ if (!request) return FASTLY_STATUS_BADF;
353
+ const name = readUtf8(pointer, length).toLowerCase();
354
+ return requestHeaderList(request.headers.filter(([key]) => key.toLowerCase() === name).map(([, value]) => value), buffer, size, cursor, end, written, 'values');
355
+ },
307
356
  header_value_get(handle, namePointer, nameLength, valuePointer, valueLength, writtenOut) {
308
357
  const request = requests.get(Number(handle));
309
358
  const name = readUtf8(namePointer, nameLength);
@@ -603,9 +652,11 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
603
652
  // Throwing stops the Wasm invocation; it never fabricates an effect result.
604
653
  const checkActive = () => { if (options.signal && options.signal.aborted) throw options.signal.reason || new Error('Request cancelled.'); };
605
654
  checkActive();
606
- if (options.signal) for (const namespace of Object.values(imports)) {
655
+ if (options.signal || options.hostcallDelayMs) for (const [moduleName, namespace] of Object.entries(imports)) {
607
656
  for (const [name, hostcall] of Object.entries(namespace)) if (typeof hostcall === 'function') namespace[name] = (...args) => {
608
- checkActive(); const result = hostcall(...args); checkActive(); return result;
657
+ checkActive(); const result = hostcall(...args);
658
+ monotonicMs += Number(options.hostcallDelayMs?.[`${moduleName}.${name}`] || 0);
659
+ checkActive(); return result;
609
660
  };
610
661
  }
611
662
  const module = new WebAssembly.Module(wasm);
@@ -33,6 +33,7 @@ function normalizeFastlyProviderConfig(value) {
33
33
  : {};
34
34
  return Object.freeze({
35
35
  kind: 'fastly',
36
+ maxDurationMs: require('@pulse-compute/runtime/host').normalizeRequestDuration(input.maxDurationMs),
36
37
  version: input.version ? String(input.version) : undefined,
37
38
  bindings: Object.freeze({
38
39
  configStore: String(bindings.configStore || 'pulse_config'),
@@ -67,6 +68,7 @@ function fastlyProjectConfigDocument(config) {
67
68
  return Object.freeze({
68
69
  kind: 'fastly',
69
70
  bindings: config.bindings,
71
+ maxDurationMs: config.maxDurationMs,
70
72
  build: config.build,
71
73
  local: config.local,
72
74
  providerSpecificUserland: false
@@ -100,7 +102,8 @@ const FASTLY_PROJECT_CONFIG_REFERENCE = Object.freeze({
100
102
  })
101
103
  ]),
102
104
  fields: Object.freeze([
103
- Object.freeze({ section: 'fastly', path: 'fastly.bindings.s3', type: 'Readonly<Record<string, S3Binding & { backend: string }>>', default: '`{}`', scope: 'Fastly Native S3', description: 'Maps logical names to fixed HTTPS endpoint, bucket, region, named static backend, accessKeyIdSecret, secretAccessKeySecret, optional sessionTokenSecret, maxTextBytes (1–32768, default 32768) and timeoutMs (1–30000, default 10000).', security: 'Credentials resolve through the configured Secret Store at execution. Dynamic backend authority and guest endpoint overrides are forbidden.' }),
105
+ 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.'}),
106
+ Object.freeze({ section: 'fastly', path: 'fastly.bindings.s3', type: 'Readonly<Record<string, S3Binding & { backend: string }>>', default: '`{}`', scope: 'Fastly Native S3', description: 'Maps logical names to fixed HTTPS endpoint, bucket, region, named static backend, accessKeyIdSecret, secretAccessKeySecret, optional sessionTokenSecret, maxTextBytes (1–2097152, default 32768) and timeoutMs (1–30000, default 10000).', security: 'Credentials resolve through the configured Secret Store at execution. Dynamic backend authority and guest endpoint overrides are forbidden.' }),
104
107
  Object.freeze({ section: 'fastly', path: 'fastly.configStore', type: 'string', default: '`pulse_config`', scope: 'Fastly config capability', description: 'Fastly Config Store resource name.' }),
105
108
  Object.freeze({ section: 'fastly', path: 'fastly.secretStore', type: 'string', default: '`pulse_secrets`', scope: 'Fastly secret capability', description: 'Fastly Secret Store resource name.', security: 'The name is build metadata; secret values are never embedded by this option.' }),
106
109
  Object.freeze({ section: 'fastly', path: 'fastly.kv', type: 'Readonly<Record<string, string>>', default: '`{}`', scope: 'Fastly KV capability', description: 'Maps logical ctx.kv names to Fastly KV Store resource names.', diagnostics: Object.freeze(['PULSE_FASTLY_KV_BINDINGS_INVALID']) }),
@@ -332,7 +332,7 @@ function createDriver() {
332
332
  });
333
333
  },
334
334
  executionOptions(providerConfig = {}, values = {}) {
335
- return { ...values, bindings: providerConfig.bindings };
335
+ return { ...values, maxDurationMs: providerConfig.maxDurationMs, bindings: providerConfig.bindings };
336
336
  }
337
337
  });
338
338
  }