@pulse-compute/provider-fastly 1.0.0-beta.3 → 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.
- package/README.md +6 -6
- package/dist/config-schema.json +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/build/canonical-target.js +5 -0
- package/src/build/digest-native.js +25 -0
- package/src/build/native-application-errors.js +117 -0
- package/src/build/native-http-effects.js +89 -16
- package/src/build/native-platform-capabilities.js +153 -40
- package/src/build/native-request-body.js +44 -0
- package/src/build/native-request-headers.js +93 -0
- package/src/build/native-string-values.js +113 -0
- package/src/build/request-budget.as.ts +49 -0
- package/src/build/request-budget.js +43 -0
- package/src/build/s3-native.as.ts +3 -1
- package/src/build/time-native.js +49 -0
- package/src/config-api.js +1 -0
- package/src/config-schema.json +3 -0
- package/src/index.ts +4 -0
- package/src/javascript/package-effects.js +4 -1
- package/src/javascript/runtime-host.js +5 -2
- package/src/javascript/target-support-policy.js +7 -0
- package/src/javascript/target.js +6 -1
- package/src/provider-contract.js +3 -1
- package/src/runtime/canonical-api-runtime.js +2 -0
- package/src/testing/native-platform-capabilities-host.js +54 -3
- package/src/toolchain/config.js +4 -1
- package/src/toolchain/index.js +1 -1
|
@@ -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
|
|
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')),
|
package/src/config-schema.json
CHANGED
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.
|
|
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 : {};
|
package/src/javascript/target.js
CHANGED
|
@@ -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',
|
package/src/provider-contract.js
CHANGED
|
@@ -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.
|
|
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);
|
|
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);
|
package/src/toolchain/config.js
CHANGED
|
@@ -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({
|
|
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']) }),
|
package/src/toolchain/index.js
CHANGED
|
@@ -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
|
}
|